G0~G8 성과·동맹 측정 OS 작업 일괄 고정

8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본
폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을
git 이력으로 고정하는 것이 목적이다.

- contracts/routes/services: measurement, outcome_trajectory, rupture_repair,
  deliberate_practice, calibration_transfer, supervision_research,
  multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트
- infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행)
- apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가
- docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG
- scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트

engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
This commit is contained in:
Yun Chan 2026-08-08 01:30:53 +09:00
parent 93dd8f82d7
commit 16e791e044
390 changed files with 243188 additions and 499 deletions

View file

@ -0,0 +1,765 @@
-- 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')
);