From a121f79d6a732445d90bd2c6bd6aedb5677cca07 Mon Sep 17 00:00:00 2001 From: Chaim Date: Sat, 30 May 2026 21:29:26 +0000 Subject: [PATCH] feat(audit): log_action_safe + V22 blocks_stale + citation resolver (FU-7) Co-Authored-By: Claude Sonnet 4.6 --- mcp-server/src/legal_mcp/services/audit.py | 20 +++++++++++ mcp-server/src/legal_mcp/services/db.py | 42 +++++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/mcp-server/src/legal_mcp/services/audit.py b/mcp-server/src/legal_mcp/services/audit.py index f1e648a..9992499 100644 --- a/mcp-server/src/legal_mcp/services/audit.py +++ b/mcp-server/src/legal_mcp/services/audit.py @@ -44,6 +44,26 @@ async def log_action( json.dumps(details or {}, ensure_ascii=False)[:200]) +async def log_action_safe( + action: str, + case_id: "UUID | None" = None, + document_id: "UUID | None" = None, + details: dict | None = None, + user: str = "system", +) -> None: + """Non-fatal audit: never let an audit-log failure break the caller's action. + + The authoritative integrity trail is git (X5 §2.1); audit_log is the + 'who/what/when' observability layer, so a write failure is logged as a + warning and swallowed. + """ + try: + await log_action(action, case_id=case_id, document_id=document_id, + details=details, user=user) + except Exception as e: # noqa: BLE001 — observability must not break the op + logger.warning("audit log_action failed (non-fatal) for %s: %s", action, e) + + async def get_audit_log( case_id: UUID | None = None, action: str | None = None, diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 632746e..0db67a0 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -1106,6 +1106,16 @@ CREATE INDEX IF NOT EXISTS idx_case_law_searchable ON case_law (searchable); """ +# ── V22: cases.blocks_stale — DOCX↔blocks drift flag (GAP-17 / INV-EX1) ── +# Set true when revise_draft/apply_user_edit make active_draft_path the live +# source-of-truth without re-syncing decision_blocks; cleared when blocks are +# re-exported or re-saved. Surfaced by health-check. Source-of-truth remains +# decision_blocks — this only flags known drift (no fragile DOCX→blocks reparse). +SCHEMA_V22_SQL = """ +ALTER TABLE cases ADD COLUMN IF NOT EXISTS blocks_stale boolean NOT NULL DEFAULT false; +""" + + async def _run_schema_migrations(pool: asyncpg.Pool) -> None: async with pool.acquire() as conn: await conn.execute(SCHEMA_SQL) @@ -1130,7 +1140,8 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None: await conn.execute(SCHEMA_V19_SQL) await conn.execute(SCHEMA_V20_SQL) await conn.execute(SCHEMA_V21_SQL) - logger.info("Database schema initialized (v1-v21)") + await conn.execute(SCHEMA_V22_SQL) + logger.info("Database schema initialized (v1-v22)") async def init_schema() -> None: @@ -1206,6 +1217,35 @@ async def get_active_draft_path(case_id: UUID) -> str | None: return row["active_draft_path"] if row else None +async def mark_blocks_stale(case_id: UUID, stale: bool) -> None: + """Flag/clear DOCX↔blocks drift for a case (GAP-17).""" + pool = await get_pool() + async with pool.acquire() as conn: + await conn.execute( + "UPDATE cases SET blocks_stale = $1, updated_at = now() WHERE id = $2", + stale, case_id, + ) + + +async def resolve_citation_case_law_ids(ids) -> dict: + """Structural citation→corpus resolution (GAP-20 / INV-AUD3). + + Given case_law_id values referenced by a decision's citations/provenance, + split into resolvable (exist in case_law) vs unresolvable. + """ + resolved, unresolved = [], [] + pool = await get_pool() + async with pool.acquire() as conn: + for cid in ids: + try: + exists = await conn.fetchval( + "SELECT EXISTS(SELECT 1 FROM case_law WHERE id = $1)", cid) + except Exception: + exists = False + (resolved if exists else unresolved).append(cid) + return {"resolved": resolved, "unresolved": unresolved} + + def _normalize_case_number(s: str) -> str: """Canonicalise a case number for tolerant lookup.