diff --git a/docs/spec/X8-field-provenance.md b/docs/spec/X8-field-provenance.md index 31158d9..001a33c 100644 --- a/docs/spec/X8-field-provenance.md +++ b/docs/spec/X8-field-provenance.md @@ -35,14 +35,14 @@ | `case_number` (citation) | CHAIR (חובה) | מפתח idempotency | | `full_text`, `extraction_status`, `source_kind` | DETERMINISTIC | — | | `case_name`, `court`, `date`, `headnote`, `summary`, `key_quote`, `subject_tags`, `appeal_subtype`, `precedent_level`, `source_type`, `citation_formatted` | CHAIR או OPUS | Opus ממלא רק אם ריק | -| `is_binding` | CHAIR (default true) | קובע prompt-הלכה | +| `is_binding` | CHAIR (default true) **אך** DETERMINISTIC-false ל-`source_type=appeals_committee` | קובע prompt-הלכה. סמכות מבנית ([INV-DM7](02-data-model.md#inv-dm7-סיווג-הלכה--סמכות-נגזרת--תפקיד-כלל-מסווג-שני-צירים-לא-enum-אחד)): מקור-ועדה הוא תמיד persuasive — נכפה ל-false ב-`create_external_case_law` ונאכף ע"י `case_law_committee_not_binding_check` | | chunks (`content`/`section_type`/`page_number`) | DETERMINISTIC | — | | `embedding` (chunks) | Voyage (לא-LLM-reasoning) | ⚠ לא-GENERATED ([gap-audit GAP-09](gap-audit.md)) | | כל `halachot` | OPUS | נכנס pending_review | ### 2ב. החלטה פנימית (`case_law`, source_kind=`internal_committee`) כמו 2א, ובנוסף: `case_number` **חובה**; `chair_name`/`district`/`proceeding_type` — CHAIR או OPUS או DERIVED; -`source_type` = `appeals_committee` (DETERMINISTIC קבוע). placeholder `"(טרם חולץ)"` מסומן ל-chair_name/district +`source_type` = `appeals_committee` (DETERMINISTIC קבוע); `is_binding` = `false` (DETERMINISTIC קבוע — נכפה ב-`create_internal_committee_decision`, [INV-DM7](02-data-model.md#inv-dm7-סיווג-הלכה--סמכות-נגזרת--תפקיד-כלל-מסווג-שני-צירים-לא-enum-אחד): החלטת-ועדה אינה מחייבת ועדה אחרת ולא את עצמה — תמיד persuasive). placeholder `"(טרם חולץ)"` מסומן ל-chair_name/district ריקים ומטופל כריק ע"י ה-extractor. ### 2ג. מסמך-תיק (`documents`) diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 75ad0c1..d737ecb 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -1669,6 +1669,27 @@ CREATE INDEX IF NOT EXISTS idx_hcc_canonical """ +SCHEMA_V42_SQL = """ +-- INV-DM7: authority (binding/persuasive) is STRUCTURAL for committee sources. +-- An appeals-committee decision (source_kind='internal_committee', or any row +-- with source_type='appeals_committee') is persuasive, NEVER binding — a +-- committee's ruling does not bind another committee, nor itself. is_binding +-- stays a stored column (it selects the halacha-extraction prompt), but for +-- committee sources it is a faithful cache of the derived value, not a chair +-- guess. Mirrors 02-data-model.md §INV-DM7 / X8-field-provenance.md. +-- Step 1 — normalize legacy rows at the source (G1), idempotent. +UPDATE case_law SET is_binding = FALSE + WHERE is_binding IS TRUE + AND (source_kind = 'internal_committee' OR source_type = 'appeals_committee'); +-- Step 2 — constrain so a binding committee row can never be written again. +DO $$ BEGIN + ALTER TABLE case_law ADD CONSTRAINT case_law_committee_not_binding_check + CHECK (NOT ((source_kind = 'internal_committee' + OR source_type = 'appeals_committee') AND is_binding)); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; +""" + + # Stable, arbitrary key for the session-level advisory lock that serialises # schema DDL across processes. Every short-lived process (cron drains, services) # re-runs the idempotent migrations on startup; without this lock two processes @@ -1686,7 +1707,7 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None: await _apply_schema_ddl(conn) finally: await conn.execute("SELECT pg_advisory_unlock($1)", _MIGRATION_LOCK_KEY) - logger.info("Database schema initialized (v1-v41)") + logger.info("Database schema initialized (v1-v42)") async def _apply_schema_ddl(conn: asyncpg.Connection) -> None: @@ -1732,6 +1753,7 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None: await conn.execute(SCHEMA_V39_SQL) await conn.execute(SCHEMA_V40_SQL) await conn.execute(SCHEMA_V41_SQL) + await conn.execute(SCHEMA_V42_SQL) async def init_schema() -> None: @@ -4249,6 +4271,11 @@ async def create_external_case_law( source_kind='cited_only' (auto-discovered), promote it to source_kind='external_upload' and fill in the missing fields. """ + # INV-DM7: an appeals-committee source is persuasive even when uploaded via + # the external path — coerce to non-binding so it matches the committee + # invariant and case_law_committee_not_binding_check (G1). + if source_type == "appeals_committee": + is_binding = False pool = await get_pool() tags_json = json.dumps(subject_tags or [], ensure_ascii=False) async with pool.acquire() as conn: @@ -4317,7 +4344,7 @@ async def create_internal_committee_decision( appeal_subtype: str = "", subject_tags: list[str] | None = None, summary: str = "", - is_binding: bool = True, + is_binding: bool = False, document_id: UUID | None = None, proceeding_type: str = "ערר", ) -> dict: @@ -4327,6 +4354,10 @@ async def create_internal_committee_decision( exist as both 'ערר' and 'בל"מ' (an extension-of-time request can be filed against an existing appeal with the same number). """ + # INV-DM7: authority is structural for this source — an appeals-committee + # decision is persuasive, never binding. Coerce regardless of caller so the + # value can never violate case_law_committee_not_binding_check (G1). + is_binding = False pool = await get_pool() case_number = _canonical_case_number(case_number) tags_json = json.dumps(subject_tags or [], ensure_ascii=False) @@ -4470,6 +4501,12 @@ async def update_case_law(case_law_id: UUID, **fields) -> dict | None: "proceeding_type", "citation_formatted", "parties", } updates = {k: v for k, v in fields.items() if k in allowed} + # INV-DM7: reclassifying a row to an appeals-committee source makes it + # persuasive — coerce is_binding in the same patch so the UPDATE can't + # violate case_law_committee_not_binding_check (e.g. the Gemini metadata + # extractor relabels an external court_ruling as appeals_committee). + if updates.get("source_type") == "appeals_committee": + updates["is_binding"] = False if not updates: return await get_case_law(case_law_id) diff --git a/mcp-server/src/legal_mcp/services/internal_decisions.py b/mcp-server/src/legal_mcp/services/internal_decisions.py index 3816fb7..248ed7e 100644 --- a/mcp-server/src/legal_mcp/services/internal_decisions.py +++ b/mcp-server/src/legal_mcp/services/internal_decisions.py @@ -77,7 +77,7 @@ async def _create_internal_record(**kw) -> dict: appeal_subtype=(kw.get("appeal_subtype") or "").strip(), subject_tags=list(kw.get("subject_tags") or []), summary=(kw.get("summary") or "").strip(), - is_binding=kw.get("is_binding", True), + is_binding=kw.get("is_binding", False), # INV-DM7: committee = persuasive document_id=kw.get("document_id"), proceeding_type=kw.get("proceeding_type") or "ערר", ) @@ -108,7 +108,7 @@ async def ingest_internal_decision( appeal_subtype: str = "", subject_tags: list[str] | None = None, summary: str = "", - is_binding: bool = True, + is_binding: bool = False, # INV-DM7: committee sources are persuasive file_path: str | Path | None = None, text: str | None = None, document_id: UUID | None = None, @@ -229,6 +229,7 @@ async def migrate_from_external_corpus(dry_run: bool = False) -> dict: await conn.execute( """UPDATE case_law SET source_kind = 'internal_committee', + is_binding = FALSE, -- INV-DM7: committee = persuasive district = CASE WHEN $2 <> '' THEN $2 ELSE district END WHERE id = $1""", row["id"], district, diff --git a/web/app.py b/web/app.py index c7c44be..e1ffbb5 100644 --- a/web/app.py +++ b/web/app.py @@ -7141,7 +7141,7 @@ async def internal_decisions_upload( practice_area: str = Form(""), appeal_subtype: str = Form(""), subject_tags: str = Form("[]"), - is_binding: bool = Form(True), + is_binding: bool = Form(False), # INV-DM7: committee = persuasive (coerced at db too) summary: str = Form(""), ): """Upload a planning appeals-committee decision to the internal corpus.