GardenTrack's frost alert decides whether it is going to get cold enough tonight to kill something you are growing, using how frost-tender the crop is and whether it grows back. Those two fields live one level deeper in our plant data than the function reading them, so it read both as empty and returned 'no warning' for all 151 crops on any temperature. Nothing errored. It was caught by a person noticing a card that should have appeared and did not.
What the feature is supposed to do
You are growing a tomato. Tonight the forecast low is 29°F. The app should tell you, before dark, that the tomato will not survive it and you have two options: cover it, or pick what is on it.
That is the whole feature. It is also the single most valuable thing this app can do, because it is the one piece of information that is worthless the morning after.
The function that makes the call is thirty lines long. It has existed, tested, since 29 July. On 30 July we shipped the weather feed that gives it a forecast to read.
It has never once fired.
The two fields
Deciding whether a plant dies tonight sounds like it should be a temperature comparison. It is not, and the reason is worth a paragraph because it is the interesting half of this.
The obvious design compares the forecast low to the crop's own kill temperature. We checked. That number exists on 31 of our 151 crops, and where it exists it means two different things. Rosemary is recorded at 5°F. A tomato is recorded as "tender." Both are true. The rosemary number describes an established shrub surviving a winter; the tomato word describes tonight. Threshold across those and you get confident nonsense.
So the call keys off two other fields instead:
frost_sensitivity— tender, half-hardy, hardy, very hardy. Present on 151 of 151 crops.lifecycle— whether losing the top of the plant ends it or it grows back.
Tender annual at 29°F: that plant is finished. Tender perennial: it loses its leaves and its fruit and comes back. Very hardy at 29°F: nothing, deliberately. Frost improves kale. Warning someone about kale is how they learn to ignore the warning about the tomato.
Both fields are sourced. Both are cited. Both are on every crop we ship.
What actually happened
The function signature asks for an object with those two fields on it. Our plant records keep them one level down, under care.
frostWarning(crop, 29) // looks for crop.frost_sensitivity -> nothing there
frostWarning(crop.care, 29) // looks for care.frost_sensitivity -> "tender"
We handed it the whole plant record when it needed the care section inside that record. Same information, one drawer up. It opened the filing cabinet, did not find the folder, and reported that there was no folder.
The first line of the function is a guard: if there is no frost class, return nothing. That guard is correct. It exists so a crop with missing data produces silence instead of a guess, which is the behavior we want.
So the function received a tomato, looked for a frost class, found undefined, and returned "no warning." Then it did that for the pepper. Then for all 149 others. At 29°F. At 20°F. At any temperature you like, forever.
Nothing threw. Nothing logged. The types were satisfied, because every field on that parameter is optional — it has to be, since a crop with no recorded frost class is a real case the function is built to handle. TypeScript had no way to know we had handed it the wrong object. It was a valid object. It was just the parent of the right one.
Two failures hiding one
Here is the part that made it survive.
The function was written on 29 July. Nothing could call it usefully until 30 July, because the app had no weather. So for a day there were two reasons the frost alert never appeared, and only one of them was visible. We knew about the missing forecast. We were, in a sense, expecting silence.
Then the weather feed shipped. The forecast arrived. The silence continued, and now it meant something completely different.
A bug behind a known gap is a bug with an alibi. When the gap closes, the alibi does not automatically expire — you have to remember to go back and check the thing you had already explained to yourself.
How it was caught
Not by a test. Not by the type checker. Not by review.
We put a fake forecast into a development copy of the app — tonight's low, 29°F — and looked at the screen to see the new warning card. The card was not there. The garden on that test device holds a tomato, two peppers and a bean, all tender annuals, all of which should have been named on it.
The whole detection method was a person expecting something and not seeing it.
That is worth sitting with, because it does not scale. There is no version of this project where somebody eyeballs every screen in every state. If that tomato had been the only tender thing in the test garden, or if the seeded temperature had been 34°F instead of 29°F, the card would have been correctly absent and we would have shipped.
The fix, and the fix for the fix
The immediate repair is four lines: a small function that reaches into care and hands back the shape the frost engine wants. One place in the codebase now knows about that nesting. Every caller goes through it.
The repair that matters more is the test. It walks all 151 crops in the shipped data file and asserts three things:
- Every crop has a frost class where the app expects to find one. All 151 do.
- A tender annual at 29°F produces a "you will lose this" warning, and a very hardy crop at 29°F produces nothing.
- At 28°F, more than forty crops warn.
The third one is the important assertion and it is deliberately crude. A blanket silence — the exact failure we had — passes any test that only checks individual crops behave correctly, because "no warning" is a legitimate answer for 68 of ours. What it cannot pass is a count. If the frost alert ever goes quiet across the whole catalog again, that number goes to zero and the build stops.
Why we are telling you this
Because the failure mode generalises, and you are probably relying on something with the same shape.
A wrong answer that crashes is cheap. You see it, you fix it, nobody is misled. A wrong answer that returns confidently and never crashes will sit in production being trusted. Ours returned the most reassuring possible output — nothing is wrong — on the night it mattered most.
Every gardening app, ours included, sits on a stack of these judgments: which plants are tender, when your last frost is, what "days to maturity" counts from. Most of them are invisible when they go wrong. The plant just dies, in October, and it looks like weather.
We publish these because a claim to be data-driven is only worth something if you also show what happens when the data is right and the code is wrong.
Where we are still wrong
Three things we're honestly still working on.
The numeric path is dead code. The function can sharpen its wording when a crop has a recorded kill temperature. Those numbers are not in our published data at all. The code compiles, the branch never runs, and we left it in.
The count assertion is a floor, not a check. Forty-plus crops warning at 28°F catches total silence. It would not catch the frost alert being wrong for one family — every brassica, say — while the total stayed healthy.
An alert has still never reached a real phone. The keypair, the subscriptions table, the nightly sweep and the sender all exist, and the database queries return the right plants with the right frost classes. Nobody has subscribed yet. Until one arrives on somebody's lock screen we are describing a mechanism, not a result — which is the same class of claim this whole post is about.
One more, smaller and more embarrassing. The first draft of this post said the bug survived for three weeks, because that felt about right. It was one day. Checking took forty seconds and would not have happened if the post had not been about exactly this.
The frost-date lookup is free, no signup.
The app that runs on top of it is in beta now. Join the beta if you want in.