# Timezone-aware datetime migration — handoff > Follow-up artifact spawned by the timeago-shared-display work > (`docs/superpowers/specs/2026-05-16-timeago-shared-display-design.md`). > This is **not an implementation plan** — it is a brief for the next > session to brainstorm the actual spec from evidence. Handoffs that > pre-bake a plan tend to bury false premises; treat the "open > questions" below as load-bearing. ## Context `left4me`'s `l4d2web` package uses naive datetimes throughout the runtime because `models.py` declares `DateTime` columns without `timezone=True`. SQLAlchemy strips tzinfo on store (SQLite has no native TZ type) and returns naive datetimes on read. The `now_utc()` factory at `models.py:22-23` writes aware UTC, but the DB layer collapses it back to naive on round-trip. The pattern's cost is a defensive shell of `.replace(tzinfo=None)` calls sprinkled across routes and services, plus a permanent mental tax: *all naive datetimes in this codebase are UTC by convention, but nothing in the type system says so.* Aware-everywhere would let the defensive shell come down, the convention become explicit, and the `_ensure_utc` defensive line in `l4d2web/l4d2web/services/timeago.py` become a no-op safety net rather than load-bearing. This was discussed during the timeago-shared-display brainstorm and deliberately scoped out to keep the diff focused. The user explicitly asked for this handoff so the work isn't forgotten. ## What is currently known (evidence) ### The convention is documented in code `l4d2web/l4d2web/routes/profile_routes.py:86-88` carries the canonical explanation: ```python # Strip tz so the marker matches what a subsequent DB read returns # (SQLite DateTime columns don't preserve tzinfo). user.password_changed_at = now_utc().replace(tzinfo=None) new_marker = user.password_changed_at.isoformat() ``` The `pw_changed_at` session marker (`auth.py:52`, set in many routes) is compared as an ISO string, so the on-DB and on-session representations must match. Today they're both naive; an aware migration must keep both sides in sync. ### Known workaround sites (definitive enumeration not done) From a grep at the time of the timeago work: - `l4d2web/l4d2web/routes/profile_routes.py:88` — password-changed-at write - `l4d2web/l4d2web/routes/server_routes.py:210, 234` — `cutoff` / `recent_cutoff` for live-state staleness - `l4d2web/l4d2web/routes/page_routes.py:174` — `cutoff` for the same - `l4d2web/l4d2web/services/live_state_poller.py:37` — `_now()` helper returns naive UTC The session that picks this up **must re-enumerate fresh** — the list above is a starting point, not an exhaustive inventory. New sites may have appeared after this handoff was written. ### Column declarations to flip `l4d2web/l4d2web/models.py` declares ~20 `DateTime` columns without `timezone=True`. Concrete count and list to be produced fresh during the next session — column lists drift. ### What's *already* aware - `now_utc()` (`models.py:22-23`) returns `datetime.now(UTC)` - Most route-local time variables built via `datetime.now(UTC)` *before* the defensive `.replace(tzinfo=None)` is applied - `timeago.py::_ensure_utc` (added in this session) handles both inputs defensively ### Tests touching naive datetimes `l4d2web/tests/*.py` contain ~30+ instances of `datetime.now(UTC).isoformat()` for the `pw_changed_at` session marker, and at least a few `datetime.now(UTC).replace(tzinfo=None)` / `now - timedelta(...)` patterns for fixture construction (e.g. `test_servers.py:491, 535, 540`). These will need to be reviewed once the production-side convention flips. ## Open questions the next session must answer These are load-bearing. Do not draft an implementation plan that assumes any particular answer. 1. **SQLite + `DateTime(timezone=True)` round-trip behaviour in SQLAlchemy 2.x:** does storing an aware UTC datetime and reading it back yield an *aware* UTC datetime (Python 3.13 + SQLAlchemy 2.x + pysqlite)? Or does it still strip and require a custom `TypeDecorator`? Spike with a throwaway test before assuming. 2. **Existing data:** the production SQLite DB at left4.me has rows stored as text-without-timezone (current behaviour). If the new column type changes how SQLAlchemy parses those strings on read, we may need a one-shot data migration (rewrite values to include `+00:00`). Verify behaviour on a copy of the production DB. 3. **Alembic migrations** (`l4d2web/migrations/` if it exists, or wherever migrations live): do any existing migration files reference `DateTime` columns? If yes, do those need to be updated too? Any backfills that build datetime literals? 4. **The session `pw_changed_at` marker round-trip** is the highest-risk site. It's compared as an ISO string in `auth.py:52`. After the migration, both sides must produce the same ISO form (either both naive UTC or both aware UTC). Plan must spell out the marker format and where the conversion happens. 5. **Comparisons crossing the boundary** (DB-side aware vs Python-side naive constants, or vice versa) will raise `TypeError` post-migration. `server_routes.py:210` (`latest.last_seen_at >= cutoff`) is the canonical example. Enumerate every such comparison; ensure both sides get fixed in lockstep. 6. **Scope cut for tests:** do the test fixtures need to flip in the same PR, or can the production code flip first and tests trail? (Likely needs to flip together — comparisons in test assertions will break if either side is mixed.) ## Suggested first steps for the next session 1. Read this handoff and the parent spec (`docs/superpowers/specs/2026-05-16-timeago-shared-display-design.md`). 2. Re-enumerate `.replace(tzinfo=None)` sites: `grep -rn 'replace(tzinfo=None)' l4d2web/`. 3. Re-enumerate `DateTime` column declarations: `grep -n 'mapped_column(DateTime' l4d2web/l4d2web/models.py`. 4. Run the round-trip spike (open question #1) in a throwaway script or test. Document the actual behaviour observed. 5. Take a copy of the production SQLite DB; read existing datetime rows back through the candidate column definition. Document whether they parse as aware (and to what timezone) or require backfill. 6. Brainstorm scope and ordering with the user — only then write the spec. ## Pointers — files to read first - `docs/superpowers/specs/2026-05-16-timeago-shared-display-design.md` — parent spec, "Out of scope" + "Follow-up spec" sections. - `l4d2web/l4d2web/models.py:1-30, 22-23` — `now_utc()` factory, first few column declarations. - `l4d2web/l4d2web/routes/profile_routes.py:75-93` — the documented workaround comment that gave the convention its name. - `l4d2web/l4d2web/auth.py:40-60` — session marker comparison. - `l4d2web/l4d2web/services/timeago.py` — `_ensure_utc` defensive line that becomes a no-op after the migration. ## Anti-goals - **Don't bundle this with unrelated cleanup.** It's a mechanical refactor when sized correctly. Adding "while I'm in there" scope grows the blast radius. - **Don't skip the round-trip spike.** Believing SQLAlchemy's `timezone=True` "just works" on SQLite without checking is exactly the kind of false premise this handoff is shaped to prevent. - **Don't trust this handoff's enumerations as exhaustive.** They are starting points from a point-in-time grep. Re-run them.