The missing field that could erase your timezone
An omitted timezone field looked harmless until an independent review showed it could erase a couple's saved choice. The repair was small; the contract was not.
A request that never mentions a timezone can still delete one.
That was the bug hiding in the first slice of VowAdvisor's future weekly
briefing. The feature needs to know which Sunday evening a couple means, so I
added a nullable IANA timezone field to the shared wedding profile. The database
column was nullable. The API accepted null. The UI had a native selector.
The shape looked backwards-compatible.
It was not.
The bug was in the absence
The profile update endpoint replaces the profile. It does not patch whichever fields happen to appear in a request. That distinction matters when a client is old, a mock is incomplete, or a caller forgets to carry a new field forward.
Imagine a couple has already chosen America/Detroit. An older client sends the
same profile back without a timezone key because it predates the field. If the
server turns every missing value into null, the saved timezone disappears.
The next weekly briefing would not merely be scheduled at the wrong local hour. It would have no trustworthy local hour to use at all. Sunday evening is a product decision, not a timestamp you can safely invent after the fact.
That is the part I missed while looking at the schema. I was thinking about the new field's type. The real contract was about the difference between an instruction to leave a value alone and an instruction to clear it.
Three states, one field
The useful version of the contract has three distinct states:
// Omitted: leave the saved choice alone.
{
"weddingDate": "2027-06-12"
}
// Explicit null: clear the saved choice.
{
"weddingDate": "2027-06-12",
"timezone": null
}
// IANA identifier: set or replace the saved choice.
{
"weddingDate": "2027-06-12",
"timezone": "America/Detroit"
}
Those examples look almost insultingly simple. That is why this class of bug survives. A nullable database column encourages you to think in two values: string or null. An API replacement contract has a third value before either of those: not present.
Missing is not empty data. It is an instruction.
Why the happy path missed it
The ordinary tests were not wrong. They covered a valid IANA identifier, an invalid identifier, and the profile round trip. Those are the checks you write when the new field is in front of you and you want to prove that it works.
The dangerous request was the one that looked like an old request. It did not
mention timezone at all, so a test fixture that always included the new field
could not exercise it. The real profile payload was a whole-row replacement,
but the mock contract had been quietly treating the field as optional and
coalescing absence into null.
Independent review caught the seam because it asked a more annoying question: what happens to an already-saved choice when a legacy payload comes through?
That question is more valuable than another happy-path assertion. It is also the kind of question that is easy to avoid when the code, the schema, and the new UI all look internally consistent.
The smallest repair
The repair did not add a migration, a compatibility layer, or a new abstraction. It made the existing contract say what it meant.
The browser keeps a current timezone value visible even if it is a valid API alias that the browser's supported-values list does not include. That matters for old saved profiles: a selector should not make a real value disappear just because its local list is narrower than the server's accepted vocabulary.
The test mock now rejects a replacement payload that omits timezone, instead
of silently turning omission into null. The end-to-end test starts with a
saved timezone, sends the legacy-shaped payload, expects validation to reject
it, and then checks that the saved choice is still there.
The API still has the three meanings. An explicit null can clear the choice.
An IANA identifier can set it. An omitted field is not allowed to masquerade as
either one in the replacement contract.
That is a small code change. It is a much more important test boundary.
Defaults are product policy in disguise
The timezone field exists for a future weekly briefing with a deliberately narrow policy: both partners receive it by default, on Sunday evening, in the couple's stored timezone, once a week. There is no proactive monitoring hiding behind the word "briefing."
That policy makes null meaningful. Until a couple chooses a timezone, there
is no safe local Sunday evening to schedule, so no briefing should be sent.
The default is not a blank UI state waiting for a designer to fill in. It is a
data contract with a safety consequence.
This is true far beyond timezones. A missing notification preference, a missing currency, a missing consent record, and a missing deletion scope can each mean "do not act" rather than "use the default." If the API erases those meanings while normalising input, the product policy has already been changed — just not in a file anyone thought to call policy.
The briefing is not shipped
The timezone foundation is real and validated. The weekly briefing is not delivered, merged, or deployed. The remaining work belongs behind the shared Platform and Boatlift contracts for notification preferences and scheduled callbacks, including suppression, unsubscribe, failure handling, and job telemetry. There is no honest shortcut through a raw Platform request, a local worker, or an email send hidden in the product.
That boundary is part of the feature, not plumbing to route around. A briefing that can save the wrong timezone, ignore an opt-out, or bypass the shared kill-switch is not a smaller version of the feature. It is a different and less trustworthy one.
The reusable lesson is the unglamorous one: when you add a field to a replacement payload, write down what omission means before you write the validator. Then test the old request, not just the new one. A field that is missing from the wire can still be present in someone's life.