"""런타임 스키마 준비 상태 정책. 권위 스키마는 ``infra/db/init/*.sql``이다. 앱 기동 중 불완전한 스키마 보정은 로컬 개발 환경에서만 허용하고, 스테이징과 운영은 owner migration을 요구하며 fail-closed한다. """ from __future__ import annotations from dataclasses import dataclass from typing import Any, Protocol from .config import settings class SchemaConnection(Protocol): async def fetchrow(self, query: str, *args: Any) -> Any: ... @dataclass(frozen=True, slots=True) class RuntimeSchemaContract: component: str relations: tuple[str, ...] columns: tuple[str, ...] = () policies: tuple[str, ...] = () triggers: tuple[str, ...] = () indexes: tuple[str, ...] = () REVIEW_SCHEMA_CONTRACT = RuntimeSchemaContract( component="review/evaluation", relations=( "app.session_evaluation", "app.case_worksheet", "app.live_coach_events", "app.safety_events", "app.session_review_status", "app.session_share_link", "app.session_archive_state", ), columns=( "app.live_coach_events.event_type", "app.live_coach_events.credit_delta", "app.live_coach_events.credit_balance", "app.live_coach_events.reason", "app.session_review_status.worksheet_status", "app.session_review_status.worksheet_note", "app.session_review_status.worksheet_reviewed_at", ), policies=( "app.session_evaluation.p_session_evaluation_select", "app.case_worksheet.p_case_worksheet_select", "app.live_coach_events.p_live_coach_events_select", "app.safety_events.p_safety_events_select", "app.session_review_status.p_session_review_status_select", "app.session_share_link.p_session_share_select", "app.session_archive_state.p_session_archive_select", ), ) NOTIFICATION_SCHEMA_CONTRACT = RuntimeSchemaContract( component="notification", relations=("app.notification_event", "app.notification_delivery"), policies=( "app.notification_event.p_notification_event_admin_all", "app.notification_delivery.p_notification_delivery_admin_all", ), ) MEASUREMENT_SCHEMA_CONTRACT = RuntimeSchemaContract( component="outcome/alliance measurement", relations=( "app.measurement_instrument", "app.measurement_event", "app.alliance_pulse", "app.self_assessment", "audit.alliance_pulse_status_event", "audit.model_run", "ds.benchmark_case", "ds.benchmark_observation", ), columns=( "app.measurement_event.source_kind", "app.measurement_event.perspective", "app.measurement_event.instrument_id", "app.measurement_event.model_run_id", "app.measurement_event.supersedes_id", "app.measurement_event.pulse_id", "app.alliance_pulse.checkpoint", "app.alliance_pulse.status", "app.self_assessment.scores", "audit.alliance_pulse_status_event.from_status", "audit.alliance_pulse_status_event.to_status", "audit.alliance_pulse_status_event.changed_at", "audit.model_run.prompt_bundle_hash", "audit.model_run.input_evidence_hash", ), policies=( "app.measurement_instrument.p_measurement_instrument_select", "app.measurement_event.p_measurement_event_select", "app.alliance_pulse.p_alliance_pulse_select", "app.self_assessment.p_self_assessment_select", "audit.alliance_pulse_status_event.p_alliance_pulse_status_event_select", "audit.model_run.p_model_run_select", "ds.benchmark_case.p_benchmark_case_select", "ds.benchmark_observation.p_benchmark_observation_select", ), triggers=( "app.measurement_event.trg_measurement_event_append_only", "app.alliance_pulse.trg_alliance_pulse_transition_guard", "app.alliance_pulse.trg_alliance_pulse_delete_guard", "app.alliance_pulse.trg_alliance_pulse_status_audit", "app.self_assessment.trg_self_assessment_append_only", "audit.alliance_pulse_status_event.trg_alliance_pulse_status_event_append_only", "audit.model_run.trg_model_run_append_only", ), ) OUTCOME_TRAJECTORY_SCHEMA_CONTRACT = RuntimeSchemaContract( component="longitudinal outcome trajectory", relations=( "ds.synthetic_outcome_arc", "app.outcome_trajectory_revision", "app.outcome_trajectory_observation", "app.relationship_memory_event", "app.relationship_memory_projection", ), columns=( "ds.synthetic_outcome_arc.data_classification", "ds.synthetic_outcome_arc.clinical_claim_allowed", "app.outcome_trajectory_revision.supersedes_revision_id", "app.outcome_trajectory_revision.source_fingerprint", "app.outcome_trajectory_revision.assessment", "app.outcome_trajectory_observation.measurement_id", "app.outcome_trajectory_observation.status", "app.outcome_trajectory_observation.missing_reason", "app.relationship_memory_event.resolves_event_id", "app.relationship_memory_event.visible_to", "app.relationship_memory_projection.ai_view", ), policies=( "ds.synthetic_outcome_arc.p_synthetic_outcome_arc_select", "app.outcome_trajectory_revision.p_outcome_trajectory_revision_select", "app.outcome_trajectory_observation.p_outcome_trajectory_observation_select", "app.relationship_memory_event.p_relationship_memory_event_select", "app.relationship_memory_projection.p_relationship_memory_projection_select", ), triggers=( "app.measurement_event.trg_outcome_submission_turn_ownership", "ds.synthetic_outcome_arc.trg_synthetic_outcome_arc_append_only", "app.outcome_trajectory_revision.trg_outcome_trajectory_revision_append_only", "app.outcome_trajectory_observation.trg_outcome_trajectory_observation_append_only", "app.relationship_memory_event.trg_relationship_memory_link", "app.relationship_memory_event.trg_relationship_memory_event_append_only", "app.relationship_memory_projection.trg_relationship_projection_view", "app.relationship_memory_projection.trg_relationship_memory_projection_append_only", ), indexes=("app.measurement_event.uq_measurement_event_outcome_submission_axis",), ) RUPTURE_REPAIR_SCHEMA_CONTRACT = RuntimeSchemaContract( component="rupture and repair evidence ledger", relations=( "app.rupture_episode", "app.rupture_observation_event", "app.rupture_reconciliation_revision", "app.rupture_safety_reference", ), columns=( "app.rupture_episode.case_id", "app.rupture_episode.visible_to", "app.rupture_observation_event.sequence_no", "app.rupture_observation_event.evidence_turn_ids", "app.rupture_observation_event.model_run_id", "app.rupture_observation_event.supersedes_observation_id", "app.rupture_reconciliation_revision.supersedes_revision_id", "app.rupture_reconciliation_revision.disposition", "app.rupture_safety_reference.safety_event_id", ), policies=( "app.rupture_episode.p_rupture_episode_select", "app.rupture_episode.p_rupture_episode_insert", "app.rupture_observation_event.p_rupture_observation_select", "app.rupture_observation_event.p_rupture_observation_insert", "app.rupture_reconciliation_revision.p_rupture_reconciliation_select", "app.rupture_reconciliation_revision.p_rupture_reconciliation_insert", "app.rupture_safety_reference.p_rupture_safety_select", "app.rupture_safety_reference.p_rupture_safety_insert", ), triggers=( "app.rupture_episode.trg_rupture_episode_anchor", "app.rupture_episode.trg_rupture_episode_append_only", "app.rupture_observation_event.trg_rupture_observation_contract", "app.rupture_observation_event.trg_rupture_observation_append_only", "app.rupture_reconciliation_revision.trg_rupture_reconciliation_contract", "app.rupture_reconciliation_revision.trg_rupture_reconciliation_append_only", "app.rupture_safety_reference.trg_rupture_safety_contract", "app.rupture_safety_reference.trg_rupture_safety_append_only", ), indexes=("app.rupture_observation_event.uq_rupture_observation_episode_sequence",), ) DELIBERATE_PRACTICE_SCHEMA_CONTRACT = RuntimeSchemaContract( component="deliberate practice evidence ledger", relations=( "app.practice_prescription_submission", "app.practice_coaching_card", "app.practice_prescription", "app.practice_episode_submission", "app.practice_attempt_evidence", "app.competency_graph_snapshot", "app.practice_curriculum_decision_event", "app.practice_teacher_correction", ), columns=( "app.practice_prescription_submission.content_hash", "app.practice_coaching_card.evidence_turn_ids", "app.practice_coaching_card.uncertainty", "app.practice_prescription.activity_mode", "app.practice_prescription.scenario_novelty", "app.practice_episode_submission.mastery_allowed", "app.practice_episode_submission.mastery_blockers", "app.practice_attempt_evidence.client_response", "app.practice_attempt_evidence.utterance_template_id", "app.competency_graph_snapshot.supersedes_snapshot_id", "app.practice_curriculum_decision_event.selected_prescription_record_id", "app.practice_teacher_correction.supersedes_correction_id", ), policies=( "app.practice_prescription_submission.p_practice_prescription_submission_select", "app.practice_prescription_submission.p_practice_prescription_submission_insert", "app.practice_teacher_correction.p_practice_teacher_correction_select", "app.practice_teacher_correction.p_practice_teacher_correction_insert", ), triggers=( "app.practice_episode_submission.trg_practice_episode_transfer_gate", "app.practice_attempt_evidence.trg_practice_attempt_contract", "app.competency_graph_snapshot.trg_competency_snapshot_chain", "app.practice_curriculum_decision_event.trg_practice_curriculum_decision_contract", "app.practice_teacher_correction.trg_practice_teacher_correction_contract", ), indexes=( "app.practice_prescription.uq_practice_prescription_record_learner", "app.practice_attempt_evidence.idx_practice_attempt_episode_sequence", "app.competency_graph_snapshot.idx_competency_graph_snapshot_latest", "app.practice_teacher_correction.idx_practice_teacher_correction_attempt", ), ) CALIBRATION_TRANSFER_SCHEMA_CONTRACT = RuntimeSchemaContract( component="calibration mirror and unseen transfer ledger", relations=( "app.calibration_prediction_history", "app.calibration_prediction_revision", "app.calibration_prediction_lock", "app.calibration_performance_observation", "app.calibration_assessment_snapshot", "app.calibration_metacognitive_prescription", "app.calibration_transfer_suite", "app.calibration_transfer_trial", "app.calibration_transfer_assessment", "app.calibration_subgroup_drift_report", "app.calibration_teacher_review_event", "app.calibration_transfer_execution_event", ), columns=( "app.calibration_prediction_revision.supersedes_prediction_revision_id", "app.calibration_prediction_revision.predicted_success_probability", "app.calibration_prediction_lock.locked_sequence", "app.calibration_performance_observation.revealed_sequence", "app.calibration_performance_observation.uncertainty", "app.calibration_assessment_snapshot.assessment_payload", "app.calibration_metacognitive_prescription.prescription_payload", "app.calibration_transfer_suite.clinical_claim_allowed", "app.calibration_transfer_trial.relationship_style", "app.calibration_transfer_trial.phrase_family_id", "app.calibration_transfer_assessment.assessment_payload", "app.calibration_subgroup_drift_report.data_classification", "app.calibration_teacher_review_event.supersedes_review_id", "app.calibration_transfer_execution_event.original_transfer_trial_record_id", "app.calibration_transfer_execution_event.practice_session_id", "app.calibration_transfer_execution_event.normalized_evaluator_labels", "app.calibration_transfer_execution_event.source_kind", "app.calibration_transfer_execution_event.perspective", "app.calibration_transfer_execution_event.model_run_id", "app.calibration_transfer_execution_event.instrument_id", "app.calibration_transfer_execution_event.instrument_version", "app.calibration_transfer_execution_event.observer_version", "app.calibration_transfer_execution_event.evidence_turn_ids", ), policies=( "app.calibration_teacher_review_event.p_calibration_teacher_review_event_select", "app.calibration_teacher_review_event.p_calibration_teacher_review_event_insert", "app.calibration_transfer_execution_event.p_calibration_transfer_execution_event_select", "app.calibration_transfer_execution_event.p_calibration_transfer_execution_event_insert", ), triggers=( "app.calibration_prediction_revision.trg_calibration_prediction_revision_contract", "app.calibration_prediction_lock.trg_calibration_prediction_lock_contract", "app.calibration_performance_observation.trg_calibration_observation_reveal_contract", "app.calibration_assessment_snapshot.trg_calibration_assessment_chain", "app.calibration_transfer_assessment.trg_calibration_transfer_assessment_contract", "app.calibration_subgroup_drift_report.trg_calibration_subgroup_drift_contract", "app.calibration_teacher_review_event.trg_calibration_teacher_review_contract", "app.calibration_transfer_execution_event.trg_calibration_transfer_execution_contract", "app.calibration_transfer_execution_event.trg_calibration_transfer_execution_append_only", ), indexes=( "app.calibration_prediction_revision.idx_calibration_prediction_revision_latest", "app.calibration_performance_observation.idx_calibration_observation_learner_competency", "app.calibration_transfer_trial.idx_calibration_transfer_trial_suite_competency", "app.calibration_teacher_review_event.idx_calibration_teacher_review_target", "app.calibration_transfer_execution_event.idx_calibration_transfer_execution_learner_competency", "app.calibration_transfer_execution_event.idx_calibration_transfer_execution_trial", ), ) SUPERVISION_RESEARCH_SCHEMA_CONTRACT = RuntimeSchemaContract( component="supervision and research evidence ledger", relations=( "app.supervision_evidence_pointer", "app.supervision_attention_snapshot", "app.supervision_attention_item", "app.supervision_attention_reason", "app.supervision_teacher_ai_disagreement", "app.supervision_calibration_dataset_row", "audit.supervision_teacher_event", "app.supervision_curriculum_gap_snapshot", "app.supervision_evaluation_batch", "app.supervision_evaluation_observation", "app.supervision_drift_report", "app.supervision_drift_subgroup_metric", "app.supervision_phase3_manifest", "app.supervision_phase3_artifact", ), columns=( "app.supervision_evidence_pointer.consumer_view", "app.supervision_evidence_pointer.content_hash", "app.supervision_attention_item.evidence_pointer_ids", "app.supervision_attention_item.drilldown_routes", "app.supervision_teacher_ai_disagreement.raw_transcript_included", "app.supervision_calibration_dataset_row.row_hash", "audit.supervision_teacher_event.content_hash", "app.supervision_curriculum_gap_snapshot.status", "app.supervision_evaluation_batch.prompt_version", "app.supervision_drift_report.status", "app.supervision_phase3_manifest.artifact_count", "app.supervision_phase3_artifact.source_pointer_id", ), policies=( "app.supervision_evidence_pointer.p_supervision_evidence_pointer_select", "app.supervision_evidence_pointer.p_supervision_evidence_pointer_insert", "app.supervision_attention_snapshot.p_supervision_attention_snapshot_select", "app.supervision_attention_snapshot.p_supervision_attention_snapshot_insert", "app.supervision_attention_item.p_supervision_attention_item_select", "app.supervision_attention_item.p_supervision_attention_item_insert", "app.supervision_curriculum_gap_snapshot.p_supervision_curriculum_gap_select", "app.supervision_curriculum_gap_snapshot.p_supervision_curriculum_gap_insert", "app.supervision_teacher_ai_disagreement.p_supervision_teacher_ai_disagreement_insert", "app.supervision_calibration_dataset_row.p_supervision_calibration_dataset_row_insert", "audit.supervision_teacher_event.p_supervision_teacher_event_select", "audit.supervision_teacher_event.p_supervision_teacher_event_insert", ), triggers=( "app.supervision_evidence_pointer.trg_supervision_evidence_pointer_contract", "app.supervision_attention_item.trg_supervision_attention_item_contract", "app.supervision_attention_reason.trg_supervision_attention_reason_contract", "app.supervision_teacher_ai_disagreement.trg_supervision_teacher_ai_actor", "audit.supervision_teacher_event.trg_supervision_teacher_event_append_only", "audit.supervision_teacher_event.trg_supervision_teacher_event_pointer_array", ), ) CONTINUOUS_IMPROVEMENT_SCHEMA_CONTRACT = RuntimeSchemaContract( component="continuous improvement approval ledger", relations=( "app.ci_ingestion_submission", "app.ci_source_artifact", "app.ci_agentic_job", "app.ci_content_pipeline", "app.ci_red_team_review", "app.ci_red_team_finding", "app.ci_content_benchmark", "app.ci_content_qualification", "app.ci_gate_artifact", "app.ci_model_calibration_snapshot", "app.ci_model_change_gate", "app.ci_release_gate", "audit.ci_human_approval_event", "app.ci_catalog_entry", "audit.ci_lifecycle_event", "app.ci_operational_incident", "app.ci_regression_dag_node", ), columns=( "app.ci_ingestion_submission.data_classification", "app.ci_agentic_job.data_classification", "app.ci_agentic_job.status", "app.ci_content_pipeline.state", "app.ci_content_pipeline.draft_payload", "app.ci_content_qualification.gate_state", "app.ci_gate_artifact.artifact_kind", "app.ci_model_change_gate.gate_decision", "app.ci_model_change_gate.state", "app.ci_release_gate.state", "audit.ci_human_approval_event.decision", "app.ci_catalog_entry.approval_event_id", "audit.ci_lifecycle_event.event_status", "audit.ci_lifecycle_event.executor_receipt_id", "audit.ci_lifecycle_event.executor_evidence_refs", "app.ci_operational_incident.pii_included", "app.ci_regression_dag_node.depends_on_record_ids", ), policies=( "app.ci_ingestion_submission.p_ci_ingestion_submission_admin_insert", "app.ci_agentic_job.p_ci_agentic_job_select", "app.ci_agentic_job.p_ci_agentic_job_insert", "app.ci_agentic_job.p_ci_agentic_job_update", "app.ci_catalog_entry.p_ci_catalog_entry_select", "app.ci_catalog_entry.p_ci_catalog_entry_insert", "audit.ci_human_approval_event.p_ci_human_approval_select", "audit.ci_human_approval_event.p_ci_human_approval_insert", "audit.ci_lifecycle_event.p_ci_lifecycle_select", "audit.ci_lifecycle_event.p_ci_lifecycle_monitor_insert", "audit.ci_lifecycle_event.p_ci_lifecycle_admin_insert", ), triggers=( "app.ci_content_pipeline.trg_ci_content_pipeline_sources", "app.ci_agentic_job.trg_ci_agentic_job_update_contract", "app.ci_content_qualification.trg_ci_content_qualification_sources", "app.ci_model_change_gate.trg_ci_model_gate_artifacts", "app.ci_release_gate.trg_ci_release_gate_artifacts", "app.ci_regression_dag_node.trg_ci_dag_dependencies", "app.ci_red_team_review.trg_ci_red_team_review_contract", "app.ci_red_team_finding.trg_ci_red_team_finding_contract", "audit.ci_human_approval_event.trg_ci_human_approval_contract", "app.ci_catalog_entry.trg_ci_catalog_approval", "audit.ci_lifecycle_event.trg_ci_lifecycle_contract", "app.ci_model_change_gate.trg_ci_model_snapshot_contract", ), indexes=( "app.ci_agentic_job.idx_ci_agentic_job_ready", ), ) MULTIMODAL_ALLIANCE_SCHEMA_CONTRACT = RuntimeSchemaContract( component="multimodal alliance consent and evidence ledger", relations=( "app.multimodal_ingestion_request", "app.multimodal_consent_snapshot", "app.multimodal_audio_asset", "app.multimodal_audio_timeline", "app.multimodal_word_timestamp", "app.multimodal_voice_event", "app.multimodal_axis_measurement", "app.multimodal_fusion_decision", "app.multimodal_deletion_request", "audit.multimodal_deletion_tombstone", ), columns=( "app.multimodal_ingestion_request.content_hash", "app.multimodal_consent_snapshot.consent_status", "app.multimodal_audio_asset.retained_until", "app.multimodal_audio_timeline.clock_version", "app.multimodal_word_timestamp.token_hash", "app.multimodal_voice_event.claim_scope", "app.multimodal_axis_measurement.source_kind", "app.multimodal_fusion_decision.fusion_applied", "app.multimodal_fusion_decision.incremental_gain", "app.multimodal_deletion_request.scopes", "audit.multimodal_deletion_tombstone.deletion_proof", ), policies=( "app.multimodal_audio_asset.p_multimodal_audio_asset_select", "app.multimodal_ingestion_request.p_multimodal_human_ingestion_insert", "app.multimodal_consent_snapshot.p_multimodal_consent_insert", "app.multimodal_deletion_request.p_multimodal_deletion_request_insert", "audit.multimodal_deletion_tombstone.p_multimodal_tombstone_select", "audit.multimodal_deletion_tombstone.p_multimodal_tombstone_insert", ), triggers=( "app.multimodal_consent_snapshot.trg_multimodal_consent_sequence", "app.multimodal_audio_asset.trg_multimodal_audio_consent", "app.multimodal_audio_timeline.trg_multimodal_timeline_consent", "app.multimodal_word_timestamp.trg_multimodal_word_clock", "app.multimodal_voice_event.trg_multimodal_event_clock", "app.multimodal_fusion_decision.trg_multimodal_fusion_contract", "audit.multimodal_deletion_tombstone.trg_multimodal_deletion_tombstone_append_only", ), indexes=( "app.multimodal_consent_snapshot.idx_multimodal_consent_latest", "app.multimodal_audio_timeline.idx_multimodal_timeline_session", "app.multimodal_word_timestamp.idx_multimodal_word_clock", "app.multimodal_voice_event.idx_multimodal_event_clock", "app.multimodal_axis_measurement.idx_multimodal_measurement_session", "app.multimodal_deletion_request.idx_multimodal_deletion_pending", ), ) async def schema_contract_ready( conn: SchemaConnection, contract: RuntimeSchemaContract, ) -> bool: row = await conn.fetchrow( """ SELECT NOT EXISTS ( SELECT 1 FROM unnest($1::text[]) AS required(relation_name) WHERE to_regclass(required.relation_name) IS NULL ) AND NOT EXISTS ( SELECT 1 FROM unnest($2::text[]) AS required(qualified_name) WHERE NOT EXISTS ( SELECT 1 FROM information_schema.columns c WHERE c.table_schema = split_part(required.qualified_name, '.', 1) AND c.table_name = split_part(required.qualified_name, '.', 2) AND c.column_name = split_part(required.qualified_name, '.', 3) ) ) AND NOT EXISTS ( SELECT 1 FROM unnest($3::text[]) AS required(qualified_name) WHERE NOT EXISTS ( SELECT 1 FROM pg_policies p WHERE p.schemaname = split_part(required.qualified_name, '.', 1) AND p.tablename = split_part(required.qualified_name, '.', 2) AND p.policyname = split_part(required.qualified_name, '.', 3) ) ) AND NOT EXISTS ( SELECT 1 FROM unnest($4::text[]) AS required(qualified_name) WHERE NOT EXISTS ( SELECT 1 FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = split_part(required.qualified_name, '.', 1) AND c.relname = split_part(required.qualified_name, '.', 2) AND t.tgname = split_part(required.qualified_name, '.', 3) AND NOT t.tgisinternal ) ) AND NOT EXISTS ( SELECT 1 FROM unnest($5::text[]) AS required(qualified_name) WHERE NOT EXISTS ( SELECT 1 FROM pg_indexes i WHERE i.schemaname = split_part(required.qualified_name, '.', 1) AND i.tablename = split_part(required.qualified_name, '.', 2) AND i.indexname = split_part(required.qualified_name, '.', 3) ) ) AS ready """, list(contract.relations), list(contract.columns), list(contract.policies), list(contract.triggers), list(contract.indexes), ) return bool(row and row["ready"]) def runtime_schema_bootstrap_required( contract: RuntimeSchemaContract | str, *, ready: bool, ) -> bool: """로컬 보정 필요 여부를 반환하고, dev 외 환경에서는 불완전 스키마를 차단한다.""" if ready: return False component = ( contract.component if isinstance(contract, RuntimeSchemaContract) else contract ) if settings.environment != "dev": raise RuntimeError( f"{component} runtime DB schema is incomplete; run owner migration/init and " "scripts/check-deploy-preflight.py before starting the API" ) return True