# X11 Citation Corroboration — Phase 1 (Signal) 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 the citation-corroboration **signal** — classify each incoming citation's *treatment*, link it to the *specific halacha* it supports, and expose per-halacha corroboration — **without yet changing the approval gate** (that is Phase 2).

**Architecture:** A new schema version (V24) adds a `treatment` column to `precedent_internal_citations` and a `halacha_citation_corroboration` link table. A new service `corroboration.py` does three deterministic-or-LLM steps: (1) classify treatment of a citing passage via Opus 4.8 (INV-COR2), (2) match a citing passage to one halacha via pgvector cosine over `halachot.embedding` (INV-COR3), (3) aggregate distinct positive citations per halacha (INV-COR4). A read-only MCP tool reports the result with full provenance (INV-COR6). Auto-approval (INV-COR4/G10) and enrichment are **Phase 2** (separate plan).

**Tech Stack:** Python 3.12, asyncpg + pgvector, FastMCP (`@mcp.tool()`), `claude_session.query_json(model=, effort=)`, `embeddings.embed_texts`, pytest (offline deterministic tests mirroring `tests/test_fu2b_reconcile.py`).

**Spec:** [docs/spec/X11-citation-corroboration.md](../../spec/X11-citation-corroboration.md) (INV-COR1–COR6). Prerequisite (clean identity graph) is **done** (Shafer merge + corpus scan).

---

## File Structure

- Create: `mcp-server/src/legal_mcp/services/corroboration.py` — the corroboration service (classify · match · aggregate · persist). One responsibility: turn the citation graph into per-halacha corroboration rows.
- Modify: `mcp-server/src/legal_mcp/services/db.py` — add `SCHEMA_V24_SQL` + helpers `store_corroboration`, `list_corroboration_for_halacha`.
- Modify: `mcp-server/src/legal_mcp/server.py` — register read-only MCP tool `halacha_corroboration`.
- Create: `mcp-server/tests/test_corroboration.py` — offline deterministic tests (treatment parse, aggregation, independence rule).

**Treatment vocabulary (INV-COR2):** positive = `{followed, explained}`; negative = `{distinguished, criticized, questioned, overruled}`; neutral = `{mentioned}`. Defined once in `corroboration.py`.

---

## Task 1: Schema V24 — treatment column + corroboration link table

**Files:**
- Modify: `mcp-server/src/legal_mcp/services/db.py` (add `SCHEMA_V24_SQL` constant near the other `SCHEMA_V23_SQL`; register it in `_run_schema_migrations`, db.py:54-ish)

- [ ] **Step 1: Add the schema constant**

Add after the `SCHEMA_V23_SQL = """..."""` block:

```python
SCHEMA_V24_SQL = """
-- X11: citation corroboration (treatment + halacha-level link)
ALTER TABLE precedent_internal_citations
    ADD COLUMN IF NOT EXISTS treatment TEXT DEFAULT '';

CREATE TABLE IF NOT EXISTS halacha_citation_corroboration (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    halacha_id UUID NOT NULL REFERENCES halachot(id) ON DELETE CASCADE,
    citing_case_law_id UUID REFERENCES case_law(id) ON DELETE CASCADE,
    citing_decision_id UUID REFERENCES decisions(id) ON DELETE SET NULL,
    source_citation_id UUID NOT NULL,      -- the precedent_internal_citations / case_law_citations row
    treatment TEXT NOT NULL,                -- followed/explained/distinguished/criticized/questioned/overruled/mentioned
    match_score NUMERIC(4,3) DEFAULT 0,     -- cosine(context, rule_statement)
    match_context TEXT DEFAULT '',
    created_at TIMESTAMPTZ DEFAULT now(),
    UNIQUE (halacha_id, source_citation_id)
);
CREATE INDEX IF NOT EXISTS idx_hcc_halacha ON halacha_citation_corroboration(halacha_id);
"""
```

- [ ] **Step 2: Register it in `_run_schema_migrations`**

In `_run_schema_migrations` (db.py), after `await conn.execute(SCHEMA_V23_SQL)` add:

```python
        await conn.execute(SCHEMA_V24_SQL)
```

And update the log line to `"Database schema initialized (v1-v24)"`.

- [ ] **Step 3: Apply + verify against the dev DB**

Run (from `mcp-server/`):
```bash
DOTENV_PATH=/home/chaim/.env DATA_DIR=/home/chaim/legal-ai/data .venv/bin/python -c "
import asyncio; from legal_mcp.services import db
async def m():
    pool=await db.get_pool()
    cols=await pool.fetch(\"SELECT column_name FROM information_schema.columns WHERE table_name='precedent_internal_citations' AND column_name='treatment'\")
    t=await pool.fetchval(\"SELECT to_regclass('halacha_citation_corroboration')\")
    print('treatment col:', bool(cols), '| table:', t)
asyncio.run(m())"
```
Expected: `treatment col: True | table: halacha_citation_corroboration`

- [ ] **Step 4: Commit**
```bash
git add mcp-server/src/legal_mcp/services/db.py
git commit -m "feat(db): V24 — citation treatment column + halacha corroboration link table (X11)"
```

---

## Task 2: Treatment classifier (deterministic parse, unit-tested)

The LLM call classifies a citing passage; the **parse/validate** of its output is the deterministic, unit-tested unit (mirrors `halacha_extractor._coerce_halacha`).

**Files:**
- Create: `mcp-server/src/legal_mcp/services/corroboration.py`
- Test: `mcp-server/tests/test_corroboration.py`

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

```python
# tests/test_corroboration.py
from __future__ import annotations
import pytest
from legal_mcp.services import corroboration as cor

@pytest.mark.parametrize("raw,expected", [
    ({"treatment": "followed"}, "followed"),
    ({"treatment": "OVERRULED"}, "overruled"),     # case-insensitive
    ({"treatment": "bananas"}, "mentioned"),        # unknown -> neutral default
    ({}, "mentioned"),                               # missing -> neutral default
])
def test_coerce_treatment(raw, expected):
    assert cor._coerce_treatment(raw) == expected

def test_treatment_polarity():
    assert cor.is_positive("followed") and cor.is_positive("explained")
    assert cor.is_negative("distinguished") and cor.is_negative("overruled")
    assert not cor.is_positive("mentioned") and not cor.is_negative("mentioned")
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd mcp-server && .venv/bin/python -m pytest tests/test_corroboration.py -q`
Expected: FAIL — `ModuleNotFoundError: corroboration` / `_coerce_treatment` undefined.

- [ ] **Step 3: Write minimal implementation**

```python
# src/legal_mcp/services/corroboration.py
"""X11 citation corroboration — classify treatment, match to halacha, aggregate.

Phase 1: builds the SIGNAL only (no approval changes). See docs/spec/X11.
All LLM calls go through the local claude_session bridge (Opus 4.8 @ xhigh),
same architectural rule as the other extractors (local MCP only).
"""
from __future__ import annotations
import logging
from legal_mcp import config
from legal_mcp.config import parse_llm_json
from legal_mcp.services import claude_session

logger = logging.getLogger(__name__)

TREATMENT_POSITIVE = {"followed", "explained"}
TREATMENT_NEGATIVE = {"distinguished", "criticized", "questioned", "overruled"}
TREATMENT_NEUTRAL = {"mentioned"}
_VALID_TREATMENT = TREATMENT_POSITIVE | TREATMENT_NEGATIVE | TREATMENT_NEUTRAL

def is_positive(t: str) -> bool: return t in TREATMENT_POSITIVE
def is_negative(t: str) -> bool: return t in TREATMENT_NEGATIVE

def _coerce_treatment(raw: dict) -> str:
    t = str((raw or {}).get("treatment", "")).strip().lower()
    return t if t in _VALID_TREATMENT else "mentioned"
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd mcp-server && .venv/bin/python -m pytest tests/test_corroboration.py -q`
Expected: PASS (3 params + polarity).

- [ ] **Step 5: Add the LLM classifier call (integration, not unit-tested)**

Append to `corroboration.py`:

```python
_TREATMENT_PROMPT = """אתה משפטן בכיר. נתון ציטוט של פסק/החלטה קודמים בתוך החלטה מאוחרת.
סווג כיצד ההחלטה המאוחרת **מטפלת** בתקדים המצוטט, לפי אחת מהקטגוריות:
- followed   — אימצה והחילה את ההלכה.
- explained  — הסבירה/הזכירה בלי לחלוק.
- distinguished — אבחנה (קבעה שלא חל בנסיבות).
- criticized — מתחה ביקורת בלי לבטל.
- questioned — הטילה ספק.
- overruled  — דחתה/ביטלה את ההלכה.
- mentioned  — אזכור-אגב בלי טיפול.
החזר JSON בלבד: {"treatment": "<קטגוריה>"}.
"""

async def classify_treatment(cited_citation: str, context: str) -> str:
    """Return one treatment label for how `context` treats `cited_citation`."""
    user = f"תקדים מצוטט: {cited_citation}\n\n--- ההקשר המצטט ---\n{context}\n--- סוף ---"
    try:
        result = await claude_session.query_json(
            user, system=_TREATMENT_PROMPT,
            model=config.HALACHA_EXTRACT_MODEL or None,
            effort=config.HALACHA_EXTRACT_EFFORT or None,
        )
    except Exception as e:
        logger.warning("classify_treatment failed: %s", e)
        return "mentioned"
    return _coerce_treatment(result if isinstance(result, dict) else {})
```

- [ ] **Step 6: Commit**
```bash
git add mcp-server/src/legal_mcp/services/corroboration.py mcp-server/tests/test_corroboration.py
git commit -m "feat(corroboration): treatment classifier + polarity (INV-COR2, X11)"
```

---

## Task 3: Halacha matcher — pgvector cosine, threshold (INV-COR3)

The matcher links a citing passage to the single best halacha of the cited precedent. The **threshold decision** is the deterministic unit; the pgvector lookup is integration.

**Files:**
- Modify: `mcp-server/src/legal_mcp/services/corroboration.py`
- Modify: `mcp-server/src/legal_mcp/services/db.py` (add `nearest_halacha_for_vector`)
- Test: `mcp-server/tests/test_corroboration.py`

- [ ] **Step 1: Write the failing test (threshold gate is the unit)**

Append to `tests/test_corroboration.py`:

```python
def test_match_accepts_above_threshold():
    # (halacha_id, similarity) above floor -> accepted
    assert cor.accept_match(("h1", 0.62), floor=0.50) == "h1"

def test_match_rejects_below_threshold():
    # below floor -> None (INV-COR3: don't attach to a different legal point)
    assert cor.accept_match(("h1", 0.41), floor=0.50) is None

def test_match_rejects_empty():
    assert cor.accept_match(None, floor=0.50) is None
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd mcp-server && .venv/bin/python -m pytest tests/test_corroboration.py::test_match_accepts_above_threshold -q`
Expected: FAIL — `accept_match` undefined.

- [ ] **Step 3: Implement the threshold gate + env floor**

Add to `config.py` (near `HALACHA_EXTRACT_*`):
```python
HALACHA_CORROBORATION_MATCH_FLOOR = float(os.environ.get("HALACHA_CORROBORATION_MATCH_FLOOR", "0.50"))
HALACHA_CORROBORATION_MIN_CITES = int(os.environ.get("HALACHA_CORROBORATION_MIN_CITES", "2"))
```
Add to `corroboration.py`:
```python
def accept_match(best: tuple[str, float] | None, floor: float = config.HALACHA_CORROBORATION_MATCH_FLOOR) -> str | None:
    """Return the halacha_id iff similarity clears the floor (INV-COR3)."""
    if not best:
        return None
    halacha_id, sim = best
    return halacha_id if sim >= floor else None
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd mcp-server && .venv/bin/python -m pytest tests/test_corroboration.py -q`
Expected: PASS (all, incl. Task 2).

- [ ] **Step 5: Add the pgvector lookup (integration)**

Add to `db.py`:
```python
async def nearest_halacha_for_vector(case_law_id: UUID, vec: list[float]) -> tuple[str, float] | None:
    """Best-matching halacha of `case_law_id` for a context embedding (cosine)."""
    pool = await get_pool()
    row = await pool.fetchrow(
        "SELECT id::text AS id, 1 - (embedding <=> $2) AS sim "
        "FROM halachot WHERE case_law_id = $1 AND embedding IS NOT NULL "
        "ORDER BY embedding <=> $2 LIMIT 1",
        case_law_id, vec,
    )
    return (row["id"], float(row["sim"])) if row else None
```

- [ ] **Step 6: Commit**
```bash
git add mcp-server/src/legal_mcp/services/corroboration.py mcp-server/src/legal_mcp/services/db.py mcp-server/src/legal_mcp/config.py mcp-server/tests/test_corroboration.py
git commit -m "feat(corroboration): halacha matcher + cosine threshold (INV-COR3, X11)"
```

---

## Task 4: Aggregator — distinct positive citations, negative-flag (INV-COR4)

Pure function: given per-citation results for one halacha, count **distinct** positive citing sources and detect any negative treatment.

**Files:**
- Modify: `mcp-server/src/legal_mcp/services/corroboration.py`
- Test: `mcp-server/tests/test_corroboration.py`

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

Append to `tests/test_corroboration.py`:

```python
def _link(src, treatment):
    return {"source_id": src, "treatment": treatment}

def test_aggregate_counts_distinct_positive():
    links = [_link("d1","followed"), _link("d1","explained"), _link("d2","followed")]
    agg = cor.aggregate(links, min_cites=2)
    assert agg["positive_sources"] == 2          # d1 counted once (INV-COR4 independence)
    assert agg["has_negative"] is False
    assert agg["corroborated"] is True

def test_aggregate_negative_blocks():
    links = [_link("d1","followed"), _link("d2","followed"), _link("d3","distinguished")]
    agg = cor.aggregate(links, min_cites=2)
    assert agg["has_negative"] is True
    assert agg["corroborated"] is False          # any negative -> not corroborated (INV-COR2)

def test_aggregate_below_threshold():
    agg = cor.aggregate([_link("d1","followed")], min_cites=2)
    assert agg["corroborated"] is False           # single source insufficient (INV-COR4)
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd mcp-server && .venv/bin/python -m pytest tests/test_corroboration.py::test_aggregate_counts_distinct_positive -q`
Expected: FAIL — `aggregate` undefined.

- [ ] **Step 3: Implement**

Add to `corroboration.py`:
```python
def aggregate(links: list[dict], min_cites: int = config.HALACHA_CORROBORATION_MIN_CITES) -> dict:
    """Aggregate per-halacha corroboration (INV-COR4/COR2).

    links: [{"source_id": str, "treatment": str}, ...] already matched to ONE halacha.
    positive_sources = count of DISTINCT source_id whose treatment is positive.
    has_negative = any negative treatment present.
    corroborated = positive_sources >= min_cites AND not has_negative.
    """
    positive = {l["source_id"] for l in links if is_positive(l["treatment"])}
    has_negative = any(is_negative(l["treatment"]) for l in links)
    return {
        "positive_sources": len(positive),
        "has_negative": has_negative,
        "corroborated": len(positive) >= min_cites and not has_negative,
    }
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd mcp-server && .venv/bin/python -m pytest tests/test_corroboration.py -q`
Expected: PASS (all).

- [ ] **Step 5: Commit**
```bash
git add mcp-server/src/legal_mcp/services/corroboration.py mcp-server/tests/test_corroboration.py
git commit -m "feat(corroboration): aggregator — distinct positive + negative-flag (INV-COR4, X11)"
```

---

## Task 5: Orchestration + persistence (build the signal for one precedent)

Wires Tasks 2–4 over a precedent's incoming citations and stores `halacha_citation_corroboration` rows (INV-COR6 provenance). Integration (no new offline unit).

**Files:**
- Modify: `mcp-server/src/legal_mcp/services/corroboration.py`
- Modify: `mcp-server/src/legal_mcp/services/db.py` (add `store_corroboration`, `incoming_citations_for_precedent`)

- [ ] **Step 1: Add DB helpers**

```python
# db.py
async def incoming_citations_for_precedent(case_law_id: UUID) -> list[dict]:
    """All incoming citations (both graphs) with their context + source id."""
    pool = await get_pool()
    rows = await pool.fetch(
        "SELECT id::text AS source_id, source_case_law_id::text AS citing_case_law_id, "
        "       NULL::text AS citing_decision_id, match_context AS context "
        "FROM precedent_internal_citations WHERE cited_case_law_id = $1 "
        "UNION ALL "
        "SELECT id::text, NULL, decision_id::text, context_text "
        "FROM case_law_citations WHERE case_law_id = $1",
        case_law_id,
    )
    return [dict(r) for r in rows]

async def store_corroboration(halacha_id: str, source_id: str, citing_case_law_id, citing_decision_id, treatment: str, score: float, context: str) -> None:
    pool = await get_pool()
    await pool.execute(
        "INSERT INTO halacha_citation_corroboration "
        "(halacha_id, citing_case_law_id, citing_decision_id, source_citation_id, treatment, match_score, match_context) "
        "VALUES ($1,$2,$3,$4,$5,$6,$7) "
        "ON CONFLICT (halacha_id, source_citation_id) DO UPDATE SET "
        "treatment=EXCLUDED.treatment, match_score=EXCLUDED.match_score",
        halacha_id, citing_case_law_id, citing_decision_id, source_id, treatment, score, context,
    )
```

- [ ] **Step 2: Add the orchestrator**

```python
# corroboration.py
from uuid import UUID
from legal_mcp.services import db, embeddings

async def build_for_precedent(case_law_id: str | UUID) -> dict:
    """For one cited precedent: classify+match+store each incoming citation. Idempotent."""
    if isinstance(case_law_id, str):
        case_law_id = UUID(case_law_id)
    cits = await db.incoming_citations_for_precedent(case_law_id)
    linked = 0
    for c in cits:
        ctx = (c.get("context") or "").strip()
        if not ctx:
            continue
        vecs = await embeddings.embed_texts([ctx], input_type="query")
        best = await db.nearest_halacha_for_vector(case_law_id, vecs[0])
        halacha_id = accept_match(best)
        if not halacha_id:
            continue
        treatment = await classify_treatment(c.get("citing_case_law_id") or c.get("citing_decision_id") or "", ctx)
        await db.store_corroboration(
            halacha_id, c["source_id"],
            c.get("citing_case_law_id"), c.get("citing_decision_id"),
            treatment, best[1], ctx,
        )
        linked += 1
    return {"citations": len(cits), "linked": linked}
```

- [ ] **Step 3: Integration smoke-test on Shafer (the fixed precedent, 7 citations)**

Run (from `mcp-server/`):
```bash
DOTENV_PATH=/home/chaim/.env DATA_DIR=/home/chaim/legal-ai/data .venv/bin/python -c "
import asyncio; from legal_mcp.services import corroboration as cor
print(asyncio.run(cor.build_for_precedent('9024da7b-f408-4b6f-808f-c514a83728e4')))"
```
Expected: `{'citations': 7, 'linked': <0..7>}` and no exception. Inspect `halacha_citation_corroboration` rows for treatment/score sanity.

- [ ] **Step 4: Commit**
```bash
git add mcp-server/src/legal_mcp/services/corroboration.py mcp-server/src/legal_mcp/services/db.py
git commit -m "feat(corroboration): orchestrator + persistence over both citation graphs (X11)"
```

---

## Task 6: Read-only MCP tool `halacha_corroboration`

Exposes per-halacha corroboration + provenance to the chair/agents (INV-COR6). **No approval change** — reporting only.

**Files:**
- Modify: `mcp-server/src/legal_mcp/services/db.py` (add `list_corroboration_for_halacha`)
- Modify: `mcp-server/src/legal_mcp/server.py` (register tool)

- [ ] **Step 1: Add the DB read**

```python
# db.py
async def list_corroboration_for_halacha(halacha_id: UUID) -> list[dict]:
    pool = await get_pool()
    rows = await pool.fetch(
        "SELECT treatment, match_score, match_context, citing_case_law_id::text, "
        "       citing_decision_id::text, created_at "
        "FROM halacha_citation_corroboration WHERE halacha_id = $1 "
        "ORDER BY match_score DESC", halacha_id,
    )
    return [dict(r) for r in rows]
```

- [ ] **Step 2: Register the MCP tool** (copy an existing `@mcp.tool()` idiom in `server.py`)

```python
@mcp.tool()
async def halacha_corroboration(halacha_id: str) -> dict:
    """החזר את ה-corroboration של הלכה: הציטוטים שמתקפים אותה, הטיפול, וסיכום (X11, read-only)."""
    from uuid import UUID
    from legal_mcp.services import corroboration as cor, db
    links = await db.list_corroboration_for_halacha(UUID(halacha_id))
    agg = cor.aggregate(
        [{"source_id": (l["citing_case_law_id"] or l["citing_decision_id"] or ""), "treatment": l["treatment"]} for l in links]
    )
    return {"halacha_id": halacha_id, "summary": agg, "citations": links}
```

- [ ] **Step 3: Verify the tool loads** (restart MCP server is required to pick up new tools)

Run: `cd mcp-server && .venv/bin/python -c "from legal_mcp import server; print('halacha_corroboration' in [t.name for t in server.mcp._tool_manager.list_tools()])"`
Expected: `True` (adjust the introspection call to the FastMCP version if needed; the point is the import succeeds and the tool registers).

- [ ] **Step 4: Commit**
```bash
git add mcp-server/src/legal_mcp/services/db.py mcp-server/src/legal_mcp/server.py
git commit -m "feat(mcp): halacha_corroboration read-only tool (INV-COR6, X11)"
```

---

## Out of scope (Phase 2 — separate plan)

- **Auto-approval wiring (INV-COR4/COR5 + G10 behavior):** make `corroborated == True` set `halachot.review_status='approved'` with `reviewer='corroborated (≥N judicial citations)'`, leaving the uncorroborated/negative tail in the chair gate. **Sensitive** — gated on Dafna validating the signal from Phase 1 first.
- **Enrichment (INV-COR3 secondary):** sharpen `rule_statement` from citing framing.
- **Backfill:** run `build_for_precedent` across all precedents with halachot + incoming citations.
- **Treatment backfill of `case_law_citations.citation_type`** (currently default `'support'`).

---

## Self-Review

**Spec coverage:** INV-COR2 (Task 2 classifier + polarity), INV-COR3 (Task 3 match floor + pgvector), INV-COR4 (Task 4 distinct positive + min_cites), INV-COR6 (Task 5 store provenance + Task 6 report). INV-COR1 (principle, no code) and INV-COR5 (chair-gate preserved) are honored by **not** changing approval in Phase 1 — explicitly deferred to Phase 2. ✔
**Placeholders:** none — every code step has concrete content; the LLM-dependent steps carry the exact prompt + I/O contract; tuning values (`MATCH_FLOOR`, `MIN_CITES`) are real env-defaults, not TBDs.
**Type consistency:** `accept_match` returns `halacha_id|None`; `aggregate` consumes `[{"source_id","treatment"}]`; `_coerce_treatment`/`is_positive`/`is_negative` share the `_VALID_TREATMENT` sets. `build_for_precedent` uses `accept_match` + `classify_treatment` + `store_corroboration` with matching signatures. ✔
