\set ON_ERROR_STOP on \if :{?app_role} \else \set app_role vignette \endif BEGIN; INSERT INTO app.app_user ( user_id, external_id, role, cohort, account_status, consent_at ) VALUES ('41000000-0000-0000-0000-000000000001', 'g4-smoke-learner', 'learner', 'g4-smoke', 'approved', now()), ('41000000-0000-0000-0000-000000000002', 'g4-smoke-teacher', 'instructor', 'g4-smoke', 'approved', now()), ('41000000-0000-0000-0000-000000000003', 'g4-smoke-other', 'learner', 'g4-other', 'approved', now()); INSERT INTO app.sessions (id, learner_id, session_no) VALUES ('42000000-0000-0000-0000-000000000001', '41000000-0000-0000-0000-000000000001', 1); INSERT INTO app.turns (id, session_id, seq, speaker, text_masked, actor_kind) VALUES ( '43000000-0000-0000-0000-000000000001', '42000000-0000-0000-0000-000000000001', 1, 'client', '[G4 smoke masked scene]', 'client_ai' ); SET LOCAL ROLE :"app_role"; SELECT set_config('app.ai_context', '1', true); SELECT set_config('app.current_ai_view', 'evaluator', true); SELECT set_config('app.current_role', 'admin', true); SELECT set_config('app.current_uid', '41000000-0000-0000-0000-000000000002', true); INSERT INTO app.practice_prescription_submission ( submission_id, session_id, learner_id, content_hash, created_by_role ) VALUES ( '44000000-0000-0000-0000-000000000001', '42000000-0000-0000-0000-000000000001', '41000000-0000-0000-0000-000000000001', repeat('a', 64), 'agent' ); INSERT INTO app.practice_coaching_card ( coaching_card_record_id, submission_id, session_id, learner_id, card_key, scene_id, coach_claim, card_payload, evidence_turn_ids, source_refs, uncertainty, counterevidence ) VALUES ( '45000000-0000-0000-0000-000000000001', '44000000-0000-0000-0000-000000000001', '42000000-0000-0000-0000-000000000001', '41000000-0000-0000-0000-000000000001', 'oas-g4-card-smoke', 'scene-smoke', '내담자 반응을 확인하는 재연습 장면을 실행한다.', '{"card_id":"oas-g4-card-smoke"}', ARRAY['43000000-0000-0000-0000-000000000001']::UUID[], ARRAY['source-pack:g4-smoke:v1'], 0.2, ARRAY[]::TEXT[] ); INSERT INTO app.practice_prescription ( prescription_record_id, prescription_key, submission_id, coaching_card_record_id, session_id, learner_id, competency_id, criterion_id, observable_behavior, activity_mode, scenario_variant_id, scenario_novelty, difficulty_level, prescription_payload, evidence_turn_ids, uncertainty, counterevidence ) VALUES ( '46000000-0000-0000-0000-000000000001', 'oas-g4-practice-smoke', '44000000-0000-0000-0000-000000000001', '45000000-0000-0000-0000-000000000001', '42000000-0000-0000-0000-000000000001', '41000000-0000-0000-0000-000000000001', 'competency.empathy.smoke', 'criterion.reflect-and-check', '정서를 반영한 뒤 내담자에게 맞는지 확인한다.', 'replay', 'variant-smoke-familiar', 'familiar', 1, '{ "schema_version":"vignette.practice-prescription.v1", "event_name":"practice.prescribed", "prescription_id":"oas-g4-practice-smoke", "coaching_card_id":"oas-g4-card-smoke", "scene_id":"scene-smoke", "competency_id":"competency.empathy.smoke", "criterion_id":"criterion.reflect-and-check", "observable_behavior":"정서를 반영한 뒤 내담자에게 맞는지 확인한다.", "activity":{ "mode":"replay", "launch_intent":"practice.replay.launch", "scenario_variant_id":"variant-smoke-familiar", "scenario_novelty":"familiar", "difficulty_level":1, "pause_at_evidence_ref":"43000000-0000-0000-0000-000000000001" }, "can_launch":true, "evidence_refs":[{ "ref_id":"43000000-0000-0000-0000-000000000001", "scene_id":"scene-smoke","turn_index":1,"actor":"client","kind":"scene_context" }], "source_refs":["source-pack:g4-smoke:v1"], "uncertainty":0.2, "counterevidence":[] }'::JSONB, ARRAY['43000000-0000-0000-0000-000000000001']::UUID[], 0.2, ARRAY[]::TEXT[] ); INSERT INTO app.competency_graph_snapshot ( snapshot_id, learner_id, session_id, snapshot_no, content_hash, source_prescription_submission_id, graph_payload, evidence_turn_ids, created_by_role ) VALUES ( '47000000-0000-0000-0000-000000000001', '41000000-0000-0000-0000-000000000001', '42000000-0000-0000-0000-000000000001', 1, repeat('b', 64), '44000000-0000-0000-0000-000000000001', '{ "schema_version":"vignette.competency-graph.v1", "definitions":[{ "competency_id":"competency.empathy.smoke", "label_ko":"스모크 공감", "description":"스모크 검증을 위한 합성 공감 역량 정의다.", "prerequisite_ids":[] }], "states":[{ "competency_id":"competency.empathy.smoke", "band":"unassessed","forgetting_risk":0.7,"uncertainty":1.0, "attempt_count":0,"familiar_demonstrations":0, "unseen_transfer_demonstrations":0,"highest_familiar_difficulty":0, "evidence_refs":[],"counterevidence":[] }] }'::JSONB, ARRAY['43000000-0000-0000-0000-000000000001']::UUID[], 'agent' ); INSERT INTO app.practice_curriculum_decision_event ( decision_id, source_snapshot_id, selected_prescription_record_id, learner_id, session_id, content_hash, decision_payload, created_by_role ) VALUES ( '48000000-0000-0000-0000-000000000001', '47000000-0000-0000-0000-000000000001', '46000000-0000-0000-0000-000000000001', '41000000-0000-0000-0000-000000000001', '42000000-0000-0000-0000-000000000001', repeat('c', 64), '{ "schema_version":"vignette.curriculum-decision.v1", "selected_prescription_id":"oas-g4-practice-smoke", "competency_id":"competency.empathy.smoke", "competency_band":"unassessed","forgetting_risk":0.7, "mode":"replay", "selection_basis":["weakest_available_band:unassessed","forgetting_risk:0.700"], "deferred_prescription_ids":[],"blocked_prescription_reasons":[] }'::JSONB, 'agent' ); -- A client-model database context must not inherit the human admin GUC or see -- evaluator-owned prescription/curriculum state. SELECT set_config('app.current_ai_view', 'client', true); DO $$ BEGIN IF ( (SELECT count(*) FROM app.practice_prescription_submission) + (SELECT count(*) FROM app.practice_coaching_card) + (SELECT count(*) FROM app.practice_prescription) + (SELECT count(*) FROM app.competency_graph_snapshot) + (SELECT count(*) FROM app.practice_curriculum_decision_event) ) <> 0 THEN RAISE EXCEPTION 'client AI view leaked evaluator practice data'; END IF; END $$; SELECT set_config('app.current_ai_view', 'evaluator', true); INSERT INTO app.practice_episode_submission ( episode_submission_id, episode_key, prescription_record_id, session_id, learner_id, content_hash, assessment_payload, progress, mastery_allowed, mastery_blockers, uncertainty, evidence_turn_ids, counterevidence, created_by_role ) VALUES ( '49000000-0000-0000-0000-000000000001', 'oas-g4-episode-smoke-valid', '46000000-0000-0000-0000-000000000001', '42000000-0000-0000-0000-000000000001', '41000000-0000-0000-0000-000000000001', repeat('d', 64), '{ "progress":"mastered","mastery_allowed":true,"competency_id":"competency.empathy.smoke", "attempts":[ {"outcome":"needs_retry","scenario_novelty":"familiar","scenario_variant_id":"variant-smoke-familiar","utterance_template_id":"template-baseline"}, {"outcome":"passed","scenario_novelty":"familiar","scenario_variant_id":"variant-smoke-familiar","utterance_template_id":"template-familiar"}, {"outcome":"passed","scenario_novelty":"unseen_transfer","scenario_variant_id":"variant-smoke-unseen","utterance_template_id":"template-adapted"} ] }'::JSONB, 'mastered', TRUE, ARRAY[]::TEXT[], 0.2, ARRAY['43000000-0000-0000-0000-000000000001']::UUID[], ARRAY[]::TEXT[], 'learner' ); INSERT INTO app.practice_attempt_evidence ( attempt_record_id, attempt_key, episode_submission_id, session_id, learner_id, sequence_no, scenario_variant_id, scenario_novelty, difficulty_level, criterion_status, client_response, outcome, utterance_template_id, uncertainty, evidence_turn_ids, counterevidence, attempt_payload ) VALUES ( '4a000000-0000-0000-0000-000000000001','oas-g4-attempt-smoke-1', '49000000-0000-0000-0000-000000000001','42000000-0000-0000-0000-000000000001', '41000000-0000-0000-0000-000000000001',1,'variant-smoke-familiar','familiar',1, 'not_observed','rejecting','needs_retry','template-baseline',0.2, ARRAY['43000000-0000-0000-0000-000000000001']::UUID[],ARRAY['baseline_miss'], '{"kind":"baseline"}'::JSONB ), ( '4a000000-0000-0000-0000-000000000002','oas-g4-attempt-smoke-2', '49000000-0000-0000-0000-000000000001','42000000-0000-0000-0000-000000000001', '41000000-0000-0000-0000-000000000001',2,'variant-smoke-familiar','familiar',1, 'observed','engaged','passed','template-familiar',0.2, ARRAY['43000000-0000-0000-0000-000000000001']::UUID[],ARRAY[]::TEXT[], '{"kind":"familiar-pass"}'::JSONB ), ( '4a000000-0000-0000-0000-000000000003','oas-g4-attempt-smoke-3', '49000000-0000-0000-0000-000000000001','42000000-0000-0000-0000-000000000001', '41000000-0000-0000-0000-000000000001',3,'variant-smoke-unseen','unseen_transfer',4, 'observed','explicit_alignment','passed','template-adapted',0.2, ARRAY['43000000-0000-0000-0000-000000000001']::UUID[],ARRAY[]::TEXT[], '{"kind":"unseen-pass"}'::JSONB ); INSERT INTO app.competency_graph_snapshot ( snapshot_id, learner_id, session_id, snapshot_no, content_hash, supersedes_snapshot_id, source_episode_submission_id, graph_payload, evidence_turn_ids, created_by_role ) VALUES ( '4b000000-0000-0000-0000-000000000001', '41000000-0000-0000-0000-000000000001', '42000000-0000-0000-0000-000000000001', 2, repeat('e', 64), '47000000-0000-0000-0000-000000000001', '49000000-0000-0000-0000-000000000001', '{ "schema_version":"vignette.competency-graph.v1", "definitions":[{ "competency_id":"competency.empathy.smoke","label_ko":"스모크 공감", "description":"스모크 검증을 위한 합성 공감 역량 정의다.","prerequisite_ids":[] }], "states":[{ "competency_id":"competency.empathy.smoke","band":"transfer_verified", "forgetting_risk":0.45,"uncertainty":0.2,"attempt_count":3, "familiar_demonstrations":1,"unseen_transfer_demonstrations":1, "highest_familiar_difficulty":1,"evidence_refs":[],"counterevidence":[] }] }'::JSONB, ARRAY['43000000-0000-0000-0000-000000000001']::UUID[], 'learner' ); INSERT INTO app.practice_curriculum_decision_event ( decision_id, source_snapshot_id, selected_prescription_record_id, learner_id, session_id, content_hash, decision_payload, created_by_role ) VALUES ( '4b000000-0000-0000-0000-000000000002', '4b000000-0000-0000-0000-000000000001', '46000000-0000-0000-0000-000000000001', '41000000-0000-0000-0000-000000000001', '42000000-0000-0000-0000-000000000001', repeat('f', 64), '{"selected_prescription_id":"oas-g4-practice-smoke"}'::JSONB, 'learner' ); -- Learner-self, teacher cohort and unrelated-user isolation. SELECT set_config('app.ai_context', '', true); SELECT set_config('app.current_ai_view', '', true); SELECT set_config('app.current_role', 'learner', true); SELECT set_config('app.current_uid', '41000000-0000-0000-0000-000000000001', true); DO $$ BEGIN IF (SELECT count(*) FROM app.practice_prescription) <> 1 THEN RAISE EXCEPTION 'learner self RLS smoke failed'; END IF; END $$; SELECT set_config('app.current_uid', '41000000-0000-0000-0000-000000000003', true); DO $$ BEGIN IF (SELECT count(*) FROM app.practice_prescription) <> 0 THEN RAISE EXCEPTION 'unrelated learner RLS leaked practice data'; END IF; END $$; SELECT set_config('app.current_role', 'instructor', true); SELECT set_config('app.current_uid', '41000000-0000-0000-0000-000000000002', true); SELECT set_config('app.current_cohort', 'g4-smoke', true); DO $$ BEGIN IF (SELECT count(*) FROM app.practice_prescription) <> 1 THEN RAISE EXCEPTION 'teacher cohort RLS smoke failed'; END IF; END $$; SELECT set_config('app.current_cohort', 'g4-other', true); DO $$ BEGIN IF (SELECT count(*) FROM app.practice_prescription) <> 0 THEN RAISE EXCEPTION 'teacher cross-cohort RLS leaked practice data'; END IF; END $$; -- Teacher correction remains append-only and cannot mutate the original attempt. SELECT set_config('app.current_cohort', 'g4-smoke', true); INSERT INTO app.practice_teacher_correction ( correction_id, submission_id, content_hash, attempt_record_id, episode_submission_id, session_id, learner_id, correction_no, corrected_outcome, correction_reason, evidence_turn_ids, counterevidence, created_by_uid, created_by_role ) VALUES ( '4c000000-0000-0000-0000-000000000001', '4c000000-0000-0000-0000-000000000002', repeat('1', 64), '4a000000-0000-0000-0000-000000000001', '49000000-0000-0000-0000-000000000001', '42000000-0000-0000-0000-000000000001', '41000000-0000-0000-0000-000000000001', 1, 'needs_retry', '내담자 후속 반응을 근거로 재시도가 필요하다고 확인했다.', ARRAY['43000000-0000-0000-0000-000000000001']::UUID[], ARRAY['client_response_not_engaged'], '41000000-0000-0000-0000-000000000002', 'instructor' ); RESET ROLE; -- Same submission id with changed content is rejected. DO $$ BEGIN BEGIN INSERT INTO app.practice_prescription_submission ( submission_id, session_id, learner_id, content_hash, created_by_role ) VALUES ( '44000000-0000-0000-0000-000000000001', '42000000-0000-0000-0000-000000000001', '41000000-0000-0000-0000-000000000001', repeat('9', 64), 'agent' ); RAISE EXCEPTION 'changed idempotent submission was accepted'; EXCEPTION WHEN unique_violation THEN NULL; END; END $$; -- Memorized phrase cannot be promoted to mastery. DO $$ BEGIN BEGIN INSERT INTO app.practice_episode_submission ( episode_submission_id, episode_key, prescription_record_id, session_id, learner_id, content_hash, assessment_payload, progress, mastery_allowed, mastery_blockers, uncertainty, evidence_turn_ids, counterevidence, created_by_role ) VALUES ( '49000000-0000-0000-0000-000000000009', 'oas-g4-episode-smoke-hack', '46000000-0000-0000-0000-000000000001', '42000000-0000-0000-0000-000000000001', '41000000-0000-0000-0000-000000000001', repeat('8', 64), '{ "progress":"mastered","mastery_allowed":true,"competency_id":"competency.empathy.smoke", "attempts":[ {"outcome":"passed","scenario_novelty":"familiar","scenario_variant_id":"v1","utterance_template_id":"memorized"}, {"outcome":"passed","scenario_novelty":"unseen_transfer","scenario_variant_id":"v2","utterance_template_id":"memorized"} ] }'::JSONB, 'mastered', TRUE, ARRAY[]::TEXT[], 0.2, ARRAY['43000000-0000-0000-0000-000000000001']::UUID[], ARRAY[]::TEXT[], 'learner' ); RAISE EXCEPTION 'memorized phrase mastery was accepted'; EXCEPTION WHEN check_violation THEN NULL; END; END $$; DO $$ BEGIN BEGIN UPDATE app.practice_attempt_evidence SET outcome = 'passed' WHERE attempt_record_id = '4a000000-0000-0000-0000-000000000001'; RAISE EXCEPTION 'append-only practice attempt update was accepted'; EXCEPTION WHEN object_not_in_prerequisite_state THEN NULL; END; END $$; ROLLBACK; SELECT 'g4_smoke_rollback_residue' AS check_name, ( (SELECT count(*) FROM app.practice_prescription_submission WHERE submission_id = '44000000-0000-0000-0000-000000000001') + (SELECT count(*) FROM app.practice_episode_submission WHERE episode_submission_id = '49000000-0000-0000-0000-000000000001') + (SELECT count(*) FROM app.practice_teacher_correction WHERE correction_id = '4c000000-0000-0000-0000-000000000001') + (SELECT count(*) FROM app.app_user WHERE user_id = '41000000-0000-0000-0000-000000000001') ) AS residue_count;