"""Postgres persistence for the G7 consent-bound multimodal ledger.""" from __future__ import annotations import hashlib import json from collections.abc import Mapping, Sequence from datetime import UTC, datetime, timedelta from typing import Any from uuid import NAMESPACE_URL, UUID, uuid4, uuid5 import asyncpg from .. import db from ..config import settings from ..contracts.multimodal_alliance import ( AlignedVoiceTimeline, FusionCalibration, ModalityAxisMeasurement, ) from ..deps import Principal, Role from .multimodal_alliance import build_calibrated_axis_read_model class MultimodalAllianceError(RuntimeError): pass class MultimodalAllianceNotFoundError(MultimodalAllianceError): pass class MultimodalAllianceConflictError(MultimodalAllianceError): pass class MultimodalAllianceStateError(MultimodalAllianceError): pass class MultimodalConsentRequiredError(MultimodalAllianceStateError): pass class MultimodalConsentWithdrawnError(MultimodalAllianceStateError): pass def _value(row: Any, key: str, default: Any = None) -> Any: if row is None: return default if isinstance(row, Mapping): return row.get(key, default) try: return row[key] except (KeyError, TypeError): return getattr(row, key, default) def _canonical_hash(payload: object) -> str: encoded = json.dumps( payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str, ).encode("utf-8") return hashlib.sha256(encoded).hexdigest() async def _visible_session(conn: asyncpg.Connection, session_id: UUID) -> Any: row = await conn.fetchrow( "SELECT id, learner_id FROM app.sessions WHERE id = $1", session_id, ) if row is None: raise MultimodalAllianceNotFoundError("session not found or not visible") return row async def _latest_consent(conn: asyncpg.Connection, session_id: UUID) -> Any: return await conn.fetchrow( """ SELECT consent_snapshot_id, learner_id, sequence_no, consent_status, retain_audio, retain_derived_features, transcript_retained, retention_days, policy_version, reason_code, created_at FROM app.multimodal_consent_snapshot WHERE session_id = $1 ORDER BY sequence_no DESC LIMIT 1 """, session_id, ) async def _lock_consent_processing( conn: asyncpg.Connection, session_id: UUID ) -> None: """Serialize consent mutation with all consent-bound persistence. The database triggers still enforce the latest snapshot at INSERT time. This shared transaction lock additionally gives concurrent withdrawal and evaluator persistence one unambiguous order, so a writer that starts after withdrawal commits cannot persist derived voice data from a stale grant. """ await conn.execute( "SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))", f"multimodal-consent:{session_id}", ) async def _existing_request( conn: asyncpg.Connection, *, submission_id: UUID, request_kind: str, content_hash: str, session_id: UUID, learner_id: UUID, ) -> UUID | None: row = await conn.fetchrow( """ SELECT request_kind, content_hash, session_id, learner_id, result_id FROM app.multimodal_ingestion_request WHERE submission_id = $1 """, submission_id, ) if row is None: return None if ( _value(row, "request_kind") != request_kind or _value(row, "content_hash") != content_hash or UUID(str(_value(row, "session_id"))) != session_id or UUID(str(_value(row, "learner_id"))) != learner_id ): raise MultimodalAllianceConflictError( "submission_id was already used with different multimodal content" ) return UUID(str(_value(row, "result_id"))) async def _insert_request( conn: asyncpg.Connection, *, submission_id: UUID, content_hash: str, request_kind: str, session_id: UUID, learner_id: UUID, result_id: UUID, created_by_role: str, ) -> None: await conn.execute( """ INSERT INTO app.multimodal_ingestion_request ( submission_id, content_hash, request_kind, session_id, learner_id, result_id, created_by_role ) VALUES ($1,$2,$3,$4,$5,$6,$7) """, submission_id, content_hash, request_kind, session_id, learner_id, result_id, created_by_role, ) def _withdrawal_ids(submission_id: UUID) -> tuple[UUID, UUID]: deletion_submission_id = uuid5( NAMESPACE_URL, f"vignette:g7:withdrawal-submission:{submission_id}" ) deletion_request_id = uuid5( NAMESPACE_URL, f"vignette:g7:withdrawal-request:{submission_id}" ) return deletion_submission_id, deletion_request_id async def _insert_deletion_request( conn: asyncpg.Connection, *, submission_id: UUID, deletion_request_id: UUID, session_id: UUID, learner_id: UUID, scopes: Sequence[str], request_reason: str, requested_by_uid: UUID, created_by_role: str, ) -> tuple[UUID, bool]: unique_scopes = tuple(sorted(set(scopes))) if not unique_scopes or not set(unique_scopes) <= { "audio", "derived_features", "transcript", }: raise MultimodalAllianceStateError("unsupported deletion scope") payload = { "session_id": str(session_id), "learner_id": str(learner_id), "scopes": unique_scopes, "request_reason": request_reason, "requested_by_uid": str(requested_by_uid), } content_hash = _canonical_hash(payload) existing = await _existing_request( conn, submission_id=submission_id, request_kind="deletion_request", content_hash=content_hash, session_id=session_id, learner_id=learner_id, ) if existing is not None: return existing, True await _insert_request( conn, submission_id=submission_id, content_hash=content_hash, request_kind="deletion_request", session_id=session_id, learner_id=learner_id, result_id=deletion_request_id, created_by_role=created_by_role, ) await conn.execute( """ INSERT INTO app.multimodal_deletion_request ( deletion_request_id, submission_id, session_id, learner_id, scopes, request_reason, requested_by_uid ) VALUES ($1,$2,$3,$4,$5::text[],$6,$7) """, deletion_request_id, submission_id, session_id, learner_id, list(unique_scopes), request_reason, requested_by_uid, ) return deletion_request_id, False async def append_consent_snapshot( *, principal: Principal, session_id: UUID, submission_id: UUID, consent_status: str, retain_audio: bool, retain_derived_features: bool, transcript_retained: bool, retention_days: int | None, policy_version: str, reason_code: str | None, ) -> dict[str, Any]: if principal.role != Role.LEARNER: raise MultimodalAllianceStateError( "only the learner may change multimodal consent" ) if consent_status not in {"granted", "withdrawn", "not_granted"}: raise MultimodalAllianceStateError("unsupported consent status") if not transcript_retained: raise MultimodalAllianceStateError( "G7 deletes audio and derived features only; masked transcript remains retained" ) if consent_status == "granted": if not retain_derived_features or retention_days is None: raise MultimodalAllianceStateError( "granted multimodal consent requires derived retention and expiry" ) elif retain_audio or retain_derived_features or retention_days is not None: raise MultimodalAllianceStateError( "ungranted consent must not retain audio or derived features" ) learner_id = UUID(principal.user_id) payload = { "session_id": str(session_id), "consent_status": consent_status, "retain_audio": retain_audio, "retain_derived_features": retain_derived_features, "transcript_retained": transcript_retained, "retention_days": retention_days, "policy_version": policy_version.strip(), "reason_code": reason_code.strip() if reason_code else None, } content_hash = _canonical_hash(payload) consent_snapshot_id = uuid4() deletion_request_id: UUID | None = None async with db.acquire( role=principal.role.value, user_id=principal.user_id, cohort_ids=principal.cohort_ids, ) as conn: await _lock_consent_processing(conn, session_id) session = await _visible_session(conn, session_id) if UUID(str(_value(session, "learner_id"))) != learner_id: raise MultimodalAllianceNotFoundError("session not found or not visible") existing = await _existing_request( conn, submission_id=submission_id, request_kind="consent", content_hash=content_hash, session_id=session_id, learner_id=learner_id, ) if existing is not None: deletion = await conn.fetchrow( """ SELECT deletion_request_id FROM app.multimodal_deletion_request WHERE session_id = $1 AND request_reason = 'consent_withdrawal' ORDER BY requested_at DESC LIMIT 1 """, session_id, ) return { "submission_id": submission_id, "consent_snapshot_id": existing, "consent_status": consent_status, "deletion_request_id": ( UUID(str(_value(deletion, "deletion_request_id"))) if deletion is not None else None ), "idempotent_replay": True, } latest = await _latest_consent(conn, session_id) if latest is not None and _value(latest, "consent_status") == "withdrawn": raise MultimodalAllianceConflictError( "withdrawn multimodal consent is terminal for this session" ) sequence_no = int(_value(latest, "sequence_no", 0)) + 1 await _insert_request( conn, submission_id=submission_id, content_hash=content_hash, request_kind="consent", session_id=session_id, learner_id=learner_id, result_id=consent_snapshot_id, created_by_role="learner", ) try: await conn.execute( """ INSERT INTO app.multimodal_consent_snapshot ( consent_snapshot_id, submission_id, session_id, learner_id, sequence_no, consent_status, retain_audio, retain_derived_features, transcript_retained, retention_days, policy_version, reason_code, created_by_uid ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) """, consent_snapshot_id, submission_id, session_id, learner_id, sequence_no, consent_status, retain_audio, retain_derived_features, transcript_retained, retention_days, payload["policy_version"], payload["reason_code"], learner_id, ) if consent_status == "withdrawn": deletion_submission_id, deletion_request_id = _withdrawal_ids( submission_id ) await _insert_deletion_request( conn, submission_id=deletion_submission_id, deletion_request_id=deletion_request_id, session_id=session_id, learner_id=learner_id, scopes=("audio", "derived_features"), request_reason="consent_withdrawal", requested_by_uid=learner_id, created_by_role="learner", ) except (asyncpg.CheckViolationError, asyncpg.ForeignKeyViolationError) as exc: raise MultimodalAllianceStateError( "multimodal consent violated ownership or retention invariants" ) from exc except asyncpg.UniqueViolationError as exc: raise MultimodalAllianceConflictError( "multimodal consent submission already exists" ) from exc return { "submission_id": submission_id, "consent_snapshot_id": consent_snapshot_id, "consent_status": consent_status, "deletion_request_id": deletion_request_id, "idempotent_replay": False, } async def assert_voice_processing_allowed( *, principal: Principal, session_id: str ) -> dict[str, Any] | None: """Fail closed for enrolled sessions after refusal/withdrawal. A database without the G7 migration is treated as a legacy, non-enrolled deployment so the pre-existing voice cascade remains available. Any other database error propagates before STT is invoked. """ try: session_uuid = UUID(session_id) except ValueError: return None try: 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_uuid) consent = await _latest_consent(conn, session_uuid) except ( RuntimeError, asyncpg.UndefinedTableError, MultimodalAllianceNotFoundError, ) as exc: if settings.environment == "dev": return None raise MultimodalAllianceStateError( "multimodal consent state is unavailable; voice processing is blocked" ) from exc if consent is None: return None status = str(_value(consent, "consent_status")) if status == "withdrawn": raise MultimodalConsentWithdrawnError( "multimodal consent was withdrawn; new voice processing is blocked" ) if status != "granted": raise MultimodalConsentRequiredError( "multimodal voice processing consent was not granted" ) return { "consent_snapshot_id": UUID(str(_value(consent, "consent_snapshot_id"))), "retain_audio": bool(_value(consent, "retain_audio")), "retain_derived_features": bool(_value(consent, "retain_derived_features")), "retention_days": int(_value(consent, "retention_days")), } async def append_runtime_timeline( *, session_id: str, submission_id: UUID, timeline: AlignedVoiceTimeline, audio_asset: Mapping[str, Any] | None, ) -> dict[str, Any] | None: """Append a consent-bound runtime STT timeline using evaluator RLS. Legacy development sessions may run without the G7 migration. Staging and production never reinterpret a missing schema/session as permission. """ try: session_uuid = UUID(session_id) except ValueError as exc: if settings.environment == "dev": return None raise MultimodalAllianceStateError( "multimodal timeline session identity is invalid" ) from exc try: async with db.acquire(ai_view="evaluator", ai_context=True) as conn: await _visible_session(conn, session_uuid) consent = await _latest_consent(conn, session_uuid) if consent is None: return None consent_status = str(_value(consent, "consent_status")) if consent_status == "withdrawn": raise MultimodalConsentWithdrawnError( "multimodal consent was withdrawn; timeline persistence is blocked" ) if consent_status != "granted": raise MultimodalConsentRequiredError( "multimodal voice processing consent was not granted" ) if not bool(_value(consent, "retain_derived_features")): return { "submission_id": submission_id, "retention": "derived_features_disabled", } retained_audio_asset = ( audio_asset if bool(_value(consent, "retain_audio")) else None ) if retained_audio_asset is None and bool(_value(consent, "retain_audio")): raise MultimodalAllianceStateError( "consented audio retention requires runtime audio metadata" ) return await append_timeline( conn=conn, session_id=session_uuid, submission_id=submission_id, timeline=timeline, audio_asset=retained_audio_asset, ) except MultimodalAllianceNotFoundError as exc: if settings.environment == "dev": return None raise MultimodalAllianceStateError( "multimodal timeline session is unavailable" ) from exc except MultimodalAllianceError: raise except (RuntimeError, asyncpg.UndefinedTableError) as exc: if settings.environment == "dev": return None raise MultimodalAllianceStateError( "multimodal timeline persistence is unavailable" ) from exc async def append_timeline( *, conn: asyncpg.Connection, session_id: UUID, submission_id: UUID, timeline: AlignedVoiceTimeline, audio_asset: Mapping[str, Any] | None, ) -> dict[str, Any]: await _lock_consent_processing(conn, session_id) await conn.execute( "SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))", f"multimodal-timeline:{session_id}", ) session = await _visible_session(conn, session_id) learner_id = UUID(str(_value(session, "learner_id"))) consent = await _latest_consent(conn, session_id) if consent is None or _value(consent, "consent_status") != "granted": raise MultimodalConsentRequiredError( "latest granted consent is required before voice processing" ) if not bool(_value(consent, "retain_derived_features")): raise MultimodalConsentRequiredError( "derived feature processing was not granted" ) retain_audio = bool(_value(consent, "retain_audio")) if retain_audio != (audio_asset is not None): raise MultimodalAllianceStateError( "audio metadata must exactly follow the consent retention snapshot" ) payload = { "session_id": str(session_id), "timeline": timeline.model_dump(mode="json"), "audio_asset": dict(audio_asset) if audio_asset is not None else None, "consent_snapshot_id": str(_value(consent, "consent_snapshot_id")), } content_hash = _canonical_hash(payload) existing = await _existing_request( conn, submission_id=submission_id, request_kind="timeline", content_hash=content_hash, session_id=session_id, learner_id=learner_id, ) if existing is not None: return { "submission_id": submission_id, "timeline_id": existing, "idempotent_replay": True, } timeline_id = uuid4() audio_asset_id = uuid4() if audio_asset is not None else None await _insert_request( conn, submission_id=submission_id, content_hash=content_hash, request_kind="timeline", session_id=session_id, learner_id=learner_id, result_id=timeline_id, created_by_role="evaluator", ) try: if audio_asset is not None: retention_days = int(_value(consent, "retention_days")) retained_until = datetime.now(UTC) + timedelta(days=retention_days) await conn.execute( """ INSERT INTO app.multimodal_audio_asset ( audio_asset_id, timeline_submission_id, session_id, learner_id, consent_snapshot_id, audio_ref, audio_sha256, media_type, byte_size, duration_ms, retained_until ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) """, audio_asset_id, submission_id, session_id, learner_id, _value(consent, "consent_snapshot_id"), str(audio_asset["audio_ref"]), str(audio_asset["audio_sha256"]), str(audio_asset["media_type"]), int(audio_asset["byte_size"]), timeline.audio_duration_ms, retained_until, ) await conn.execute( """ INSERT INTO app.multimodal_audio_timeline ( timeline_id, submission_id, session_id, learner_id, consent_snapshot_id, audio_asset_id, audio_duration_ms, word_count, event_count ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) """, timeline_id, submission_id, session_id, learner_id, _value(consent, "consent_snapshot_id"), audio_asset_id, timeline.audio_duration_ms, len(timeline.words), len(timeline.events), ) for word in timeline.words: await conn.execute( """ INSERT INTO app.multimodal_word_timestamp ( word_timestamp_id, timeline_id, session_id, learner_id, word_index, start_ms, end_ms, speaker, token_hash ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) """, uuid4(), timeline_id, session_id, learner_id, word.word_index, word.start_ms, word.end_ms, word.speaker, word.token_hash, ) for event in timeline.events: await conn.execute( """ INSERT INTO app.multimodal_voice_event ( voice_event_record_id, timeline_id, session_id, learner_id, event_id, event_type, start_ms, end_ms, actor, observed_feature, uncertainty, source ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) """, uuid4(), timeline_id, session_id, learner_id, event.event_id, event.event_type, event.start_ms, event.end_ms, event.actor, event.observed_feature, event.uncertainty, event.source, ) await conn.execute( "SET CONSTRAINTS app.trg_multimodal_timeline_counts, " "app.trg_multimodal_word_counts, app.trg_multimodal_event_counts " "IMMEDIATE" ) except (asyncpg.CheckViolationError, asyncpg.ForeignKeyViolationError) as exc: raise MultimodalAllianceStateError( "multimodal timeline violated consent, clock, or observation invariants" ) from exc except asyncpg.UniqueViolationError as exc: raise MultimodalAllianceConflictError( "multimodal timeline submission already exists" ) from exc return { "submission_id": submission_id, "timeline_id": timeline_id, "audio_asset_id": audio_asset_id, "idempotent_replay": False, } def _measurement_values( measurement: ModalityAxisMeasurement, provenance: Mapping[str, str], ) -> tuple[Any, ...]: return ( measurement.measurement_id, measurement.axis, measurement.modality, measurement.status, measurement.value, measurement.confidence, measurement.uncertainty, list(measurement.evidence_refs), measurement.model_run_id, provenance["instrument_id"], provenance["instrument_version"], provenance["model_name"], provenance["prompt_version"], f"model_inferred_{measurement.modality}", measurement.error_code, ) async def append_measurement_fusion( *, conn: asyncpg.Connection, session_id: UUID, submission_id: UUID, text: ModalityAxisMeasurement, voice: ModalityAxisMeasurement, calibration: FusionCalibration, text_provenance: Mapping[str, str], voice_provenance: Mapping[str, str], ) -> dict[str, Any]: if text.modality != "text" or voice.modality != "voice": raise MultimodalAllianceStateError( "text and voice measurements must keep independent modality provenance" ) if text.status != "ready": raise MultimodalAllianceStateError( "a ready text measurement is required as the fail-safe baseline" ) await _lock_consent_processing(conn, session_id) session = await _visible_session(conn, session_id) learner_id = UUID(str(_value(session, "learner_id"))) consent = await _latest_consent(conn, session_id) consent_status = _value(consent, "consent_status") if consent is not None else None if voice.status == "ready" and consent_status != "granted": raise MultimodalConsentRequiredError( "ready voice measurement requires current granted consent" ) if consent_status == "withdrawn" and voice.status != "missing": raise MultimodalConsentWithdrawnError( "withdrawal permits only an explicit missing voice measurement" ) result = build_calibrated_axis_read_model( text=text, voice=voice, calibration=calibration, ) payload = { "session_id": str(session_id), "text": text.model_dump(mode="json"), "voice": voice.model_dump(mode="json"), "calibration": calibration.model_dump(mode="json"), "text_provenance": dict(text_provenance), "voice_provenance": dict(voice_provenance), } content_hash = _canonical_hash(payload) await conn.execute( "SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))", f"multimodal-measurement:{submission_id}", ) existing = await _existing_request( conn, submission_id=submission_id, request_kind="measurement_fusion", content_hash=content_hash, session_id=session_id, learner_id=learner_id, ) if existing is not None: return { "submission_id": submission_id, "fusion_record_id": existing, "result": result.model_dump(mode="json"), "idempotent_replay": True, } fusion_record_id = uuid4() await _insert_request( conn, submission_id=submission_id, content_hash=content_hash, request_kind="measurement_fusion", session_id=session_id, learner_id=learner_id, result_id=fusion_record_id, created_by_role="evaluator", ) try: for measurement, provenance in ( (text, text_provenance), (voice, voice_provenance), ): await conn.execute( """ INSERT INTO app.multimodal_axis_measurement ( measurement_record_id, submission_id, session_id, learner_id, measurement_id, axis, modality, status, value, confidence, uncertainty, evidence_refs, model_run_id, instrument_id, instrument_version, model_name, prompt_version, source_kind, error_code ) VALUES ( $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::text[],$13,$14,$15, $16,$17,$18,$19 ) """, uuid4(), submission_id, session_id, learner_id, *_measurement_values(measurement, provenance), ) await conn.execute( """ INSERT INTO app.multimodal_fusion_decision ( fusion_record_id, submission_id, session_id, learner_id, axis, status, value, uncertainty, modalities_used, measurement_ids, fusion_applied, calibration_id, benchmark_version, text_weight, voice_weight, text_only_accuracy, fused_accuracy, minimum_incremental_gain, incremental_gain, counterevidence ) VALUES ( $1,$2,$3,$4,$5,$6,$7,$8,$9::text[],$10::text[],$11,$12,$13,$14, $15,$16,$17,$18,$19,$20::text[] ) """, fusion_record_id, submission_id, session_id, learner_id, result.axis, result.status, result.value, result.uncertainty, list(result.modalities_used), list(result.measurement_ids), result.fusion_applied, result.fusion_calibration_id, calibration.benchmark_version, calibration.text_weight, calibration.voice_weight, calibration.text_only_accuracy, calibration.fused_accuracy, calibration.minimum_incremental_gain, calibration.incremental_gain, list(result.counterevidence), ) except (asyncpg.CheckViolationError, asyncpg.ForeignKeyViolationError) as exc: raise MultimodalAllianceStateError( "multimodal measurement violated provenance or fusion invariants" ) from exc except asyncpg.UniqueViolationError as exc: raise MultimodalAllianceConflictError( "multimodal measurement submission already exists" ) from exc return { "submission_id": submission_id, "fusion_record_id": fusion_record_id, "result": result.model_dump(mode="json"), "idempotent_replay": False, } async def append_deletion_request( *, principal: Principal, session_id: UUID, submission_id: UUID, scopes: Sequence[str], request_reason: str = "learner_request", ) -> dict[str, Any]: if principal.role not in {Role.LEARNER, Role.ADMIN}: raise MultimodalAllianceStateError( "multimodal deletion request requires learner or admin role" ) learner_actor = UUID(principal.user_id) deletion_request_id = uuid4() async with db.acquire( role=principal.role.value, user_id=principal.user_id, cohort_ids=principal.cohort_ids, ) as conn: session = await _visible_session(conn, session_id) learner_id = UUID(str(_value(session, "learner_id"))) if principal.role == Role.LEARNER and learner_id != learner_actor: raise MultimodalAllianceNotFoundError("session not found or not visible") await conn.execute( "SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))", f"multimodal-deletion:{session_id}", ) deletion_request_id, replay = await _insert_deletion_request( conn, submission_id=submission_id, deletion_request_id=deletion_request_id, session_id=session_id, learner_id=learner_id, scopes=scopes, request_reason=request_reason, requested_by_uid=learner_actor, created_by_role=principal.role.value, ) return { "submission_id": submission_id, "deletion_request_id": deletion_request_id, "idempotent_replay": replay, } async def request_expired_retention_deletions( *, conn: asyncpg.Connection, as_of: datetime, limit: int = 100, ) -> list[dict[str, Any]]: """Materialize deterministic deletion work for expired raw audio assets.""" rows = await conn.fetch( """ SELECT a.audio_asset_id, a.session_id, a.learner_id FROM app.multimodal_audio_asset a WHERE a.retained_until <= $1 AND NOT EXISTS ( SELECT 1 FROM audit.multimodal_deletion_tombstone t WHERE t.session_id = a.session_id AND t.scope = 'audio' ) ORDER BY a.retained_until, a.audio_asset_id LIMIT $2 """, as_of, max(1, min(int(limit), 100)), ) results: list[dict[str, Any]] = [] for row in rows: audio_asset_id = UUID(str(_value(row, "audio_asset_id"))) session_id = UUID(str(_value(row, "session_id"))) learner_id = UUID(str(_value(row, "learner_id"))) submission_id = uuid5( NAMESPACE_URL, f"vignette:g7:retention-submission:{audio_asset_id}", ) deletion_request_id = uuid5( NAMESPACE_URL, f"vignette:g7:retention-request:{audio_asset_id}", ) request_id, replay = await _insert_deletion_request( conn, submission_id=submission_id, deletion_request_id=deletion_request_id, session_id=session_id, learner_id=learner_id, scopes=("audio",), request_reason="retention_expired", requested_by_uid=learner_id, created_by_role="evaluator", ) results.append( { "audio_asset_id": audio_asset_id, "submission_id": submission_id, "deletion_request_id": request_id, "idempotent_replay": replay, } ) return results async def complete_deletion( *, conn: asyncpg.Connection, deletion_request_id: UUID, submission_id: UUID, tombstones: Sequence[Mapping[str, Any]], actor_uid: UUID | None, actor_kind: str, ) -> dict[str, Any]: request = await conn.fetchrow( """ SELECT deletion_request_id, session_id, learner_id, scopes FROM app.multimodal_deletion_request WHERE deletion_request_id = $1 """, deletion_request_id, ) if request is None: raise MultimodalAllianceNotFoundError( "multimodal deletion request not found or not visible" ) requested_scopes = set(_value(request, "scopes", ())) ordered_tombstones = tuple(sorted(tombstones, key=lambda item: str(item["scope"]))) supplied_scopes = {str(item["scope"]) for item in ordered_tombstones} if requested_scopes != supplied_scopes or len(supplied_scopes) != len( ordered_tombstones ): raise MultimodalAllianceStateError( "deletion completion must prove every requested scope exactly once" ) session_id = UUID(str(_value(request, "session_id"))) learner_id = UUID(str(_value(request, "learner_id"))) payload = { "deletion_request_id": str(deletion_request_id), "tombstones": [dict(item) for item in ordered_tombstones], "actor_uid": str(actor_uid) if actor_uid else None, "actor_kind": actor_kind, } content_hash = _canonical_hash(payload) await conn.execute( "SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))", f"multimodal-deletion-complete:{deletion_request_id}", ) existing = await _existing_request( conn, submission_id=submission_id, request_kind="deletion_completion", content_hash=content_hash, session_id=session_id, learner_id=learner_id, ) if existing is not None: existing_rows = await conn.fetch( """ SELECT tombstone_id FROM audit.multimodal_deletion_tombstone WHERE completion_submission_id = $1 ORDER BY scope """, submission_id, ) return { "submission_id": submission_id, "deletion_request_id": deletion_request_id, "tombstone_ids": [ UUID(str(_value(row, "tombstone_id"))) for row in existing_rows ] or [existing], "idempotent_replay": True, } tombstone_ids = [uuid4() for _ in ordered_tombstones] await _insert_request( conn, submission_id=submission_id, content_hash=content_hash, request_kind="deletion_completion", session_id=session_id, learner_id=learner_id, result_id=tombstone_ids[0], created_by_role="evaluator" if actor_kind == "retention_worker" else "admin", ) try: for tombstone_id, item in zip(tombstone_ids, ordered_tombstones, strict=True): await conn.execute( """ INSERT INTO audit.multimodal_deletion_tombstone ( tombstone_id, completion_submission_id, deletion_request_id, session_id, learner_id, scope, target_ref_hash, deletion_proof, actor_uid, actor_kind, deleted_at ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) """, tombstone_id, submission_id, deletion_request_id, session_id, learner_id, item["scope"], item["target_ref_hash"], item["deletion_proof"], actor_uid, actor_kind, item["deleted_at"], ) except (asyncpg.CheckViolationError, asyncpg.ForeignKeyViolationError) as exc: raise MultimodalAllianceStateError( "multimodal deletion proof violated audit invariants" ) from exc except asyncpg.UniqueViolationError as exc: raise MultimodalAllianceConflictError( "multimodal deletion scope was already completed" ) from exc return { "submission_id": submission_id, "deletion_request_id": deletion_request_id, "tombstone_ids": tombstone_ids, "idempotent_replay": False, } async def read_session_metadata( *, principal: Principal, session_id: UUID ) -> dict[str, Any]: async with db.acquire( role=principal.role.value, user_id=principal.user_id, cohort_ids=principal.cohort_ids, ) as conn: session = await _visible_session(conn, session_id) learner_id = UUID(str(_value(session, "learner_id"))) consent_rows = await conn.fetch( """ SELECT consent_snapshot_id, sequence_no, consent_status, retain_audio, retain_derived_features, transcript_retained, retention_days, policy_version, reason_code, created_at FROM app.multimodal_consent_snapshot WHERE session_id = $1 ORDER BY sequence_no """, session_id, ) timeline_rows = await conn.fetch( """ SELECT timeline_id, audio_duration_ms, clock_version, word_count, event_count, created_at, derived_features_available FROM app.multimodal_session_metadata_v WHERE session_id = $1 ORDER BY created_at """, session_id, ) derived_available = any( bool(_value(row, "derived_features_available")) for row in timeline_rows ) derived_deleted = bool(timeline_rows) and not derived_available words: Sequence[Any] = () events: Sequence[Any] = () if derived_available: words = await conn.fetch( """ SELECT timeline_id, word_index, start_ms, end_ms, speaker, token_hash FROM app.multimodal_word_timestamp WHERE session_id = $1 ORDER BY timeline_id, word_index """, session_id, ) events = await conn.fetch( """ SELECT timeline_id, event_id, event_type, start_ms, end_ms, actor, observed_feature, uncertainty, source, claim_scope, clinical_claim_allowed FROM app.multimodal_voice_event WHERE session_id = $1 ORDER BY timeline_id, start_ms, event_id """, session_id, ) measurements = await conn.fetch( """ SELECT measurement_id, axis, modality, status, value, confidence, uncertainty, evidence_refs, model_run_id, instrument_id, instrument_version, model_name, prompt_version, source_kind, error_code, created_at FROM app.multimodal_axis_measurement WHERE session_id = $1 AND ($2::boolean = FALSE OR modality = 'text') ORDER BY created_at, axis, modality """, session_id, derived_deleted, ) fusions = await conn.fetch( """ SELECT fusion_record_id, axis, status, value, uncertainty, modalities_used, measurement_ids, fusion_applied, calibration_id, benchmark_version, incremental_gain, counterevidence, created_at FROM app.multimodal_fusion_decision WHERE session_id = $1 AND ($2::boolean = FALSE OR NOT ('voice' = ANY(modalities_used))) ORDER BY created_at, axis """, session_id, derived_deleted, ) deletions = await conn.fetch( """ SELECT r.deletion_request_id, r.scopes, r.request_reason, r.requested_at, COALESCE(array_agg(t.scope ORDER BY t.scope) FILTER (WHERE t.scope IS NOT NULL), '{}') AS completed_scopes FROM app.multimodal_deletion_request r LEFT JOIN audit.multimodal_deletion_tombstone t ON t.deletion_request_id = r.deletion_request_id WHERE r.session_id = $1 GROUP BY r.deletion_request_id, r.scopes, r.request_reason, r.requested_at ORDER BY r.requested_at """, session_id, ) return { "session_id": session_id, "learner_id": learner_id, "clinical_claim_allowed": False, "consent_snapshots": [dict(row) for row in consent_rows], "timelines": [dict(row) for row in timeline_rows], "word_timestamps": [dict(row) for row in words], "voice_events": [dict(row) for row in events], "measurements": [dict(row) for row in measurements], "fusion_decisions": [dict(row) for row in fusions], "deletion_requests": [dict(row) for row in deletions], } async def read_raw_audio_access( *, principal: Principal, session_id: UUID ) -> list[dict[str, Any]]: if principal.role not in {Role.LEARNER, Role.ADMIN}: raise MultimodalAllianceStateError( "raw audio access is limited to the learner and administrator" ) 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) rows = await conn.fetch( """ SELECT audio_asset_id, session_id, learner_id, audio_ref, audio_sha256, media_type, byte_size, duration_ms, retained_until, created_at FROM app.multimodal_raw_audio_access_v WHERE session_id = $1 ORDER BY created_at """, session_id, ) return [dict(row) for row in rows] async def read_raw_audio_asset( *, principal: Principal, session_id: UUID, audio_asset_id: UUID ) -> dict[str, Any]: """Return one consent-valid raw-audio storage handle behind learner/admin RLS.""" if principal.role not in {Role.LEARNER, Role.ADMIN}: raise MultimodalAllianceStateError( "raw audio access is limited to the learner and administrator" ) 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) row = await conn.fetchrow( """ SELECT audio_asset_id, session_id, learner_id, audio_ref, audio_sha256, media_type, byte_size, duration_ms, retained_until, created_at FROM app.multimodal_raw_audio_access_v WHERE session_id = $1 AND audio_asset_id = $2 """, session_id, audio_asset_id, ) if row is None: raise MultimodalAllianceNotFoundError( "raw audio asset not found, expired, or deleted" ) return dict(row) __all__ = [ "MultimodalAllianceConflictError", "MultimodalAllianceNotFoundError", "MultimodalAllianceStateError", "MultimodalConsentRequiredError", "MultimodalConsentWithdrawnError", "append_consent_snapshot", "append_deletion_request", "append_measurement_fusion", "append_runtime_timeline", "append_timeline", "assert_voice_processing_allowed", "complete_deletion", "read_raw_audio_access", "read_raw_audio_asset", "read_session_metadata", "request_expired_retention_deletions", ]