From 9402c7302fffd12b05bbd4b2cb3c2faeba9a9e07 Mon Sep 17 00:00:00 2001 From: Chaim Date: Tue, 30 Jun 2026 17:24:17 +0000 Subject: [PATCH] =?UTF-8?q?fix(tz):=20timezone-aware=20datetimes=20?= =?UTF-8?q?=E2=80=94=20unblock=20TZ=3DAsia/Jerusalem=20(#210)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend follow-up to INV-UI9 (X6 §"אודיט-נלווה"): make server-side datetimes explicit so flipping the container to TZ=Asia/Jerusalem cannot silently skew stored or displayed timestamps. Storage stays UTC; human display is Israel time. - audit.py: naive datetime.utcnow() → datetime.now(timezone.utc) for the TIMESTAMPTZ audit_log.created_at. Under TZ=Asia/Jerusalem the naive UTC wall-clock would be read as Israel-local and stored ~3h off. (§6: no silent skew.) - calibrate_effort.py: _ts() report stamp now Israel-time via ZoneInfo("Asia/Jerusalem") with a filename-safe "-IL" suffix (script runs on the UTC host); added a labeled "שעון ישראל" line to the report header so the chair-facing timestamp is unambiguous. --self-test still ALL PASS. - research_md.py: display fields made deterministic Israel-tz — update_chair_position timestamp (datetime.now(IL_TZ)) and parse() mtime (datetime.fromtimestamp(..., tz=IL_TZ)) — correct regardless of process TZ. Left intentionally Israel-local-day date.today()/year-cutoff call sites untouched (drafting/party_claims_summary/block_writer/court_fetch_supreme) — a Jerusalem committee wants the Israel calendar day; they resolve correctly once TZ flips. Invariants: INV-UI9 (store-UTC / display-Israel, server side); §6 (timezone-aware datetimes, no silent ~3h skew). Co-Authored-By: Claude Opus 4.8 (1M context) --- mcp-server/src/legal_mcp/services/audit.py | 4 ++-- mcp-server/src/legal_mcp/services/research_md.py | 10 ++++++++-- scripts/calibrate_effort.py | 16 ++++++++++++++-- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/mcp-server/src/legal_mcp/services/audit.py b/mcp-server/src/legal_mcp/services/audit.py index 9992499..ff29386 100644 --- a/mcp-server/src/legal_mcp/services/audit.py +++ b/mcp-server/src/legal_mcp/services/audit.py @@ -7,7 +7,7 @@ from __future__ import annotations import json import logging -from datetime import datetime +from datetime import datetime, timezone from uuid import UUID, uuid4 from legal_mcp.services import db @@ -38,7 +38,7 @@ async def log_action( VALUES ($1, $2, $3, $4, $5, $6, $7)""", uuid4(), action, case_id, document_id, json.dumps(details or {}, ensure_ascii=False, default=str), - user, datetime.utcnow(), + user, datetime.now(timezone.utc), ) logger.info("AUDIT: %s | case=%s | user=%s | %s", action, case_id, user, json.dumps(details or {}, ensure_ascii=False)[:200]) diff --git a/mcp-server/src/legal_mcp/services/research_md.py b/mcp-server/src/legal_mcp/services/research_md.py index 64e7ec2..49287d0 100644 --- a/mcp-server/src/legal_mcp/services/research_md.py +++ b/mcp-server/src/legal_mcp/services/research_md.py @@ -15,8 +15,14 @@ import os import re from datetime import datetime from pathlib import Path +from zoneinfo import ZoneInfo from typing import Any +# Display timezone (INV-UI9): timestamps surfaced to humans are rendered in +# Israel time deterministically, regardless of the process clock (the container +# may run UTC or Asia/Jerusalem). Storage stays UTC; this is display-only. +IL_TZ = ZoneInfo("Asia/Jerusalem") + # Placeholder strings — any of these means "not yet filled" CHAIR_POSITION_PLACEHOLDERS = ( "[ימולא ע\"י יו\"ר הוועדה]", @@ -191,7 +197,7 @@ def parse(file_path: Path) -> dict[str, Any]: date_str = date_match.group(1) if date_match else "" stat = file_path.stat() - mtime_iso = datetime.fromtimestamp(stat.st_mtime).isoformat() + mtime_iso = datetime.fromtimestamp(stat.st_mtime, tz=IL_TZ).isoformat() result: dict[str, Any] = { "header": { @@ -354,7 +360,7 @@ def update_chair_position( "saved": True, "section_id": section_id, "preview": preview, - "timestamp": datetime.now().isoformat(), + "timestamp": datetime.now(IL_TZ).isoformat(), } diff --git a/scripts/calibrate_effort.py b/scripts/calibrate_effort.py index 968d1e4..4fe92c7 100644 --- a/scripts/calibrate_effort.py +++ b/scripts/calibrate_effort.py @@ -58,8 +58,9 @@ import json import logging import os import sys -from datetime import datetime, timezone +from datetime import datetime from pathlib import Path +from zoneinfo import ZoneInfo from statistics import mean logger = logging.getLogger(__name__) @@ -344,8 +345,17 @@ async def _run(args, ts: str) -> dict: return {"dry_run": False, "grid": grid_summary, "by_block": by_block} +IL_TZ = ZoneInfo("Asia/Jerusalem") + + def _ts() -> str: - return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + """Filename-safe report stamp in Israel time (INV-UI9: human-facing display). + + This script runs on the host, which is UTC, so a process-clock stamp would + mislead the chair. We pin Asia/Jerusalem and suffix ``IL`` (instead of a bare + ``Z``) so the displayed/filename time is unambiguously Israel-local. + """ + return datetime.now(IL_TZ).strftime("%Y%m%dT%H%M%S-IL") def _write_report(result: dict, ts: str) -> tuple[Path, Path]: @@ -356,8 +366,10 @@ def _write_report(result: dict, ts: str) -> tuple[Path, Path]: g = result["grid"] n = g["n_finals"] + il_now = datetime.now(IL_TZ).strftime("%Y-%m-%d %H:%M") lines = [ f"# #208 — כיול model×effort מול הסופיים — {ts}\n", + f"> נוצר: {il_now} (שעון ישראל · Asia/Jerusalem)\n", f"> ⚠️ **גודל-מדגם: {n} סופיים** ({', '.join(g['finals']) or '—'}). " "זוהי **עדות-כיוון, לא רגרסיה** — מעט תיקים בעלי סופי-עלוי. " "ההמלצה אדוויזורית; ההכרעה בידי היו\"ר/המפעיל.\n",