# Time

> **Conformance:** L1 (every Magogi-foundation project)
> **Audience:** Anyone writing code that records, compares,
> displays, or wires a timestamp anywhere in a federation
> project — and especially anyone writing tests that depend on
> wall-clock behaviour.
> **Propagation (DOC-18):** changing a rule here ripples — walk DOCUMENTATION.md §10.4 and update its dependents in the same commit (README *Index of MUSTs*, the `mgf-fed standards` / `agents` CLI, and any parallel copies / pins the matrix names).

> **Scope.** Time bugs are notoriously expensive — they hide
> until daylight-saving rolls over, until a server moves
> timezones, until a leap second lands, until a NTP step
> jumps a process backward. The rules below are deliberately
> small but L1-binding: they encode the conventions that
> make time-handling boring everywhere it has to be.

> **Single source of truth (DOC-18).** This document (TM-01..TM-08)
> is the sole L1 authority for time handling. The cross-cutting
> design rule **DP-12** ([`DESIGN_PRINCIPLES.md` §12](DESIGN_PRINCIPLES.md#DP-12))
> is a one-line pointer back here — do not restate time rules there
> or anywhere else. The canonical UTC spelling is
> `datetime.now(timezone.utc)` (TM-01); `datetime.now(UTC)` (the
> `from datetime import UTC` alias, Python 3.11+) is the same instant
> and equally acceptable.

---

## Rules box (read this first)

| ID | Rule |
|---|---|
| <a id="TM-01"></a>**TM-01** | **Every `datetime` that crosses a boundary MUST be timezone-aware and UTC.** Boundaries are: persistent storage (DB, file, cache), wire format (JSON, gRPC, message bus), logs, metrics, traces. Use `datetime.now(timezone.utc)`, never `datetime.now()` (naive local) or `datetime.utcnow()` (naive UTC — removed in 3.12, deprecated earlier). |
| <a id="TM-02"></a>**TM-02** | **Wire format for datetimes MUST be ISO-8601 / RFC-3339** with an explicit timezone offset (`+00:00` or `Z`). Python: `dt.isoformat()` on a tz-aware datetime produces the right shape; `dt.isoformat(timespec="microseconds")` if you need sub-second precision pinned. |
| <a id="TM-03"></a>**TM-03** | **Duration measurements MUST use a monotonic clock**, never wall-clock subtraction. Use `time.monotonic()` for elapsed time (seconds, float), `time.perf_counter()` for high-precision benchmarks, `time.monotonic_ns()` / `time.perf_counter_ns()` when you need integer math. Wall-clock subtraction breaks the moment NTP nudges the clock backward. |
| <a id="TM-04"></a>**TM-04** | **Production code MUST NOT call `datetime.now()` directly when the call is testable.** Inject the clock via a parameter, a callable default, or a fixture-overridable provider (`mgf.common.clock.now()` if/when the cornerstone ships one). Tests then pass a `FakeClock` or `freezegun`-style fixed instant; production passes the real `datetime.now(timezone.utc)`. The point is reproducible test runs — a test that flickers on a midnight-boundary is the symptom of this rule being violated. |
| <a id="TM-05"></a>**TM-05** | **User-facing datetime display SHOULD happen at the presentation layer, never in core.** Core stores + serialises UTC (TM-01); presentation converts to the user's timezone + locale at render time. The conversion is a one-line `dt.astimezone(user_tz)` followed by `.strftime(user_format)` — keep it in the GUI / template / response-renderer, not buried in a domain helper. |
| <a id="TM-06"></a>**TM-06** | **Event timestamps in metrics, logs, and traces SHOULD use nanosecond Unix epoch integers** (`time.time_ns()`). Integer math is exact (no float drift over millions of events), the format is OTel-native, and downstream tooling (Prometheus, OpenTelemetry collectors, Loki, etc.) converts to display format at query time. Human-readable timestamps in log lines are TM-02-shaped and are an *additional* field, not the canonical one. |
| <a id="TM-07"></a>**TM-07** | **Timezone names MUST come from the IANA database** (`America/Los_Angeles`, `Europe/Paris`), never from POSIX-style abbreviations (`PST`, `CEST`). POSIX abbreviations are ambiguous (`IST` could be Israel, India, or Ireland) and don't capture DST transitions. Python: `zoneinfo.ZoneInfo("America/Los_Angeles")` (stdlib since 3.9). |
| <a id="TM-08"></a>**TM-08** | **Sleeping / scheduling MUST use a deadline-based API when the caller is async.** `asyncio.sleep(seconds)` is fine for a fixed delay; `asyncio.wait_for(coro, timeout=...)` is the deadline-aware shape. NEVER mix `time.sleep()` into async code — it blocks the event loop. |

---

## 1. The four common time mistakes (and what TM-01..TM-08 prevent)

### Mistake 1 — Naive datetimes everywhere

`datetime.now()` returns a naive datetime in *local* time. Two
servers in different timezones produce different values for
"now"; a stored naive datetime cannot be safely compared with
another stored naive datetime unless you know (out of band)
which timezone each was generated in.

**TM-01 fix:** every boundary-crossing datetime is `datetime.now(timezone.utc)`.

### Mistake 2 — Wall-clock subtraction for "how long did this take?"

```python
# WRONG
start = datetime.now()
do_work()
elapsed = (datetime.now() - start).total_seconds()
```

If NTP steps the clock backward during `do_work()`, `elapsed`
is negative; if it steps forward, `elapsed` is wildly inflated.
Either way the metric is wrong.

**TM-03 fix:** use a monotonic clock.

```python
# RIGHT
start = time.monotonic()
do_work()
elapsed = time.monotonic() - start
```

### Mistake 3 — `datetime.now()` baked into testable code

```python
# WRONG — tests can't control "now"
def is_expired(token: Token) -> bool:
    return token.expires_at < datetime.now(timezone.utc)
```

A test that calls `is_expired` is then non-deterministic at the
millisecond boundary, and *fundamentally* non-deterministic when
the test runs at midnight UTC and the token has a 1-day TTL.

**TM-04 fix:** inject the clock.

```python
# RIGHT — clock is a parameter (or a default-injected provider)
def is_expired(
    token: Token,
    *,
    now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
) -> bool:
    return token.expires_at < now()
```

Tests pass `now=lambda: fixed_instant`; production keeps the
default.

### Mistake 4 — POSIX timezone abbreviations

```python
# WRONG — IST is ambiguous (Israel? India? Ireland?)
display_tz = "IST"
```

**TM-07 fix:** IANA names only.

```python
# RIGHT
from zoneinfo import ZoneInfo
display_tz = ZoneInfo("Asia/Kolkata")  # India Standard Time
```

---

## 2. Recommended pattern: a clock provider in core

For consumers that exercise time-dependent logic in tests, the
cleanest shape is a tiny clock-provider abstraction:

```python
# src/yourapp/_clock.py
from collections.abc import Callable
from datetime import datetime, timezone

_now: Callable[[], datetime] = lambda: datetime.now(timezone.utc)

def now() -> datetime:
    """Return the current UTC instant.

    Production: wraps datetime.now(timezone.utc).
    Tests: patch via set_clock(...) or use the FakeClock fixture.
    """
    return _now()

def set_clock(provider: Callable[[], datetime]) -> None:
    """Override the clock provider. Tests only."""
    global _now
    _now = provider
```

Then production code reads `from yourapp._clock import now` and
tests inject a fake. This is TM-04 made concrete.

Whether `mgf-common` ships a federation-wide `mgf.common.clock`
helper is a v1.x discussion — for now consumers define their
own. The rule (TM-04) holds either way.

---

## 3. Wire-format examples (TM-02, TM-06)

**Human-readable wire format** (JSON, logs, API responses):

```python
>>> from datetime import datetime, timezone
>>> dt = datetime.now(timezone.utc)
>>> dt.isoformat()
'2026-05-24T17:42:13.521987+00:00'
>>> dt.isoformat(timespec="microseconds")
'2026-05-24T17:42:13.521987+00:00'   # same here; pin if needed
```

**Machine-native wire format** (OTel, metrics, traces):

```python
>>> import time
>>> time.time_ns()
1748103733521987000  # nanoseconds since Unix epoch, integer
```

Both forms MAY appear in a single log line — one human, one
machine — but the canonical event timestamp is the integer
(TM-06).

### 3.1 Serialised forms, UI render, validators, anti-patterns

*(migrated from the former DESIGN_PRINCIPLES.md §12 body — this is
the canonical home.)*

**Serialised forms.**

| Form | Example | When |
|---|---|---|
| ISO 8601 with `Z` | `2026-04-25T14:32:01.234Z` | Default for logs, config, state |
| ISO 8601 with offset | `2026-04-25T14:32:01.234+00:00` | When stdlib `.isoformat()` is the producer |
| Unix epoch (float) | `1745605921.234` | Inter-process time without parsing overhead |
| RFC 7231 (HTTP `Date`) | `Sat, 25 Apr 2026 14:32:01 GMT` | HTTP headers only |

ISO 8601 with `Z` is the default for any new structured field. The
JSON formatter at `mgf.common.observability.json_formatter` already
emits this shape for `ts`.

**UI render (TM-05 made concrete).** The presentation layer is the
**only** layer that converts UTC → local; the model layer never
holds a local-zone datetime:

```python
# In the GUI controller / template / response-renderer:
def _format_for_display(self, ts: datetime) -> str:
    assert ts.tzinfo is not None, "naive datetime in display layer"
    local = ts.astimezone()  # converts to system local zone
    return local.strftime("%Y-%m-%d %H:%M")
```

**Pydantic + datetime.** Models that carry timestamps MUST use
`datetime` with a field validator enforcing tz-awareness (TM-01):

```python
from datetime import timezone, datetime
from pydantic import BaseModel, field_validator

class Event(BaseModel):
    ts: datetime

    @field_validator("ts")
    @classmethod
    def _ensure_utc(cls, v: datetime) -> datetime:
        if v.tzinfo is None:
            raise ValueError("ts MUST be timezone-aware")
        return v.astimezone(timezone.utc)
```

**Anti-patterns.**

| Anti-pattern | Why forbidden | Right shape |
|---|---|---|
| `datetime.utcnow()` | Returns a NAIVE datetime in UTC clothing | `datetime.now(timezone.utc)` |
| `datetime.fromtimestamp(x)` | Defaults to local zone | `datetime.fromtimestamp(x, timezone.utc)` |
| `time.time()` for timestamps in records | Loses sub-second precision and tz info | `datetime.now(timezone.utc)` |
| `pytz.timezone(...)` (legacy library) | Easy to misuse via `.localize()` vs `.replace()` | stdlib `zoneinfo.ZoneInfo("Region/City")` (TM-07) |
| Comparing naive and aware datetimes | `TypeError` at runtime | Make both aware |
| String-arithmetic on ISO timestamps | Brittle | Parse with `datetime.fromisoformat`, do math, format back |

---

## 4. Async sleeping (TM-08)

```python
# RIGHT
await asyncio.sleep(0.1)                    # fixed delay
result = await asyncio.wait_for(coro, timeout=30.0)  # deadline
async with asyncio.timeout(30.0):           # contextual deadline
    result = await coro

# WRONG — blocks the event loop
import time
time.sleep(0.1)
```

In a long-running async program, a single `time.sleep` call
starves every other coroutine for the sleep duration. The lint
rule `ASYNC` in ruff catches the most common shapes; review
catches the rest.

---

## 5. Self-review checklist

- [ ] Every `datetime.now()` in the diff is `datetime.now(timezone.utc)` (TM-01).
- [ ] No `datetime.utcnow()` anywhere (removed / deprecated).
- [ ] Wire-format timestamps are `isoformat()` on tz-aware datetimes (TM-02).
- [ ] Duration measurements use `time.monotonic()` / `time.perf_counter()` (TM-03).
- [ ] Testable time-dependent code injects the clock (TM-04).
- [ ] Display-side timezone conversion happens at the presentation layer (TM-05).
- [ ] Metrics / traces use `time.time_ns()` for canonical event timestamps (TM-06).
- [ ] Timezone references use IANA names, not POSIX abbreviations (TM-07).
- [ ] No `time.sleep` inside `async def` functions (TM-08).

---

## 6. Mechanical enforcement

| Rule | Enforcement |
|---|---|
| TM-01 | ruff `DTZ` rule family catches naive datetimes at construction sites (`DTZ001`, `DTZ005`, `DTZ006`). Pre-existing in the federation's `pyproject.toml` snippet. |
| TM-02 | Review-only. No mechanical rule catches "this datetime gets serialised to JSON without tz info." |
| TM-03 | Review-only. A custom ruff plugin could catch `(datetime.now() - start).total_seconds()` — not implemented today. |
| TM-04 | Review-only. Heuristic: any production module that imports `datetime` and is tested triggers a reviewer prompt. |
| TM-05 | Review-only. |
| TM-06 | Review-only. |
| TM-07 | Review-only. A grep for `"PST"`, `"EST"`, `"IST"`, `"CET"`, `"CEST"` in string literals is a cheap CI gate; not currently in place. |
| TM-08 | ruff `ASYNC` rule family catches `time.sleep` in `async def`. Pre-existing in the federation's `pyproject.toml` snippet. |

TM-01 and TM-08 have automated lint coverage today. TM-03 and
TM-07 are the next mechanical-enforcement targets if perf
regressions or timezone bugs surface. The rest stay
review-enforced — they're concept-level rules where mechanical
detection has too many false positives to be useful as a gate.
