# FU-2b: Internal Identifier Reconciliation — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build a reversible, chair-gated migration script that rewrites `internal_committee` `case_number` values currently holding a full citation into the canonical normalized bare number (X1: trim · prefix-strip · `/`→`-`, month preserved), leaving `citation_formatted` untouched.

**Architecture:** A standalone `scripts/` migration (not editable-service code), `--dry-run` by default. Dry-run emits a reconciliation table (CSV + Hebrew Markdown) for chair review; `--apply --approved <csv>` writes a backup then updates only chair-approved rows. Extraction is deterministic (single number-token regex) — no LLM. The production apply runs only AFTER Dafna approves the table.

**Tech Stack:** Python 3.12, asyncpg, PostgreSQL@localhost:5433, pytest offline, `.venv` at `mcp-server/.venv`.

**Spec:** [docs/superpowers/specs/2026-05-31-fu2b-identifier-reconciliation-design.md](../specs/2026-05-31-fu2b-identifier-reconciliation-design.md)

**Run script:** `PY=/home/chaim/legal-ai/mcp-server/.venv/bin/python; $PY scripts/fu2b_reconcile_internal_case_numbers.py` (dry-run)
**Run tests:** `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_fu2b_reconcile.py -v`

---

## File Structure

- **Create** `scripts/fu2b_reconcile_internal_case_numbers.py` — the migration (pure `_extract_bare` + reconciliation builder + table/backup/revert writers + argparse `--dry-run`/`--apply`).
- **Create** `mcp-server/tests/test_fu2b_reconcile.py` — offline tests for `_extract_bare` + consistency flagging (imports the script module via sys.path).
- **Modify** `scripts/SCRIPTS.md` — register the new script (CLAUDE.md rule).
- **Artifact (produced, committed for review)** `data/audit/fu2b-reconciliation-<ts>.md` — the chair table from the dry-run.

No service code changes; no schema change. FK-safe (all `case_law` FKs use `id` UUID — verified).

---

## Task 1: Failing tests for `_extract_bare`

**Files:** Create `mcp-server/tests/test_fu2b_reconcile.py`

- [ ] **Step 1: Write the failing tests**

```python
"""FU-2b: deterministic bare-number extraction (offline)."""
from __future__ import annotations

import importlib.util
from pathlib import Path

import pytest

# Load the migration script as a module (it lives in scripts/, not a package).
_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "fu2b_reconcile_internal_case_numbers.py"
_spec = importlib.util.spec_from_file_location("fu2b_reconcile", _SCRIPT)
fu2b = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(fu2b)


@pytest.mark.parametrize("raw,expected_bare", [
    ("ערר ‏(‏ועדות ערר - תכנון ובנייה ירושלים‏)‏ 403/17 אהרון ברק נ'", "403-17"),
    ("ערר (...) 8136-10-24 שחר שות'", "8136-10-24"),          # month preserved
    ("בל\"מ (...) 1028/20 חלוואני ריאד", "1028-20"),
    ("8047/23", "8047-23"),                                     # already-bare-ish
    ("ערר 81002-01-21", "81002-01-21"),
])
def test_extract_bare_single_token(raw, expected_bare):
    bare, flag = fu2b._extract_bare(raw)
    assert bare == expected_bare
    assert flag == "OK"


def test_extract_bare_no_number():
    bare, flag = fu2b._extract_bare("ערר אדלר נ' הוועדה")
    assert bare is None and flag == "NO_NUMBER"


def test_extract_bare_multiple_numbers_flagged():
    # Two case-number-shaped tokens → ambiguous, must NOT auto-pick.
    bare, flag = fu2b._extract_bare("ערר 403/17 ו-1024/24 מאוחדים")
    assert bare is None and flag == "MULTI_NUMBER"


def test_extract_bare_preserves_month_not_padding():
    # Month kept exactly; 2-part stays 2-part (no invented month).
    assert fu2b._extract_bare("ערר 8126/24 פלוני")[0] == "8126-24"
    assert fu2b._extract_bare("ערר 8126-03-25 פלוני")[0] == "8126-03-25"


def test_consistency_flag_when_bare_absent_from_citation():
    # proposed bare must appear in citation_formatted, else MISMATCH.
    assert fu2b._consistency_flag("403-17", "ערר (...) 403/17 אהרון ברק") == "OK"
    assert fu2b._consistency_flag("403-17", "ערר (...) 1975/24 מישהו אחר") == "MISMATCH"
    assert fu2b._consistency_flag("403-17", "") == "NO_CITATION"
```

- [ ] **Step 2: Run to verify failure**

Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_fu2b_reconcile.py -v`
Expected: FAIL — `FileNotFoundError`/`ModuleNotFoundError` (script doesn't exist) or `AttributeError: _extract_bare`.

- [ ] **Step 3: Commit**

```bash
cd ~/legal-ai
git add mcp-server/tests/test_fu2b_reconcile.py
git commit -m "test(fu2b): failing tests for bare-number extraction (FU-2b)"
```

---

## Task 2: The migration script (dry-run + apply + backup)

**Files:** Create `scripts/fu2b_reconcile_internal_case_numbers.py`

- [ ] **Step 1: Write the script**

```python
#!/usr/bin/env python3
"""FU-2b — reconcile internal_committee case_number → canonical bare number.

Rewrites case_number values that currently hold a full citation into the
canonical normalized bare number (X1: trim · prefix-strip · '/'→'-', month
preserved). citation_formatted is the display field and is left untouched.

DETERMINISTIC — no LLM. Extraction takes the single case-number-shaped token
from the value; 0 or >1 tokens are flagged for chair review, never guessed.

Usage (must use the mcp-server venv — asyncpg/pgvector vendored there):
    PY=/home/chaim/legal-ai/mcp-server/.venv/bin/python

    # Dry-run (default): builds the reconciliation table for chair review.
    $PY scripts/fu2b_reconcile_internal_case_numbers.py

    # Apply ONLY the chair-approved rows (after Dafna's review), backup first:
    $PY scripts/fu2b_reconcile_internal_case_numbers.py --apply \
        --approved data/audit/fu2b-approved-<ts>.csv

Scope: source_kind='internal_committee' only (external → #68/FU-2c). FK-safe:
all case_law FKs reference case_law.id (UUID), not case_number.
"""
from __future__ import annotations

import argparse
import asyncio
import csv
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "mcp-server" / "src"))

if "POSTGRES_URL" not in os.environ:
    os.environ["POSTGRES_URL"] = (
        f"postgres://{os.environ.get('POSTGRES_USER','legal_ai')}:"
        f"{os.environ.get('POSTGRES_PASSWORD','')}@"
        f"{os.environ.get('POSTGRES_HOST','127.0.0.1')}:"
        f"{os.environ.get('POSTGRES_PORT','5433')}/"
        f"{os.environ.get('POSTGRES_DB','legal_ai')}"
    )

AUDIT_DIR = REPO_ROOT / "data" / "audit"
_TOKEN_RE = re.compile(r"[0-9]{2,6}(?:[-/][0-9]{1,2}){1,2}")


def _extract_bare(case_number: str) -> tuple[str | None, str]:
    """Return (canonical_bare, flag). flag ∈ {OK, NO_NUMBER, MULTI_NUMBER}.

    Deterministic: finds case-number-shaped tokens (NNNN/YY or NNNN-MM-YY).
    Exactly one → normalize '/'→'-' (month preserved, none invented). 0 or >1
    → None + flag (chair decides; never guess).
    """
    tokens = _TOKEN_RE.findall(case_number or "")
    if len(tokens) == 1:
        return tokens[0].replace("/", "-"), "OK"
    if not tokens:
        return None, "NO_NUMBER"
    return None, "MULTI_NUMBER"


def _consistency_flag(bare: str | None, citation_formatted: str) -> str:
    """OK if bare appears in citation_formatted; MISMATCH if not; NO_CITATION if empty."""
    if not citation_formatted:
        return "NO_CITATION"
    if not bare:
        return "NO_NUMBER"
    # compare against the citation with separators unified, to match 403/17 vs 403-17
    cf = citation_formatted.replace("/", "-")
    return "OK" if bare in cf else "MISMATCH"


async def _build_reconciliation() -> list[dict]:
    from legal_mcp.services import db
    pool = await db.get_pool()
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            "SELECT id, case_number, proceeding_type, coalesce(citation_formatted,'') AS cf "
            "FROM case_law WHERE source_kind='internal_committee' ORDER BY case_number")
    # detect dup serials across proceeding_type for a DUP_CHECK flag
    out: list[dict] = []
    for r in rows:
        bare, flag = _extract_bare(r["case_number"])
        cons = _consistency_flag(bare, r["cf"])
        changes = bare is not None and bare != r["case_number"]
        out.append({
            "id": str(r["id"]),
            "current_case_number": r["case_number"],
            "proposed_bare": bare or "",
            "proceeding_type": r["proceeding_type"] or "",
            "citation_formatted": r["cf"],
            "extract_flag": flag,
            "consistency": cons,
            "will_change": "yes" if changes else "no",
        })
    # DUP_CHECK: same proposed_bare appearing on >1 row (any proceeding_type)
    from collections import Counter
    bare_counts = Counter(d["proposed_bare"] for d in out if d["proposed_bare"])
    for d in out:
        if d["proposed_bare"] and bare_counts[d["proposed_bare"]] > 1:
            d["dup_check"] = "DUP_CHECK"
        else:
            d["dup_check"] = ""
    return out


def _ts() -> str:
    return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")


def _write_table(rows: list[dict], ts: str) -> tuple[Path, Path]:
    AUDIT_DIR.mkdir(parents=True, exist_ok=True)
    csv_path = AUDIT_DIR / f"fu2b-reconciliation-{ts}.csv"
    md_path = AUDIT_DIR / f"fu2b-reconciliation-{ts}.md"
    cols = ["id", "current_case_number", "proposed_bare", "proceeding_type",
            "citation_formatted", "extract_flag", "consistency", "dup_check", "will_change"]
    with csv_path.open("w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=cols)
        w.writeheader()
        w.writerows(rows)
    changing = [r for r in rows if r["will_change"] == "yes"]
    flagged = [r for r in rows if r["extract_flag"] != "OK" or r["consistency"] == "MISMATCH" or r["dup_check"]]
    with md_path.open("w", encoding="utf-8") as f:
        f.write(f"# FU-2b — טבלת-תיאום מזהים (internal_committee) — {ts}\n\n")
        f.write(f"- סה\"כ רשומות: {len(rows)}\n- ישתנו: {len(changing)}\n- מסומנות לסקירה: {len(flagged)}\n\n")
        f.write("## דורש הכרעת-יו\"ר (flags)\n\n")
        f.write("| current_case_number | proposed_bare | proc | flags |\n|---|---|---|---|\n")
        for r in flagged:
            fl = " ".join(x for x in [r["extract_flag"] if r["extract_flag"] != "OK" else "",
                                       r["consistency"] if r["consistency"] == "MISMATCH" else "",
                                       r["dup_check"]] if x)
            f.write(f"| {r['current_case_number'][:50]} | {r['proposed_bare']} | {r['proceeding_type']} | {fl} |\n")
        f.write("\n## כל השינויים המוצעים\n\n")
        f.write("| current_case_number | → proposed_bare | proc |\n|---|---|---|\n")
        for r in changing:
            f.write(f"| {r['current_case_number'][:55]} | {r['proposed_bare']} | {r['proceeding_type']} |\n")
    return csv_path, md_path


async def _apply(approved_csv: Path, ts: str) -> dict:
    from legal_mcp.services import db
    with approved_csv.open(encoding="utf-8") as f:
        approved = [r for r in csv.DictReader(f)
                    if r.get("will_change") == "yes" and r.get("proposed_bare")]
    if not approved:
        return {"applied": 0, "note": "no approved changing rows"}
    AUDIT_DIR.mkdir(parents=True, exist_ok=True)
    backup = AUDIT_DIR / f"fu2b-backup-{ts}.csv"
    pool = await db.get_pool()
    applied = 0
    with backup.open("w", newline="", encoding="utf-8") as bf:
        bw = csv.writer(bf)
        bw.writerow(["id", "old_case_number"])
        async with pool.acquire() as conn:
            for r in approved:
                old = await conn.fetchval("SELECT case_number FROM case_law WHERE id=$1", r["id"])
                if old is None:
                    continue
                bw.writerow([r["id"], old])
                await conn.execute(
                    "UPDATE case_law SET case_number=$2 WHERE id=$1 "
                    "AND source_kind='internal_committee'",
                    r["id"], r["proposed_bare"])
                applied += 1
    return {"applied": applied, "backup": str(backup)}


async def main() -> int:
    parser = argparse.ArgumentParser(description="FU-2b internal case_number reconciliation")
    parser.add_argument("--apply", action="store_true", help="apply approved changes (default: dry-run)")
    parser.add_argument("--approved", type=str, help="path to chair-approved CSV (required with --apply)")
    args = parser.parse_args()
    ts = _ts()

    if not args.apply:
        rows = await _build_reconciliation()
        csv_path, md_path = _write_table(rows, ts)
        changing = sum(1 for r in rows if r["will_change"] == "yes")
        flagged = sum(1 for r in rows if r["extract_flag"] != "OK" or r["consistency"] == "MISMATCH" or r["dup_check"])
        print(f"DRY-RUN: {len(rows)} rows | will_change={changing} | flagged={flagged}")
        print(f"  table:  {md_path}")
        print(f"  csv:    {csv_path}")
        print("Review the table with the chair, then run --apply --approved <reviewed.csv>.")
        return 0

    if not args.approved:
        print("ERROR: --apply requires --approved <csv> (the chair-reviewed table).", file=sys.stderr)
        return 2
    result = await _apply(Path(args.approved), ts)
    print(f"APPLIED: {result}")
    return 0


if __name__ == "__main__":
    sys.exit(asyncio.run(main()))
```

- [ ] **Step 2: Run the unit tests**

Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_fu2b_reconcile.py -v`
Expected: ALL pass (extraction + flags + consistency).

- [ ] **Step 3: Commit**

```bash
cd ~/legal-ai
chmod +x scripts/fu2b_reconcile_internal_case_numbers.py
git add scripts/fu2b_reconcile_internal_case_numbers.py
git commit -m "feat(fu2b): chair-gated internal case_number reconciliation script (GAP-07/08)"
```

---

## Task 3: Dry-run against the DB → produce the chair table

**Files:** Produces `data/audit/fu2b-reconciliation-<ts>.{csv,md}`

- [ ] **Step 1: Run the dry-run**

```bash
cd ~/legal-ai && set -a && source ~/.env 2>/dev/null && set +a
PY=/home/chaim/legal-ai/mcp-server/.venv/bin/python
$PY scripts/fu2b_reconcile_internal_case_numbers.py
```
Expected output: `DRY-RUN: 56 rows | will_change=~52 | flagged=~1` (the ~1 = the 8047/23 DUP_CHECK pair → 2 rows flagged). Note the exact numbers.

- [ ] **Step 2: Sanity-check the produced table**

Open `data/audit/fu2b-reconciliation-<ts>.md`. Verify:
- `will_change` rows: each `current_case_number` (full citation) → a clean `proposed_bare` matching the number inside it.
- `flagged` section: should contain the `8047-23` DUP_CHECK pair (ערר + בל"מ) and ideally nothing else (0 MULTI_NUMBER, 0 MISMATCH expected per the analysis).
- If MULTI_NUMBER / MISMATCH rows appear unexpectedly, STOP and report them (the analysis predicted 0; an unexpected flag means the data changed and needs investigation before chair review).

- [ ] **Step 3: Commit the produced table as a review artifact**

```bash
cd ~/legal-ai
git add data/audit/fu2b-reconciliation-*.md data/audit/fu2b-reconciliation-*.csv
git commit -m "chore(fu2b): dry-run reconciliation table for chair review (GAP-07/08)"
```
(If `data/audit/` is gitignored, skip the commit and report the path instead — the table still exists on disk for review.)

---

## Task 4: SCRIPTS.md + PR

- [ ] **Step 1: Register the script in `scripts/SCRIPTS.md`**

Add a row to the active-scripts table (match the file's existing table format) describing `fu2b_reconcile_internal_case_numbers.py`: purpose (FU-2b internal case_number reconciliation, GAP-07/08), status (active, chair-gated), usage (dry-run default / `--apply --approved`).

- [ ] **Step 2: Full suite + commit + push + PR**

```bash
cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/ -q   # report summary (expect all pass)
cd ~/legal-ai
git add scripts/SCRIPTS.md
git commit -m "docs(scripts): register fu2b reconciliation script (FU-2b)"
git push -u origin fix/fu2b-identifier-reconciliation
```
Then create the PR via the Gitea REST API (token from `~/.git-credentials`) and merge per the standing PR+merge rule. The PR delivers the **tooling + dry-run table**; the production `--apply` is the separate gated step below.

---

## Task 5: [HUMAN GATE] Chair review + gated apply (NOT automated)

> This task is the chair-approval gate. It is NOT executed by an implementer subagent.

- [ ] **Step 1:** Present `data/audit/fu2b-reconciliation-<ts>.md` to the controller, who presents it to Dafna: the ~52 proposed changes + the `8047-23` ערר/בל"מ DUP_CHECK pair. Dafna confirms the mapping and adjudicates whether 8047/23 is two distinct proceedings (keep both) or a mis-tagged duplicate (manual delete, separate).
- [ ] **Step 2:** Save the reviewed table as `data/audit/fu2b-approved-<ts>.csv` (rows Dafna approved; `will_change=yes` only for those).
- [ ] **Step 3:** Run the gated apply against the DB:
  ```bash
  cd ~/legal-ai && set -a && source ~/.env && set +a
  PY=/home/chaim/legal-ai/mcp-server/.venv/bin/python
  $PY scripts/fu2b_reconcile_internal_case_numbers.py --apply --approved data/audit/fu2b-approved-<ts>.csv
  ```
- [ ] **Step 4:** Verify: re-run dry-run → `will_change=0` (idempotent); spot-check `get_case_by_number` still resolves a migrated case; confirm a backup CSV was written (revert path). Mark TaskMaster #67 done.

---

## Self-Review Notes

- **GAP-07/08 (internal)** → Task 2 script + Task 3 dry-run + Task 5 gated apply. Canonical form per X1 (month preserved) — `_extract_bare` replaces only `/`→`-` on the single extracted token, never strips/pads a month.
- **Reversible:** `_apply` writes `fu2b-backup-<ts>.csv` (id, old_case_number) before each UPDATE.
- **Chair gate:** `--apply` requires `--approved <csv>`; production apply is Task 5 (human), not part of the PR merge.
- **Determinism / safety:** 0/>1 token → flagged, never guessed; consistency + DUP_CHECK flags surface the 8047 edge.
- **Scope:** `source_kind='internal_committee'` only (the UPDATE has the `AND source_kind='internal_committee'` guard); external → #68.
- **FK-safe:** verified all 11 `case_law` FKs use `id` (UUID).
- **Type consistency:** `_extract_bare(case_number)->(bare|None,flag)`, `_consistency_flag(bare,citation)->str` — names match tests (Task 1) and script (Task 2).
