Index

Data That Must Survive

Time Zones Are Part of the Data Model

One metering path assigns an event to a billing month after converting its occurrence time into the account’s timezone. The same path derives its daily usage key directly from the occurrence timestamp without that conversion.

Near midnight, those two fields can disagree:

event instant:         May 31, 21:30 UTC
local business time:   June 1, 00:30
billing period:        June
stored usage date:     May 31

Each expression looks like a date calculation. They model different things. The defect appears only when the difference changes a boundary.

One timestamp can answer several questions

An event usually has one occurrence instant: a point on the global timeline. From that instant, the system may need several derived values:

occurred_at:
    which instant did the event happen?

local_display_time:
    what did a person in this timezone see on the clock?

usage_date:
    which local business day owns the event?

billing_period_start:
    which local billing month owns the event?

These values are related but not substitutes.

Store the instant with an offset or normalized to UTC. Store a civil date when the date itself is the business fact. Store the timezone identifier used to derive business periods when the derivation must remain explainable.

Calling every value timestamp or date makes accidental conversion easy. Names should reveal which question the field answers.

An offset is not a timezone

+02:00 tells us the difference from UTC at one instant. It does not contain the rules for next month, the history for last year, or daylight-saving transitions.

Europe/Helsinki is a timezone identifier. Given a timezone database and an instant, it can produce the applicable offset and local clock reading.

instant + timezone rules -> local civil time

instant + fixed offset   -> one shifted clock reading

A recurring rule such as “run at 09:00 local time on the first day of each month” needs a timezone, not the offset observed when the rule was created. If the offset changes seasonally, the UTC instant of the next occurrence changes while the local rule remains 09:00.

For historic decisions, preserve the timezone identifier and the original instant. Timezone databases can be corrected, so high-stakes systems may also need the resolved offset or rule-version provenance used at the time.

Business dates should come from occurrence, not processing

The retained queued metering path captures the occurrence timestamp before dispatch and carries it in the job. That prevents queue delay from moving an event into another day or billing month.

The wrong derivation is tempting:

usage_date = worker_now()

A request at 23:59 may wait until 00:02. A retry may run hours later. A replay may run next week. Processing time describes when the system handled the fact, not when the fact occurred.

Persist both when both matter:

occurred_at:
    source or ingress instant

recorded_at:
    persistence instant

processed_at:
    worker completion instant

Billing, deduplication, and event ordering usually follow occurred_at. Operational latency follows the difference between occurrence, recording, and processing.

If the source does not supply a trustworthy time, the ingress boundary can assign one and record that provenance. It should not silently pretend that worker time came from the source.

The retained code correctly converts the occurrence instant into a configured timezone before taking the start of the month:

billing_period_start =
    occurred_at
    -> account timezone
    -> local month start
    -> civil date

Its daily aggregate uses the occurrence timestamp’s existing timezone directly:

usage_date =
    occurred_at
    -> date

When the timestamp is normalized to UTC, the day and month can diverge from the account’s business calendar. A query which sums local June by daily date may then omit an event whose billing ledger calls it June.

The repair principle is broader than one line of code:

calendar = timezone policy for this owner
local_occurred_at = instant converted through calendar
usage_date = local_occurred_at date
billing_period_start = local_occurred_at month start

Derive all related civil values from the same localized value or a shared calendar service. Do not let each projection rediscover its own timezone.

The retained repository does not contain this complete repair. It supplies evidence of the mismatch, not evidence that production data was affected or corrected.

A date is not midnight UTC

A civil date such as 2026-06-01 has no time or offset. Turning it into an instant requires a timezone and a choice of local time.

June 1 in Helsinki begins at one UTC instant
June 1 in New York begins at another

Storing a billing-period start as a date is appropriate when the business fact is “June in this account’s calendar.” Converting it to UTC midnight changes the meaning.

When a query needs instant bounds, derive them deliberately:

inclusive start:
    local period start -> timezone -> UTC instant

exclusive end:
    next local period start -> timezone -> UTC instant

Using an exclusive end avoids guessing the last representable instant of a day and remains clear across timestamp precision.

The interval length may not be a whole multiple of 24 hours when it crosses a daylight-saving change. That is normal for a local calendar interval.

Some local clock readings do not identify an instant

During a spring transition, a local clock can skip an hour. During an autumn transition, it can repeat one.

In Helsinki on a spring transition, the clock advances from 02:59 to 04:00. The local time 03:30 does not occur. During an autumn transition, 03:30 occurs twice with different offsets.

A bare local value such as:

2026-10-25 03:30

cannot identify which repeated instant was intended. The model needs an offset, an explicit earlier/later policy, or source provenance which already resolves the instant.

For schedules, define behavior rather than relying on a date library’s default:

  • skip a nonexistent local time;
  • move it forward to the first valid instant;
  • run once at the earlier repeated time;
  • run once at the later repeated time; or
  • run at both repeated instants.

There is no universal correct choice. Billing cutoffs, reminders, transport windows, and human appointments can require different policies.

The fixture crosses boundaries on purpose

The DATA-06 fixture uses the JavaScript runtime’s timezone database to inspect fixed UTC instants in named zones. It verifies a month boundary where UTC May belongs to local June, a skipped spring hour, and two distinct autumn instants which render as the same local clock time.

It also compares the retained derivation shapes:

direct UTC date:
    May 31

localized business date:
    June 1

localized billing period:
    June 1

The fixture does not run the private metering code, prove a production incident, or validate every timezone database version. Its timestamps and owner are synthetic. Its purpose is to make the type distinctions and boundary behavior reproducible.

Time policy belongs beside the business rule

UTC remains the right default for instants, ordering, durations, and system exchange. “Store everything in UTC” becomes wrong only when it is used to erase civil facts.

Store a local date when the domain owns a date. Store a named timezone with a recurring local rule. Store an instant for something which happened. Derive display time at the reader’s boundary. Preserve occurrence separately from processing.

Reverse the owner-calendar rule if the contract defines one global UTC billing period for everyone. Then derive both day and month in UTC and say so. The problem is not UTC; it is letting two implicit calendars classify the same event.

A timezone bug is often a data-model bug wearing a formatting costume. Once the system names instant, civil time, date, recurrence, and calendar separately, the boundary calculations become ordinary and testable.