An AI assistant turns “November 1 at 1:30 a.m. in New York” into a clean appointment record. The JSON looks correct. The problem is that this local time can identify two different moments. Choosing one silently is a business decision disguised as date parsing.

This guide builds a narrow, testable boundary for an AI-assisted intake workflow: resolve an ordinary local time, ask for an explicit choice when the clock repeats, and reject a time skipped by the clock change. It produces a review record only. It never books an appointment or sends a reminder.

Give local time enough context

Fictional business Orbit Desk accepts requests for overnight equipment consultations. Its configured business zone is America/New_York. The AI can extract a requested date and time, but an application must validate that request before anything reaches a calendar.

A local clock reading is not always a unique instant. PEP 495 describes repeated local times as a fold and missing local times as a gap. It also explains that datetime construction and replacement do not themselves enforce strict validity. Merely attaching a timezone is therefore insufficient validation.

Python’s zoneinfo documentation describes the fold attribute and support for the first-party tzdata package. This example reads that package’s New York data file directly, making the tested data source explicit rather than relying on whichever system database happens to be installed.

What the worked example actually returns

New York request Policy Observed result
2026-10-22 09:00 No choice needed 2026-10-22 13:00 UTC
2026-11-01 01:30 No explicit choice Rejected as ambiguous
2026-11-01 01:30 Earlier occurrence 2026-11-01 05:30 UTC, local offset −04:00
2026-11-01 01:30 Later occurrence 2026-11-01 06:30 UTC, local offset −05:00
2026-03-08 02:30 Request another time Rejected as nonexistent
Actual results from the article’s tests using Python 3.14.3 and tzdata 2026.2 on Windows. These are fixture conversions, not calendar bookings or a claim that all zones share these transition dates.

The two November records differ by an hour even though their requested clock text is identical. The application should display both the date and offset when asking the requester to choose. The assistant must not invent that choice because a default happens to exist in a datetime API.

The conversion boundary

The algorithm tries both fold values, converts each candidate to UTC and back, and keeps only candidates that return to the requested wall time. No valid candidate means a gap. Two distinct UTC candidates mean ambiguity. One means an ordinary time. Deduplicate in UTC: comparing local representations alone can hide the distinction this workflow needs.

Save the following as appointment.py. Inputs must use exact YYYY-MM-DD HH:MM text, years 2020–2035, and the fixed configured zone. An offset-bearing timestamp belongs in a different input contract and is rejected here. Choice values are earlier or later, and are accepted only when needed.

"""Resolve a fictional New York appointment; never book or send anything."""
from datetime import datetime, timezone
from importlib.resources import files
from zoneinfo import ZoneInfo
import json
import tzdata

ZONE = 'America/New_York'
with files('tzdata.zoneinfo').joinpath('America', 'New_York').open('rb') as stream:
    NEW_YORK = ZoneInfo.from_file(stream, key=ZONE)


def resolve(local_text, choice=None):
    if type(local_text) is not str or len(local_text) != 16:
        raise ValueError('Use YYYY-MM-DD HH:MM')
    wall = datetime.strptime(local_text, '%Y-%m-%d %H:%M')
    if wall.strftime('%Y-%m-%d %H:%M') != local_text:
        raise ValueError('Use exact YYYY-MM-DD HH:MM')
    if not 2020 <= wall.year <= 2035:
        raise ValueError('Example supports years 2020 through 2035')
    if choice not in (None, 'earlier', 'later'):
        raise ValueError('Choice must be earlier or later')
    candidates = set()
    for fold in (0, 1):
        instant = wall.replace(tzinfo=NEW_YORK, fold=fold).astimezone(timezone.utc)
        back = instant.astimezone(NEW_YORK).replace(tzinfo=None)
        if back == wall:
            candidates.add(instant)
    ordered = sorted(candidates)
    if not ordered:
        raise ValueError('Local time does not exist; request another time')
    if len(ordered) == 2 and choice is None:
        raise ValueError('Ambiguous local time; choose earlier or later')
    if len(ordered) == 1 and choice is not None:
        raise ValueError('Choice is only applicable to ambiguous times')
    instant = ordered[-1] if choice == 'later' else ordered[0]
    return {'requested_local': local_text, 'zone': ZONE, 'choice': choice,
            'resolved_local': instant.astimezone(NEW_YORK).isoformat(),
            'utc': instant.isoformat(), 'tzdata_version': tzdata.__version__}


if __name__ == '__main__':
    print(json.dumps([resolve('2026-11-01 01:30', 'earlier'),
                      resolve('2026-11-01 01:30', 'later')], indent=2))

Reproduce the setup and failures

Prerequisite: Python 3.14 available as python on Windows and package-index network access. Create a new disposable folder and save both files there. The tested environment was Python 3.14.3 with tzdata 2026.2. The version pin records this example’s evidence; it is not a recommendation to keep timezone rules frozen indefinitely.

Save these tests as test_appointment.py:

import unittest
from appointment import resolve


class AppointmentTests(unittest.TestCase):
    def test_regular(self):
        self.assertEqual(resolve('2026-10-22 09:00')['utc'], '2026-10-22T13:00:00+00:00')

    def test_choice_required(self):
        with self.assertRaisesRegex(ValueError, 'Ambiguous'):
            resolve('2026-11-01 01:30')

    def test_earlier(self):
        self.assertEqual(resolve('2026-11-01 01:30', 'earlier')['utc'], '2026-11-01T05:30:00+00:00')

    def test_later(self):
        self.assertEqual(resolve('2026-11-01 01:30', 'later')['utc'], '2026-11-01T06:30:00+00:00')

    def test_gap(self):
        with self.assertRaisesRegex(ValueError, 'does not exist'):
            resolve('2026-03-08 02:30')

    def test_format(self):
        for value in ('2026-11-01T01:30', '2026-11-01 01:30-04:00', None, 123):
            with self.subTest(value=value):
                with self.assertRaises(ValueError): resolve(value)

    def test_invalid_date(self):
        with self.assertRaises(ValueError): resolve('2026-02-30 09:00')

    def test_bad_choice(self):
        with self.assertRaises(ValueError): resolve('2026-11-01 01:30', 'guess')

    def test_unnecessary_choice(self):
        with self.assertRaisesRegex(ValueError, 'only applicable'):
            resolve('2026-10-22 09:00', 'earlier')

    def test_supported_years(self):
        for year in (2019, 2036):
            with self.assertRaises(ValueError): resolve(f'{year}-01-15 09:00')

    def test_record(self):
        r = resolve('2026-11-01 01:30', 'later')
        self.assertEqual(r['zone'], 'America/New_York')
        self.assertEqual(r['resolved_local'], '2026-11-01T01:30:00-05:00')
        self.assertEqual(r['choice'], 'later')
        self.assertEqual(r['tzdata_version'], '2026.2')


if __name__ == '__main__':
    unittest.main()

Run these Windows commands from that folder. They create a local virtual environment and install only the pinned timezone-data dependency:

python -m venv .venv
.venv\Scripts\python -m pip install tzdata==2026.2
.venv\Scripts\python -X utf8 -m unittest -v test_appointment.py
.venv\Scripts\python -X utf8 appointment.py

Eleven test methods passed: ordinary time, required choice, both repeated occurrences, the missing spring time, input-format cases, an invalid date, invalid and unnecessary choices, supported-year bounds, and the returned audit fields. The final HTML’s files were extracted and the setup plus commands reproduced in a new disposable directory. The program prints:

[
  {
    "requested_local": "2026-11-01 01:30",
    "zone": "America/New_York",
    "choice": "earlier",
    "resolved_local": "2026-11-01T01:30:00-04:00",
    "utc": "2026-11-01T05:30:00+00:00",
    "tzdata_version": "2026.2"
  },
  {
    "requested_local": "2026-11-01 01:30",
    "zone": "America/New_York",
    "choice": "later",
    "resolved_local": "2026-11-01T01:30:00-05:00",
    "utc": "2026-11-01T06:30:00+00:00",
    "tzdata_version": "2026.2"
  }
]

Turn the result into a reviewable handoff

Keep the requested local text, configured zone, explicit ambiguity choice, resolved local timestamp with offset, UTC instant and timezone-data version together. Add the source request ID and an idempotency key in the real application. This example does not implement storage, duplicate prevention or calendar delivery.

On an ambiguity, present the two alternatives and wait for the user’s selection. On a gap, request a valid replacement time rather than silently moving the request forward. An invalid input must remain a review item; it should not turn into a guessed appointment. Once approved, pass the confirmed record through your application’s separate authorization and booking boundary.

For an AI agent, a useful task instruction is: “Extract the requested local date/time and supplied zone context. Do not infer an ambiguity choice. Return unresolved requests for clarification. Produce a review record; booking requires the application’s separate approved action.” This is a proposed workflow contract, not a tested integration with an assistant host. See why an agent skill is not permission to send and the retryable CRM handoff example for adjacent boundaries.

Limits that matter before connecting a calendar

The runtime tests cover this fixed New York policy and selected transitions. They do not establish a universal timezone validator. Other zones, historical rule changes, half-hour shifts, date-line changes and inputs outside the stated year range need their own cases. The program neither parses natural language nor verifies the customer’s intended location; an AI extraction can still get those wrong.

A future recurring appointment needs an explicit policy about local wall time versus fixed UTC time. Treat recurrence as a separate feature and test it across transitions. The example only resolves one requested occurrence; adding a fixed number of seconds is not a demonstrated recurrence implementation here.

Timezone rules can change. Before updating your data dependency, rerun transition tests and review affected future appointments under your business policy. Retain enough original intent to explain a conversion. No calendar API, reminder service, booking transaction, production scheduler or business outcome was tested, and no existing website automation schedule was changed.

Jason Ead publishes AlgoThoughts AI News and connects practical engineering with My Biz Heroes: websites, automation, workflows, AI tools and training. Explore Jason’s work. The cover is generated conceptual art; the interior table reports executed conversion tests.