I asked the assistant in my wedding-planning app to set our date to March 30th.
It saved 2025-03-30. A date that was already more than a year in the past.
My first instinct was the obvious one: it misparsed the month, or the locale flipped day and month, or somewhere in the stack a string got mangled. All wrong. The model parsed “March 30th” perfectly. It knew the day, it knew the month, and then it did exactly what it should have done given what it knew — it filled in the year from the only present it had ever experienced.
Nobody had told it what year it was. So it used its training cutoff.
That reframing is the whole post. The model wasn’t broken and it wasn’t hallucinating. It was reasoning correctly from a missing premise, which is a much more uncomfortable failure mode, because it looks like competence right up until it doesn’t.
Why a wrong year is worse than a wrong number
A bad guest count is a bad guest count. A bad wedding date is load-bearing.
The whole product is a planning engine: it takes the wedding date and works backwards to tell a couple what they should be doing right now. Book the venue by here, send save-the-dates by there, lock final numbers three weeks out. Every phase is computed as an offset from that one field.
Feed it a date in the past and the engine doesn’t crash — it does something worse. It confidently reports that every single phase is overdue, and the countdown on the dashboard renders a negative number. A couple who did nothing wrong opens the app and finds out they’re catastrophically late to their own wedding.
One silently wrong field, and every downstream computation is wrong in a way that still looks like a working product. That’s the part worth internalizing: the blast radius of a model’s mistake has nothing to do with how big the mistake was.
Door 1: tell it what day it is (and the timezone trap)
The fix starts embarrassingly simple. The system prompt never stated the date, so I stated it:
TODAY IS Wednesday, August 5, 2026 (2026-08-05), Mexico time.
That is the present: do not assume any other year, not even to
calculate how long until something.
WEDDING DATES — HARD RULE:
- A wedding is always in the future. NEVER save a date before today.
- If they give you a day and month without a year, use the NEXT
occurrence that hasn't passed yet.
- When you save a date, say the full year back in your confirmation
so the couple can correct you.
- If the date is ambiguous or sounds like the past, ask for the year
instead of guessing.
Two things in there are doing real work beyond the obvious.
The instruction to say the full year out loud when confirming turns the couple into a validation layer. “Done, March 30th, 2027” is checkable. “Done!” is not. If the model is going to be wrong, make it be wrong legibly.
And the instruction to ask instead of guess matters because the default behavior of a helpful model under ambiguity is to pick something and move on. Ambiguity is exactly the moment you want it to stop being helpful.
But the interesting part is which today.
The server runs on Vercel, in UTC. Mexico City is six hours behind. So between 6pm and midnight local time, new Date() on the server has already rolled over to tomorrow. A couple planning their wedding at 9pm — which is when engaged people actually do this — would get an assistant confidently telling them the wrong day. I’d have fixed the year and introduced a subtler bug that only fires at night.
// The product's reference timezone. The Vercel server runs in UTC, so between
// 6pm and midnight in Mexico "today" in UTC is already tomorrow: without this,
// a couple messaging the assistant at night would get the wrong day.
const TZ = "America/Mexico_City";
// Today in Mexico, YYYY-MM-DD. en-CA gives exactly that ordering.
export function todayISO(): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: TZ,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
}
The en-CA trick is worth stealing: it’s the locale whose native date format is already ISO ordering, so you get 2026-08-07 out of Intl without hand-assembling the string from parts.
Door 2: the server gets the last word
Prompting is a request, not a guarantee. It moves the odds; it doesn’t close the door. Anything the model can get wrong at temperature > 0, it eventually will — and this one writes to the database.
So the tool handler validates before it ever reaches Postgres. But how it rejects is the design decision I’d defend hardest:
// Last word on the date belongs to the server, not the model. A wedding in the
// past poisons everything — the planning engine marks every phase overdue and
// the countdown goes negative — and the model doesn't always get the year right.
// Rather than silently rewriting the value, reject it and hand back the valid
// date, so the model proposes it to the couple instead of guessing again.
if (!isTodayOrFuture(wd, today)) {
const [, m, d] = wd.split("-").map(Number);
const next = nextFutureOccurrence(m, d, today);
return { result: { error:
`Saved nothing: ${wd} has already passed (today is ${today}) and a wedding ` +
`cannot be in the past.` + (next ? ` If they meant that day and month, the ` +
`next time it falls is ${next}. Propose it to the couple and only save once ` +
`they confirm.` : ``)
}};
}
The tempting version is to quietly coerce: detect the past date, add a year, save it. It’s one line and the user never sees an error.
Don’t. If the couple actually meant something else, you’ve now written a wrong date to the database with more confidence than the model had, and nobody will ever catch it — the one human who could verify it never got asked. Silent correction converts a visible failure into an invisible one, which is a trade I’ll take approximately never.
Instead the error message is written for the model, as instructions. It says what was rejected, why, what the valid alternative is, and what to do next: propose, don’t retry. The system prompt closes the loop from the other side — “if update_brief returns an error, do not retry with another guess.” Otherwise you get a model cheerfully burning tokens trying 2026, then 2028, then 2025 again.
Tool errors are prompt surface. Most people write them like log lines for a human who’ll never read them. They’re the highest-leverage text in an agentic system, because they arrive exactly when the model is already wrong and still has a chance to recover.
Door 3: the door nobody was watching
While auditing the path I found the model had never been the only way in. The app has two plain <input type="date"> fields — one in onboarding, one in the wedding form — and both cheerfully accepted a date in 2019 with no complaint whatsoever.
The LLM got the blame for a class of bad data the humans could produce by hand. One attribute each:
<input type="date" min={todayISO()} ... />
The lesson generalizes past this bug: when an agent writes to the same tables your UI writes to, hardening the agent’s path and calling it done just means the next bad row arrives through the door you didn’t look at. The invariant belongs to the data, not to whichever writer you happened to be debugging that week.
The bug only the tests found
Writing the tests is where this stopped being a date bug and started being interesting.
I had a helper that turns YYYY-MM-DD into a Date anchored at noon — noon specifically, so a daylight-saving shift can never slide a date a full day. It returns null on Invalid Date, which I assumed was enough validation.
It is not. Feed JavaScript a date that doesn’t exist:
new Date("2027-02-30T12:00:00")
// → Wed Mar 02 2027. Not Invalid Date. It silently overflows.
February 30th doesn’t fail. It rolls forward into March 2nd, as a perfectly valid Date object that passes every null check you put in front of it. A couple could have gotten a February 30th wedding quietly relocated to March, or — depending on the path — a malformed value making it all the way to Postgres where the update dies without a sound.
The fix is a round-trip check. Don’t ask whether it parsed; ask whether what came out is what went in:
// Is this a real YYYY-MM-DD date? atNoon() is NOT enough: Date's parser overflows
// "2027-02-30" into March 2 instead of returning Invalid Date, so we verify that
// the reconstructed date is the one we were given.
export function isValidISODate(dateOnly: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateOnly)) return false;
const d = atNoon(dateOnly);
if (!d) return false;
const [y, m, day] = dateOnly.split("-").map(Number);
return d.getFullYear() === y && d.getMonth() + 1 === m && d.getDate() === day;
}
That check is also what makes “next future occurrence” honest. Ask for the next February 29th and a naive loop hands you a date that doesn’t exist in most years; the validity check makes it skip forward until it finds a real leap year. Fourteen date tests, and the ones that earned their keep were the stupid-sounding edge cases: February 29th, a day that already passed this year, the year boundary.
What I’d take to the next one
Four things, in the order I’d apply them:
The model’s “now” is a fact you supply, not one it has. Anything time-relative — deadlines, ages, “next Tuesday,” “how long until” — is computed against whatever present the model inherited from training unless you overwrite it. This is not an edge case; it’s the default, and it gets quietly worse the further a deployment drifts from its cutoff.
Timezone is part of that fact. “Today” is not a global constant. If your server and your users disagree about what day it is, your agent will be confidently wrong for a predictable slice of every single day.
Reject and propose; never silently correct. A guard that fixes the model’s mistake behind everyone’s back is a guard that manufactures unfalsifiable data. Hand the problem back with enough context to solve it, and let a human confirm.
The layer that catches it is rarely the layer that caused it. The prompt made it rare. The server guard made it impossible. The input attribute closed a door the model was never even standing at. No single one of those was the fix.
The framing I keep returning to: this was never a hallucination. The model reasoned correctly from an incomplete premise, and the incomplete premise was mine. Most of the “the model got it wrong” bugs I’ve chased since have had the same shape — not a model that failed to think, but a context I failed to build.
Ruiciro Rivera — Senior AI engineer, AI enthusiast, and builder of worlds. By day I build production LLM systems; by night I ship my own products with Claude — and, occasionally, a video game. Find me on LinkedIn and GitHub.
← back