fix(corpus): committee decisions are persuasive, never binding (INV-DM7)
ועדת-ערר 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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user