-- Outcome & Alliance OS G5: completed-session transfer execution ledger. -- Prerequisites: 06_session_evaluation.sql, 07_measurement_foundation.sql, -- 11_calibration_transfer.sql, 15_self_directed_practice_runtime.sql. CREATE TABLE IF NOT EXISTS app.calibration_transfer_execution_event ( execution_event_id UUID PRIMARY KEY, original_transfer_trial_record_id UUID NOT NULL REFERENCES app.calibration_transfer_trial(transfer_trial_record_id) ON DELETE RESTRICT, transfer_suite_record_id UUID NOT NULL, practice_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_.-]+$'), 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), training_phrase_collision BOOLEAN NOT NULL DEFAULT FALSE, 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 '{}', normalized_evaluator_labels JSONB NOT NULL CHECK (jsonb_typeof(normalized_evaluator_labels) = 'object'), counterevidence TEXT[] NOT NULL DEFAULT '{}', source_kind TEXT NOT NULL DEFAULT 'model_inferred' CHECK (source_kind = 'model_inferred'), perspective TEXT NOT NULL DEFAULT 'independent_observer' CHECK (perspective = 'independent_observer'), model_run_id UUID NOT NULL REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT, instrument_id TEXT NOT NULL DEFAULT 'unseen-transfer-g5', instrument_version TEXT NOT NULL DEFAULT '1.0.0', observer_version TEXT NOT NULL CHECK (length(btrim(observer_version)) > 0), 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_by_role TEXT NOT NULL DEFAULT 'learner' CHECK (created_by_role = 'learner'), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (original_transfer_trial_record_id, practice_session_id), UNIQUE (practice_session_id), CONSTRAINT calibration_transfer_execution_instrument_fkey FOREIGN KEY (instrument_id, instrument_version) REFERENCES app.measurement_instrument(instrument_id, instrument_version) ON DELETE RESTRICT, CONSTRAINT calibration_transfer_execution_instrument_check CHECK ( instrument_id = 'unseen-transfer-g5' AND instrument_version = '1.0.0' ), 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) ); -- This init file is also the forward owner migration for already-created -- development/NAS schemas. Preserve existing rows while adding the G0 -- instrument provenance required by the current execution ledger. ALTER TABLE app.calibration_transfer_execution_event ADD COLUMN IF NOT EXISTS instrument_id TEXT DEFAULT 'unseen-transfer-g5'; ALTER TABLE app.calibration_transfer_execution_event ADD COLUMN IF NOT EXISTS instrument_version TEXT DEFAULT '1.0.0'; UPDATE app.calibration_transfer_execution_event SET instrument_id = 'unseen-transfer-g5' WHERE instrument_id IS NULL; UPDATE app.calibration_transfer_execution_event SET instrument_version = '1.0.0' WHERE instrument_version IS NULL; ALTER TABLE app.calibration_transfer_execution_event ALTER COLUMN instrument_id SET DEFAULT 'unseen-transfer-g5', ALTER COLUMN instrument_id SET NOT NULL, ALTER COLUMN instrument_version SET DEFAULT '1.0.0', ALTER COLUMN instrument_version SET NOT NULL; DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'calibration_transfer_execution_instrument_fkey' AND conrelid = 'app.calibration_transfer_execution_event'::regclass ) THEN ALTER TABLE app.calibration_transfer_execution_event ADD CONSTRAINT calibration_transfer_execution_instrument_fkey FOREIGN KEY (instrument_id, instrument_version) REFERENCES app.measurement_instrument(instrument_id, instrument_version) ON DELETE RESTRICT; END IF; IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'calibration_transfer_execution_instrument_check' AND conrelid = 'app.calibration_transfer_execution_event'::regclass ) THEN ALTER TABLE app.calibration_transfer_execution_event ADD CONSTRAINT calibration_transfer_execution_instrument_check CHECK ( instrument_id = 'unseen-transfer-g5' AND instrument_version = '1.0.0' ); END IF; END; $$; CREATE INDEX IF NOT EXISTS idx_calibration_transfer_execution_learner_competency ON app.calibration_transfer_execution_event(learner_id, competency_id, created_at); CREATE INDEX IF NOT EXISTS idx_calibration_transfer_execution_trial ON app.calibration_transfer_execution_event(original_transfer_trial_record_id); CREATE OR REPLACE FUNCTION audit.enforce_calibration_transfer_execution() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE original_row app.calibration_transfer_trial%ROWTYPE; training_phrases TEXT[]; practice_learner UUID; practice_started TIMESTAMPTZ; practice_ended TIMESTAMPTZ; original_created TIMESTAMPTZ; evaluation_status TEXT; evaluation_scope TEXT; run_session UUID; run_role TEXT; run_status TEXT; BEGIN SELECT trial.* INTO original_row FROM app.calibration_transfer_trial trial WHERE trial.transfer_trial_record_id = NEW.original_transfer_trial_record_id; IF NOT FOUND THEN RAISE EXCEPTION 'original transfer trial does not exist'; END IF; SELECT suite.training_phrase_family_ids INTO training_phrases FROM app.calibration_transfer_suite suite WHERE suite.transfer_suite_record_id = original_row.transfer_suite_record_id; IF NOT FOUND THEN RAISE EXCEPTION 'original transfer suite does not exist'; END IF; original_created := original_row.created_at; IF NEW.transfer_suite_record_id IS DISTINCT FROM original_row.transfer_suite_record_id OR NEW.learner_id IS DISTINCT FROM original_row.learner_id OR NEW.competency_id IS DISTINCT FROM original_row.competency_id OR NEW.scenario_variant_id IS DISTINCT FROM original_row.scenario_variant_id OR NEW.scenario_novelty IS DISTINCT FROM original_row.scenario_novelty OR NEW.context_variant IS DISTINCT FROM original_row.context_variant OR NEW.relationship_style IS DISTINCT FROM original_row.relationship_style OR NEW.difficulty_level IS DISTINCT FROM original_row.difficulty_level OR NEW.expression_variant IS DISTINCT FROM original_row.expression_variant OR NEW.synthetic_subgroup IS DISTINCT FROM original_row.synthetic_subgroup OR NEW.scenario_family_id IS DISTINCT FROM original_row.scenario_family_id OR NEW.phrase_family_id IS DISTINCT FROM original_row.phrase_family_id OR NEW.training_phrase_collision IS DISTINCT FROM (original_row.phrase_family_id = ANY(training_phrases)) THEN RAISE EXCEPTION 'actual transfer context must be copied from server ledger'; END IF; IF NOT EXISTS ( SELECT 1 FROM app.calibration_prediction_history history JOIN app.calibration_prediction_lock prediction_lock ON prediction_lock.history_id = history.history_id WHERE history.learner_id = NEW.learner_id AND history.competency_id = NEW.competency_id AND prediction_lock.created_at <= original_created ) THEN RAISE EXCEPTION 'self-prediction lock must precede transfer reveal'; END IF; SELECT session.learner_id, session.started_at, session.ended_at, evaluation.status, evaluation.scope INTO practice_learner, practice_started, practice_ended, evaluation_status, evaluation_scope FROM app.sessions session LEFT JOIN app.session_evaluation evaluation ON evaluation.session_id = session.id WHERE session.id = NEW.practice_session_id; IF NOT FOUND OR practice_learner IS DISTINCT FROM NEW.learner_id THEN RAISE EXCEPTION 'practice session must belong to learner'; END IF; IF practice_ended IS NULL OR evaluation_status IS DISTINCT FROM 'ready' OR evaluation_scope IS DISTINCT FROM 'session_end' THEN RAISE EXCEPTION 'practice session must be ended with ready session_end evaluation'; END IF; IF practice_started <= original_created THEN RAISE EXCEPTION 'practice session must start after original transfer trial'; END IF; IF EXISTS ( SELECT 1 FROM unnest(NEW.evidence_turn_ids) evidence_turn_id LEFT JOIN app.turns turn_row ON turn_row.id = evidence_turn_id WHERE turn_row.id IS NULL OR turn_row.session_id <> NEW.practice_session_id ) THEN RAISE EXCEPTION 'actual transfer evidence turns must belong to practice session'; END IF; IF NOT ( NEW.normalized_evaluator_labels ?& ARRAY[ 'technique_codes','client_state_codes','appropriateness', 'intent_deviation_dimensions','evaluator_error_count' ] ) OR EXISTS ( SELECT 1 FROM jsonb_object_keys(NEW.normalized_evaluator_labels) AS keys(label_key) WHERE label_key <> ALL(ARRAY[ 'technique_codes','client_state_codes','appropriateness', 'intent_deviation_dimensions','evaluator_error_count' ]) ) THEN RAISE EXCEPTION 'actual transfer event accepts normalized evaluator labels only'; END IF; SELECT session_id, agent_role, status INTO run_session, run_role, run_status FROM audit.model_run WHERE model_run_id = NEW.model_run_id; IF NOT FOUND OR run_session IS DISTINCT FROM NEW.practice_session_id OR run_role IS DISTINCT FROM 'evaluator' OR run_status IS DISTINCT FROM 'ready' THEN RAISE EXCEPTION 'actual transfer requires ready evaluator model provenance'; END IF; RETURN NEW; END; $$; DROP TRIGGER IF EXISTS trg_calibration_transfer_execution_contract ON app.calibration_transfer_execution_event; CREATE TRIGGER trg_calibration_transfer_execution_contract BEFORE INSERT ON app.calibration_transfer_execution_event FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_transfer_execution(); DROP TRIGGER IF EXISTS trg_calibration_transfer_execution_append_only ON app.calibration_transfer_execution_event; CREATE TRIGGER trg_calibration_transfer_execution_append_only BEFORE UPDATE OR DELETE ON app.calibration_transfer_execution_event FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation(); ALTER TABLE app.calibration_transfer_execution_event ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS p_calibration_transfer_execution_event_select ON app.calibration_transfer_execution_event; CREATE POLICY p_calibration_transfer_execution_event_select ON app.calibration_transfer_execution_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 learner WHERE learner.user_id = learner_id AND learner.cohort = current_setting('app.current_cohort', true) )) )) ); DROP POLICY IF EXISTS p_calibration_transfer_execution_event_insert ON app.calibration_transfer_execution_event; CREATE POLICY p_calibration_transfer_execution_event_insert ON app.calibration_transfer_execution_event FOR INSERT WITH CHECK ( app.is_ai_context() AND current_setting('app.current_ai_view', true) = 'evaluator' AND learner_id = app.current_uid() AND created_by_role = 'learner' );