-- Outcome & Alliance OS G5: Calibration Mirror & unseen-transfer ledgers. -- Prerequisites: 02_schema.sql, 04_audit_eval_rls.sql, 07_measurement_foundation.sql. INSERT INTO app.measurement_instrument ( instrument_id, instrument_version, name_ko, instrument_kind, construct, validation_basis, scoring_schema, metadata ) VALUES ( 'calibration-mirror-g5', '1.0.0', '외부평가 전 자기보정', 'training_metric', 'self_calibration', '외부평가 공개 전 자기예측과 독립 수행 관측의 차이를 역량별로 추적한다.', '{"prediction_probability":{"min":0,"max":1},"calibration_error":{"min":0,"max":1}}'::jsonb, '{"clinical_claim_allowed":false,"aggregate_total_forbidden":true}'::jsonb ), ( 'unseen-transfer-g5', '1.0.0', '미지 사례 역량 전이', 'training_metric', 'transfer', '훈련 문구와 분리된 합성 미지 사례에서 역량 유지와 subgroup drift를 추적한다.', '{"transfer_success_rate":{"min":0,"max":1}}'::jsonb, '{"clinical_claim_allowed":false,"data_classification":"synthetic_educational","aggregate_total_forbidden":true}'::jsonb ), ( 'vignette.calibration-self-prediction', '1.0.0', '외부평가 전 자기보정 (호환용)', 'training_metric', 'self_calibration', '외부평가 공개 전 자기예측과 독립 수행 관측의 차이를 역량별로 추적한다.', '{"prediction_probability":{"min":0,"max":1},"calibration_error":{"min":0,"max":1}}'::jsonb, '{"clinical_claim_allowed":false,"aggregate_total_forbidden":true}'::jsonb ) ON CONFLICT (instrument_id, instrument_version) DO NOTHING; CREATE TABLE IF NOT EXISTS app.calibration_prediction_history ( history_id UUID PRIMARY KEY, session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT, learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT, competency_id TEXT NOT NULL CHECK (competency_id ~ '^competency\.[a-z0-9_.-]+$'), practice_block_id TEXT NOT NULL CHECK (practice_block_id ~ '^oas-g5-block-[a-z0-9-]+$'), scenario_variant_id TEXT NOT NULL CHECK (length(btrim(scenario_variant_id)) > 0), phrase_family_id TEXT NOT NULL CHECK (length(btrim(phrase_family_id)) > 0), visible_to TEXT[] NOT NULL DEFAULT '{counselor,evaluator,supervisor,research}' CHECK ( cardinality(visible_to) > 0 AND visible_to <@ ARRAY['counselor','evaluator','supervisor','research']::TEXT[] ), created_by_role TEXT NOT NULL DEFAULT 'learner' CHECK (created_by_role IN ('learner','migration')), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (learner_id, practice_block_id) ); CREATE INDEX IF NOT EXISTS idx_calibration_prediction_history_learner ON app.calibration_prediction_history(learner_id, created_at, history_id); CREATE TABLE IF NOT EXISTS app.calibration_prediction_revision ( prediction_revision_id UUID PRIMARY KEY, submission_id UUID NOT NULL UNIQUE, content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'), history_id UUID NOT NULL REFERENCES app.calibration_prediction_history(history_id) ON DELETE RESTRICT, session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT, learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT, revision_no INT NOT NULL CHECK (revision_no >= 1), supersedes_prediction_revision_id UUID REFERENCES app.calibration_prediction_revision(prediction_revision_id) ON DELETE RESTRICT, predicted_success_probability DOUBLE PRECISION NOT NULL CHECK (predicted_success_probability BETWEEN 0 AND 1), confidence DOUBLE PRECISION NOT NULL CHECK (confidence BETWEEN 0 AND 1), recorded_sequence INT NOT NULL CHECK (recorded_sequence >= 1), revision_reason TEXT NOT NULL CHECK (length(btrim(revision_reason)) > 0), source_kind TEXT NOT NULL DEFAULT 'learner_reported' CHECK (source_kind = 'learner_reported'), perspective TEXT NOT NULL DEFAULT 'learner_self_report' CHECK (perspective = 'learner_self_report'), model_run_id UUID CHECK (model_run_id IS NULL), instrument_id TEXT NOT NULL, instrument_version TEXT NOT NULL, evidence_turn_ids UUID[] NOT NULL DEFAULT '{}', created_by_role TEXT NOT NULL DEFAULT 'learner' CHECK (created_by_role = 'learner'), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (history_id, revision_no), UNIQUE (history_id, recorded_sequence), UNIQUE (supersedes_prediction_revision_id), UNIQUE (prediction_revision_id, history_id, session_id, learner_id), FOREIGN KEY (instrument_id, instrument_version) REFERENCES app.measurement_instrument(instrument_id, instrument_version) ON DELETE RESTRICT, CHECK (prediction_revision_id IS DISTINCT FROM supersedes_prediction_revision_id) ); CREATE INDEX IF NOT EXISTS idx_calibration_prediction_revision_latest ON app.calibration_prediction_revision(history_id, revision_no DESC); CREATE TABLE IF NOT EXISTS app.calibration_prediction_lock ( lock_id UUID PRIMARY KEY, submission_id UUID NOT NULL UNIQUE, content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'), history_id UUID NOT NULL UNIQUE REFERENCES app.calibration_prediction_history(history_id) ON DELETE RESTRICT, prediction_revision_id UUID NOT NULL UNIQUE, session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT, learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT, locked_sequence INT NOT NULL CHECK (locked_sequence >= 1), created_by_role TEXT NOT NULL DEFAULT 'learner' CHECK (created_by_role = 'learner'), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), FOREIGN KEY (prediction_revision_id, history_id, session_id, learner_id) REFERENCES app.calibration_prediction_revision( prediction_revision_id, history_id, session_id, learner_id ) ON DELETE RESTRICT, UNIQUE (lock_id, history_id, session_id, learner_id) ); CREATE TABLE IF NOT EXISTS app.calibration_performance_observation ( observation_id UUID PRIMARY KEY, submission_id UUID NOT NULL UNIQUE, content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'), history_id UUID NOT NULL UNIQUE REFERENCES app.calibration_prediction_history(history_id) ON DELETE RESTRICT, prediction_lock_id UUID NOT NULL UNIQUE, session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT, learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT, competency_id TEXT NOT NULL CHECK (competency_id ~ '^competency\.[a-z0-9_.-]+$'), practice_block_id TEXT NOT NULL CHECK (practice_block_id ~ '^oas-g5-block-[a-z0-9-]+$'), scenario_variant_id TEXT NOT NULL CHECK (length(btrim(scenario_variant_id)) > 0), phrase_family_id TEXT NOT NULL CHECK (length(btrim(phrase_family_id)) > 0), status TEXT NOT NULL CHECK (status IN ('passed','failed','insufficient_evidence')), source_kind TEXT NOT NULL CHECK (source_kind IN ('model_inferred','observed_runtime')), perspective TEXT NOT NULL CHECK ( perspective IN ('independent_observer','runtime_observation') ), model_run_id UUID REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT, instrument_id TEXT NOT NULL, instrument_version TEXT NOT NULL, uncertainty DOUBLE PRECISION NOT NULL CHECK (uncertainty BETWEEN 0 AND 1), evidence_turn_ids UUID[] NOT NULL DEFAULT '{}', counterevidence TEXT[] NOT NULL DEFAULT '{}', revealed_sequence INT NOT NULL CHECK (revealed_sequence >= 1), visible_to TEXT[] NOT NULL DEFAULT '{counselor,evaluator,supervisor,research}' CHECK ( cardinality(visible_to) > 0 AND visible_to <@ ARRAY['counselor','evaluator','supervisor','research']::TEXT[] ), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), FOREIGN KEY (prediction_lock_id, history_id, session_id, learner_id) REFERENCES app.calibration_prediction_lock(lock_id, history_id, session_id, learner_id) ON DELETE RESTRICT, FOREIGN KEY (instrument_id, instrument_version) REFERENCES app.measurement_instrument(instrument_id, instrument_version) ON DELETE RESTRICT, CHECK ( (source_kind = 'model_inferred' AND perspective = 'independent_observer' AND model_run_id IS NOT NULL) OR (source_kind = 'observed_runtime' AND perspective = 'runtime_observation') ), CHECK ( (status = 'insufficient_evidence' AND uncertainty = 1 AND cardinality(evidence_turn_ids) = 0) OR (status IN ('passed','failed') AND cardinality(evidence_turn_ids) > 0) ), CHECK (status <> 'failed' OR cardinality(counterevidence) > 0), UNIQUE (observation_id, session_id, learner_id, competency_id) ); CREATE INDEX IF NOT EXISTS idx_calibration_observation_learner_competency ON app.calibration_performance_observation(learner_id, competency_id, created_at); CREATE TABLE IF NOT EXISTS app.calibration_assessment_snapshot ( assessment_snapshot_id UUID PRIMARY KEY, submission_id UUID NOT NULL UNIQUE, content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'), session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT, learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT, competency_id TEXT NOT NULL CHECK (competency_id ~ '^competency\.[a-z0-9_.-]+$'), snapshot_no INT NOT NULL CHECK (snapshot_no >= 1), supersedes_assessment_snapshot_id UUID REFERENCES app.calibration_assessment_snapshot(assessment_snapshot_id) ON DELETE RESTRICT, source_observation_ids UUID[] NOT NULL CHECK (cardinality(source_observation_ids) > 0), assessment_payload JSONB NOT NULL CHECK (jsonb_typeof(assessment_payload) = 'object'), model_run_id UUID NOT NULL REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT, instrument_id TEXT NOT NULL, instrument_version TEXT NOT NULL, evidence_turn_ids UUID[] NOT NULL DEFAULT '{}', visible_to TEXT[] NOT NULL DEFAULT '{counselor,evaluator,supervisor,research}' CHECK ( cardinality(visible_to) > 0 AND visible_to <@ ARRAY['counselor','evaluator','supervisor','research']::TEXT[] ), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), FOREIGN KEY (instrument_id, instrument_version) REFERENCES app.measurement_instrument(instrument_id, instrument_version) ON DELETE RESTRICT, UNIQUE (learner_id, competency_id, snapshot_no), UNIQUE (supersedes_assessment_snapshot_id), UNIQUE (assessment_snapshot_id, session_id, learner_id, competency_id), CHECK (assessment_snapshot_id IS DISTINCT FROM supersedes_assessment_snapshot_id) ); CREATE INDEX IF NOT EXISTS idx_calibration_assessment_latest ON app.calibration_assessment_snapshot(learner_id, competency_id, snapshot_no DESC); CREATE TABLE IF NOT EXISTS app.calibration_metacognitive_prescription ( prescription_id UUID PRIMARY KEY, assessment_snapshot_id UUID NOT NULL UNIQUE, session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT, learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT, competency_id TEXT NOT NULL CHECK (competency_id ~ '^competency\.[a-z0-9_.-]+$'), prescription_payload JSONB NOT NULL CHECK (jsonb_typeof(prescription_payload) = 'object'), model_run_id UUID NOT NULL REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT, instrument_id TEXT NOT NULL, instrument_version TEXT NOT NULL, evidence_turn_ids UUID[] NOT NULL DEFAULT '{}', visible_to TEXT[] NOT NULL DEFAULT '{counselor,evaluator,supervisor,research}' CHECK ( cardinality(visible_to) > 0 AND visible_to <@ ARRAY['counselor','evaluator','supervisor','research']::TEXT[] ), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), FOREIGN KEY (assessment_snapshot_id, session_id, learner_id, competency_id) REFERENCES app.calibration_assessment_snapshot( assessment_snapshot_id, session_id, learner_id, competency_id ) ON DELETE RESTRICT, FOREIGN KEY (instrument_id, instrument_version) REFERENCES app.measurement_instrument(instrument_id, instrument_version) ON DELETE RESTRICT ); CREATE TABLE IF NOT EXISTS app.calibration_transfer_suite ( transfer_suite_record_id UUID PRIMARY KEY, submission_id UUID NOT NULL UNIQUE, content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'), suite_key TEXT NOT NULL CHECK (suite_key ~ '^oas-g5-suite-[a-z0-9-]+$'), session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT, learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT, training_phrase_family_ids TEXT[] NOT NULL CHECK (cardinality(training_phrase_family_ids) > 0), model_run_id UUID NOT NULL REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT, instrument_id TEXT NOT NULL, instrument_version TEXT NOT NULL, data_classification TEXT NOT NULL DEFAULT 'synthetic_educational' CHECK (data_classification = 'synthetic_educational'), clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE), visible_to TEXT[] NOT NULL DEFAULT '{counselor,evaluator,supervisor,research}' CHECK ( cardinality(visible_to) > 0 AND visible_to <@ ARRAY['counselor','evaluator','supervisor','research']::TEXT[] ), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), FOREIGN KEY (instrument_id, instrument_version) REFERENCES app.measurement_instrument(instrument_id, instrument_version) ON DELETE RESTRICT, UNIQUE (learner_id, suite_key), UNIQUE (transfer_suite_record_id, session_id, learner_id) ); CREATE TABLE IF NOT EXISTS app.calibration_transfer_trial ( transfer_trial_record_id UUID PRIMARY KEY, transfer_suite_record_id UUID NOT NULL, session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT, learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT, trial_key TEXT NOT NULL CHECK (trial_key ~ '^oas-g5-transfer-[a-z0-9-]+$'), competency_id TEXT NOT NULL CHECK (competency_id ~ '^competency\.[a-z0-9_.-]+$'), scenario_variant_id TEXT NOT NULL CHECK (length(btrim(scenario_variant_id)) > 0), scenario_novelty TEXT NOT NULL DEFAULT 'unseen_transfer' CHECK (scenario_novelty = 'unseen_transfer'), context_variant TEXT NOT NULL CHECK (length(btrim(context_variant)) > 0), relationship_style TEXT NOT NULL CHECK ( relationship_style IN ('collaborative','withdrawn','confrontational','ambivalent') ), difficulty_level INT NOT NULL CHECK (difficulty_level BETWEEN 1 AND 5), expression_variant TEXT NOT NULL CHECK (length(btrim(expression_variant)) > 0), synthetic_subgroup TEXT NOT NULL CHECK (synthetic_subgroup ~ '^synthetic-[a-z0-9-]+$'), scenario_family_id TEXT NOT NULL CHECK (length(btrim(scenario_family_id)) > 0), phrase_family_id TEXT NOT NULL CHECK (length(btrim(phrase_family_id)) > 0), status TEXT NOT NULL CHECK (status IN ('passed','failed','insufficient_evidence')), uncertainty DOUBLE PRECISION NOT NULL CHECK (uncertainty BETWEEN 0 AND 1), evidence_turn_ids UUID[] NOT NULL DEFAULT '{}', counterevidence TEXT[] NOT NULL DEFAULT '{}', model_run_id UUID NOT NULL REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT, instrument_id TEXT NOT NULL, instrument_version TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), FOREIGN KEY (transfer_suite_record_id, session_id, learner_id) REFERENCES app.calibration_transfer_suite(transfer_suite_record_id, session_id, learner_id) ON DELETE RESTRICT, FOREIGN KEY (instrument_id, instrument_version) REFERENCES app.measurement_instrument(instrument_id, instrument_version) ON DELETE RESTRICT, UNIQUE (transfer_suite_record_id, trial_key), UNIQUE (transfer_trial_record_id, transfer_suite_record_id, session_id, learner_id, competency_id), CHECK ( (status = 'insufficient_evidence' AND uncertainty = 1 AND cardinality(evidence_turn_ids) = 0) OR (status IN ('passed','failed') AND cardinality(evidence_turn_ids) > 0) ), CHECK (status <> 'failed' OR cardinality(counterevidence) > 0) ); CREATE INDEX IF NOT EXISTS idx_calibration_transfer_trial_suite_competency ON app.calibration_transfer_trial(transfer_suite_record_id, competency_id, created_at); CREATE TABLE IF NOT EXISTS app.calibration_transfer_assessment ( transfer_assessment_id UUID PRIMARY KEY, transfer_suite_record_id UUID NOT NULL, session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT, learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT, competency_id TEXT NOT NULL CHECK (competency_id ~ '^competency\.[a-z0-9_.-]+$'), source_trial_ids UUID[] NOT NULL CHECK (cardinality(source_trial_ids) > 0), assessment_payload JSONB NOT NULL CHECK (jsonb_typeof(assessment_payload) = 'object'), evidence_turn_ids UUID[] NOT NULL DEFAULT '{}', model_run_id UUID NOT NULL REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT, instrument_id TEXT NOT NULL, instrument_version TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), FOREIGN KEY (transfer_suite_record_id, session_id, learner_id) REFERENCES app.calibration_transfer_suite(transfer_suite_record_id, session_id, learner_id) ON DELETE RESTRICT, FOREIGN KEY (instrument_id, instrument_version) REFERENCES app.measurement_instrument(instrument_id, instrument_version) ON DELETE RESTRICT, UNIQUE (transfer_suite_record_id, competency_id), UNIQUE (transfer_assessment_id, session_id, learner_id) ); CREATE TABLE IF NOT EXISTS app.calibration_subgroup_drift_report ( drift_report_id UUID PRIMARY KEY, transfer_suite_record_id UUID NOT NULL, session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT, learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT, competency_id TEXT NOT NULL CHECK (competency_id ~ '^competency\.[a-z0-9_.-]+$'), source_trial_ids UUID[] NOT NULL CHECK (cardinality(source_trial_ids) > 0), report_payload JSONB NOT NULL CHECK (jsonb_typeof(report_payload) = 'object'), model_run_id UUID NOT NULL REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT, instrument_id TEXT NOT NULL, instrument_version TEXT NOT NULL, data_classification TEXT NOT NULL DEFAULT 'synthetic_educational' CHECK (data_classification = 'synthetic_educational'), clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), FOREIGN KEY (transfer_suite_record_id, session_id, learner_id) REFERENCES app.calibration_transfer_suite(transfer_suite_record_id, session_id, learner_id) ON DELETE RESTRICT, FOREIGN KEY (instrument_id, instrument_version) REFERENCES app.measurement_instrument(instrument_id, instrument_version) ON DELETE RESTRICT, UNIQUE (transfer_suite_record_id, competency_id), UNIQUE (drift_report_id, session_id, learner_id) ); CREATE TABLE IF NOT EXISTS app.calibration_teacher_review_event ( review_id UUID PRIMARY KEY, submission_id UUID NOT NULL UNIQUE, content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'), target_kind TEXT NOT NULL CHECK ( target_kind IN ('calibration_assessment','transfer_assessment','drift_report') ), target_id UUID NOT NULL, session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT, learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT, review_no INT NOT NULL CHECK (review_no >= 1), supersedes_review_id UUID REFERENCES app.calibration_teacher_review_event(review_id) ON DELETE RESTRICT, disposition TEXT NOT NULL CHECK ( disposition IN ('confirmed','corrected','needs_more_evidence') ), correction_payload JSONB NOT NULL DEFAULT '{}'::JSONB CHECK (jsonb_typeof(correction_payload) = 'object'), review_reason TEXT NOT NULL CHECK (length(btrim(review_reason)) > 0), evidence_turn_ids UUID[] NOT NULL DEFAULT '{}', counterevidence TEXT[] NOT NULL DEFAULT '{}', created_by_uid UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT, created_by_role TEXT NOT NULL CHECK (created_by_role IN ('instructor','admin')), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (target_kind, target_id, review_no), UNIQUE (supersedes_review_id), CHECK (review_id IS DISTINCT FROM supersedes_review_id), CHECK (disposition = 'corrected' OR correction_payload = '{}'::JSONB) ); CREATE INDEX IF NOT EXISTS idx_calibration_teacher_review_target ON app.calibration_teacher_review_event(target_kind, target_id, review_no DESC); CREATE OR REPLACE FUNCTION audit.enforce_calibration_session_learner() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE anchor_learner UUID; BEGIN SELECT s.learner_id INTO anchor_learner FROM app.sessions s WHERE s.id = NEW.session_id; IF anchor_learner IS NULL OR anchor_learner IS DISTINCT FROM NEW.learner_id THEN RAISE EXCEPTION 'calibration row must match its session learner' USING ERRCODE = '23514'; END IF; RETURN NEW; END; $$; CREATE OR REPLACE FUNCTION audit.enforce_calibration_evidence_ownership() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF cardinality(NEW.evidence_turn_ids) > 0 AND ( SELECT count(DISTINCT t.id) FROM app.turns t WHERE t.session_id = NEW.session_id AND t.id = ANY(NEW.evidence_turn_ids) ) <> cardinality(NEW.evidence_turn_ids) THEN RAISE EXCEPTION 'calibration evidence turns must belong to its session' USING ERRCODE = '23514'; END IF; RETURN NEW; END; $$; CREATE OR REPLACE FUNCTION audit.enforce_calibration_payload_boundary() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE payload JSONB; BEGIN payload := COALESCE( to_jsonb(NEW)->'assessment_payload', to_jsonb(NEW)->'prescription_payload', to_jsonb(NEW)->'report_payload', to_jsonb(NEW)->'correction_payload', '{}'::JSONB ); IF payload::TEXT ~* '"(raw_transcript|transcript|text_masked|utterance_text|quote|total_score|overall_score)"[[:space:]]*:' THEN RAISE EXCEPTION 'calibration payload cannot store transcript text or aggregate score' USING ERRCODE = '23514'; END IF; RETURN NEW; END; $$; CREATE OR REPLACE FUNCTION audit.enforce_calibration_prediction_revision() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE history_row app.calibration_prediction_history%ROWTYPE; latest_id UUID; latest_no INT; latest_sequence INT; BEGIN PERFORM pg_advisory_xact_lock(hashtextextended(NEW.history_id::TEXT, 5)); SELECT * INTO history_row FROM app.calibration_prediction_history WHERE history_id = NEW.history_id; IF history_row.history_id IS NULL OR history_row.session_id IS DISTINCT FROM NEW.session_id OR history_row.learner_id IS DISTINCT FROM NEW.learner_id THEN RAISE EXCEPTION 'prediction revision must match its history anchor' USING ERRCODE = '23514'; END IF; IF EXISTS (SELECT 1 FROM app.calibration_prediction_lock l WHERE l.history_id = NEW.history_id) OR EXISTS (SELECT 1 FROM app.calibration_performance_observation o WHERE o.history_id = NEW.history_id) THEN RAISE EXCEPTION 'self-prediction cannot be revised after lock or external reveal' USING ERRCODE = '55000'; END IF; SELECT prediction_revision_id, revision_no, recorded_sequence INTO latest_id, latest_no, latest_sequence FROM app.calibration_prediction_revision WHERE history_id = NEW.history_id ORDER BY revision_no DESC LIMIT 1; IF latest_id IS NULL THEN IF NEW.revision_no <> 1 OR NEW.supersedes_prediction_revision_id IS NOT NULL THEN RAISE EXCEPTION 'first self-prediction revision must start at one without supersedes' USING ERRCODE = '23514'; END IF; ELSIF NEW.revision_no <> latest_no + 1 OR NEW.supersedes_prediction_revision_id IS DISTINCT FROM latest_id OR NEW.recorded_sequence <= latest_sequence THEN RAISE EXCEPTION 'self-prediction revision must be contiguous and supersede latest' USING ERRCODE = '23514'; END IF; RETURN NEW; END; $$; CREATE OR REPLACE FUNCTION audit.enforce_calibration_prediction_lock() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE latest_id UUID; latest_sequence INT; BEGIN PERFORM pg_advisory_xact_lock(hashtextextended(NEW.history_id::TEXT, 5)); SELECT prediction_revision_id, recorded_sequence INTO latest_id, latest_sequence FROM app.calibration_prediction_revision WHERE history_id = NEW.history_id ORDER BY revision_no DESC LIMIT 1; IF latest_id IS NULL OR NEW.prediction_revision_id IS DISTINCT FROM latest_id OR NEW.locked_sequence < latest_sequence THEN RAISE EXCEPTION 'prediction lock must target the latest revision at or after its sequence' USING ERRCODE = '23514'; END IF; RETURN NEW; END; $$; CREATE OR REPLACE FUNCTION audit.enforce_calibration_observation_reveal() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE history_row app.calibration_prediction_history%ROWTYPE; lock_sequence INT; BEGIN SELECT * INTO history_row FROM app.calibration_prediction_history WHERE history_id = NEW.history_id; SELECT locked_sequence INTO lock_sequence FROM app.calibration_prediction_lock WHERE lock_id = NEW.prediction_lock_id AND history_id = NEW.history_id; IF history_row.history_id IS NULL OR lock_sequence IS NULL OR NEW.revealed_sequence <= lock_sequence OR NEW.session_id IS DISTINCT FROM history_row.session_id OR NEW.learner_id IS DISTINCT FROM history_row.learner_id OR NEW.competency_id IS DISTINCT FROM history_row.competency_id OR NEW.practice_block_id IS DISTINCT FROM history_row.practice_block_id OR NEW.scenario_variant_id IS DISTINCT FROM history_row.scenario_variant_id OR NEW.phrase_family_id IS DISTINCT FROM history_row.phrase_family_id THEN RAISE EXCEPTION 'external observation requires a matching locked prediction and later reveal sequence' USING ERRCODE = '23514'; END IF; RETURN NEW; END; $$; CREATE OR REPLACE FUNCTION audit.enforce_calibration_assessment_chain() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE latest_id UUID; latest_no INT; matched_count INT; BEGIN PERFORM pg_advisory_xact_lock(hashtextextended(NEW.learner_id::TEXT || ':' || NEW.competency_id, 6)); SELECT assessment_snapshot_id, snapshot_no INTO latest_id, latest_no FROM app.calibration_assessment_snapshot WHERE learner_id = NEW.learner_id AND competency_id = NEW.competency_id ORDER BY snapshot_no DESC LIMIT 1; IF latest_id IS NULL THEN IF NEW.snapshot_no <> 1 OR NEW.supersedes_assessment_snapshot_id IS NOT NULL THEN RAISE EXCEPTION 'first calibration assessment snapshot must start at one' USING ERRCODE = '23514'; END IF; ELSIF NEW.snapshot_no <> latest_no + 1 OR NEW.supersedes_assessment_snapshot_id IS DISTINCT FROM latest_id THEN RAISE EXCEPTION 'calibration assessment must supersede latest snapshot' USING ERRCODE = '23514'; END IF; SELECT count(DISTINCT o.observation_id) INTO matched_count FROM app.calibration_performance_observation o WHERE o.observation_id = ANY(NEW.source_observation_ids) AND o.learner_id = NEW.learner_id AND o.competency_id = NEW.competency_id; IF matched_count <> cardinality(NEW.source_observation_ids) OR NEW.assessment_payload->>'competency_id' IS DISTINCT FROM NEW.competency_id THEN RAISE EXCEPTION 'calibration assessment sources or competency do not match' USING ERRCODE = '23514'; END IF; RETURN NEW; END; $$; CREATE OR REPLACE FUNCTION audit.enforce_calibration_transfer_children() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE matched_count INT; BEGIN IF TG_TABLE_NAME = 'calibration_transfer_assessment' THEN SELECT count(DISTINCT t.transfer_trial_record_id) INTO matched_count FROM app.calibration_transfer_trial t WHERE t.transfer_trial_record_id = ANY(NEW.source_trial_ids) AND t.transfer_suite_record_id = NEW.transfer_suite_record_id AND t.competency_id = NEW.competency_id; IF matched_count <> cardinality(NEW.source_trial_ids) OR NEW.assessment_payload->>'competency_id' IS DISTINCT FROM NEW.competency_id THEN RAISE EXCEPTION 'transfer assessment sources or competency do not match' USING ERRCODE = '23514'; END IF; ELSIF TG_TABLE_NAME = 'calibration_subgroup_drift_report' THEN SELECT count(DISTINCT t.transfer_trial_record_id) INTO matched_count FROM app.calibration_transfer_trial t WHERE t.transfer_trial_record_id = ANY(NEW.source_trial_ids) AND t.transfer_suite_record_id = NEW.transfer_suite_record_id AND t.competency_id = NEW.competency_id; IF matched_count <> cardinality(NEW.source_trial_ids) OR NEW.report_payload->>'competency_id' IS DISTINCT FROM NEW.competency_id THEN RAISE EXCEPTION 'subgroup drift sources or competency do not match' USING ERRCODE = '23514'; END IF; END IF; RETURN NEW; END; $$; CREATE OR REPLACE FUNCTION audit.enforce_calibration_teacher_review() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE target_session UUID; target_learner UUID; latest_id UUID; latest_no INT; BEGIN IF NEW.target_kind = 'calibration_assessment' THEN SELECT session_id, learner_id INTO target_session, target_learner FROM app.calibration_assessment_snapshot WHERE assessment_snapshot_id = NEW.target_id; ELSIF NEW.target_kind = 'transfer_assessment' THEN SELECT session_id, learner_id INTO target_session, target_learner FROM app.calibration_transfer_assessment WHERE transfer_assessment_id = NEW.target_id; ELSE SELECT session_id, learner_id INTO target_session, target_learner FROM app.calibration_subgroup_drift_report WHERE drift_report_id = NEW.target_id; END IF; IF target_session IS NULL OR NEW.session_id IS DISTINCT FROM target_session OR NEW.learner_id IS DISTINCT FROM target_learner THEN RAISE EXCEPTION 'teacher review target is missing or mismatched' USING ERRCODE = '23514'; END IF; PERFORM pg_advisory_xact_lock(hashtextextended(NEW.target_kind || ':' || NEW.target_id::TEXT, 7)); SELECT review_id, review_no INTO latest_id, latest_no FROM app.calibration_teacher_review_event WHERE target_kind = NEW.target_kind AND target_id = NEW.target_id ORDER BY review_no DESC LIMIT 1; IF latest_id IS NULL THEN IF NEW.review_no <> 1 OR NEW.supersedes_review_id IS NOT NULL THEN RAISE EXCEPTION 'first teacher review must start at one' USING ERRCODE = '23514'; END IF; ELSIF NEW.review_no <> latest_no + 1 OR NEW.supersedes_review_id IS DISTINCT FROM latest_id THEN RAISE EXCEPTION 'teacher review must supersede latest review' USING ERRCODE = '23514'; END IF; RETURN NEW; END; $$; DO $$ DECLARE table_name TEXT; BEGIN FOREACH table_name IN ARRAY ARRAY[ 'calibration_prediction_history','calibration_prediction_revision', 'calibration_prediction_lock','calibration_performance_observation', 'calibration_assessment_snapshot','calibration_metacognitive_prescription', 'calibration_transfer_suite','calibration_transfer_trial', 'calibration_transfer_assessment','calibration_subgroup_drift_report', 'calibration_teacher_review_event' ] LOOP EXECUTE format('DROP TRIGGER IF EXISTS trg_%s_append_only ON app.%I', table_name, table_name); EXECUTE format( 'CREATE TRIGGER trg_%s_append_only BEFORE UPDATE OR DELETE ON app.%I ' 'FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation()', table_name, table_name ); EXECUTE format('DROP TRIGGER IF EXISTS trg_%s_session_learner ON app.%I', table_name, table_name); EXECUTE format( 'CREATE TRIGGER trg_%s_session_learner BEFORE INSERT ON app.%I ' 'FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_session_learner()', table_name, table_name ); EXECUTE format('ALTER TABLE app.%I ENABLE ROW LEVEL SECURITY', table_name); END LOOP; END; $$; DO $$ DECLARE table_name TEXT; BEGIN FOREACH table_name IN ARRAY ARRAY[ 'calibration_prediction_revision','calibration_performance_observation', 'calibration_assessment_snapshot','calibration_metacognitive_prescription', 'calibration_transfer_trial','calibration_transfer_assessment', 'calibration_teacher_review_event' ] LOOP EXECUTE format('DROP TRIGGER IF EXISTS trg_%s_evidence_ownership ON app.%I', table_name, table_name); EXECUTE format( 'CREATE TRIGGER trg_%s_evidence_ownership BEFORE INSERT ON app.%I ' 'FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_evidence_ownership()', table_name, table_name ); END LOOP; END; $$; DROP TRIGGER IF EXISTS trg_calibration_prediction_revision_contract ON app.calibration_prediction_revision; CREATE TRIGGER trg_calibration_prediction_revision_contract BEFORE INSERT ON app.calibration_prediction_revision FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_prediction_revision(); DROP TRIGGER IF EXISTS trg_calibration_prediction_lock_contract ON app.calibration_prediction_lock; CREATE TRIGGER trg_calibration_prediction_lock_contract BEFORE INSERT ON app.calibration_prediction_lock FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_prediction_lock(); DROP TRIGGER IF EXISTS trg_calibration_observation_reveal_contract ON app.calibration_performance_observation; CREATE TRIGGER trg_calibration_observation_reveal_contract BEFORE INSERT ON app.calibration_performance_observation FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_observation_reveal(); DROP TRIGGER IF EXISTS trg_calibration_assessment_chain ON app.calibration_assessment_snapshot; CREATE TRIGGER trg_calibration_assessment_chain BEFORE INSERT ON app.calibration_assessment_snapshot FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_assessment_chain(); DROP TRIGGER IF EXISTS trg_calibration_transfer_assessment_contract ON app.calibration_transfer_assessment; CREATE TRIGGER trg_calibration_transfer_assessment_contract BEFORE INSERT ON app.calibration_transfer_assessment FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_transfer_children(); DROP TRIGGER IF EXISTS trg_calibration_subgroup_drift_contract ON app.calibration_subgroup_drift_report; CREATE TRIGGER trg_calibration_subgroup_drift_contract BEFORE INSERT ON app.calibration_subgroup_drift_report FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_transfer_children(); DROP TRIGGER IF EXISTS trg_calibration_teacher_review_contract ON app.calibration_teacher_review_event; CREATE TRIGGER trg_calibration_teacher_review_contract BEFORE INSERT ON app.calibration_teacher_review_event FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_teacher_review(); DO $$ DECLARE table_name TEXT; BEGIN FOREACH table_name IN ARRAY ARRAY[ 'calibration_assessment_snapshot','calibration_metacognitive_prescription', 'calibration_transfer_assessment','calibration_subgroup_drift_report', 'calibration_teacher_review_event' ] LOOP EXECUTE format('DROP TRIGGER IF EXISTS trg_%s_payload_boundary ON app.%I', table_name, table_name); EXECUTE format( 'CREATE TRIGGER trg_%s_payload_boundary BEFORE INSERT ON app.%I ' 'FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_payload_boundary()', table_name, table_name ); END LOOP; END; $$; -- Human SELECT: learner-self, teacher cohort, admin. AI SELECT/INSERT: evaluator only. DO $$ DECLARE table_name TEXT; BEGIN FOREACH table_name IN ARRAY ARRAY[ 'calibration_prediction_history','calibration_prediction_revision', 'calibration_prediction_lock','calibration_performance_observation', 'calibration_assessment_snapshot','calibration_metacognitive_prescription', 'calibration_transfer_suite','calibration_transfer_trial', 'calibration_transfer_assessment','calibration_subgroup_drift_report' ] LOOP EXECUTE format('DROP POLICY IF EXISTS p_%s_select ON app.%I', table_name, table_name); EXECUTE format( 'CREATE POLICY p_%s_select ON app.%I FOR SELECT USING (' '(app.is_ai_context() AND current_setting(''app.current_ai_view'', true) = ''evaluator'') OR ' '(NOT app.is_ai_context() AND (' 'app.current_role_name() = ''admin'' OR ' '(app.current_role_name() = ''learner'' AND learner_id = app.current_uid()) OR ' '(app.current_role_name() = ''instructor'' AND EXISTS (' 'SELECT 1 FROM app.app_user u WHERE u.user_id = learner_id ' 'AND u.cohort = current_setting(''app.current_cohort'', true)))))' ')', table_name, table_name ); END LOOP; END; $$; DO $$ DECLARE table_name TEXT; BEGIN FOREACH table_name IN ARRAY ARRAY[ 'calibration_prediction_history','calibration_prediction_revision','calibration_prediction_lock' ] LOOP EXECUTE format('DROP POLICY IF EXISTS p_%s_insert ON app.%I', table_name, table_name); EXECUTE format( 'CREATE POLICY p_%s_insert ON app.%I FOR INSERT WITH CHECK (' 'NOT app.is_ai_context() AND app.current_role_name() = ''learner'' ' 'AND learner_id = app.current_uid())', table_name, table_name ); END LOOP; END; $$; DO $$ DECLARE table_name TEXT; BEGIN FOREACH table_name IN ARRAY ARRAY[ 'calibration_performance_observation','calibration_assessment_snapshot', 'calibration_metacognitive_prescription','calibration_transfer_suite', 'calibration_transfer_trial','calibration_transfer_assessment', 'calibration_subgroup_drift_report' ] LOOP EXECUTE format('DROP POLICY IF EXISTS p_%s_insert ON app.%I', table_name, table_name); EXECUTE format( 'CREATE POLICY p_%s_insert ON app.%I FOR INSERT WITH CHECK (' 'app.is_ai_context() AND current_setting(''app.current_ai_view'', true) = ''evaluator'')', table_name, table_name ); END LOOP; END; $$; DROP POLICY IF EXISTS p_calibration_teacher_review_event_select ON app.calibration_teacher_review_event; DROP POLICY IF EXISTS p_calibration_teacher_review_event_insert ON app.calibration_teacher_review_event; CREATE POLICY p_calibration_teacher_review_event_select ON app.calibration_teacher_review_event FOR SELECT USING ( (app.is_ai_context() AND current_setting('app.current_ai_view', true) = 'evaluator') OR (NOT app.is_ai_context() AND ( app.current_role_name() = 'admin' OR (app.current_role_name() = 'learner' AND learner_id = app.current_uid()) OR (app.current_role_name() = 'instructor' AND EXISTS ( SELECT 1 FROM app.app_user u WHERE u.user_id = learner_id AND u.cohort = current_setting('app.current_cohort', true) )) )) ); CREATE POLICY p_calibration_teacher_review_event_insert ON app.calibration_teacher_review_event FOR INSERT WITH CHECK ( NOT app.is_ai_context() AND ( app.current_role_name() = 'admin' OR ( app.current_role_name() = 'instructor' AND created_by_uid = app.current_uid() AND created_by_role = 'instructor' AND EXISTS ( SELECT 1 FROM app.app_user u WHERE u.user_id = learner_id AND u.cohort = current_setting('app.current_cohort', true) ) ) ) );