fix(corpus): committee decisions are persuasive, never binding (INV-DM7)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 9s

ועדת-ערר decisions (source_kind=internal_committee / source_type=
appeals_committee) are persuasive authority — they do not bind another
committee, nor the committee itself. The stored is_binding column wrongly
defaulted to True across the FastAPI form + service/db layers, so 46 of 92
committee rows were marked binding and got the BINDING halacha-extraction
prompt. Authority is structural for this source (INV-DM7) — normalize at the
source (G1), not trust the input.

Changes:
- db.create_internal_committee_decision: coerce is_binding=False (structural)
- db.create_external_case_law: coerce False when source_type=appeals_committee
- db.update_case_law: coerce False when a patch relabels source_type=
  appeals_committee (Gemini reclassification path)
- internal_decisions.migrate_from_external_corpus: set is_binding=FALSE on
  the external→internal reclassification UPDATE
- service + FastAPI-form defaults: True → False for the internal path
- SCHEMA_V42: backfill legacy committee rows (is_binding True→False) +
  CHECK constraint case_law_committee_not_binding_check so a binding
  committee row can never be written again ("so it doesn't recur")
- spec: X8-field-provenance aligned to INV-DM7

Verified against live DB in a rollback transaction: 46 rows flip to 0;
court_ruling (239) + cited_only (31) unaffected; binding-committee INSERT
rejected by the constraint.

Invariants: INV-DM7 (authority ⊥ rule-type) · G1 (normalize at source) ·
G2 (single source of truth, no parallel path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 10:52:30 +00:00
parent a06e15e1e1
commit 5a69980adf
4 changed files with 45 additions and 7 deletions

View File

@@ -35,14 +35,14 @@
| `case_number` (citation) | CHAIR (חובה) | מפתח idempotency | | `case_number` (citation) | CHAIR (חובה) | מפתח idempotency |
| `full_text`, `extraction_status`, `source_kind` | DETERMINISTIC | — | | `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 ממלא רק אם ריק | | `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 | — | | chunks (`content`/`section_type`/`page_number`) | DETERMINISTIC | — |
| `embedding` (chunks) | Voyage (לא-LLM-reasoning) | ⚠ לא-GENERATED ([gap-audit GAP-09](gap-audit.md)) | | `embedding` (chunks) | Voyage (לא-LLM-reasoning) | ⚠ לא-GENERATED ([gap-audit GAP-09](gap-audit.md)) |
| כל `halachot` | OPUS | נכנס pending_review | | כל `halachot` | OPUS | נכנס pending_review |
### 2ב. החלטה פנימית (`case_law`, source_kind=`internal_committee`) ### 2ב. החלטה פנימית (`case_law`, source_kind=`internal_committee`)
כמו 2א, ובנוסף: `case_number` **חובה**; `chair_name`/`district`/`proceeding_type` — CHAIR או OPUS או DERIVED; כמו 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. ריקים ומטופל כריק ע"י ה-extractor.
### 2ג. מסמך-תיק (`documents`) ### 2ג. מסמך-תיק (`documents`)

View File

@@ -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 # Stable, arbitrary key for the session-level advisory lock that serialises
# schema DDL across processes. Every short-lived process (cron drains, services) # schema DDL across processes. Every short-lived process (cron drains, services)
# re-runs the idempotent migrations on startup; without this lock two processes # 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) await _apply_schema_ddl(conn)
finally: finally:
await conn.execute("SELECT pg_advisory_unlock($1)", _MIGRATION_LOCK_KEY) 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: 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_V39_SQL)
await conn.execute(SCHEMA_V40_SQL) await conn.execute(SCHEMA_V40_SQL)
await conn.execute(SCHEMA_V41_SQL) await conn.execute(SCHEMA_V41_SQL)
await conn.execute(SCHEMA_V42_SQL)
async def init_schema() -> None: 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='cited_only' (auto-discovered), promote it to
source_kind='external_upload' and fill in the missing fields. 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() pool = await get_pool()
tags_json = json.dumps(subject_tags or [], ensure_ascii=False) tags_json = json.dumps(subject_tags or [], ensure_ascii=False)
async with pool.acquire() as conn: async with pool.acquire() as conn:
@@ -4317,7 +4344,7 @@ async def create_internal_committee_decision(
appeal_subtype: str = "", appeal_subtype: str = "",
subject_tags: list[str] | None = None, subject_tags: list[str] | None = None,
summary: str = "", summary: str = "",
is_binding: bool = True, is_binding: bool = False,
document_id: UUID | None = None, document_id: UUID | None = None,
proceeding_type: str = "ערר", proceeding_type: str = "ערר",
) -> dict: ) -> dict:
@@ -4327,6 +4354,10 @@ async def create_internal_committee_decision(
exist as both 'ערר' and 'בל"מ' (an extension-of-time request can be exist as both 'ערר' and 'בל"מ' (an extension-of-time request can be
filed against an existing appeal with the same number). 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() pool = await get_pool()
case_number = _canonical_case_number(case_number) case_number = _canonical_case_number(case_number)
tags_json = json.dumps(subject_tags or [], ensure_ascii=False) 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", "proceeding_type", "citation_formatted", "parties",
} }
updates = {k: v for k, v in fields.items() if k in allowed} 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: if not updates:
return await get_case_law(case_law_id) return await get_case_law(case_law_id)

View File

@@ -77,7 +77,7 @@ async def _create_internal_record(**kw) -> dict:
appeal_subtype=(kw.get("appeal_subtype") or "").strip(), appeal_subtype=(kw.get("appeal_subtype") or "").strip(),
subject_tags=list(kw.get("subject_tags") or []), subject_tags=list(kw.get("subject_tags") or []),
summary=(kw.get("summary") or "").strip(), 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"), document_id=kw.get("document_id"),
proceeding_type=kw.get("proceeding_type") or "ערר", proceeding_type=kw.get("proceeding_type") or "ערר",
) )
@@ -108,7 +108,7 @@ async def ingest_internal_decision(
appeal_subtype: str = "", appeal_subtype: str = "",
subject_tags: list[str] | None = None, subject_tags: list[str] | None = None,
summary: str = "", summary: str = "",
is_binding: bool = True, is_binding: bool = False, # INV-DM7: committee sources are persuasive
file_path: str | Path | None = None, file_path: str | Path | None = None,
text: str | None = None, text: str | None = None,
document_id: UUID | 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( await conn.execute(
"""UPDATE case_law """UPDATE case_law
SET source_kind = 'internal_committee', SET source_kind = 'internal_committee',
is_binding = FALSE, -- INV-DM7: committee = persuasive
district = CASE WHEN $2 <> '' THEN $2 ELSE district END district = CASE WHEN $2 <> '' THEN $2 ELSE district END
WHERE id = $1""", WHERE id = $1""",
row["id"], district, row["id"], district,

View File

@@ -7141,7 +7141,7 @@ async def internal_decisions_upload(
practice_area: str = Form(""), practice_area: str = Form(""),
appeal_subtype: str = Form(""), appeal_subtype: str = Form(""),
subject_tags: 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(""), summary: str = Form(""),
): ):
"""Upload a planning appeals-committee decision to the internal corpus. """Upload a planning appeals-committee decision to the internal corpus.