2 Commits

Author SHA1 Message Date
b2d673fdb0 Merge pull request 'fix(tz): timezone-aware datetimes — unblock TZ=Asia/Jerusalem (#210)' (#368) from worktree-agent-a29624b2a56f70804 into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 9s
2026-06-30 17:25:55 +00:00
9402c7302f fix(tz): timezone-aware datetimes — unblock TZ=Asia/Jerusalem (#210)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
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) <noreply@anthropic.com>
2026-06-30 17:24:17 +00:00
3 changed files with 24 additions and 6 deletions

View File

@@ -7,7 +7,7 @@ from __future__ import annotations
import json import json
import logging import logging
from datetime import datetime from datetime import datetime, timezone
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from legal_mcp.services import db from legal_mcp.services import db
@@ -38,7 +38,7 @@ async def log_action(
VALUES ($1, $2, $3, $4, $5, $6, $7)""", VALUES ($1, $2, $3, $4, $5, $6, $7)""",
uuid4(), action, case_id, document_id, uuid4(), action, case_id, document_id,
json.dumps(details or {}, ensure_ascii=False, default=str), 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, logger.info("AUDIT: %s | case=%s | user=%s | %s", action, case_id, user,
json.dumps(details or {}, ensure_ascii=False)[:200]) json.dumps(details or {}, ensure_ascii=False)[:200])

View File

@@ -15,8 +15,14 @@ import os
import re import re
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo
from typing import Any 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" # Placeholder strings — any of these means "not yet filled"
CHAIR_POSITION_PLACEHOLDERS = ( 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 "" date_str = date_match.group(1) if date_match else ""
stat = file_path.stat() 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] = { result: dict[str, Any] = {
"header": { "header": {
@@ -354,7 +360,7 @@ def update_chair_position(
"saved": True, "saved": True,
"section_id": section_id, "section_id": section_id,
"preview": preview, "preview": preview,
"timestamp": datetime.now().isoformat(), "timestamp": datetime.now(IL_TZ).isoformat(),
} }

View File

@@ -58,8 +58,9 @@ import json
import logging import logging
import os import os
import sys import sys
from datetime import datetime, timezone from datetime import datetime
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo
from statistics import mean from statistics import mean
logger = logging.getLogger(__name__) 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} return {"dry_run": False, "grid": grid_summary, "by_block": by_block}
IL_TZ = ZoneInfo("Asia/Jerusalem")
def _ts() -> str: 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]: 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"] g = result["grid"]
n = g["n_finals"] n = g["n_finals"]
il_now = datetime.now(IL_TZ).strftime("%Y-%m-%d %H:%M")
lines = [ lines = [
f"# #208 — כיול model×effort מול הסופיים — {ts}\n", f"# #208 — כיול model×effort מול הסופיים — {ts}\n",
f"> נוצר: {il_now} (שעון ישראל · Asia/Jerusalem)\n",
f"> ⚠️ **גודל-מדגם: {n} סופיים** ({', '.join(g['finals']) or ''}). " f"> ⚠️ **גודל-מדגם: {n} סופיים** ({', '.join(g['finals']) or ''}). "
"זוהי **עדות-כיוון, לא רגרסיה** — מעט תיקים בעלי סופי-עלוי. " "זוהי **עדות-כיוון, לא רגרסיה** — מעט תיקים בעלי סופי-עלוי. "
"ההמלצה אדוויזורית; ההכרעה בידי היו\"ר/המפעיל.\n", "ההמלצה אדוויזורית; ההכרעה בידי היו\"ר/המפעיל.\n",