from __future__ import annotations from collections.abc import Callable from datetime import date, datetime, timezone from decimal import Decimal from typing import Any from .config import Settings ConnectionFactory = Callable[[], Any] class ReaderContractError(RuntimeError): """The live database session does not match the adapter's read-only contract.""" def _json_value(value: Any) -> Any: if isinstance(value, Decimal): return float(value) if isinstance(value, (date, datetime)): return value.isoformat() if isinstance(value, tuple): return list(value) return value def _column_name(column: Any) -> str: return str(column.name) if hasattr(column, "name") else str(column[0]) def _row_dict(cursor: Any, row: Any) -> dict[str, Any] | None: if row is None: return None names = [_column_name(column) for column in cursor.description] return {name: _json_value(value) for name, value in zip(names, row, strict=True)} def _rows(cursor: Any) -> list[dict[str, Any]]: names = [_column_name(column) for column in cursor.description] return [ {name: _json_value(value) for name, value in zip(names, row, strict=True)} for row in cursor.fetchall() ] class CloudSqlClaimReader: def __init__(self, settings: Settings, *, connection_factory: ConnectionFactory | None = None): self.settings = settings self._connector: Any | None = None if connection_factory is not None: self._connection_factory = connection_factory return from google.cloud.sql.connector import Connector, IPTypes self._connector = Connector(refresh_strategy="LAZY") def connect() -> Any: return self._connector.connect( settings.instance_connection_name, "pg8000", user=settings.iam_database_user, db=settings.database, enable_iam_auth=True, ip_type=IPTypes.PRIVATE, ) self._connection_factory = connect def close(self) -> None: if self._connector is not None: self._connector.close() def check_ready(self) -> dict[str, Any]: connection = self._connection_factory() try: cursor = connection.cursor() self._begin_read_only(cursor) session = self._load_session_contract(cursor) connection.rollback() return { "ready": True, "database": session["database"], "authorization_role": self.settings.authorization_role, "transaction_read_only": True, } finally: connection.close() def read_claim(self, claim_id: str | None = None) -> dict[str, Any] | None: connection = self._connection_factory() try: cursor = connection.cursor() self._begin_read_only(cursor) session = self._load_session_contract(cursor) claim = self._load_claim(cursor, claim_id) if claim is None: connection.rollback() return None evidence = self._load_evidence(cursor, claim["id"]) edges = self._load_edges(cursor, claim["id"]) proposal_counts = self._load_proposal_counts(cursor) related_proposals = self._load_related_proposals(cursor, claim["id"]) recent_proposals = self._load_recent_proposals(cursor) connection.rollback() finally: connection.close() generated_at = datetime.now(timezone.utc).isoformat() return { "schema": "livingip.observatory-canonical-claim.v1", "generated_at": generated_at, "read_only": True, "provenance": { "gcp_project": self.settings.project_id, "cloud_sql_instance": self.settings.instance_connection_name, "database": session["database"], "database_principal": session["database_principal"], "authorization_role": self.settings.authorization_role, "transaction_read_only": True, "write_privileges_denied": bool(session["required_writes_denied"]), "service_revision": self.settings.service_revision, }, "canonical": { "relation": "public.claims", "claim": claim, "evidence_relation": "public.claim_evidence -> public.sources", "evidence": evidence, "edge_relation": "public.claim_edges", "edges": edges, }, "proposal_ledger": { "relation": "kb_stage.kb_proposals", "distinct_from_canonical": True, "status_counts": proposal_counts, "related": related_proposals, "recent": recent_proposals, "semantics": { "pending_review": "staged only; not canonical", "approved": "reviewed intent; not canonical until applied_at is set", "applied": "proposal receipt only; canonical rows remain authoritative", }, }, } @staticmethod def _begin_read_only(cursor: Any) -> None: cursor.execute("set transaction read only") cursor.execute("set local statement_timeout = '5000ms'") cursor.execute("set local lock_timeout = '1000ms'") def _load_session_contract(self, cursor: Any) -> dict[str, Any]: cursor.execute( """ select current_database() as database, current_user as database_principal, current_setting('transaction_read_only') as transaction_read_only, pg_has_role(current_user, %s, 'member') as has_authorization_role, has_table_privilege(current_user, 'public.claims', 'select') and has_table_privilege(current_user, 'public.sources', 'select') and has_table_privilege(current_user, 'public.claim_evidence', 'select') and has_table_privilege(current_user, 'public.claim_edges', 'select') and has_table_privilege(current_user, 'kb_stage.kb_proposals', 'select') as has_required_reads, not ( has_table_privilege(current_user, 'public.claims', 'insert,update,delete,truncate') or has_table_privilege(current_user, 'public.sources', 'insert,update,delete,truncate') or has_table_privilege(current_user, 'public.claim_evidence', 'insert,update,delete,truncate') or has_table_privilege(current_user, 'public.claim_edges', 'insert,update,delete,truncate') or has_table_privilege(current_user, 'kb_stage.kb_proposals', 'insert,update,delete,truncate') ) as required_writes_denied """, (self.settings.authorization_role,), ) session = _row_dict(cursor, cursor.fetchone()) if session is None: raise ReaderContractError("database session contract returned no row") if session["database"] != self.settings.database: raise ReaderContractError("database session selected an unexpected database") if session["database_principal"] != self.settings.iam_database_user: raise ReaderContractError("database session selected an unexpected principal") if session["transaction_read_only"] != "on": raise ReaderContractError("database session is not read-only") if not session["has_authorization_role"] or not session["has_required_reads"]: raise ReaderContractError("database principal lacks the exact read authorization") if not session["required_writes_denied"]: raise ReaderContractError("database principal has an unexpected write privilege") return session @staticmethod def _load_claim(cursor: Any, claim_id: str | None) -> dict[str, Any] | None: columns = """ select c.id::text as id, c.type::text as type, left(c.text, 12000) as text, c.status::text as status, c.confidence, coalesce(c.tags, '{}'::text[]) as tags, c.superseded_by::text as superseded_by, c.created_at, c.updated_at from public.claims c """ if claim_id is not None: cursor.execute(columns + " where c.id = %s::uuid", (claim_id,)) return _row_dict(cursor, cursor.fetchone()) cursor.execute( columns + """ where exists ( select 1 from public.claim_evidence ce where ce.claim_id = c.id ) order by coalesce(c.updated_at, c.created_at) desc, c.id desc limit 1 """ ) claim = _row_dict(cursor, cursor.fetchone()) if claim is not None: return claim cursor.execute(columns + " order by coalesce(c.updated_at, c.created_at) desc, c.id desc limit 1") return _row_dict(cursor, cursor.fetchone()) @staticmethod def _load_evidence(cursor: Any, claim_id: str) -> list[dict[str, Any]]: cursor.execute( """ select ce.id::text as evidence_id, ce.role::text as role, ce.weight, ce.created_at as linked_at, s.id::text as source_id, s.source_type::text as source_type, s.url, s.storage_path, left(coalesce(s.excerpt, ''), 2400) as excerpt, s.hash as content_hash, s.captured_at, s.created_at as source_created_at from public.claim_evidence ce join public.sources s on s.id = ce.source_id where ce.claim_id = %s::uuid order by ce.weight desc nulls last, ce.created_at, ce.id limit 30 """, (claim_id,), ) return _rows(cursor) @staticmethod def _load_edges(cursor: Any, claim_id: str) -> list[dict[str, Any]]: cursor.execute( """ select e.id::text as edge_id, case when e.from_claim = %s::uuid then 'outgoing' else 'incoming' end as direction, e.edge_type::text as edge_type, e.weight, case when e.from_claim = %s::uuid then e.to_claim::text else e.from_claim::text end as connected_claim_id, e.created_at from public.claim_edges e where e.from_claim = %s::uuid or e.to_claim = %s::uuid order by e.created_at, e.id limit 40 """, (claim_id, claim_id, claim_id, claim_id), ) return _rows(cursor) @staticmethod def _load_proposal_counts(cursor: Any) -> dict[str, int]: cursor.execute( "select status::text as status, count(*)::int as count " "from kb_stage.kb_proposals group by status order by status" ) return {str(status): int(count) for status, count in cursor.fetchall()} @staticmethod def _load_related_proposals(cursor: Any, claim_id: str) -> list[dict[str, Any]]: cursor.execute( """ select p.id::text as id, p.proposal_type::text as proposal_type, p.status::text as status, to_jsonb(p)->>'source_ref' as source_ref, p.reviewed_at, p.applied_at, p.updated_at from kb_stage.kb_proposals p where p.payload::text like %s order by p.updated_at desc, p.id desc limit 20 """, (f"%{claim_id}%",), ) return _rows(cursor) @staticmethod def _load_recent_proposals(cursor: Any) -> list[dict[str, Any]]: cursor.execute( """ select p.id::text as id, p.proposal_type::text as proposal_type, p.status::text as status, to_jsonb(p)->>'source_ref' as source_ref, p.reviewed_at, p.applied_at, p.updated_at from kb_stage.kb_proposals p order by p.updated_at desc, p.id desc limit 10 """ ) return _rows(cursor)