315 lines
12 KiB
Python
315 lines
12 KiB
Python
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)
|