"""G3 rupture/repair append-only PostgreSQL store. Internal evaluator writes run under an explicit evaluator AI view. Human reads and corrections run under RBAC/cohort RLS. Safety rows are only referenced and never used to derive lifecycle or reconciliation status. """ from __future__ import annotations import hashlib import json from collections.abc import Mapping, Sequence from typing import Any from uuid import UUID import asyncpg from .. import db from ..contracts.rupture_repair import RuptureLifecycleState, RuptureType from ..deps import Principal, Role class RuptureRepairNotFoundError(LookupError): pass class RuptureRepairStateError(ValueError): pass class RuptureRepairConflictError(RuntimeError): pass def _value(row: Mapping[str, Any], key: str, default: Any = None) -> Any: try: return row[key] except (KeyError, TypeError): return default def _canonical_hash(payload: Mapping[str, Any]) -> str: serialized = json.dumps( payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True, default=str, ) return hashlib.sha256(serialized.encode("utf-8")).hexdigest() def _human_view(principal: Principal) -> str: return "counselor" if principal.role == Role.LEARNER else "supervisor" def _created_role(principal: Principal) -> str: return "instructor" if principal.role == Role.TEACHER else principal.role.value def _ensure_unique_nonempty_evidence(evidence_turn_ids: Sequence[UUID]) -> None: if not evidence_turn_ids or len(set(evidence_turn_ids)) != len(evidence_turn_ids): raise RuptureRepairStateError( "evidence_turn_ids must be non-empty and unique" ) def _ensure_visible_to(visible_to: Sequence[str], *, required_view: str) -> tuple[str, ...]: allowed = {"counselor", "evaluator", "supervisor", "research"} normalized = tuple(dict.fromkeys(item.strip() for item in visible_to if item.strip())) if not normalized or not set(normalized) <= allowed: raise RuptureRepairStateError("visible_to contains an unsupported AI view") if required_view not in normalized: raise RuptureRepairStateError(f"visible_to must include {required_view}") return normalized async def _visible_session( conn: asyncpg.Connection, session_id: UUID ) -> Mapping[str, Any]: row = await conn.fetchrow( """ SELECT id, case_id, learner_id FROM app.sessions WHERE id = $1 """, session_id, ) if row is None: raise RuptureRepairNotFoundError("session not found or not visible") if _value(row, "case_id") is None: raise RuptureRepairStateError("rupture episode requires a case_id") return row async def _existing_by_idempotency( conn: asyncpg.Connection, *, table: str, id_column: str, session_id: UUID, idempotency_key: UUID, content_hash: str, ) -> UUID | None: if table not in { "app.rupture_observation_event", "app.rupture_reconciliation_revision", } or id_column not in {"observation_id", "revision_id"}: raise AssertionError("unsupported rupture idempotency lookup") row = await conn.fetchrow( f""" SELECT {id_column}, content_hash FROM {table} WHERE session_id = $1 AND idempotency_key = $2 """, session_id, idempotency_key, ) if row is None: return None if str(_value(row, "content_hash")) != content_hash: raise RuptureRepairConflictError( "idempotency key was already used with different rupture content" ) return UUID(str(_value(row, id_column))) async def append_evaluator_observation( *, conn: asyncpg.Connection, session_id: UUID, episode_key: str, idempotency_key: UUID, event_kind: str, from_state: RuptureLifecycleState | None, to_state: RuptureLifecycleState, rupture_type: RuptureType, source_kind: str, perspective: str, ai_view: str, confidence: float | None, uncertainty: float, evidence_turn_ids: Sequence[UUID], counterevidence: Sequence[str], model_run_id: UUID | None, safety_event_ids: Sequence[int] = (), visible_to: Sequence[str] = ( "counselor", "evaluator", "supervisor", "research", ), ) -> dict[str, UUID]: """Append one evaluator/runtime lifecycle observation with de-duplication.""" if ai_view != "evaluator": raise RuptureRepairStateError("internal observation requires ai_view=evaluator") if source_kind == "model_inferred": if perspective != "independent_observer" or model_run_id is None: raise RuptureRepairStateError( "model observation requires independent_observer and model_run_id" ) elif source_kind == "observed_runtime": if perspective != "runtime_observation": raise RuptureRepairStateError( "runtime observation requires runtime_observation perspective" ) else: raise RuptureRepairStateError( "internal observation source must be model_inferred or observed_runtime" ) _ensure_unique_nonempty_evidence(evidence_turn_ids) if len(set(safety_event_ids)) != len(safety_event_ids): raise RuptureRepairStateError("safety_event_ids must be unique") audiences = _ensure_visible_to(visible_to, required_view="evaluator") normalized_key = episode_key.strip() if not normalized_key: raise RuptureRepairStateError("episode_key must not be blank") payload = { "session_id": str(session_id), "episode_key": normalized_key, "event_kind": event_kind, "from_state": from_state, "to_state": to_state, "rupture_type": rupture_type, "source_kind": source_kind, "perspective": perspective, "ai_view": ai_view, "confidence": confidence, "uncertainty": uncertainty, "evidence_turn_ids": sorted(str(item) for item in evidence_turn_ids), "counterevidence": list(counterevidence), "model_run_id": str(model_run_id) if model_run_id else None, "safety_event_ids": sorted(safety_event_ids), "visible_to": list(audiences), } content_hash = _canonical_hash(payload) await conn.execute( "SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))", f"rupture:{session_id}:{normalized_key}", ) anchor = await _visible_session(conn, session_id) episode = await conn.fetchrow( """ INSERT INTO app.rupture_episode ( session_id, case_id, learner_id, episode_key, visible_to, created_by_role ) VALUES ($1, $2, $3, $4, $5::text[], 'agent') ON CONFLICT (session_id, episode_key) DO NOTHING RETURNING episode_id """, session_id, _value(anchor, "case_id"), _value(anchor, "learner_id"), normalized_key, list(audiences), ) if episode is None: episode = await conn.fetchrow( """ SELECT episode_id FROM app.rupture_episode WHERE session_id = $1 AND episode_key = $2 """, session_id, normalized_key, ) if episode is None: raise RuptureRepairConflictError("rupture episode could not be created") episode_id = UUID(str(_value(episode, "episode_id"))) existing = await _existing_by_idempotency( conn, table="app.rupture_observation_event", id_column="observation_id", session_id=session_id, idempotency_key=idempotency_key, content_hash=content_hash, ) if existing is not None: return {"episode_id": episode_id, "observation_id": existing} try: row = await conn.fetchrow( """ INSERT INTO app.rupture_observation_event ( episode_id, session_id, idempotency_key, content_hash, event_kind, from_state, to_state, rupture_type, source_kind, perspective, ai_view, confidence, uncertainty, evidence_turn_ids, counterevidence, model_run_id, visible_to, created_by_role ) VALUES ( $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13, $14::uuid[],$15::text[],$16,$17::text[],'agent' ) RETURNING observation_id """, episode_id, session_id, idempotency_key, content_hash, event_kind, from_state, to_state, rupture_type, source_kind, perspective, ai_view, confidence, uncertainty, list(evidence_turn_ids), list(counterevidence), model_run_id, list(audiences), ) except (asyncpg.CheckViolationError, asyncpg.ForeignKeyViolationError) as exc: raise RuptureRepairStateError( "rupture observation violated evidence/provenance/state invariants" ) from exc except asyncpg.UniqueViolationError as exc: raise RuptureRepairConflictError( "rupture observation idempotency or supersession conflict" ) from exc assert row is not None observation_id = UUID(str(_value(row, "observation_id"))) for safety_event_id in safety_event_ids: try: await conn.execute( """ INSERT INTO app.rupture_safety_reference ( episode_id, session_id, safety_event_id ) VALUES ($1,$2,$3) ON CONFLICT (episode_id, safety_event_id) DO NOTHING """, episode_id, session_id, safety_event_id, ) except (asyncpg.CheckViolationError, asyncpg.ForeignKeyViolationError) as exc: raise RuptureRepairStateError( "safety reference must belong to the rupture session" ) from exc return {"episode_id": episode_id, "observation_id": observation_id} async def append_reconciliation_revision( *, conn: asyncpg.Connection, session_id: UUID, episode_id: UUID, idempotency_key: UUID, fast_warning_observation_id: UUID, deep_observation_id: UUID | None, fast_warning_id: str, provisional_status: str, deep_status: str, disposition: str, uncertainty: float, evidence_turn_ids: Sequence[UUID], counterevidence: Sequence[str], model_run_id: UUID, ai_view: str, visible_to: Sequence[str] = ( "counselor", "evaluator", "supervisor", "research", ), ) -> dict[str, Any]: if ai_view != "evaluator": raise RuptureRepairStateError("reconciliation requires ai_view=evaluator") audiences = _ensure_visible_to(visible_to, required_view="evaluator") if len(set(evidence_turn_ids)) != len(evidence_turn_ids): raise RuptureRepairStateError("evidence_turn_ids must be unique") warning = fast_warning_id.strip() if not warning: raise RuptureRepairStateError("fast_warning_id must not be blank") payload = { "session_id": str(session_id), "episode_id": str(episode_id), "fast_warning_observation_id": str(fast_warning_observation_id), "deep_observation_id": str(deep_observation_id) if deep_observation_id else None, "fast_warning_id": warning, "provisional_status": provisional_status, "deep_status": deep_status, "disposition": disposition, "uncertainty": uncertainty, "evidence_turn_ids": sorted(str(item) for item in evidence_turn_ids), "counterevidence": list(counterevidence), "model_run_id": str(model_run_id), "visible_to": list(audiences), } content_hash = _canonical_hash(payload) await conn.execute( "SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))", f"rupture-reconciliation:{episode_id}", ) await _visible_session(conn, session_id) existing = await _existing_by_idempotency( conn, table="app.rupture_reconciliation_revision", id_column="revision_id", session_id=session_id, idempotency_key=idempotency_key, content_hash=content_hash, ) if existing is not None: existing_row = await conn.fetchrow( """ SELECT revision_no FROM app.rupture_reconciliation_revision WHERE revision_id = $1 """, existing, ) return { "episode_id": episode_id, "revision_id": existing, "revision_no": int(_value(existing_row or {}, "revision_no", 1)), } latest = await conn.fetchrow( """ SELECT revision_id, revision_no FROM app.rupture_reconciliation_revision WHERE episode_id = $1 ORDER BY revision_no DESC LIMIT 1 """, episode_id, ) revision_no = int(_value(latest or {}, "revision_no", 0)) + 1 supersedes = _value(latest or {}, "revision_id") try: row = await conn.fetchrow( """ INSERT INTO app.rupture_reconciliation_revision ( episode_id, session_id, revision_no, idempotency_key, content_hash, supersedes_revision_id, fast_warning_observation_id, deep_observation_id, fast_warning_id, provisional_status, deep_status, disposition, uncertainty, evidence_turn_ids, counterevidence, model_run_id, visible_to ) VALUES ( $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13, $14::uuid[],$15::text[],$16,$17::text[] ) RETURNING revision_id, revision_no """, episode_id, session_id, revision_no, idempotency_key, content_hash, supersedes, fast_warning_observation_id, deep_observation_id, warning, provisional_status, deep_status, disposition, uncertainty, list(evidence_turn_ids), list(counterevidence), model_run_id, list(audiences), ) except (asyncpg.CheckViolationError, asyncpg.ForeignKeyViolationError) as exc: raise RuptureRepairStateError( "reconciliation violated evidence/provenance/direction invariants" ) from exc except asyncpg.UniqueViolationError as exc: raise RuptureRepairConflictError( "reconciliation idempotency or revision conflict" ) from exc assert row is not None return { "episode_id": episode_id, "revision_id": UUID(str(_value(row, "revision_id"))), "revision_no": int(_value(row, "revision_no")), } async def append_human_correction( *, principal: Principal, session_id: UUID, episode_id: UUID, idempotency_key: UUID, supersedes_observation_id: UUID, rupture_type: RuptureType, corrected_status: str, uncertainty: float, evidence_turn_ids: Sequence[UUID], counterevidence: Sequence[str], correction_reason: str, ) -> UUID: if principal.role not in {Role.TEACHER, Role.ADMIN}: raise RuptureRepairStateError( "rupture correction requires teacher or admin role" ) _ensure_unique_nonempty_evidence(evidence_turn_ids) reason = correction_reason.strip() if not reason: raise RuptureRepairStateError("correction_reason must not be blank") if corrected_status not in {"missed", "partial", "resolved"}: raise RuptureRepairStateError("human correction requires a terminal status") payload = { "session_id": str(session_id), "episode_id": str(episode_id), "supersedes_observation_id": str(supersedes_observation_id), "rupture_type": rupture_type, "corrected_status": corrected_status, "uncertainty": uncertainty, "evidence_turn_ids": sorted(str(item) for item in evidence_turn_ids), "counterevidence": list(counterevidence), "correction_reason": reason, } content_hash = _canonical_hash(payload) async with db.acquire( role=principal.role.value, user_id=principal.user_id, cohort_ids=principal.cohort_ids, ) as conn: await conn.execute( "SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))", f"rupture-correction:{episode_id}", ) await _visible_session(conn, session_id) target = await conn.fetchrow( """ SELECT o.to_state FROM app.rupture_observation_event o WHERE o.observation_id = $1 AND o.episode_id = $2 AND o.session_id = $3 """, supersedes_observation_id, episode_id, session_id, ) if target is None: raise RuptureRepairNotFoundError( "superseded rupture observation not found or not visible" ) existing = await _existing_by_idempotency( conn, table="app.rupture_observation_event", id_column="observation_id", session_id=session_id, idempotency_key=idempotency_key, content_hash=content_hash, ) if existing is not None: return existing try: row = await conn.fetchrow( """ INSERT INTO app.rupture_observation_event ( episode_id, session_id, idempotency_key, content_hash, event_kind, from_state, to_state, rupture_type, source_kind, perspective, ai_view, uncertainty, evidence_turn_ids, counterevidence, supersedes_observation_id, correction_reason, visible_to, created_by_uid, created_by_role ) VALUES ( $1,$2,$3,$4,'human.corrected',$5,$6,$7, 'human_rated','supervisor_human','supervisor',$8, $9::uuid[],$10::text[],$11,$12, ARRAY['counselor','evaluator','supervisor','research']::text[],$13,$14 ) RETURNING observation_id """, episode_id, session_id, idempotency_key, content_hash, _value(target, "to_state"), corrected_status, rupture_type, uncertainty, list(evidence_turn_ids), list(counterevidence), supersedes_observation_id, reason, UUID(principal.user_id), _created_role(principal), ) except (asyncpg.CheckViolationError, asyncpg.ForeignKeyViolationError) as exc: raise RuptureRepairStateError( "human correction violated evidence or one-way resolution invariants" ) from exc except asyncpg.UniqueViolationError as exc: raise RuptureRepairConflictError( "human correction idempotency or supersession conflict" ) from exc assert row is not None return UUID(str(_value(row, "observation_id"))) async def read_rupture_repairs( *, principal: Principal, session_id: UUID ) -> dict[str, Any]: """Return a role-safe, non-aggregated rupture/repair read model.""" view = _human_view(principal) async with db.acquire( role=principal.role.value, user_id=principal.user_id, cohort_ids=principal.cohort_ids, ) as conn: await _visible_session(conn, session_id) episodes = list( await conn.fetch( """ SELECT episode_id, session_id, case_id, learner_id, episode_key, created_at FROM app.rupture_episode WHERE session_id = $1 AND $2 = ANY(visible_to) ORDER BY created_at, episode_id """, session_id, view, ) ) episode_ids = [UUID(str(_value(item, "episode_id"))) for item in episodes] if not episode_ids: return { "session_id": session_id, "requested_view": view, "clinical_claim_allowed": False, "episodes": [], } observations = list( await conn.fetch( """ SELECT observation_id, episode_id, sequence_no, event_kind, from_state, to_state, rupture_type, source_kind, perspective, ai_view, confidence, uncertainty, evidence_turn_ids, counterevidence, model_run_id, supersedes_observation_id, correction_reason, created_at FROM app.rupture_observation_event WHERE episode_id = ANY($1::uuid[]) AND $2 = ANY(visible_to) ORDER BY episode_id, sequence_no """, episode_ids, view, ) ) reconciliations = list( await conn.fetch( """ SELECT revision_id, episode_id, revision_no, supersedes_revision_id, fast_warning_observation_id, deep_observation_id, fast_warning_id, provisional_status, deep_status, disposition, uncertainty, evidence_turn_ids, counterevidence, model_run_id, created_at FROM app.rupture_reconciliation_revision WHERE episode_id = ANY($1::uuid[]) AND $2 = ANY(visible_to) ORDER BY episode_id, revision_no """, episode_ids, view, ) ) safety = list( await conn.fetch( """ SELECT rs.episode_id, rs.safety_event_id, se.turn_id, se.ko_risk_level, se.escalated, se.created_at FROM app.rupture_safety_reference rs JOIN app.safety_events se ON se.id = rs.safety_event_id WHERE rs.episode_id = ANY($1::uuid[]) ORDER BY rs.episode_id, rs.safety_event_id """, episode_ids, ) ) observations_by_episode: dict[UUID, list[dict[str, Any]]] = {} for row in observations: episode_id = UUID(str(_value(row, "episode_id"))) observations_by_episode.setdefault(episode_id, []).append(dict(row)) reconciliation_by_episode: dict[UUID, list[dict[str, Any]]] = {} for row in reconciliations: episode_id = UUID(str(_value(row, "episode_id"))) reconciliation_by_episode.setdefault(episode_id, []).append(dict(row)) safety_by_episode: dict[UUID, list[dict[str, Any]]] = {} for row in safety: episode_id = UUID(str(_value(row, "episode_id"))) safety_by_episode.setdefault(episode_id, []).append(dict(row)) result = [] for episode in episodes: episode_id = UUID(str(_value(episode, "episode_id"))) event_rows = observations_by_episode.get(episode_id, []) revision_rows = reconciliation_by_episode.get(episode_id, []) latest_event = event_rows[-1] if event_rows else None latest_revision = revision_rows[-1] if revision_rows else None latest_human_correction = next( ( item for item in reversed(event_rows) if _value(item, "event_kind") == "human.corrected" ), None, ) if latest_human_correction is not None: current_status = _value(latest_human_correction, "to_state") status_source = "human_correction" elif latest_revision is not None: current_status = _value(latest_revision, "deep_status") status_source = "deep_reconciliation" else: current_status = _value(latest_event or {}, "to_state") status_source = "lifecycle_event" result.append( { **dict(episode), "rupture_type": _value( latest_human_correction or latest_event or {}, "rupture_type" ), "current_status": current_status, "status_source": status_source, "observations": event_rows, "reconciliation_revisions": revision_rows, "safety_references": safety_by_episode.get(episode_id, []), } ) return { "session_id": session_id, "requested_view": view, "clinical_claim_allowed": False, "episodes": result, } __all__ = [ "RuptureRepairConflictError", "RuptureRepairNotFoundError", "RuptureRepairStateError", "append_evaluator_observation", "append_human_correction", "append_reconciliation_revision", "read_rupture_repairs", ]