-- Outcome & Alliance OS G0: provenance-first append-only measurement ledger. -- Prerequisites: 02_schema.sql, 04_audit_eval_rls.sql, 06_session_evaluation.sql. CREATE SCHEMA IF NOT EXISTS audit; CREATE SCHEMA IF NOT EXISTS ds; CREATE TABLE IF NOT EXISTS app.measurement_instrument ( instrument_id TEXT NOT NULL, instrument_version TEXT NOT NULL, name_ko TEXT NOT NULL, instrument_kind TEXT NOT NULL CHECK ( instrument_kind IN ('validated_measure','training_metric','simulation_signal','runtime_metric') ), construct TEXT NOT NULL CHECK ( construct IN ( 'working_alliance','session_outcome','rupture_repair','counselor_skill', 'self_calibration','transfer','simulation_progress' ) ), language TEXT NOT NULL DEFAULT 'ko-KR', license_id TEXT, validation_basis TEXT NOT NULL, scoring_schema JSONB NOT NULL DEFAULT '{}'::jsonb, metadata JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (instrument_id, instrument_version) ); INSERT INTO app.measurement_instrument ( instrument_id, instrument_version, name_ko, instrument_kind, construct, validation_basis, scoring_schema, metadata ) VALUES ( 'vignette-state-machine', 'legacy-1', '기존 상태머신 라포 누적', 'simulation_signal', 'simulation_progress', '결정론적 시뮬레이션 진행 신호. 치료동맹 척도가 아님.', '{"min":0,"max":1}'::jsonb, '{"clinical_claim_allowed":false,"legacy_signal":"session_state.rapport_credit"}'::jsonb ), ( 'vignette-alliance-ewma', 'legacy-1', '기존 라포 EWMA', 'simulation_signal', 'simulation_progress', 'rapport_credit 회기말 값의 EWMA. Working Alliance 척도가 아님.', '{"min":0,"max":1}'::jsonb, '{"clinical_claim_allowed":false,"legacy_signal":"case_profile.alliance_level"}'::jsonb ), ( 'vignette-fast-evaluator', 'legacy-1', '기존 턴 평가기', 'training_metric', 'counselor_skill', '모델 기반 경량 훈련 피드백. 인간 평정과 동등하지 않음.', '{"appropriateness":{"warn":0.25,"neutral":0.5,"pos":1.0},"rapport_signal":{"min":-1,"max":1}}'::jsonb, '{"clinical_claim_allowed":false,"loop":"fast"}'::jsonb ), ( 'vignette-deep-evaluator', 'legacy-1', '기존 회기 평가기', 'training_metric', 'counselor_skill', '모델 태그와 정성 코칭. 독립 심리측정 척도가 아님.', '{"technique_occurrence_count":{"min":0,"max":1000}}'::jsonb, '{"clinical_claim_allowed":false,"loop":"deep"}'::jsonb ), ( 'phase3-prepost', 'design-1', 'Phase 3 사전사후 자기보고', 'training_metric', 'self_calibration', '공식 문항과 한국어 타당화 확정 전 파일럿 자기보고 설계.', '{"min":0,"max":1}'::jsonb, '{"clinical_claim_allowed":false,"status":"design_pending"}'::jsonb ), ( 'phase3-runtime-kpi', 'design-1', 'Phase 3 운영 KPI', 'runtime_metric', 'transfer', '완주·검수·IAA 등 시스템 및 연구 운영 증거.', '{"min":0,"max":1}'::jsonb, '{"clinical_claim_allowed":false}'::jsonb ), ( 'alliance-pulse-learner', '1.0.0', '동맹 펄스 학습자 자기평가', 'training_metric', 'working_alliance', '수련생이 외부 관점 공개 전에 잠그는 goal/task/bond 자기평가.', '{"dimensions":["goal","task","bond"],"min":0,"max":1}'::jsonb, '{"clinical_claim_allowed":false,"perspective":"learner_self_report"}'::jsonb ), ( 'alliance-pulse-client-agent', '1.0.0', '동맹 펄스 가상내담자 보고', 'training_metric', 'working_alliance', '가상내담자 에이전트가 대화 근거로 보고한 수용 경험. 실제 내담자 보고가 아님.', '{"dimensions":["goal","task","bond"],"min":0,"max":1}'::jsonb, '{"clinical_claim_allowed":false,"perspective":"client_agent_report"}'::jsonb ), ( 'alliance-pulse-observer', '1.0.0', '동맹 펄스 독립 관찰자 추론', 'training_metric', 'working_alliance', '내부 페르소나 상태 없이 마스킹 축어록만 보는 독립 평가 모델의 추론.', '{"dimensions":["goal","task","bond"],"min":0,"max":1}'::jsonb, '{"clinical_claim_allowed":false,"perspective":"independent_observer"}'::jsonb ), ( 'alliance-pulse-supervisor', '1.0.0', '동맹 펄스 교수자 재평정', 'training_metric', 'working_alliance', '교수자 수동 근거 평정. 기존 이벤트를 수정하지 않고 새 버전으로 supersede한다.', '{"dimensions":["goal","task","bond"],"min":0,"max":1}'::jsonb, '{"clinical_claim_allowed":false,"perspective":"supervisor_human"}'::jsonb ) ON CONFLICT (instrument_id, instrument_version) DO NOTHING; CREATE TABLE IF NOT EXISTS audit.model_run ( model_run_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), session_id UUID, turn_id UUID, agent_role TEXT NOT NULL CHECK ( agent_role IN ('client','evaluator','coach','scenario','research') ), provider TEXT NOT NULL, model TEXT NOT NULL, prompt_bundle_id TEXT NOT NULL, prompt_bundle_version TEXT NOT NULL, prompt_bundle_hash TEXT NOT NULL CHECK (prompt_bundle_hash ~ '^[a-f0-9]{64}$'), structured_schema_version TEXT NOT NULL, input_evidence_hash TEXT NOT NULL CHECK (input_evidence_hash ~ '^[a-f0-9]{64}$'), status TEXT NOT NULL CHECK (status IN ('ready','degraded','error')), error_code TEXT, metadata JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CHECK ( (status = 'error' AND error_code IS NOT NULL) OR (status <> 'error') ), CHECK (status <> 'ready' OR error_code IS NULL) ); CREATE INDEX IF NOT EXISTS idx_model_run_session_created ON audit.model_run(session_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_model_run_model_created ON audit.model_run(provider, model, created_at DESC); CREATE TABLE IF NOT EXISTS app.measurement_event ( measurement_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT, turn_id UUID REFERENCES app.turns(id) ON DELETE SET NULL, supersedes_id UUID REFERENCES app.measurement_event(measurement_id) ON DELETE RESTRICT, construct TEXT NOT NULL CHECK ( construct IN ( 'working_alliance','session_outcome','rupture_repair','counselor_skill', 'self_calibration','transfer','simulation_progress' ) ), dimension TEXT NOT NULL CHECK (length(btrim(dimension)) > 0), perspective TEXT NOT NULL CHECK ( perspective IN ( 'client_agent_report','learner_self_report','independent_observer', 'supervisor_human','client_simulation','runtime_observation' ) ), source_kind TEXT NOT NULL CHECK ( source_kind IN ( 'simulated_state','model_inferred','agent_reported', 'learner_reported','human_rated','observed_runtime' ) ), instrument_id TEXT NOT NULL, instrument_version TEXT NOT NULL, value DOUBLE PRECISION, scale_min DOUBLE PRECISION NOT NULL, scale_max DOUBLE PRECISION NOT NULL, confidence DOUBLE PRECISION CHECK (confidence BETWEEN 0 AND 1), status TEXT NOT NULL DEFAULT 'ready' CHECK (status IN ('ready','degraded','error','rejected')), error_code TEXT, evidence_turn_ids UUID[] NOT NULL DEFAULT '{}', model_run_id UUID REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT, visible_to TEXT[] NOT NULL DEFAULT '{evaluator}', metadata JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), FOREIGN KEY (instrument_id, instrument_version) REFERENCES app.measurement_instrument(instrument_id, instrument_version), CHECK (scale_max > scale_min), CHECK (value IS NULL OR value BETWEEN scale_min AND scale_max), CHECK (measurement_id IS DISTINCT FROM supersedes_id), CHECK (cardinality(visible_to) > 0), CHECK (visible_to <@ ARRAY['client','counselor','evaluator','supervisor','research']::TEXT[]), CHECK ( (source_kind = 'simulated_state' AND perspective = 'client_simulation') OR (source_kind = 'model_inferred' AND perspective = 'independent_observer') OR (source_kind = 'agent_reported' AND perspective = 'client_agent_report') OR (source_kind = 'learner_reported' AND perspective = 'learner_self_report') OR (source_kind = 'human_rated' AND perspective = 'supervisor_human') OR (source_kind = 'observed_runtime' AND perspective = 'runtime_observation') ), CHECK (source_kind NOT IN ('model_inferred','agent_reported') OR model_run_id IS NOT NULL), CHECK (status <> 'ready' OR (value IS NOT NULL AND error_code IS NULL)), CHECK (status NOT IN ('error','rejected') OR (value IS NULL AND error_code IS NOT NULL)) ); CREATE UNIQUE INDEX IF NOT EXISTS uq_measurement_event_supersedes_once ON app.measurement_event(supersedes_id) WHERE supersedes_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_measurement_event_session_created ON app.measurement_event(session_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_measurement_event_session_construct ON app.measurement_event(session_id, construct, dimension, perspective, created_at DESC); CREATE INDEX IF NOT EXISTS idx_measurement_event_model_run ON app.measurement_event(model_run_id) WHERE model_run_id IS NOT NULL; CREATE OR REPLACE FUNCTION audit.reject_measurement_mutation() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION '% is append-only; write a new event with supersedes_id', TG_TABLE_NAME USING ERRCODE = '55000'; END; $$; DROP TRIGGER IF EXISTS trg_measurement_event_append_only ON app.measurement_event; CREATE TRIGGER trg_measurement_event_append_only BEFORE UPDATE OR DELETE ON app.measurement_event FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation(); DROP TRIGGER IF EXISTS trg_model_run_append_only ON audit.model_run; CREATE TRIGGER trg_model_run_append_only BEFORE UPDATE OR DELETE ON audit.model_run FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation(); CREATE TABLE IF NOT EXISTS app.alliance_pulse ( pulse_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT, checkpoint TEXT NOT NULL CHECK (checkpoint IN ('pre','mid','post')), status TEXT NOT NULL DEFAULT 'awaiting_agents' CHECK (status IN ('awaiting_agents','ready','degraded','error')), learner_locked_at TIMESTAMPTZ NOT NULL, revealed_at TIMESTAMPTZ, error_code TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (session_id, checkpoint), CONSTRAINT ck_alliance_pulse_lock_before_reveal CHECK ( revealed_at IS NULL OR revealed_at >= learner_locked_at ), CONSTRAINT ck_alliance_pulse_time_order CHECK ( created_at <= learner_locked_at AND updated_at >= created_at AND (revealed_at IS NULL OR updated_at >= revealed_at) ), CONSTRAINT ck_alliance_pulse_terminal_state CHECK ( ( status = 'awaiting_agents' AND revealed_at IS NULL AND error_code IS NULL ) OR ( status = 'ready' AND revealed_at IS NOT NULL AND error_code IS NULL ) OR ( status = 'degraded' AND revealed_at IS NOT NULL ) OR ( status = 'error' AND revealed_at IS NOT NULL AND error_code IS NOT NULL ) ) ); CREATE INDEX IF NOT EXISTS idx_alliance_pulse_session_created ON app.alliance_pulse(session_id, created_at DESC); CREATE UNIQUE INDEX IF NOT EXISTS uq_alliance_pulse_identity ON app.alliance_pulse(pulse_id, session_id); -- CREATE TABLE IF NOT EXISTS만으로는 기존 개발 DB에 새 CHECK가 보강되지 않으므로 -- 이름이 있는 제약을 멱등 추가하고 즉시 검증한다. 위반 데이터가 있으면 fail-closed한다. DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'ck_alliance_pulse_lock_before_reveal' AND conrelid = 'app.alliance_pulse'::regclass ) THEN ALTER TABLE app.alliance_pulse ADD CONSTRAINT ck_alliance_pulse_lock_before_reveal CHECK ( revealed_at IS NULL OR revealed_at >= learner_locked_at ) NOT VALID; ALTER TABLE app.alliance_pulse VALIDATE CONSTRAINT ck_alliance_pulse_lock_before_reveal; END IF; IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'ck_alliance_pulse_time_order' AND conrelid = 'app.alliance_pulse'::regclass ) THEN ALTER TABLE app.alliance_pulse ADD CONSTRAINT ck_alliance_pulse_time_order CHECK ( created_at <= learner_locked_at AND updated_at >= created_at AND (revealed_at IS NULL OR updated_at >= revealed_at) ) NOT VALID; ALTER TABLE app.alliance_pulse VALIDATE CONSTRAINT ck_alliance_pulse_time_order; END IF; -- degraded는 측정값을 보간하지 않으면서 원인 코드를 노출해야 한다. 초기 -- 제약은 ready와 degraded를 한 분기로 묶어 서비스의 fail-closed 결과를 -- 거부했으므로, 기존 DB에서도 정의가 교체되도록 이름만 확인하지 않는다. ALTER TABLE app.alliance_pulse DROP CONSTRAINT IF EXISTS ck_alliance_pulse_terminal_state; ALTER TABLE app.alliance_pulse ADD CONSTRAINT ck_alliance_pulse_terminal_state CHECK ( ( status = 'awaiting_agents' AND revealed_at IS NULL AND error_code IS NULL ) OR ( status = 'ready' AND revealed_at IS NOT NULL AND error_code IS NULL ) OR ( status = 'degraded' AND revealed_at IS NOT NULL ) OR ( status = 'error' AND revealed_at IS NOT NULL AND error_code IS NOT NULL ) ) NOT VALID; ALTER TABLE app.alliance_pulse VALIDATE CONSTRAINT ck_alliance_pulse_terminal_state; END; $$; ALTER TABLE app.measurement_event ADD COLUMN IF NOT EXISTS pulse_id UUID; DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'measurement_event_pulse_id_fkey' AND conrelid = 'app.measurement_event'::regclass ) THEN ALTER TABLE app.measurement_event ADD CONSTRAINT measurement_event_pulse_id_fkey FOREIGN KEY (pulse_id) REFERENCES app.alliance_pulse(pulse_id) ON DELETE RESTRICT; END IF; END; $$; DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'measurement_event_pulse_session_fkey' AND conrelid = 'app.measurement_event'::regclass ) THEN ALTER TABLE app.measurement_event ADD CONSTRAINT measurement_event_pulse_session_fkey FOREIGN KEY (pulse_id, session_id) REFERENCES app.alliance_pulse(pulse_id, session_id) ON DELETE RESTRICT; END IF; END; $$; CREATE INDEX IF NOT EXISTS idx_measurement_event_pulse_created ON app.measurement_event(pulse_id, created_at DESC) WHERE pulse_id IS NOT NULL; CREATE TABLE IF NOT EXISTS app.self_assessment ( self_assessment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), pulse_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, scores JSONB NOT NULL CHECK ( jsonb_typeof(scores) = 'object' AND scores ?& ARRAY['goal','task','bond'] AND scores - ARRAY['goal','task','bond'] = '{}'::jsonb AND (scores->>'goal')::double precision BETWEEN 0 AND 1 AND (scores->>'task')::double precision BETWEEN 0 AND 1 AND (scores->>'bond')::double precision BETWEEN 0 AND 1 ), evidence_turn_ids UUID[] NOT NULL DEFAULT '{}', locked_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT self_assessment_pulse_session_fkey FOREIGN KEY (pulse_id, session_id) REFERENCES app.alliance_pulse(pulse_id, session_id) ON DELETE RESTRICT, CONSTRAINT ck_self_assessment_lock_time CHECK (locked_at >= created_at) ); DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'self_assessment_pulse_session_fkey' AND conrelid = 'app.self_assessment'::regclass ) THEN ALTER TABLE app.self_assessment ADD CONSTRAINT self_assessment_pulse_session_fkey FOREIGN KEY (pulse_id, session_id) REFERENCES app.alliance_pulse(pulse_id, session_id) ON DELETE RESTRICT; END IF; IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'ck_self_assessment_lock_time' AND conrelid = 'app.self_assessment'::regclass ) THEN ALTER TABLE app.self_assessment ADD CONSTRAINT ck_self_assessment_lock_time CHECK (locked_at >= created_at) NOT VALID; ALTER TABLE app.self_assessment VALIDATE CONSTRAINT ck_self_assessment_lock_time; END IF; END; $$; DROP TRIGGER IF EXISTS trg_self_assessment_append_only ON app.self_assessment; CREATE TRIGGER trg_self_assessment_append_only BEFORE UPDATE OR DELETE ON app.self_assessment FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation(); -- alliance_pulse는 비동기 에이전트 완료 시 상태만 한 번 전이한다. 잠금 시점과 -- 회기/체크포인트는 불변이며, 상태 변경 자체는 별도 append-only 감사 이벤트로 남긴다. CREATE TABLE IF NOT EXISTS audit.alliance_pulse_status_event ( status_event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), pulse_id UUID NOT NULL REFERENCES app.alliance_pulse(pulse_id) ON DELETE RESTRICT, from_status TEXT, to_status TEXT NOT NULL, error_code TEXT, revealed_at TIMESTAMPTZ, changed_by_uid UUID, changed_by_role TEXT NOT NULL, ai_view TEXT, changed_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (pulse_id, changed_at, to_status), CHECK ( (from_status IS NULL AND to_status = 'awaiting_agents') OR ( from_status = 'awaiting_agents' AND to_status IN ('ready','degraded','error') ) ), CONSTRAINT ck_alliance_status_event_error_required CHECK ( to_status NOT IN ('degraded','error') OR error_code IS NOT NULL ), CONSTRAINT ck_alliance_status_event_error_forbidden CHECK ( to_status IN ('degraded','error') OR error_code IS NULL ), CHECK (to_status = 'awaiting_agents' OR revealed_at IS NOT NULL) ); -- 기존 개발/운영 DB의 unnamed check1/check2도 교체한다. pulse가 degraded -- 원인을 가진 채 terminal audit event를 남기는 현재 서비스 계약과 맞춘다. ALTER TABLE audit.alliance_pulse_status_event DROP CONSTRAINT IF EXISTS alliance_pulse_status_event_check1; ALTER TABLE audit.alliance_pulse_status_event DROP CONSTRAINT IF EXISTS alliance_pulse_status_event_check2; ALTER TABLE audit.alliance_pulse_status_event DROP CONSTRAINT IF EXISTS ck_alliance_status_event_error_required; ALTER TABLE audit.alliance_pulse_status_event DROP CONSTRAINT IF EXISTS ck_alliance_status_event_error_forbidden; ALTER TABLE audit.alliance_pulse_status_event ADD CONSTRAINT ck_alliance_status_event_error_required CHECK ( to_status NOT IN ('degraded','error') OR error_code IS NOT NULL ) NOT VALID; ALTER TABLE audit.alliance_pulse_status_event ADD CONSTRAINT ck_alliance_status_event_error_forbidden CHECK ( to_status IN ('degraded','error') OR error_code IS NULL ) NOT VALID; ALTER TABLE audit.alliance_pulse_status_event VALIDATE CONSTRAINT ck_alliance_status_event_error_required; ALTER TABLE audit.alliance_pulse_status_event VALIDATE CONSTRAINT ck_alliance_status_event_error_forbidden; CREATE INDEX IF NOT EXISTS idx_alliance_pulse_status_event_pulse_created ON audit.alliance_pulse_status_event(pulse_id, changed_at DESC); CREATE OR REPLACE FUNCTION audit.enforce_alliance_pulse_transition() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.pulse_id IS DISTINCT FROM OLD.pulse_id OR NEW.session_id IS DISTINCT FROM OLD.session_id OR NEW.checkpoint IS DISTINCT FROM OLD.checkpoint OR NEW.learner_locked_at IS DISTINCT FROM OLD.learner_locked_at OR NEW.created_at IS DISTINCT FROM OLD.created_at THEN RAISE EXCEPTION 'alliance_pulse identity and learner lock are immutable' USING ERRCODE = '55000'; END IF; IF OLD.status <> 'awaiting_agents' OR NEW.status NOT IN ('ready','degraded','error') THEN RAISE EXCEPTION 'alliance_pulse permits one transition from awaiting_agents to a terminal state' USING ERRCODE = '55000'; END IF; IF NEW.updated_at < OLD.updated_at OR NEW.revealed_at IS NULL OR NEW.revealed_at < OLD.learner_locked_at OR NEW.updated_at < NEW.revealed_at THEN RAISE EXCEPTION 'alliance_pulse reveal must follow the learner lock monotonically' USING ERRCODE = '55000'; END IF; RETURN NEW; END; $$; CREATE OR REPLACE FUNCTION audit.log_alliance_pulse_status() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, audit, app AS $$ BEGIN INSERT INTO audit.alliance_pulse_status_event ( pulse_id, from_status, to_status, error_code, revealed_at, changed_by_uid, changed_by_role, ai_view, changed_at ) VALUES ( NEW.pulse_id, CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE OLD.status END, NEW.status, NEW.error_code, NEW.revealed_at, app.current_uid(), app.current_role_name(), NULLIF(current_setting('app.current_ai_view', true), ''), CASE WHEN TG_OP = 'INSERT' THEN NEW.created_at ELSE NEW.updated_at END ) ON CONFLICT (pulse_id, changed_at, to_status) DO NOTHING; RETURN NEW; END; $$; DROP TRIGGER IF EXISTS trg_alliance_pulse_transition_guard ON app.alliance_pulse; CREATE TRIGGER trg_alliance_pulse_transition_guard BEFORE UPDATE ON app.alliance_pulse FOR EACH ROW EXECUTE FUNCTION audit.enforce_alliance_pulse_transition(); DROP TRIGGER IF EXISTS trg_alliance_pulse_delete_guard ON app.alliance_pulse; CREATE TRIGGER trg_alliance_pulse_delete_guard BEFORE DELETE ON app.alliance_pulse FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation(); DROP TRIGGER IF EXISTS trg_alliance_pulse_status_audit ON app.alliance_pulse; CREATE TRIGGER trg_alliance_pulse_status_audit AFTER INSERT OR UPDATE ON app.alliance_pulse FOR EACH ROW EXECUTE FUNCTION audit.log_alliance_pulse_status(); DROP TRIGGER IF EXISTS trg_alliance_pulse_status_event_append_only ON audit.alliance_pulse_status_event; CREATE TRIGGER trg_alliance_pulse_status_event_append_only BEFORE UPDATE OR DELETE ON audit.alliance_pulse_status_event FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation(); -- 기존 pulse가 있는 개발 DB에 재적용해도 최초 상태 스냅샷은 한 번만 보강한다. INSERT INTO audit.alliance_pulse_status_event ( pulse_id, from_status, to_status, error_code, revealed_at, changed_by_uid, changed_by_role, ai_view, changed_at ) SELECT p.pulse_id, NULL, 'awaiting_agents', NULL, NULL, NULL, 'migration', NULL, p.created_at FROM app.alliance_pulse p ON CONFLICT (pulse_id, changed_at, to_status) DO NOTHING; INSERT INTO audit.alliance_pulse_status_event ( pulse_id, from_status, to_status, error_code, revealed_at, changed_by_uid, changed_by_role, ai_view, changed_at ) SELECT p.pulse_id, 'awaiting_agents', p.status, p.error_code, p.revealed_at, NULL, 'migration', NULL, p.updated_at FROM app.alliance_pulse p WHERE p.status <> 'awaiting_agents' ON CONFLICT (pulse_id, changed_at, to_status) DO NOTHING; CREATE TABLE IF NOT EXISTS ds.benchmark_case ( case_id TEXT NOT NULL, version TEXT NOT NULL, scene_type TEXT NOT NULL CHECK ( scene_type IN ( 'goal_mismatch','task_mismatch','empathic_miss','withdrawal','confrontation', 'successful_repair','failed_repair','warm_but_directionless' ) ), title_ko TEXT NOT NULL, description_ko TEXT NOT NULL, turns JSONB NOT NULL CHECK (jsonb_typeof(turns) = 'array'), expected JSONB NOT NULL CHECK (jsonb_typeof(expected) = 'array'), forbidden_claims JSONB NOT NULL CHECK (jsonb_typeof(forbidden_claims) = 'array'), tags TEXT[] NOT NULL DEFAULT '{}', source_kind TEXT NOT NULL DEFAULT 'observed_runtime' CHECK (source_kind = 'observed_runtime'), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (case_id, version) ); CREATE TABLE IF NOT EXISTS ds.benchmark_observation ( observation_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), case_id TEXT NOT NULL, case_version TEXT NOT NULL, model_run_id UUID NOT NULL REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT, result JSONB NOT NULL, passed BOOLEAN NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), FOREIGN KEY (case_id, case_version) REFERENCES ds.benchmark_case(case_id, version) ON DELETE RESTRICT ); CREATE INDEX IF NOT EXISTS idx_benchmark_observation_case_created ON ds.benchmark_observation(case_id, case_version, created_at DESC); ALTER TABLE app.measurement_instrument ENABLE ROW LEVEL SECURITY; ALTER TABLE app.measurement_event ENABLE ROW LEVEL SECURITY; ALTER TABLE app.alliance_pulse ENABLE ROW LEVEL SECURITY; ALTER TABLE app.self_assessment ENABLE ROW LEVEL SECURITY; ALTER TABLE audit.alliance_pulse_status_event ENABLE ROW LEVEL SECURITY; ALTER TABLE audit.model_run ENABLE ROW LEVEL SECURITY; ALTER TABLE ds.benchmark_case ENABLE ROW LEVEL SECURITY; ALTER TABLE ds.benchmark_observation ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS p_measurement_instrument_select ON app.measurement_instrument; DROP POLICY IF EXISTS p_measurement_instrument_insert ON app.measurement_instrument; CREATE POLICY p_measurement_instrument_select ON app.measurement_instrument FOR SELECT USING (true); CREATE POLICY p_measurement_instrument_insert ON app.measurement_instrument FOR INSERT WITH CHECK ( app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') ); DROP POLICY IF EXISTS p_measurement_event_select ON app.measurement_event; DROP POLICY IF EXISTS p_measurement_event_insert ON app.measurement_event; CREATE POLICY p_measurement_event_select ON app.measurement_event 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 EXISTS (SELECT 1 FROM app.sessions s WHERE s.id = app.measurement_event.session_id) AND ( app.current_role_name() IN ('admin','instructor') OR 'counselor' = ANY(visible_to) ) ) ); CREATE POLICY p_measurement_event_insert ON app.measurement_event FOR INSERT WITH CHECK ( app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') OR ( source_kind = 'learner_reported' AND perspective = 'learner_self_report' AND 'counselor' = ANY(visible_to) AND EXISTS ( SELECT 1 FROM app.sessions s WHERE s.id = app.measurement_event.session_id AND s.learner_id = app.current_uid() ) ) ); DROP POLICY IF EXISTS p_alliance_pulse_select ON app.alliance_pulse; DROP POLICY IF EXISTS p_alliance_pulse_insert ON app.alliance_pulse; DROP POLICY IF EXISTS p_alliance_pulse_update ON app.alliance_pulse; CREATE POLICY p_alliance_pulse_select ON app.alliance_pulse FOR SELECT USING ( app.is_ai_context() OR EXISTS (SELECT 1 FROM app.sessions s WHERE s.id = app.alliance_pulse.session_id) ); CREATE POLICY p_alliance_pulse_insert ON app.alliance_pulse FOR INSERT WITH CHECK ( app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') OR EXISTS ( SELECT 1 FROM app.sessions s WHERE s.id = app.alliance_pulse.session_id AND s.learner_id = app.current_uid() ) ); CREATE POLICY p_alliance_pulse_update ON app.alliance_pulse FOR UPDATE USING ( app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') ) WITH CHECK ( app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') ); DROP POLICY IF EXISTS p_self_assessment_select ON app.self_assessment; DROP POLICY IF EXISTS p_self_assessment_insert ON app.self_assessment; CREATE POLICY p_self_assessment_select ON app.self_assessment FOR SELECT USING ( ( app.is_ai_context() AND current_setting('app.current_ai_view', true) IN ('evaluator','supervisor','research') ) OR ( NOT app.is_ai_context() AND EXISTS (SELECT 1 FROM app.sessions s WHERE s.id = app.self_assessment.session_id) ) ); CREATE POLICY p_self_assessment_insert ON app.self_assessment FOR INSERT WITH CHECK ( app.current_role_name() IN ('admin','instructor') OR ( NOT app.is_ai_context() AND learner_id = app.current_uid() AND EXISTS ( SELECT 1 FROM app.sessions s WHERE s.id = app.self_assessment.session_id AND s.learner_id = app.current_uid() ) ) ); DROP POLICY IF EXISTS p_alliance_pulse_status_event_select ON audit.alliance_pulse_status_event; CREATE POLICY p_alliance_pulse_status_event_select ON audit.alliance_pulse_status_event FOR SELECT USING ( ( app.is_ai_context() AND current_setting('app.current_ai_view', true) IN ('evaluator','supervisor','research') ) OR ( NOT app.is_ai_context() AND EXISTS ( SELECT 1 FROM app.alliance_pulse p WHERE p.pulse_id = audit.alliance_pulse_status_event.pulse_id ) ) ); DROP POLICY IF EXISTS p_model_run_select ON audit.model_run; DROP POLICY IF EXISTS p_model_run_insert ON audit.model_run; CREATE POLICY p_model_run_select ON audit.model_run FOR SELECT USING ( app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') ); CREATE POLICY p_model_run_insert ON audit.model_run FOR INSERT WITH CHECK ( app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') ); DROP POLICY IF EXISTS p_benchmark_case_select ON ds.benchmark_case; DROP POLICY IF EXISTS p_benchmark_case_insert ON ds.benchmark_case; CREATE POLICY p_benchmark_case_select ON ds.benchmark_case FOR SELECT USING ( app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') ); CREATE POLICY p_benchmark_case_insert ON ds.benchmark_case FOR INSERT WITH CHECK ( app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') ); DROP POLICY IF EXISTS p_benchmark_observation_select ON ds.benchmark_observation; DROP POLICY IF EXISTS p_benchmark_observation_insert ON ds.benchmark_observation; CREATE POLICY p_benchmark_observation_select ON ds.benchmark_observation FOR SELECT USING ( app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') ); CREATE POLICY p_benchmark_observation_insert ON ds.benchmark_observation FOR INSERT WITH CHECK ( app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') );