-- Outcome & Alliance OS G4: append-only deliberate-practice ledger. -- Prerequisites: 02_schema.sql, 04_audit_eval_rls.sql, 07_measurement_foundation.sql. CREATE TABLE IF NOT EXISTS app.practice_prescription_submission ( submission_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, content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'), 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 'agent' CHECK (created_by_role IN ('agent','migration')), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (submission_id, session_id, learner_id) ); CREATE INDEX IF NOT EXISTS idx_practice_prescription_submission_learner ON app.practice_prescription_submission(learner_id, created_at, submission_id); CREATE TABLE IF NOT EXISTS app.practice_coaching_card ( coaching_card_record_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), submission_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, card_key TEXT NOT NULL CHECK (length(btrim(card_key)) > 0), scene_id TEXT NOT NULL CHECK (length(btrim(scene_id)) > 0), coach_claim TEXT NOT NULL CHECK (length(btrim(coach_claim)) >= 10), card_payload JSONB NOT NULL CHECK (jsonb_typeof(card_payload) = 'object'), evidence_turn_ids UUID[] NOT NULL CHECK (cardinality(evidence_turn_ids) > 0), source_refs TEXT[] NOT NULL CHECK (cardinality(source_refs) > 0), uncertainty DOUBLE PRECISION NOT NULL CHECK (uncertainty BETWEEN 0 AND 1), counterevidence TEXT[] 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(), UNIQUE (submission_id, card_key), UNIQUE (coaching_card_record_id, submission_id, session_id, learner_id), CONSTRAINT practice_card_submission_fkey FOREIGN KEY (submission_id, session_id, learner_id) REFERENCES app.practice_prescription_submission(submission_id, session_id, learner_id) ON DELETE RESTRICT ); CREATE INDEX IF NOT EXISTS idx_practice_coaching_card_session ON app.practice_coaching_card(session_id, created_at, coaching_card_record_id); CREATE TABLE IF NOT EXISTS app.practice_prescription ( prescription_record_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), prescription_key TEXT NOT NULL CHECK (length(btrim(prescription_key)) > 0), submission_id UUID NOT NULL, coaching_card_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_.-]+$'), criterion_id TEXT NOT NULL CHECK (criterion_id ~ '^criterion\.[a-z0-9_.-]+$'), observable_behavior TEXT NOT NULL CHECK (length(btrim(observable_behavior)) >= 10), activity_mode TEXT NOT NULL CHECK ( activity_mode IN ('replay','branch','constrained_response','voice_retry','difficulty_ladder') ), scenario_variant_id TEXT NOT NULL CHECK (length(btrim(scenario_variant_id)) > 0), scenario_novelty TEXT NOT NULL CHECK (scenario_novelty IN ('familiar','unseen_transfer')), difficulty_level INT NOT NULL CHECK (difficulty_level BETWEEN 1 AND 5), prescription_payload JSONB NOT NULL CHECK (jsonb_typeof(prescription_payload) = 'object'), evidence_turn_ids UUID[] NOT NULL CHECK (cardinality(evidence_turn_ids) > 0), uncertainty DOUBLE PRECISION NOT NULL CHECK (uncertainty BETWEEN 0 AND 1), counterevidence TEXT[] 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(), UNIQUE (learner_id, prescription_key), UNIQUE (prescription_record_id, session_id, learner_id), CONSTRAINT practice_prescription_card_fkey FOREIGN KEY (coaching_card_record_id, submission_id, session_id, learner_id) REFERENCES app.practice_coaching_card( coaching_card_record_id, submission_id, session_id, learner_id ) ON DELETE RESTRICT ); CREATE INDEX IF NOT EXISTS idx_practice_prescription_learner_competency ON app.practice_prescription(learner_id, competency_id, created_at); CREATE TABLE IF NOT EXISTS app.practice_episode_submission ( episode_submission_id UUID PRIMARY KEY, episode_key TEXT NOT NULL CHECK (length(btrim(episode_key)) > 0), prescription_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, content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'), assessment_payload JSONB NOT NULL CHECK (jsonb_typeof(assessment_payload) = 'object'), progress TEXT NOT NULL CHECK (progress IN ('practicing','transfer_pending','mastered')), mastery_allowed BOOLEAN NOT NULL, mastery_blockers TEXT[] NOT NULL DEFAULT '{}', uncertainty DOUBLE PRECISION NOT NULL CHECK (uncertainty BETWEEN 0 AND 1), evidence_turn_ids UUID[] NOT NULL CHECK (cardinality(evidence_turn_ids) > 0), counterevidence TEXT[] 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_by_role TEXT NOT NULL CHECK (created_by_role IN ('learner','agent','migration')), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (learner_id, episode_key), UNIQUE (episode_submission_id, session_id, learner_id), CONSTRAINT practice_episode_prescription_fkey FOREIGN KEY (prescription_record_id, session_id, learner_id) REFERENCES app.practice_prescription(prescription_record_id, session_id, learner_id) ON DELETE RESTRICT, CHECK ( (progress = 'mastered' AND mastery_allowed AND cardinality(mastery_blockers) = 0) OR (progress <> 'mastered' AND NOT mastery_allowed) ) ); CREATE INDEX IF NOT EXISTS idx_practice_episode_learner_created ON app.practice_episode_submission(learner_id, created_at, episode_submission_id); CREATE TABLE IF NOT EXISTS app.practice_attempt_evidence ( attempt_record_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), attempt_key TEXT NOT NULL CHECK (length(btrim(attempt_key)) > 0), episode_submission_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, sequence_no INT NOT NULL CHECK (sequence_no >= 1), scenario_variant_id TEXT NOT NULL CHECK (length(btrim(scenario_variant_id)) > 0), scenario_novelty TEXT NOT NULL CHECK (scenario_novelty IN ('familiar','unseen_transfer')), difficulty_level INT NOT NULL CHECK (difficulty_level BETWEEN 1 AND 5), criterion_status TEXT NOT NULL CHECK (criterion_status IN ('observed','not_observed','error')), client_response TEXT CHECK ( client_response IS NULL OR client_response IN ( 'rejecting','withdrawn','compliance_only','mixed','engaged','explicit_alignment' ) ), outcome TEXT NOT NULL CHECK (outcome IN ('passed','needs_retry','insufficient_evidence')), utterance_template_id TEXT, learner_claimed_success BOOLEAN NOT NULL DEFAULT FALSE, uncertainty DOUBLE PRECISION NOT NULL CHECK (uncertainty BETWEEN 0 AND 1), evidence_turn_ids UUID[] NOT NULL CHECK (cardinality(evidence_turn_ids) > 0), counterevidence TEXT[] NOT NULL DEFAULT '{}', attempt_payload JSONB NOT NULL CHECK (jsonb_typeof(attempt_payload) = 'object'), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (episode_submission_id, attempt_key), UNIQUE (episode_submission_id, sequence_no), UNIQUE (attempt_record_id, episode_submission_id, session_id, learner_id), CONSTRAINT practice_attempt_episode_fkey FOREIGN KEY (episode_submission_id, session_id, learner_id) REFERENCES app.practice_episode_submission(episode_submission_id, session_id, learner_id) ON DELETE RESTRICT, CHECK ( outcome <> 'passed' OR ( criterion_status = 'observed' AND client_response IN ('engaged','explicit_alignment') AND uncertainty <= 0.5 ) ) ); CREATE INDEX IF NOT EXISTS idx_practice_attempt_episode_sequence ON app.practice_attempt_evidence(episode_submission_id, sequence_no); CREATE TABLE IF NOT EXISTS app.competency_graph_snapshot ( snapshot_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT, session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT, snapshot_no INT NOT NULL CHECK (snapshot_no >= 1), content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'), supersedes_snapshot_id UUID REFERENCES app.competency_graph_snapshot(snapshot_id) ON DELETE RESTRICT, source_prescription_submission_id UUID REFERENCES app.practice_prescription_submission(submission_id) ON DELETE RESTRICT, source_episode_submission_id UUID REFERENCES app.practice_episode_submission(episode_submission_id) ON DELETE RESTRICT, graph_payload JSONB NOT NULL CHECK (jsonb_typeof(graph_payload) = 'object'), evidence_turn_ids UUID[] NOT NULL DEFAULT '{}', created_by_role TEXT NOT NULL CHECK (created_by_role IN ('learner','agent','migration')), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (learner_id, snapshot_no), UNIQUE (supersedes_snapshot_id), UNIQUE (source_prescription_submission_id), UNIQUE (source_episode_submission_id), UNIQUE (snapshot_id, learner_id, session_id), CHECK (snapshot_id IS DISTINCT FROM supersedes_snapshot_id), CHECK ( (source_prescription_submission_id IS NOT NULL)::INT + (source_episode_submission_id IS NOT NULL)::INT = 1 ) ); CREATE INDEX IF NOT EXISTS idx_competency_graph_snapshot_latest ON app.competency_graph_snapshot(learner_id, snapshot_no DESC); CREATE TABLE IF NOT EXISTS app.practice_curriculum_decision_event ( decision_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), source_snapshot_id UUID NOT NULL, selected_prescription_record_id UUID NOT NULL, learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT, session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT, content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'), decision_payload JSONB NOT NULL CHECK (jsonb_typeof(decision_payload) = 'object'), created_by_role TEXT NOT NULL CHECK (created_by_role IN ('learner','agent','migration')), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (source_snapshot_id), CONSTRAINT practice_decision_snapshot_fkey FOREIGN KEY (source_snapshot_id, learner_id, session_id) REFERENCES app.competency_graph_snapshot(snapshot_id, learner_id, session_id) ON DELETE RESTRICT, CONSTRAINT practice_decision_prescription_fkey FOREIGN KEY (selected_prescription_record_id, session_id, learner_id) REFERENCES app.practice_prescription(prescription_record_id, session_id, learner_id) ON DELETE RESTRICT ); CREATE INDEX IF NOT EXISTS idx_practice_decision_learner_created ON app.practice_curriculum_decision_event(learner_id, created_at, decision_id); CREATE TABLE IF NOT EXISTS app.practice_teacher_correction ( correction_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), submission_id UUID NOT NULL UNIQUE, content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'), attempt_record_id UUID NOT NULL, episode_submission_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, correction_no INT NOT NULL CHECK (correction_no >= 1), supersedes_correction_id UUID REFERENCES app.practice_teacher_correction(correction_id) ON DELETE RESTRICT, corrected_outcome TEXT NOT NULL CHECK ( corrected_outcome IN ('passed','needs_retry','insufficient_evidence') ), correction_reason TEXT NOT NULL CHECK (length(btrim(correction_reason)) > 0), evidence_turn_ids UUID[] NOT NULL CHECK (cardinality(evidence_turn_ids) > 0), 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 (attempt_record_id, correction_no), UNIQUE (supersedes_correction_id), CHECK (correction_id IS DISTINCT FROM supersedes_correction_id), CONSTRAINT practice_correction_attempt_fkey FOREIGN KEY (attempt_record_id, episode_submission_id, session_id, learner_id) REFERENCES app.practice_attempt_evidence( attempt_record_id, episode_submission_id, session_id, learner_id ) ON DELETE RESTRICT ); CREATE INDEX IF NOT EXISTS idx_practice_teacher_correction_attempt ON app.practice_teacher_correction(attempt_record_id, correction_no DESC); CREATE OR REPLACE FUNCTION audit.enforce_practice_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 'practice row must match its session learner' USING ERRCODE = '23514'; END IF; RETURN NEW; END; $$; CREATE OR REPLACE FUNCTION audit.enforce_practice_evidence_turn_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 'practice evidence turns must belong to its session' USING ERRCODE = '23514'; END IF; RETURN NEW; END; $$; CREATE OR REPLACE FUNCTION audit.enforce_practice_episode_transfer_gate() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE attempts JSONB; has_familiar_pass BOOLEAN; has_unseen_pass BOOLEAN; has_transfer_collision BOOLEAN; BEGIN IF NEW.assessment_payload->>'progress' IS DISTINCT FROM NEW.progress OR COALESCE((NEW.assessment_payload->>'mastery_allowed')::BOOLEAN, FALSE) IS DISTINCT FROM NEW.mastery_allowed THEN RAISE EXCEPTION 'practice episode columns must match assessment payload' USING ERRCODE = '23514'; END IF; attempts := COALESCE(NEW.assessment_payload->'attempts', '[]'::JSONB); IF jsonb_typeof(attempts) <> 'array' OR jsonb_array_length(attempts) = 0 THEN RAISE EXCEPTION 'practice episode requires attempt evidence' USING ERRCODE = '23514'; END IF; SELECT EXISTS ( SELECT 1 FROM jsonb_array_elements(attempts) item WHERE item->>'outcome' = 'passed' AND item->>'scenario_novelty' = 'familiar' ) INTO has_familiar_pass; SELECT EXISTS ( SELECT 1 FROM jsonb_array_elements(attempts) item WHERE item->>'outcome' = 'passed' AND item->>'scenario_novelty' = 'unseen_transfer' AND length(btrim(COALESCE(item->>'utterance_template_id', ''))) > 0 ) INTO has_unseen_pass; SELECT EXISTS ( SELECT 1 FROM jsonb_array_elements(attempts) unseen, jsonb_array_elements(attempts) familiar WHERE unseen->>'outcome' = 'passed' AND unseen->>'scenario_novelty' = 'unseen_transfer' AND familiar->>'outcome' = 'passed' AND familiar->>'scenario_novelty' = 'familiar' AND ( unseen->>'scenario_variant_id' = familiar->>'scenario_variant_id' OR unseen->>'utterance_template_id' = familiar->>'utterance_template_id' ) ) INTO has_transfer_collision; IF NEW.progress = 'mastered' AND (NOT has_familiar_pass OR NOT has_unseen_pass OR has_transfer_collision) THEN RAISE EXCEPTION 'practice mastery requires familiar success and novel unseen transfer evidence' USING ERRCODE = '23514'; END IF; RETURN NEW; END; $$; CREATE OR REPLACE FUNCTION audit.enforce_practice_attempt_sequence_and_transfer() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE latest_sequence INT; familiar_pass_exists BOOLEAN; repeated_template_exists BOOLEAN; repeated_variant_exists BOOLEAN; BEGIN PERFORM pg_advisory_xact_lock(hashtextextended(NEW.episode_submission_id::TEXT, 0)); SELECT max(a.sequence_no) INTO latest_sequence FROM app.practice_attempt_evidence a WHERE a.episode_submission_id = NEW.episode_submission_id; IF NEW.sequence_no <> COALESCE(latest_sequence, 0) + 1 THEN RAISE EXCEPTION 'practice attempt sequence must be contiguous' USING ERRCODE = '23514'; END IF; IF NEW.outcome = 'passed' AND NEW.scenario_novelty = 'unseen_transfer' THEN IF length(btrim(COALESCE(NEW.utterance_template_id, ''))) = 0 THEN RAISE EXCEPTION 'unseen transfer pass requires a nonblank utterance template id' USING ERRCODE = '23514'; END IF; SELECT EXISTS ( SELECT 1 FROM app.practice_attempt_evidence a WHERE a.episode_submission_id = NEW.episode_submission_id AND a.sequence_no < NEW.sequence_no AND a.scenario_novelty = 'familiar' AND a.outcome = 'passed' ) INTO familiar_pass_exists; SELECT EXISTS ( SELECT 1 FROM app.practice_attempt_evidence a WHERE a.episode_submission_id = NEW.episode_submission_id AND a.sequence_no < NEW.sequence_no AND a.scenario_novelty = 'familiar' AND a.utterance_template_id = NEW.utterance_template_id ) INTO repeated_template_exists; SELECT EXISTS ( SELECT 1 FROM app.practice_attempt_evidence a WHERE a.episode_submission_id = NEW.episode_submission_id AND a.sequence_no < NEW.sequence_no AND a.scenario_novelty = 'familiar' AND a.scenario_variant_id = NEW.scenario_variant_id ) INTO repeated_variant_exists; IF NOT familiar_pass_exists OR repeated_template_exists OR repeated_variant_exists THEN RAISE EXCEPTION 'unseen transfer pass cannot reuse familiar variant or memorized phrase' USING ERRCODE = '23514'; END IF; END IF; RETURN NEW; END; $$; CREATE OR REPLACE FUNCTION audit.enforce_competency_snapshot_chain() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE latest_snapshot_id UUID; latest_snapshot_no INT; latest_graph JSONB; source_progress TEXT; source_competency TEXT; source_learner UUID; source_session UUID; promoted_ids TEXT[]; BEGIN PERFORM pg_advisory_xact_lock(hashtextextended(NEW.learner_id::TEXT, 1)); SELECT s.snapshot_id, s.snapshot_no, s.graph_payload INTO latest_snapshot_id, latest_snapshot_no, latest_graph FROM app.competency_graph_snapshot s WHERE s.learner_id = NEW.learner_id ORDER BY s.snapshot_no DESC LIMIT 1; IF latest_snapshot_id IS NULL THEN IF NEW.snapshot_no <> 1 OR NEW.supersedes_snapshot_id IS NOT NULL THEN RAISE EXCEPTION 'first competency graph snapshot must be revision 1 without supersedes' USING ERRCODE = '23514'; END IF; ELSIF NEW.snapshot_no <> latest_snapshot_no + 1 OR NEW.supersedes_snapshot_id IS DISTINCT FROM latest_snapshot_id THEN RAISE EXCEPTION 'competency graph snapshot must supersede the latest revision in order' USING ERRCODE = '23514'; END IF; IF NEW.source_prescription_submission_id IS NOT NULL THEN SELECT p.learner_id, p.session_id INTO source_learner, source_session FROM app.practice_prescription_submission p WHERE p.submission_id = NEW.source_prescription_submission_id; ELSE SELECT e.learner_id, e.session_id INTO source_learner, source_session FROM app.practice_episode_submission e WHERE e.episode_submission_id = NEW.source_episode_submission_id; END IF; IF source_learner IS NULL OR source_learner IS DISTINCT FROM NEW.learner_id OR source_session IS DISTINCT FROM NEW.session_id THEN RAISE EXCEPTION 'competency graph snapshot source must match learner and session' USING ERRCODE = '23514'; END IF; SELECT array_agg(new_state->>'competency_id') INTO promoted_ids FROM jsonb_array_elements(COALESCE(NEW.graph_payload->'states', '[]'::JSONB)) new_state WHERE new_state->>'band' = 'transfer_verified' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements(COALESCE(latest_graph->'states', '[]'::JSONB)) old_state WHERE old_state->>'competency_id' = new_state->>'competency_id' AND old_state->>'band' = 'transfer_verified' ); IF cardinality(promoted_ids) > 0 THEN IF NEW.source_episode_submission_id IS NULL THEN RAISE EXCEPTION 'new transfer_verified competency requires a mastered practice episode' USING ERRCODE = '23514'; END IF; SELECT e.progress, e.assessment_payload->>'competency_id' INTO source_progress, source_competency FROM app.practice_episode_submission e WHERE e.episode_submission_id = NEW.source_episode_submission_id; IF source_progress IS DISTINCT FROM 'mastered' OR cardinality(promoted_ids) <> 1 OR promoted_ids[1] IS DISTINCT FROM source_competency THEN RAISE EXCEPTION 'competency transfer promotion must match its mastered episode' USING ERRCODE = '23514'; END IF; END IF; RETURN NEW; END; $$; CREATE OR REPLACE FUNCTION audit.enforce_practice_curriculum_decision() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE selected_competency TEXT; selected_novelty TEXT; selected_difficulty INT; snapshot_state JSONB; BEGIN SELECT p.competency_id, p.scenario_novelty, p.difficulty_level INTO selected_competency, selected_novelty, selected_difficulty FROM app.practice_prescription p WHERE p.prescription_record_id = NEW.selected_prescription_record_id AND p.learner_id = NEW.learner_id; SELECT state INTO snapshot_state FROM app.competency_graph_snapshot s, jsonb_array_elements(COALESCE(s.graph_payload->'states', '[]'::JSONB)) state WHERE s.snapshot_id = NEW.source_snapshot_id AND state->>'competency_id' = selected_competency; IF selected_competency IS NULL OR snapshot_state IS NULL THEN RAISE EXCEPTION 'curriculum decision must target a competency in its graph snapshot' USING ERRCODE = '23514'; END IF; IF NEW.decision_payload->>'selected_prescription_id' IS DISTINCT FROM ( SELECT p.prescription_key FROM app.practice_prescription p WHERE p.prescription_record_id = NEW.selected_prescription_record_id ) THEN RAISE EXCEPTION 'curriculum decision payload must match selected prescription' USING ERRCODE = '23514'; END IF; IF selected_novelty = 'familiar' AND COALESCE((snapshot_state->>'familiar_demonstrations')::INT, 0) >= 2 AND selected_difficulty <= COALESCE( (snapshot_state->>'highest_familiar_difficulty')::INT, 0 ) THEN RAISE EXCEPTION 'curriculum decision cannot reward repeated easy familiar practice' USING ERRCODE = '23514'; END IF; RETURN NEW; END; $$; CREATE OR REPLACE FUNCTION audit.enforce_practice_teacher_correction() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE target app.practice_attempt_evidence%ROWTYPE; latest_correction_id UUID; latest_correction_no INT; familiar_pass_exists BOOLEAN; repeated_template_exists BOOLEAN; repeated_variant_exists BOOLEAN; BEGIN PERFORM pg_advisory_xact_lock(hashtextextended(NEW.attempt_record_id::TEXT, 2)); SELECT * INTO target FROM app.practice_attempt_evidence a WHERE a.attempt_record_id = NEW.attempt_record_id; IF target.attempt_record_id IS NULL OR target.episode_submission_id IS DISTINCT FROM NEW.episode_submission_id OR target.session_id IS DISTINCT FROM NEW.session_id OR target.learner_id IS DISTINCT FROM NEW.learner_id THEN RAISE EXCEPTION 'teacher correction must target one visible practice attempt' USING ERRCODE = '23514'; END IF; SELECT c.correction_id, c.correction_no INTO latest_correction_id, latest_correction_no FROM app.practice_teacher_correction c WHERE c.attempt_record_id = NEW.attempt_record_id ORDER BY c.correction_no DESC LIMIT 1; IF latest_correction_id IS NULL THEN IF NEW.correction_no <> 1 OR NEW.supersedes_correction_id IS NOT NULL THEN RAISE EXCEPTION 'first teacher correction must be revision 1 without supersedes' USING ERRCODE = '23514'; END IF; ELSIF NEW.correction_no <> latest_correction_no + 1 OR NEW.supersedes_correction_id IS DISTINCT FROM latest_correction_id THEN RAISE EXCEPTION 'teacher correction must supersede the latest revision in order' USING ERRCODE = '23514'; END IF; IF NEW.corrected_outcome = 'passed' THEN IF target.criterion_status <> 'observed' OR target.client_response NOT IN ('engaged','explicit_alignment') OR target.uncertainty > 0.5 THEN RAISE EXCEPTION 'teacher pass correction requires observed behavior and engaged client evidence' USING ERRCODE = '23514'; END IF; IF target.scenario_novelty = 'unseen_transfer' THEN IF length(btrim(COALESCE(target.utterance_template_id, ''))) = 0 THEN RAISE EXCEPTION 'teacher transfer correction requires a nonblank utterance template id' USING ERRCODE = '23514'; END IF; SELECT EXISTS ( SELECT 1 FROM app.practice_attempt_evidence prior WHERE prior.episode_submission_id = target.episode_submission_id AND prior.sequence_no < target.sequence_no AND prior.scenario_novelty = 'familiar' AND COALESCE( ( SELECT correction.corrected_outcome FROM app.practice_teacher_correction correction WHERE correction.attempt_record_id = prior.attempt_record_id ORDER BY correction.correction_no DESC LIMIT 1 ), prior.outcome ) = 'passed' ) INTO familiar_pass_exists; SELECT EXISTS ( SELECT 1 FROM app.practice_attempt_evidence prior WHERE prior.episode_submission_id = target.episode_submission_id AND prior.sequence_no < target.sequence_no AND prior.scenario_novelty = 'familiar' AND prior.utterance_template_id = target.utterance_template_id ) INTO repeated_template_exists; SELECT EXISTS ( SELECT 1 FROM app.practice_attempt_evidence prior WHERE prior.episode_submission_id = target.episode_submission_id AND prior.sequence_no < target.sequence_no AND prior.scenario_novelty = 'familiar' AND prior.scenario_variant_id = target.scenario_variant_id ) INTO repeated_variant_exists; IF NOT familiar_pass_exists OR repeated_template_exists OR repeated_variant_exists THEN RAISE EXCEPTION 'teacher correction cannot bypass unseen transfer or memorized phrase gates' USING ERRCODE = '23514'; END IF; END IF; END IF; RETURN NEW; END; $$; DO $$ DECLARE table_name TEXT; BEGIN FOREACH table_name IN ARRAY ARRAY[ 'practice_prescription_submission', 'practice_coaching_card', 'practice_prescription', 'practice_episode_submission', 'practice_attempt_evidence', 'competency_graph_snapshot', 'practice_curriculum_decision_event', 'practice_teacher_correction' ] LOOP 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_practice_session_learner()', table_name, table_name ); 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 ); END LOOP; END; $$; DO $$ DECLARE table_name TEXT; BEGIN FOREACH table_name IN ARRAY ARRAY[ 'practice_coaching_card', 'practice_prescription', 'practice_episode_submission', 'practice_attempt_evidence', 'competency_graph_snapshot', 'practice_teacher_correction' ] 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_practice_evidence_turn_ownership()', table_name, table_name ); END LOOP; END; $$; DROP TRIGGER IF EXISTS trg_practice_episode_transfer_gate ON app.practice_episode_submission; CREATE TRIGGER trg_practice_episode_transfer_gate BEFORE INSERT ON app.practice_episode_submission FOR EACH ROW EXECUTE FUNCTION audit.enforce_practice_episode_transfer_gate(); DROP TRIGGER IF EXISTS trg_practice_attempt_contract ON app.practice_attempt_evidence; CREATE TRIGGER trg_practice_attempt_contract BEFORE INSERT ON app.practice_attempt_evidence FOR EACH ROW EXECUTE FUNCTION audit.enforce_practice_attempt_sequence_and_transfer(); DROP TRIGGER IF EXISTS trg_competency_snapshot_chain ON app.competency_graph_snapshot; CREATE TRIGGER trg_competency_snapshot_chain BEFORE INSERT ON app.competency_graph_snapshot FOR EACH ROW EXECUTE FUNCTION audit.enforce_competency_snapshot_chain(); DROP TRIGGER IF EXISTS trg_practice_curriculum_decision_contract ON app.practice_curriculum_decision_event; CREATE TRIGGER trg_practice_curriculum_decision_contract BEFORE INSERT ON app.practice_curriculum_decision_event FOR EACH ROW EXECUTE FUNCTION audit.enforce_practice_curriculum_decision(); DROP TRIGGER IF EXISTS trg_practice_teacher_correction_contract ON app.practice_teacher_correction; CREATE TRIGGER trg_practice_teacher_correction_contract BEFORE INSERT ON app.practice_teacher_correction FOR EACH ROW EXECUTE FUNCTION audit.enforce_practice_teacher_correction(); DO $$ DECLARE table_name TEXT; BEGIN FOREACH table_name IN ARRAY ARRAY[ 'practice_prescription_submission', 'practice_coaching_card', 'practice_prescription', 'practice_episode_submission', 'practice_attempt_evidence', 'competency_graph_snapshot', 'practice_curriculum_decision_event', 'practice_teacher_correction' ] LOOP EXECUTE format('ALTER TABLE app.%I ENABLE ROW LEVEL SECURITY', table_name); END LOOP; END; $$; -- Prescription authoring is evaluator-only. Human reads are learner-self or cohort-scoped. DROP POLICY IF EXISTS p_practice_prescription_submission_select ON app.practice_prescription_submission; DROP POLICY IF EXISTS p_practice_prescription_submission_insert ON app.practice_prescription_submission; CREATE POLICY p_practice_prescription_submission_select ON app.practice_prescription_submission FOR SELECT USING ( (app.is_ai_context() AND current_setting('app.current_ai_view', true) = ANY(visible_to)) 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_practice_prescription_submission_insert ON app.practice_prescription_submission FOR INSERT WITH CHECK ( app.is_ai_context() AND current_setting('app.current_ai_view', true) = 'evaluator' AND created_by_role = 'agent' ); DO $$ DECLARE table_name TEXT; BEGIN FOREACH table_name IN ARRAY ARRAY[ 'practice_coaching_card', 'practice_prescription' ] LOOP EXECUTE format('DROP POLICY IF EXISTS p_%s_select ON app.%I', table_name, table_name); EXECUTE format('DROP POLICY IF EXISTS p_%s_insert 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) = ANY(visible_to)) ' '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 ); 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; $$; -- Learners append their own attempt transaction; evaluator/migration may append internally. DO $$ DECLARE table_name TEXT; learner_created_guard TEXT; ai_select_guard TEXT; BEGIN FOREACH table_name IN ARRAY ARRAY[ 'practice_episode_submission', 'practice_attempt_evidence', 'competency_graph_snapshot', 'practice_curriculum_decision_event' ] LOOP learner_created_guard := CASE WHEN table_name = 'practice_attempt_evidence' THEN '' ELSE ' AND created_by_role = ''learner''' END; ai_select_guard := CASE WHEN table_name = 'practice_episode_submission' THEN 'current_setting(''app.current_ai_view'', true) = ANY(visible_to)' WHEN table_name = 'practice_attempt_evidence' THEN 'EXISTS (SELECT 1 FROM app.practice_episode_submission parent ' 'WHERE parent.episode_submission_id = app.practice_attempt_evidence.episode_submission_id ' 'AND current_setting(''app.current_ai_view'', true) = ANY(parent.visible_to))' ELSE 'current_setting(''app.current_ai_view'', true) ' 'IN (''counselor'',''evaluator'',''supervisor'',''research'')' END; EXECUTE format('DROP POLICY IF EXISTS p_%s_select ON app.%I', table_name, table_name); EXECUTE format('DROP POLICY IF EXISTS p_%s_insert 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 %s) 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, ai_select_guard ); 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'') OR ' '(NOT app.is_ai_context() AND app.current_role_name() = ''learner'' ' 'AND learner_id = app.current_uid()%s))', table_name, table_name, learner_created_guard ); END LOOP; END; $$; DROP POLICY IF EXISTS p_practice_teacher_correction_select ON app.practice_teacher_correction; DROP POLICY IF EXISTS p_practice_teacher_correction_insert ON app.practice_teacher_correction; CREATE POLICY p_practice_teacher_correction_select ON app.practice_teacher_correction FOR SELECT USING ( (app.is_ai_context() AND EXISTS ( SELECT 1 FROM app.practice_attempt_evidence attempt JOIN app.practice_episode_submission episode ON episode.episode_submission_id = attempt.episode_submission_id WHERE attempt.attempt_record_id = app.practice_teacher_correction.attempt_record_id AND current_setting('app.current_ai_view', true) = ANY(episode.visible_to) )) 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_practice_teacher_correction_insert ON app.practice_teacher_correction 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) ) ) ) );