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:
parent
93dd8f82d7
commit
16e791e044
390 changed files with 243188 additions and 499 deletions
|
|
@ -8,6 +8,29 @@ APP_DB_PASSWORD=change-me-app
|
|||
INSTALL_RAG=false
|
||||
USER_UPLOAD_DIR=/app/uploads
|
||||
SESSION_SECRET=change-me-random
|
||||
# G3~G8 evaluator/automation ingestion tokens. Generate six distinct random
|
||||
# values of at least 32 characters; never reuse one token across boundaries.
|
||||
VIGNETTE_RUPTURE_INTERNAL_TOKEN=replace-with-random-g3-token
|
||||
VIGNETTE_PRACTICE_INTERNAL_TOKEN=replace-with-random-g4-token
|
||||
VIGNETTE_CALIBRATION_TRANSFER_INTERNAL_TOKEN=replace-with-random-g5-token
|
||||
VIGNETTE_SUPERVISION_RESEARCH_INTERNAL_TOKEN=replace-with-random-g6-token
|
||||
VIGNETTE_MULTIMODAL_ALLIANCE_INTERNAL_TOKEN=replace-with-random-g7-token
|
||||
VIGNETTE_CONTINUOUS_IMPROVEMENT_INTERNAL_TOKEN=replace-with-random-g8-token
|
||||
# G8 agentic scheduler and drift ingestion are independently opt-in.
|
||||
VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_ENABLED=false
|
||||
VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_INTERVAL_SECONDS=3600
|
||||
VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_STARTUP_DELAY_SECONDS=30
|
||||
VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_RETRY_DELAY_SECONDS=300
|
||||
VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_LEASE_TIMEOUT_SECONDS=1800
|
||||
VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_ENGINE_TIMEOUT_SECONDS=300
|
||||
VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_BATCH_SIZE=1
|
||||
VIGNETTE_CONTINUOUS_IMPROVEMENT_DRIFT_TRIGGER_ENABLED=false
|
||||
# Human authorization alone never executes rollback. Keep this disabled until a
|
||||
# TLS-authenticated, idempotent NAS control-plane endpoint is provisioned.
|
||||
VIGNETTE_CONTINUOUS_IMPROVEMENT_ROLLBACK_EXECUTOR_ENABLED=false
|
||||
VIGNETTE_CONTINUOUS_IMPROVEMENT_ROLLBACK_EXECUTOR_ENDPOINT=
|
||||
VIGNETTE_CONTINUOUS_IMPROVEMENT_ROLLBACK_EXECUTOR_TOKEN=
|
||||
VIGNETTE_CONTINUOUS_IMPROVEMENT_ROLLBACK_EXECUTOR_TIMEOUT_SECONDS=30
|
||||
AUTH_ALLOWED_EMAIL_DOMAINS=["hs.ac.kr","twentyoz.kr"]
|
||||
AUTH_TEACHER_EMAILS=[]
|
||||
AUTH_ADMIN_EMAILS=[]
|
||||
|
|
@ -40,6 +63,16 @@ SMTP_STARTTLS=true
|
|||
SMTP_SSL=false
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
VIGNETTE_VOICE_STT_PROVIDER=openai
|
||||
DEEPGRAM_API_KEY=
|
||||
DEEPGRAM_STT_URL=wss://api.deepgram.com/v1/listen
|
||||
DEEPGRAM_STT_MODEL=nova-3
|
||||
DEEPGRAM_STT_LANGUAGE=ko
|
||||
DEEPGRAM_ENDPOINTING_MS=300
|
||||
DEEPGRAM_UTTERANCE_END_MS=1200
|
||||
DEEPGRAM_KEEPALIVE_SECONDS=4
|
||||
DEEPGRAM_FINALIZE_TIMEOUT_SECONDS=15
|
||||
DEEPGRAM_MIP_OPT_OUT=true
|
||||
VIGNETTE_VOICE_POC_SAMPLE_TTS=false
|
||||
VIGNETTE_VOICE_POC_SAMPLE_TTS_DIR=
|
||||
VIGNETTE_VOICE_TTS_PROVIDER=openai
|
||||
|
|
@ -49,6 +82,8 @@ ANTHROPIC_API_KEY=
|
|||
# 엔진(이식성): 게이트웨이는 claude_cli/claude_api/codex_cli/agy_cli를 라우팅한다.
|
||||
ENGINE_MODE=claude_cli
|
||||
ENGINE_URL=http://host.docker.internal:9099
|
||||
# 선택: NAS/원격 preview에서는 API와 호스트 gateway에 동일한 32자 이상 임의 값을 주입한다.
|
||||
ENGINE_GATEWAY_SHARED_SECRET=
|
||||
# 상담 왕복은 호스트의 privacy-safe 상주 Claude lane으로 분리한다.
|
||||
VIGNETTE_LIVE_CLIENT_PROVIDER=claude_cli
|
||||
# 아래 값은 컨테이너가 아니라 호스트의 engine_gateway 프로세스에 주입한다.
|
||||
|
|
|
|||
|
|
@ -5,4 +5,13 @@ Docker Compose: web · api · postgres(pgvector) · Caddy proxy.
|
|||
- API 이미지는 저장소 루트 컨텍스트에서 빌드한다. `data/personas` 같은 런타임 입력을 컨테이너 안 `/app` 기준으로 찾기 때문이다.
|
||||
- 엔진 게이트웨이는 compose 밖 호스트 프로세스로 두고, API는 `ENGINE_URL`로 호출한다.
|
||||
- RAG 임베딩/리랭커 의존성은 API 이미지 build arg `INSTALL_RAG=true`일 때만 설치한다. 기본 이미지는 슬림 모드이며 RAG 모델 미설정 시 앱이 명시적으로 degraded 처리한다.
|
||||
- 배포 전 루트에서 `python scripts/check-deploy-preflight.py --env-file infra/.env`를 실행한다. DB까지 검증할 때는 API app-role `DATABASE_URL`을 넘기고 `--require-app-role`을 붙인다.
|
||||
- 배포 전 루트에서 `python scripts/check-deploy-preflight.py --env-file infra/.env`를 실행한다. 프리플라이트는 G3~G8 내부 쓰기 토큰 6개가 각각 32자 이상이며 서로 다른 값인지 검사한다. DB까지 검증할 때는 API app-role `DATABASE_URL`을 넘기고 `--require-app-role`을 붙여 G0~G8 migration sentinel과 RLS 역할을 함께 확인한다.
|
||||
- Compose 음성 설정은 `VIGNETTE_VOICE_STT_PROVIDER`, `DEEPGRAM_API_KEY`, `DEEPGRAM_STT_*`와
|
||||
`VIGNETTE_VOICE_TTS_PROVIDER`를 API에 주입한다. `deepgram` 선택 상태에서 key가 없고 OpenAI key만 있으면
|
||||
runtime은 `openai-batch-fallback`을 보고할 수 있으므로, 배포 완료 판정에는 authenticated public WSS
|
||||
`ready`의 STT/TTS provider/model 네 값을 기대값과 정확히 비교하는 mic-free preflight를 사용한다.
|
||||
- mic-free preflight는 장치 열거·캡처를 하지 않으며 물리 마이크/50분 증거가 아니다. full soak는 실행 직전
|
||||
사용자 명시 동의와 `--confirm-physical-capture`가 있을 때만 장치를 연다. Higgs는 dev 전용이다. 운영 TTS는
|
||||
OpenAI API `gpt-4o-mini-tts`로 고정하고 Services Agreement의 앱 통합·출력 소유 근거와 최종 사용자 AI 생성
|
||||
음성 고지를 함께 유지한다. 배포 완료 시 public health/ready의 provider/model exact match를 증거로 남긴다.
|
||||
- 배포·soak evidence에는 API key, auth cookie, session ID, 축어록, 응답 payload와 원음을 기록하지 않는다.
|
||||
|
|
|
|||
765
infra/db/init/07_measurement_foundation.sql
Normal file
765
infra/db/init/07_measurement_foundation.sql
Normal 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')
|
||||
);
|
||||
553
infra/db/init/08_outcome_trajectory.sql
Normal file
553
infra/db/init/08_outcome_trajectory.sql
Normal file
|
|
@ -0,0 +1,553 @@
|
|||
-- Outcome & Alliance OS G2: synthetic expected arcs, longitudinal revisions,
|
||||
-- observation provenance, and role-safe relationship memory.
|
||||
-- Prerequisites: 02_schema.sql, 04_audit_eval_rls.sql, 07_measurement_foundation.sql.
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS audit;
|
||||
CREATE SCHEMA IF NOT EXISTS ds;
|
||||
|
||||
-- The expected arc is an educational synthetic reference, never a clinical norm.
|
||||
CREATE TABLE IF NOT EXISTS ds.synthetic_outcome_arc (
|
||||
arc_id TEXT PRIMARY KEY,
|
||||
schema_version TEXT NOT NULL
|
||||
CHECK (schema_version = 'vignette.synthetic-outcome-arc.v1'),
|
||||
title_ko TEXT NOT NULL CHECK (length(btrim(title_ko)) > 0),
|
||||
data_classification TEXT NOT NULL
|
||||
CHECK (data_classification = 'synthetic_educational'),
|
||||
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE
|
||||
CHECK (clinical_claim_allowed = FALSE),
|
||||
provenance_note TEXT NOT NULL CHECK (length(btrim(provenance_note)) >= 20),
|
||||
expected_arc JSONB NOT NULL CHECK (
|
||||
jsonb_typeof(expected_arc) = 'object'
|
||||
AND expected_arc->>'data_classification' = 'synthetic_educational'
|
||||
AND (expected_arc->>'clinical_claim_allowed')::boolean = FALSE
|
||||
AND jsonb_array_length(expected_arc->'distributions') = 15
|
||||
),
|
||||
content_hash TEXT NOT NULL UNIQUE CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO ds.synthetic_outcome_arc (
|
||||
arc_id, schema_version, title_ko, data_classification,
|
||||
clinical_claim_allowed, provenance_note, expected_arc, content_hash
|
||||
) VALUES (
|
||||
'oas-g2-arc-001',
|
||||
'vignette.synthetic-outcome-arc.v1',
|
||||
'교육용 초기 5회기 기대 궤적',
|
||||
'synthetic_educational',
|
||||
FALSE,
|
||||
'교육용 합성 사례의 결정론 테스트 분포이며 실제 내담자, 임상 규준, 치료 효과 또는 진단 예측을 나타내지 않는다.',
|
||||
$arc${
|
||||
"schema_version":"vignette.synthetic-outcome-arc.v1",
|
||||
"arc_id":"oas-g2-arc-001",
|
||||
"title_ko":"교육용 초기 5회기 기대 궤적",
|
||||
"data_classification":"synthetic_educational",
|
||||
"clinical_claim_allowed":false,
|
||||
"provenance_note":"교육용 합성 사례의 결정론 테스트 분포이며 실제 내담자, 임상 규준, 치료 효과 또는 진단 예측을 나타내지 않는다.",
|
||||
"distributions":[
|
||||
{"session_no":1,"axis":"distress_load","mean":0.70,"standard_deviation":0.10,"lower_reference":0.50,"upper_reference":0.90,"sample_size":200,"expected_direction":"lower_is_better"},
|
||||
{"session_no":1,"axis":"daily_functioning","mean":0.30,"standard_deviation":0.10,"lower_reference":0.10,"upper_reference":0.50,"sample_size":200,"expected_direction":"higher_is_better"},
|
||||
{"session_no":1,"axis":"learning_engagement","mean":0.40,"standard_deviation":0.10,"lower_reference":0.20,"upper_reference":0.60,"sample_size":200,"expected_direction":"higher_is_better"},
|
||||
{"session_no":2,"axis":"distress_load","mean":0.62,"standard_deviation":0.10,"lower_reference":0.42,"upper_reference":0.82,"sample_size":200,"expected_direction":"lower_is_better"},
|
||||
{"session_no":2,"axis":"daily_functioning","mean":0.40,"standard_deviation":0.10,"lower_reference":0.20,"upper_reference":0.60,"sample_size":200,"expected_direction":"higher_is_better"},
|
||||
{"session_no":2,"axis":"learning_engagement","mean":0.48,"standard_deviation":0.10,"lower_reference":0.28,"upper_reference":0.68,"sample_size":200,"expected_direction":"higher_is_better"},
|
||||
{"session_no":3,"axis":"distress_load","mean":0.54,"standard_deviation":0.10,"lower_reference":0.34,"upper_reference":0.74,"sample_size":200,"expected_direction":"lower_is_better"},
|
||||
{"session_no":3,"axis":"daily_functioning","mean":0.50,"standard_deviation":0.10,"lower_reference":0.30,"upper_reference":0.70,"sample_size":200,"expected_direction":"higher_is_better"},
|
||||
{"session_no":3,"axis":"learning_engagement","mean":0.56,"standard_deviation":0.10,"lower_reference":0.36,"upper_reference":0.76,"sample_size":200,"expected_direction":"higher_is_better"},
|
||||
{"session_no":4,"axis":"distress_load","mean":0.46,"standard_deviation":0.10,"lower_reference":0.26,"upper_reference":0.66,"sample_size":200,"expected_direction":"lower_is_better"},
|
||||
{"session_no":4,"axis":"daily_functioning","mean":0.60,"standard_deviation":0.10,"lower_reference":0.40,"upper_reference":0.80,"sample_size":200,"expected_direction":"higher_is_better"},
|
||||
{"session_no":4,"axis":"learning_engagement","mean":0.64,"standard_deviation":0.10,"lower_reference":0.44,"upper_reference":0.84,"sample_size":200,"expected_direction":"higher_is_better"},
|
||||
{"session_no":5,"axis":"distress_load","mean":0.38,"standard_deviation":0.10,"lower_reference":0.18,"upper_reference":0.58,"sample_size":200,"expected_direction":"lower_is_better"},
|
||||
{"session_no":5,"axis":"daily_functioning","mean":0.68,"standard_deviation":0.10,"lower_reference":0.48,"upper_reference":0.88,"sample_size":200,"expected_direction":"higher_is_better"},
|
||||
{"session_no":5,"axis":"learning_engagement","mean":0.72,"standard_deviation":0.10,"lower_reference":0.52,"upper_reference":0.92,"sample_size":200,"expected_direction":"higher_is_better"}
|
||||
]
|
||||
}$arc$::jsonb,
|
||||
'4a813340ededc4624e1607308579d0c2cfff23024ac68de0cd3c8d4b8706ab8f'
|
||||
)
|
||||
ON CONFLICT (arc_id) DO NOTHING;
|
||||
|
||||
INSERT INTO app.measurement_instrument (
|
||||
instrument_id, instrument_version, name_ko, instrument_kind, construct,
|
||||
validation_basis, scoring_schema, metadata
|
||||
) VALUES (
|
||||
'vignette-session-outcome', '1.0.0', '교육용 회기 성과 3축 관측',
|
||||
'training_metric', 'session_outcome',
|
||||
'교육용 합성 사례에서 부담감·일상 기능·학습 참여를 축별로 기록한다. 임상 척도나 치료 효과 지표가 아니다.',
|
||||
'{"dimensions":["distress_load","daily_functioning","learning_engagement"],"min":0,"max":1}'::jsonb,
|
||||
'{"clinical_claim_allowed":false,"data_classification":"synthetic_educational"}'::jsonb
|
||||
), (
|
||||
'vignette-session-outcome-checkin', '1.0.0', '학습자 회기 성과 3축 체크인',
|
||||
'training_metric', 'session_outcome',
|
||||
'수련생이 종료된 합성 회기를 돌아보며 기록하는 부담감·일상 기능·학습 참여 자기보고. 임상 척도나 치료 효과 지표가 아니다.',
|
||||
'{"dimensions":["distress_load","daily_functioning","learning_engagement"],"min":0,"max":1}'::jsonb,
|
||||
'{"clinical_claim_allowed":false,"data_classification":"synthetic_educational","perspective":"learner_self_report"}'::jsonb
|
||||
)
|
||||
ON CONFLICT (instrument_id, instrument_version) DO NOTHING;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'ck_measurement_event_outcome_submission_metadata'
|
||||
AND conrelid = 'app.measurement_event'::regclass
|
||||
) THEN
|
||||
ALTER TABLE app.measurement_event
|
||||
ADD CONSTRAINT ck_measurement_event_outcome_submission_metadata CHECK (
|
||||
NOT (
|
||||
construct = 'session_outcome'
|
||||
AND source_kind = 'learner_reported'
|
||||
AND instrument_id = 'vignette-session-outcome-checkin'
|
||||
)
|
||||
OR (
|
||||
instrument_version = '1.0.0'
|
||||
AND perspective = 'learner_self_report'
|
||||
AND dimension IN ('distress_load','daily_functioning','learning_engagement')
|
||||
AND metadata ?& ARRAY['submission_id','submission_hash']
|
||||
AND metadata->>'submission_id' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
||||
AND metadata->>'submission_hash' ~ '^[a-f0-9]{64}$'
|
||||
)
|
||||
) NOT VALID;
|
||||
ALTER TABLE app.measurement_event
|
||||
VALIDATE CONSTRAINT ck_measurement_event_outcome_submission_metadata;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_measurement_event_outcome_submission_axis
|
||||
ON app.measurement_event (
|
||||
session_id,
|
||||
(metadata->>'submission_id'),
|
||||
dimension
|
||||
)
|
||||
WHERE construct = 'session_outcome'
|
||||
AND source_kind = 'learner_reported'
|
||||
AND perspective = 'learner_self_report'
|
||||
AND instrument_id = 'vignette-session-outcome-checkin'
|
||||
AND instrument_version = '1.0.0';
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_outcome_submission_turn_ownership()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NOT (
|
||||
NEW.construct = 'session_outcome'
|
||||
AND NEW.source_kind = 'learner_reported'
|
||||
AND NEW.perspective = 'learner_self_report'
|
||||
AND NEW.instrument_id = 'vignette-session-outcome-checkin'
|
||||
) THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF cardinality(NEW.evidence_turn_ids) > 0 AND (
|
||||
SELECT count(DISTINCT t.id)
|
||||
FROM app.turns t
|
||||
WHERE t.id = ANY(NEW.evidence_turn_ids)
|
||||
AND t.session_id = NEW.session_id
|
||||
) <> cardinality(NEW.evidence_turn_ids) THEN
|
||||
RAISE EXCEPTION 'outcome submission evidence turns must belong to its session'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_outcome_submission_turn_ownership
|
||||
ON app.measurement_event;
|
||||
CREATE TRIGGER trg_outcome_submission_turn_ownership
|
||||
BEFORE INSERT ON app.measurement_event
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_outcome_submission_turn_ownership();
|
||||
|
||||
-- Every computation is a new immutable revision. A recomputation never overwrites
|
||||
-- the previous assessment, even when the evidence fingerprint is unchanged.
|
||||
CREATE TABLE IF NOT EXISTS app.outcome_trajectory_revision (
|
||||
revision_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
anchor_session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT,
|
||||
case_id UUID NOT NULL,
|
||||
learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
||||
expected_arc_id TEXT NOT NULL REFERENCES ds.synthetic_outcome_arc(arc_id) ON DELETE RESTRICT,
|
||||
revision_no INT NOT NULL CHECK (revision_no >= 1),
|
||||
supersedes_revision_id UUID REFERENCES app.outcome_trajectory_revision(revision_id) ON DELETE RESTRICT,
|
||||
source_fingerprint TEXT NOT NULL CHECK (source_fingerprint ~ '^[a-f0-9]{64}$'),
|
||||
assessment JSONB NOT NULL CHECK (
|
||||
jsonb_typeof(assessment) = 'object'
|
||||
AND assessment->>'schema_version' = 'vignette.outcome-trajectory-assessment.v1'
|
||||
AND assessment->>'data_classification' = 'synthetic_educational'
|
||||
AND (assessment->>'clinical_claim_allowed')::boolean = FALSE
|
||||
),
|
||||
observation_count INT NOT NULL CHECK (observation_count BETWEEN 3 AND 15),
|
||||
missing_observation_count INT NOT NULL CHECK (
|
||||
missing_observation_count BETWEEN 0 AND observation_count
|
||||
),
|
||||
recompute_reason TEXT NOT NULL CHECK (length(btrim(recompute_reason)) > 0),
|
||||
computed_by UUID REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
||||
computed_role TEXT NOT NULL CHECK (
|
||||
computed_role IN ('learner','instructor','admin','agent','migration')
|
||||
),
|
||||
computed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (case_id, revision_no),
|
||||
UNIQUE (supersedes_revision_id),
|
||||
CHECK (revision_id IS DISTINCT FROM supersedes_revision_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_outcome_trajectory_case_revision
|
||||
ON app.outcome_trajectory_revision(case_id, revision_no DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_outcome_trajectory_anchor
|
||||
ON app.outcome_trajectory_revision(anchor_session_id, revision_no DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.outcome_trajectory_observation (
|
||||
observation_snapshot_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
revision_id UUID NOT NULL REFERENCES app.outcome_trajectory_revision(revision_id) ON DELETE RESTRICT,
|
||||
measurement_id UUID REFERENCES app.measurement_event(measurement_id) ON DELETE RESTRICT,
|
||||
session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT,
|
||||
session_no INT NOT NULL CHECK (session_no BETWEEN 1 AND 5),
|
||||
axis TEXT NOT NULL CHECK (
|
||||
axis IN ('distress_load','daily_functioning','learning_engagement')
|
||||
),
|
||||
status TEXT NOT NULL CHECK (status IN ('observed','missing','error')),
|
||||
value DOUBLE PRECISION CHECK (value BETWEEN 0 AND 1),
|
||||
raw_value DOUBLE PRECISION,
|
||||
scale_min DOUBLE PRECISION,
|
||||
scale_max DOUBLE PRECISION,
|
||||
confidence DOUBLE PRECISION CHECK (confidence BETWEEN 0 AND 1),
|
||||
source_kind TEXT NOT NULL CHECK (
|
||||
source_kind IN (
|
||||
'simulated_state','model_inferred','agent_reported',
|
||||
'learner_reported','human_rated','observed_runtime'
|
||||
)
|
||||
),
|
||||
perspective TEXT NOT NULL CHECK (
|
||||
perspective IN (
|
||||
'client_agent_report','learner_self_report','independent_observer',
|
||||
'supervisor_human','client_simulation','runtime_observation'
|
||||
)
|
||||
),
|
||||
instrument_id TEXT NOT NULL,
|
||||
instrument_version TEXT NOT NULL,
|
||||
model_run_id UUID REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT,
|
||||
evidence_turn_ids UUID[] NOT NULL DEFAULT '{}',
|
||||
missing_reason TEXT,
|
||||
source_created_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (revision_id, session_no, axis),
|
||||
CHECK (
|
||||
(status = 'observed'
|
||||
AND measurement_id IS NOT NULL
|
||||
AND value IS NOT NULL
|
||||
AND raw_value IS NOT NULL
|
||||
AND scale_min IS NOT NULL
|
||||
AND scale_max IS NOT NULL
|
||||
AND scale_max > scale_min
|
||||
AND confidence IS NOT NULL
|
||||
AND missing_reason IS NULL)
|
||||
OR
|
||||
(status IN ('missing','error')
|
||||
AND value IS NULL
|
||||
AND raw_value IS NULL
|
||||
AND confidence IS NULL
|
||||
AND missing_reason IS NOT NULL)
|
||||
),
|
||||
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)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_outcome_observation_revision
|
||||
ON app.outcome_trajectory_observation(revision_id, session_no, axis);
|
||||
CREATE INDEX IF NOT EXISTS idx_outcome_observation_measurement
|
||||
ON app.outcome_trajectory_observation(measurement_id)
|
||||
WHERE measurement_id IS NOT NULL;
|
||||
|
||||
-- Relationship events contain no role-private sentences. Role-specific summaries
|
||||
-- live in the projection table so RLS protects the text itself, not only the row.
|
||||
CREATE TABLE IF NOT EXISTS app.relationship_memory_event (
|
||||
memory_event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT,
|
||||
case_id UUID NOT NULL,
|
||||
event_type TEXT NOT NULL CHECK (
|
||||
event_type IN (
|
||||
'goal_agreement','task_agreement','rupture_withdrawal',
|
||||
'rupture_confrontation','repair_attempt','repair_confirmed','unresolved_rupture'
|
||||
)
|
||||
),
|
||||
resolves_event_id UUID REFERENCES app.relationship_memory_event(memory_event_id) ON DELETE RESTRICT,
|
||||
visible_to TEXT[] NOT NULL CHECK (
|
||||
cardinality(visible_to) > 0
|
||||
AND visible_to <@ ARRAY['client','counselor','evaluator','supervisor','research']::TEXT[]
|
||||
),
|
||||
evidence_turn_ids UUID[] NOT NULL CHECK (cardinality(evidence_turn_ids) > 0),
|
||||
source_kind TEXT NOT NULL CHECK (
|
||||
source_kind IN ('model_inferred','agent_reported','human_rated','observed_runtime')
|
||||
),
|
||||
model_run_id UUID REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT,
|
||||
created_by UUID REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (memory_event_id IS DISTINCT FROM resolves_event_id),
|
||||
CHECK (resolves_event_id IS NULL OR event_type = 'repair_confirmed'),
|
||||
CHECK (source_kind NOT IN ('model_inferred','agent_reported') OR model_run_id IS NOT NULL)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_relationship_memory_case_created
|
||||
ON app.relationship_memory_event(case_id, created_at, memory_event_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_relationship_memory_session
|
||||
ON app.relationship_memory_event(session_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.relationship_memory_projection (
|
||||
projection_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
memory_event_id UUID NOT NULL REFERENCES app.relationship_memory_event(memory_event_id) ON DELETE RESTRICT,
|
||||
ai_view TEXT NOT NULL CHECK (
|
||||
ai_view IN ('client','counselor','evaluator','supervisor','research')
|
||||
),
|
||||
summary TEXT NOT NULL CHECK (length(btrim(summary)) > 0),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (memory_event_id, ai_view)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_relationship_projection_view
|
||||
ON app.relationship_memory_projection(ai_view, memory_event_id);
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_relationship_memory_link()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
resolved_case UUID;
|
||||
resolved_type TEXT;
|
||||
BEGIN
|
||||
IF (
|
||||
SELECT count(DISTINCT t.id)
|
||||
FROM app.turns t
|
||||
WHERE t.id = ANY(NEW.evidence_turn_ids)
|
||||
AND t.session_id = NEW.session_id
|
||||
) <> cardinality(NEW.evidence_turn_ids) THEN
|
||||
RAISE EXCEPTION 'relationship memory evidence turns must belong to its session'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
IF NEW.resolves_event_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT case_id, event_type INTO resolved_case, resolved_type
|
||||
FROM app.relationship_memory_event
|
||||
WHERE memory_event_id = NEW.resolves_event_id;
|
||||
|
||||
IF resolved_case IS NULL OR resolved_case IS DISTINCT FROM NEW.case_id THEN
|
||||
RAISE EXCEPTION 'relationship repair must reference an event in the same case'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF resolved_type NOT IN (
|
||||
'rupture_withdrawal','rupture_confrontation','unresolved_rupture','repair_attempt'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'relationship repair must resolve a rupture or repair attempt'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_relationship_projection_view()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM app.relationship_memory_event e
|
||||
WHERE e.memory_event_id = NEW.memory_event_id
|
||||
AND NEW.ai_view = ANY(e.visible_to)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'relationship projection view must be declared by its event'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_outcome_trajectory_revision_append_only
|
||||
ON app.outcome_trajectory_revision;
|
||||
CREATE TRIGGER trg_outcome_trajectory_revision_append_only
|
||||
BEFORE UPDATE OR DELETE ON app.outcome_trajectory_revision
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_outcome_trajectory_observation_append_only
|
||||
ON app.outcome_trajectory_observation;
|
||||
CREATE TRIGGER trg_outcome_trajectory_observation_append_only
|
||||
BEFORE UPDATE OR DELETE ON app.outcome_trajectory_observation
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_synthetic_outcome_arc_append_only
|
||||
ON ds.synthetic_outcome_arc;
|
||||
CREATE TRIGGER trg_synthetic_outcome_arc_append_only
|
||||
BEFORE UPDATE OR DELETE ON ds.synthetic_outcome_arc
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_relationship_memory_link
|
||||
ON app.relationship_memory_event;
|
||||
CREATE TRIGGER trg_relationship_memory_link
|
||||
BEFORE INSERT ON app.relationship_memory_event
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_relationship_memory_link();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_relationship_memory_event_append_only
|
||||
ON app.relationship_memory_event;
|
||||
CREATE TRIGGER trg_relationship_memory_event_append_only
|
||||
BEFORE UPDATE OR DELETE ON app.relationship_memory_event
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_relationship_projection_view
|
||||
ON app.relationship_memory_projection;
|
||||
CREATE TRIGGER trg_relationship_projection_view
|
||||
BEFORE INSERT ON app.relationship_memory_projection
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_relationship_projection_view();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_relationship_memory_projection_append_only
|
||||
ON app.relationship_memory_projection;
|
||||
CREATE TRIGGER trg_relationship_memory_projection_append_only
|
||||
BEFORE UPDATE OR DELETE ON app.relationship_memory_projection
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation();
|
||||
|
||||
ALTER TABLE ds.synthetic_outcome_arc ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE app.outcome_trajectory_revision ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE app.outcome_trajectory_observation ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE app.relationship_memory_event ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE app.relationship_memory_projection ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS p_synthetic_outcome_arc_select ON ds.synthetic_outcome_arc;
|
||||
DROP POLICY IF EXISTS p_synthetic_outcome_arc_insert ON ds.synthetic_outcome_arc;
|
||||
CREATE POLICY p_synthetic_outcome_arc_select ON ds.synthetic_outcome_arc
|
||||
FOR SELECT USING (true);
|
||||
CREATE POLICY p_synthetic_outcome_arc_insert ON ds.synthetic_outcome_arc
|
||||
FOR INSERT WITH CHECK (
|
||||
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS p_outcome_trajectory_revision_select
|
||||
ON app.outcome_trajectory_revision;
|
||||
DROP POLICY IF EXISTS p_outcome_trajectory_revision_insert
|
||||
ON app.outcome_trajectory_revision;
|
||||
CREATE POLICY p_outcome_trajectory_revision_select ON app.outcome_trajectory_revision
|
||||
FOR SELECT USING (
|
||||
app.is_ai_context()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM app.sessions s
|
||||
WHERE s.id = app.outcome_trajectory_revision.anchor_session_id
|
||||
)
|
||||
);
|
||||
CREATE POLICY p_outcome_trajectory_revision_insert ON app.outcome_trajectory_revision
|
||||
FOR INSERT WITH CHECK (
|
||||
app.is_ai_context()
|
||||
OR (
|
||||
EXISTS (
|
||||
SELECT 1 FROM app.sessions s
|
||||
WHERE s.id = app.outcome_trajectory_revision.anchor_session_id
|
||||
AND s.case_id = app.outcome_trajectory_revision.case_id
|
||||
AND s.learner_id = app.outcome_trajectory_revision.learner_id
|
||||
)
|
||||
AND (
|
||||
app.current_role_name() IN ('admin','instructor')
|
||||
OR (
|
||||
app.current_role_name() = 'learner'
|
||||
AND learner_id = app.current_uid()
|
||||
AND computed_by = app.current_uid()
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS p_outcome_trajectory_observation_select
|
||||
ON app.outcome_trajectory_observation;
|
||||
DROP POLICY IF EXISTS p_outcome_trajectory_observation_insert
|
||||
ON app.outcome_trajectory_observation;
|
||||
CREATE POLICY p_outcome_trajectory_observation_select
|
||||
ON app.outcome_trajectory_observation
|
||||
FOR SELECT USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM app.outcome_trajectory_revision r
|
||||
WHERE r.revision_id = app.outcome_trajectory_observation.revision_id
|
||||
)
|
||||
);
|
||||
CREATE POLICY p_outcome_trajectory_observation_insert
|
||||
ON app.outcome_trajectory_observation
|
||||
FOR INSERT WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM app.outcome_trajectory_revision r
|
||||
WHERE r.revision_id = app.outcome_trajectory_observation.revision_id
|
||||
AND r.case_id = (
|
||||
SELECT s.case_id FROM app.sessions s
|
||||
WHERE s.id = app.outcome_trajectory_observation.session_id
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS p_relationship_memory_event_select
|
||||
ON app.relationship_memory_event;
|
||||
DROP POLICY IF EXISTS p_relationship_memory_event_insert
|
||||
ON app.relationship_memory_event;
|
||||
CREATE POLICY p_relationship_memory_event_select ON app.relationship_memory_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.relationship_memory_event.session_id
|
||||
)
|
||||
AND (
|
||||
(app.current_role_name() = 'learner' AND 'counselor' = ANY(visible_to))
|
||||
OR (app.current_role_name() = 'instructor' AND visible_to && ARRAY['evaluator','supervisor']::TEXT[])
|
||||
OR app.current_role_name() = 'admin'
|
||||
)
|
||||
)
|
||||
);
|
||||
CREATE POLICY p_relationship_memory_event_insert ON app.relationship_memory_event
|
||||
FOR INSERT WITH CHECK (
|
||||
app.is_ai_context()
|
||||
OR (
|
||||
app.current_role_name() IN ('admin','instructor')
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM app.sessions s
|
||||
WHERE s.id = app.relationship_memory_event.session_id
|
||||
AND s.case_id = app.relationship_memory_event.case_id
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS p_relationship_memory_projection_select
|
||||
ON app.relationship_memory_projection;
|
||||
DROP POLICY IF EXISTS p_relationship_memory_projection_insert
|
||||
ON app.relationship_memory_projection;
|
||||
CREATE POLICY p_relationship_memory_projection_select
|
||||
ON app.relationship_memory_projection
|
||||
FOR SELECT USING (
|
||||
(
|
||||
app.is_ai_context()
|
||||
AND ai_view = current_setting('app.current_ai_view', true)
|
||||
)
|
||||
OR (
|
||||
NOT app.is_ai_context()
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM app.relationship_memory_event e
|
||||
WHERE e.memory_event_id = app.relationship_memory_projection.memory_event_id
|
||||
)
|
||||
AND (
|
||||
(app.current_role_name() = 'learner' AND ai_view = 'counselor')
|
||||
OR (app.current_role_name() = 'instructor' AND ai_view IN ('evaluator','supervisor'))
|
||||
OR app.current_role_name() = 'admin'
|
||||
)
|
||||
)
|
||||
);
|
||||
CREATE POLICY p_relationship_memory_projection_insert
|
||||
ON app.relationship_memory_projection
|
||||
FOR INSERT WITH CHECK (
|
||||
app.is_ai_context()
|
||||
OR (
|
||||
app.current_role_name() IN ('admin','instructor')
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM app.relationship_memory_event e
|
||||
WHERE e.memory_event_id = app.relationship_memory_projection.memory_event_id
|
||||
)
|
||||
)
|
||||
);
|
||||
634
infra/db/init/09_rupture_repair.sql
Normal file
634
infra/db/init/09_rupture_repair.sql
Normal file
|
|
@ -0,0 +1,634 @@
|
|||
-- Outcome & Alliance OS G3: append-only rupture/repair evidence and reconciliation ledger.
|
||||
-- Prerequisites: 02_schema.sql, 04_audit_eval_rls.sql, 07_measurement_foundation.sql.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.rupture_episode (
|
||||
episode_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT,
|
||||
case_id UUID NOT NULL,
|
||||
learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
||||
episode_key TEXT NOT NULL CHECK (length(btrim(episode_key)) > 0),
|
||||
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_uid UUID REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
||||
created_by_role TEXT NOT NULL CHECK (
|
||||
created_by_role IN ('agent','instructor','admin','migration')
|
||||
),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (session_id, episode_key),
|
||||
UNIQUE (episode_id, session_id),
|
||||
UNIQUE (episode_id, case_id, learner_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rupture_episode_session_created
|
||||
ON app.rupture_episode(session_id, created_at, episode_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_rupture_episode_case_learner
|
||||
ON app.rupture_episode(case_id, learner_id, created_at, episode_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.rupture_observation_event (
|
||||
observation_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
episode_id UUID NOT NULL REFERENCES app.rupture_episode(episode_id) ON DELETE RESTRICT,
|
||||
session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT,
|
||||
sequence_no INT NOT NULL CHECK (sequence_no >= 1),
|
||||
idempotency_key UUID NOT NULL,
|
||||
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
||||
event_kind TEXT NOT NULL CHECK (
|
||||
event_kind IN (
|
||||
'rupture.detected','rupture.recognized','rupture.missed',
|
||||
'repair.attempted','repair.partial','repair.resolved','repair.missed',
|
||||
'human.corrected'
|
||||
)
|
||||
),
|
||||
from_state TEXT CHECK (
|
||||
from_state IS NULL OR from_state IN (
|
||||
'onset','recognized','repair_attempted','missed','partial','resolved'
|
||||
)
|
||||
),
|
||||
to_state TEXT NOT NULL CHECK (
|
||||
to_state IN ('onset','recognized','repair_attempted','missed','partial','resolved')
|
||||
),
|
||||
rupture_type TEXT NOT NULL CHECK (
|
||||
rupture_type IN (
|
||||
'withdrawal','confrontation','goal_mismatch','task_mismatch',
|
||||
'empathic_miss','cultural_miss','boundary_tension',
|
||||
'premature_advice','over_disclosure'
|
||||
)
|
||||
),
|
||||
source_kind TEXT NOT NULL CHECK (
|
||||
source_kind IN ('model_inferred','observed_runtime','human_rated')
|
||||
),
|
||||
perspective TEXT NOT NULL CHECK (
|
||||
perspective IN ('independent_observer','runtime_observation','supervisor_human')
|
||||
),
|
||||
ai_view TEXT NOT NULL CHECK (ai_view IN ('evaluator','supervisor')),
|
||||
confidence DOUBLE PRECISION CHECK (confidence BETWEEN 0 AND 1),
|
||||
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 '{}',
|
||||
model_run_id UUID REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT,
|
||||
supersedes_observation_id UUID
|
||||
REFERENCES app.rupture_observation_event(observation_id) ON DELETE RESTRICT,
|
||||
correction_reason TEXT,
|
||||
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_uid UUID REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
||||
created_by_role TEXT NOT NULL CHECK (
|
||||
created_by_role IN ('agent','instructor','admin','migration')
|
||||
),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (session_id, idempotency_key),
|
||||
UNIQUE (supersedes_observation_id),
|
||||
CONSTRAINT rupture_observation_episode_session_fkey
|
||||
FOREIGN KEY (episode_id, session_id)
|
||||
REFERENCES app.rupture_episode(episode_id, session_id) ON DELETE RESTRICT,
|
||||
CHECK (observation_id IS DISTINCT FROM supersedes_observation_id),
|
||||
CHECK (
|
||||
(source_kind = 'model_inferred' AND perspective = 'independent_observer' AND model_run_id IS NOT NULL)
|
||||
OR (source_kind = 'observed_runtime' AND perspective = 'runtime_observation')
|
||||
OR (source_kind = 'human_rated' AND perspective = 'supervisor_human')
|
||||
),
|
||||
CHECK (
|
||||
(event_kind = 'human.corrected'
|
||||
AND source_kind = 'human_rated'
|
||||
AND ai_view = 'supervisor'
|
||||
AND supersedes_observation_id IS NOT NULL
|
||||
AND correction_reason IS NOT NULL
|
||||
AND length(btrim(correction_reason)) > 0)
|
||||
OR
|
||||
(event_kind <> 'human.corrected'
|
||||
AND ai_view = 'evaluator'
|
||||
AND supersedes_observation_id IS NULL
|
||||
AND correction_reason IS NULL)
|
||||
)
|
||||
);
|
||||
-- Development databases may have received an earlier G3 draft. Temporarily
|
||||
-- remove its append guard so sequence numbers can be backfilled idempotently.
|
||||
DROP TRIGGER IF EXISTS trg_rupture_observation_append_only ON app.rupture_observation_event;
|
||||
ALTER TABLE app.rupture_observation_event
|
||||
ADD COLUMN IF NOT EXISTS sequence_no INT;
|
||||
WITH ranked AS (
|
||||
SELECT observation_id,
|
||||
row_number() OVER (
|
||||
PARTITION BY episode_id ORDER BY created_at, observation_id
|
||||
) AS sequence_no
|
||||
FROM app.rupture_observation_event
|
||||
WHERE sequence_no IS NULL
|
||||
)
|
||||
UPDATE app.rupture_observation_event target
|
||||
SET sequence_no = ranked.sequence_no
|
||||
FROM ranked
|
||||
WHERE target.observation_id = ranked.observation_id;
|
||||
ALTER TABLE app.rupture_observation_event
|
||||
ALTER COLUMN sequence_no SET NOT NULL;
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'ck_rupture_observation_sequence'
|
||||
AND conrelid = 'app.rupture_observation_event'::regclass
|
||||
) THEN
|
||||
ALTER TABLE app.rupture_observation_event
|
||||
ADD CONSTRAINT ck_rupture_observation_sequence
|
||||
CHECK (sequence_no >= 1) NOT VALID;
|
||||
ALTER TABLE app.rupture_observation_event
|
||||
VALIDATE CONSTRAINT ck_rupture_observation_sequence;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_rupture_observation_episode_sequence
|
||||
ON app.rupture_observation_event(episode_id, sequence_no);
|
||||
CREATE INDEX IF NOT EXISTS idx_rupture_observation_episode_created
|
||||
ON app.rupture_observation_event(episode_id, sequence_no);
|
||||
CREATE INDEX IF NOT EXISTS idx_rupture_observation_model_run
|
||||
ON app.rupture_observation_event(model_run_id)
|
||||
WHERE model_run_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.rupture_reconciliation_revision (
|
||||
revision_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
episode_id UUID NOT NULL REFERENCES app.rupture_episode(episode_id) ON DELETE RESTRICT,
|
||||
session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT,
|
||||
revision_no INT NOT NULL CHECK (revision_no >= 1),
|
||||
idempotency_key UUID NOT NULL,
|
||||
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
||||
supersedes_revision_id UUID
|
||||
REFERENCES app.rupture_reconciliation_revision(revision_id) ON DELETE RESTRICT,
|
||||
fast_warning_observation_id UUID NOT NULL
|
||||
REFERENCES app.rupture_observation_event(observation_id) ON DELETE RESTRICT,
|
||||
deep_observation_id UUID
|
||||
REFERENCES app.rupture_observation_event(observation_id) ON DELETE RESTRICT,
|
||||
fast_warning_id TEXT NOT NULL CHECK (length(btrim(fast_warning_id)) > 0),
|
||||
provisional_status TEXT NOT NULL CHECK (provisional_status IN ('missed','partial')),
|
||||
deep_status TEXT NOT NULL CHECK (
|
||||
deep_status IN ('missed','partial','resolved','not_applicable','insufficient_evidence')
|
||||
),
|
||||
disposition TEXT NOT NULL CHECK (
|
||||
disposition IN ('confirmed','superseded_resolved','superseded_partial','dismissed')
|
||||
),
|
||||
uncertainty DOUBLE PRECISION NOT NULL CHECK (uncertainty BETWEEN 0 AND 1),
|
||||
evidence_turn_ids UUID[] NOT NULL DEFAULT '{}',
|
||||
counterevidence TEXT[] NOT NULL DEFAULT '{}',
|
||||
model_run_id UUID NOT NULL REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT,
|
||||
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 (episode_id, revision_no),
|
||||
UNIQUE (session_id, idempotency_key),
|
||||
UNIQUE (supersedes_revision_id),
|
||||
CONSTRAINT rupture_reconciliation_episode_session_fkey
|
||||
FOREIGN KEY (episode_id, session_id)
|
||||
REFERENCES app.rupture_episode(episode_id, session_id) ON DELETE RESTRICT,
|
||||
CHECK (revision_id IS DISTINCT FROM supersedes_revision_id),
|
||||
CHECK (
|
||||
(disposition = 'confirmed' AND deep_status = provisional_status)
|
||||
OR (disposition = 'superseded_resolved' AND deep_status = 'resolved')
|
||||
OR (disposition = 'superseded_partial' AND deep_status = 'partial')
|
||||
OR (disposition = 'dismissed' AND deep_status = 'not_applicable')
|
||||
)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rupture_reconciliation_episode_revision
|
||||
ON app.rupture_reconciliation_revision(episode_id, revision_no DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_rupture_reconciliation_model_run
|
||||
ON app.rupture_reconciliation_revision(model_run_id);
|
||||
|
||||
-- Safety remains a reference-only side ledger. It deliberately has no status,
|
||||
-- confidence, weight, or aggregate columns that could affect rupture resolution.
|
||||
CREATE TABLE IF NOT EXISTS app.rupture_safety_reference (
|
||||
reference_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
episode_id UUID NOT NULL REFERENCES app.rupture_episode(episode_id) ON DELETE RESTRICT,
|
||||
session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT,
|
||||
safety_event_id BIGINT NOT NULL REFERENCES app.safety_events(id) ON DELETE RESTRICT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (episode_id, safety_event_id),
|
||||
CONSTRAINT rupture_safety_episode_session_fkey
|
||||
FOREIGN KEY (episode_id, session_id)
|
||||
REFERENCES app.rupture_episode(episode_id, session_id) ON DELETE RESTRICT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rupture_safety_session
|
||||
ON app.rupture_safety_reference(session_id, episode_id);
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_rupture_episode_anchor()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
anchor_case UUID;
|
||||
anchor_learner UUID;
|
||||
BEGIN
|
||||
SELECT s.case_id, s.learner_id INTO anchor_case, anchor_learner
|
||||
FROM app.sessions s
|
||||
WHERE s.id = NEW.session_id;
|
||||
|
||||
IF anchor_case IS NULL OR anchor_learner IS NULL
|
||||
OR anchor_case IS DISTINCT FROM NEW.case_id
|
||||
OR anchor_learner IS DISTINCT FROM NEW.learner_id THEN
|
||||
RAISE EXCEPTION 'rupture episode must match its session case and learner'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.rupture_state_rank(state_name TEXT)
|
||||
RETURNS INT
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
SELECT CASE state_name
|
||||
WHEN 'onset' THEN 1
|
||||
WHEN 'recognized' THEN 2
|
||||
WHEN 'repair_attempted' THEN 3
|
||||
WHEN 'missed' THEN 4
|
||||
WHEN 'partial' THEN 5
|
||||
WHEN 'resolved' THEN 6
|
||||
ELSE 0
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_rupture_observation()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
existing_hash TEXT;
|
||||
existing_sequence INT;
|
||||
anchor_session UUID;
|
||||
latest_state TEXT;
|
||||
latest_sequence INT;
|
||||
target_episode UUID;
|
||||
target_state TEXT;
|
||||
model_session UUID;
|
||||
transition_ok BOOLEAN := FALSE;
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended(NEW.episode_id::text, 0));
|
||||
|
||||
SELECT e.content_hash, e.sequence_no INTO existing_hash, existing_sequence
|
||||
FROM app.rupture_observation_event e
|
||||
WHERE e.session_id = NEW.session_id
|
||||
AND e.idempotency_key = NEW.idempotency_key;
|
||||
IF existing_hash IS NOT NULL THEN
|
||||
IF existing_hash IS DISTINCT FROM NEW.content_hash THEN
|
||||
RAISE EXCEPTION 'rupture observation idempotency key reused with different content'
|
||||
USING ERRCODE = '23505';
|
||||
END IF;
|
||||
NEW.sequence_no := existing_sequence;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT e.session_id INTO anchor_session
|
||||
FROM app.rupture_episode e
|
||||
WHERE e.episode_id = NEW.episode_id;
|
||||
IF anchor_session IS NULL OR anchor_session IS DISTINCT FROM NEW.session_id THEN
|
||||
RAISE EXCEPTION 'rupture observation must match its episode session'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
IF (
|
||||
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 'rupture evidence turns must belong to its session'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
IF NEW.model_run_id IS NOT NULL THEN
|
||||
SELECT mr.session_id INTO model_session
|
||||
FROM audit.model_run mr
|
||||
WHERE mr.model_run_id = NEW.model_run_id;
|
||||
IF model_session IS NULL OR model_session IS DISTINCT FROM NEW.session_id THEN
|
||||
RAISE EXCEPTION 'rupture model_run must belong to its session'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
SELECT e.to_state, e.sequence_no INTO latest_state, latest_sequence
|
||||
FROM app.rupture_observation_event e
|
||||
WHERE e.episode_id = NEW.episode_id
|
||||
ORDER BY e.sequence_no DESC
|
||||
LIMIT 1;
|
||||
NEW.sequence_no := COALESCE(latest_sequence, 0) + 1;
|
||||
|
||||
IF NEW.event_kind = 'human.corrected' THEN
|
||||
SELECT e.episode_id, e.to_state INTO target_episode, target_state
|
||||
FROM app.rupture_observation_event e
|
||||
WHERE e.observation_id = NEW.supersedes_observation_id;
|
||||
IF target_episode IS NULL OR target_episode IS DISTINCT FROM NEW.episode_id THEN
|
||||
RAISE EXCEPTION 'human correction must supersede an observation in the same episode'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NEW.from_state IS DISTINCT FROM target_state THEN
|
||||
RAISE EXCEPTION 'human correction from_state must match the superseded observation'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF audit.rupture_state_rank(NEW.to_state) < audit.rupture_state_rank(target_state)
|
||||
OR audit.rupture_state_rank(NEW.to_state) < audit.rupture_state_rank(latest_state)
|
||||
OR (target_state = 'resolved' AND NEW.to_state <> 'resolved') THEN
|
||||
RAISE EXCEPTION 'human correction cannot reverse rupture resolution'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF latest_state IS NULL THEN
|
||||
transition_ok := NEW.event_kind = 'rupture.detected'
|
||||
AND NEW.from_state IS NULL AND NEW.to_state = 'onset';
|
||||
ELSE
|
||||
transition_ok := NEW.from_state = latest_state AND (
|
||||
(latest_state = 'onset' AND NEW.event_kind = 'rupture.recognized' AND NEW.to_state = 'recognized')
|
||||
OR (latest_state IN ('onset','recognized') AND NEW.event_kind = 'rupture.missed' AND NEW.to_state = 'missed')
|
||||
OR (latest_state IN ('recognized','partial') AND NEW.event_kind = 'repair.attempted' AND NEW.to_state = 'repair_attempted')
|
||||
OR (latest_state = 'repair_attempted' AND NEW.event_kind = 'repair.missed' AND NEW.to_state = 'missed')
|
||||
OR (latest_state = 'repair_attempted' AND NEW.event_kind = 'repair.partial' AND NEW.to_state = 'partial')
|
||||
OR (latest_state = 'repair_attempted' AND NEW.event_kind = 'repair.resolved' AND NEW.to_state = 'resolved')
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF NOT transition_ok THEN
|
||||
RAISE EXCEPTION 'invalid or backward rupture lifecycle transition: % -> % (%)',
|
||||
latest_state, NEW.to_state, NEW.event_kind
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.reconciliation_status_rank(status_name TEXT)
|
||||
RETURNS INT
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
SELECT CASE status_name
|
||||
WHEN 'insufficient_evidence' THEN 0
|
||||
WHEN 'not_applicable' THEN 1
|
||||
WHEN 'missed' THEN 2
|
||||
WHEN 'partial' THEN 3
|
||||
WHEN 'resolved' THEN 4
|
||||
ELSE -1
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_rupture_reconciliation()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
existing_hash TEXT;
|
||||
anchor_session UUID;
|
||||
fast_episode UUID;
|
||||
deep_episode UUID;
|
||||
model_session UUID;
|
||||
prior_episode UUID;
|
||||
prior_revision_no INT;
|
||||
prior_deep_status TEXT;
|
||||
latest_revision_no INT;
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended(NEW.episode_id::text, 1));
|
||||
|
||||
SELECT r.content_hash INTO existing_hash
|
||||
FROM app.rupture_reconciliation_revision r
|
||||
WHERE r.session_id = NEW.session_id
|
||||
AND r.idempotency_key = NEW.idempotency_key;
|
||||
IF existing_hash IS NOT NULL THEN
|
||||
IF existing_hash IS DISTINCT FROM NEW.content_hash THEN
|
||||
RAISE EXCEPTION 'rupture reconciliation idempotency key reused with different content'
|
||||
USING ERRCODE = '23505';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT e.session_id INTO anchor_session
|
||||
FROM app.rupture_episode e
|
||||
WHERE e.episode_id = NEW.episode_id;
|
||||
IF anchor_session IS NULL OR anchor_session IS DISTINCT FROM NEW.session_id THEN
|
||||
RAISE EXCEPTION 'reconciliation must match its episode session'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
SELECT o.episode_id INTO fast_episode
|
||||
FROM app.rupture_observation_event o
|
||||
WHERE o.observation_id = NEW.fast_warning_observation_id;
|
||||
IF fast_episode IS NULL OR fast_episode IS DISTINCT FROM NEW.episode_id THEN
|
||||
RAISE EXCEPTION 'fast warning observation must belong to the reconciled episode'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
IF NEW.deep_observation_id IS NOT NULL THEN
|
||||
SELECT o.episode_id INTO deep_episode
|
||||
FROM app.rupture_observation_event o
|
||||
WHERE o.observation_id = NEW.deep_observation_id;
|
||||
IF deep_episode IS NULL OR deep_episode IS DISTINCT FROM NEW.episode_id THEN
|
||||
RAISE EXCEPTION 'deep observation must belong to the reconciled episode'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
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 'reconciliation evidence turns must belong to its session'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
SELECT mr.session_id INTO model_session
|
||||
FROM audit.model_run mr
|
||||
WHERE mr.model_run_id = NEW.model_run_id;
|
||||
IF model_session IS NULL OR model_session IS DISTINCT FROM NEW.session_id THEN
|
||||
RAISE EXCEPTION 'reconciliation model_run must belong to its session'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
SELECT max(r.revision_no) INTO latest_revision_no
|
||||
FROM app.rupture_reconciliation_revision r
|
||||
WHERE r.episode_id = NEW.episode_id;
|
||||
|
||||
IF NEW.supersedes_revision_id IS NULL THEN
|
||||
IF latest_revision_no IS NOT NULL OR NEW.revision_no <> 1 THEN
|
||||
RAISE EXCEPTION 'first reconciliation revision must be revision 1 without supersedes'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
ELSE
|
||||
SELECT r.episode_id, r.revision_no, r.deep_status
|
||||
INTO prior_episode, prior_revision_no, prior_deep_status
|
||||
FROM app.rupture_reconciliation_revision r
|
||||
WHERE r.revision_id = NEW.supersedes_revision_id;
|
||||
IF prior_episode IS NULL OR prior_episode IS DISTINCT FROM NEW.episode_id
|
||||
OR prior_revision_no IS DISTINCT FROM latest_revision_no
|
||||
OR NEW.revision_no <> prior_revision_no + 1 THEN
|
||||
RAISE EXCEPTION 'reconciliation must supersede the latest revision in order'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF audit.reconciliation_status_rank(NEW.deep_status)
|
||||
< audit.reconciliation_status_rank(prior_deep_status)
|
||||
OR (prior_deep_status = 'resolved' AND NEW.deep_status <> 'resolved') THEN
|
||||
RAISE EXCEPTION 'reconciliation revision cannot reverse a resolved outcome'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_rupture_safety_reference()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
episode_session UUID;
|
||||
safety_session UUID;
|
||||
BEGIN
|
||||
SELECT e.session_id INTO episode_session
|
||||
FROM app.rupture_episode e WHERE e.episode_id = NEW.episode_id;
|
||||
SELECT s.session_id INTO safety_session
|
||||
FROM app.safety_events s WHERE s.id = NEW.safety_event_id;
|
||||
IF episode_session IS NULL OR safety_session IS NULL
|
||||
OR NEW.session_id IS DISTINCT FROM episode_session
|
||||
OR NEW.session_id IS DISTINCT FROM safety_session THEN
|
||||
RAISE EXCEPTION 'rupture safety reference must stay inside one session'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_rupture_episode_anchor ON app.rupture_episode;
|
||||
CREATE TRIGGER trg_rupture_episode_anchor
|
||||
BEFORE INSERT ON app.rupture_episode
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_rupture_episode_anchor();
|
||||
DROP TRIGGER IF EXISTS trg_rupture_episode_append_only ON app.rupture_episode;
|
||||
CREATE TRIGGER trg_rupture_episode_append_only
|
||||
BEFORE UPDATE OR DELETE ON app.rupture_episode
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_rupture_observation_contract ON app.rupture_observation_event;
|
||||
CREATE TRIGGER trg_rupture_observation_contract
|
||||
BEFORE INSERT ON app.rupture_observation_event
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_rupture_observation();
|
||||
DROP TRIGGER IF EXISTS trg_rupture_observation_append_only ON app.rupture_observation_event;
|
||||
CREATE TRIGGER trg_rupture_observation_append_only
|
||||
BEFORE UPDATE OR DELETE ON app.rupture_observation_event
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_rupture_reconciliation_contract ON app.rupture_reconciliation_revision;
|
||||
CREATE TRIGGER trg_rupture_reconciliation_contract
|
||||
BEFORE INSERT ON app.rupture_reconciliation_revision
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_rupture_reconciliation();
|
||||
DROP TRIGGER IF EXISTS trg_rupture_reconciliation_append_only ON app.rupture_reconciliation_revision;
|
||||
CREATE TRIGGER trg_rupture_reconciliation_append_only
|
||||
BEFORE UPDATE OR DELETE ON app.rupture_reconciliation_revision
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_rupture_safety_contract ON app.rupture_safety_reference;
|
||||
CREATE TRIGGER trg_rupture_safety_contract
|
||||
BEFORE INSERT ON app.rupture_safety_reference
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_rupture_safety_reference();
|
||||
DROP TRIGGER IF EXISTS trg_rupture_safety_append_only ON app.rupture_safety_reference;
|
||||
CREATE TRIGGER trg_rupture_safety_append_only
|
||||
BEFORE UPDATE OR DELETE ON app.rupture_safety_reference
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation();
|
||||
|
||||
ALTER TABLE app.rupture_episode ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE app.rupture_observation_event ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE app.rupture_reconciliation_revision ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE app.rupture_safety_reference ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS p_rupture_episode_select ON app.rupture_episode;
|
||||
DROP POLICY IF EXISTS p_rupture_episode_insert ON app.rupture_episode;
|
||||
CREATE POLICY p_rupture_episode_select ON app.rupture_episode 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.rupture_episode.session_id)
|
||||
AND (
|
||||
(app.current_role_name() = 'learner' AND learner_id = app.current_uid() AND 'counselor' = ANY(visible_to))
|
||||
OR (app.current_role_name() = 'instructor' AND visible_to && ARRAY['evaluator','supervisor']::TEXT[])
|
||||
OR app.current_role_name() = 'admin'
|
||||
)
|
||||
)
|
||||
);
|
||||
CREATE POLICY p_rupture_episode_insert ON app.rupture_episode FOR INSERT WITH CHECK (
|
||||
app.is_ai_context()
|
||||
AND current_setting('app.current_ai_view', true) = 'evaluator'
|
||||
AND 'evaluator' = ANY(visible_to)
|
||||
AND created_by_role = 'agent'
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS p_rupture_observation_select ON app.rupture_observation_event;
|
||||
DROP POLICY IF EXISTS p_rupture_observation_insert ON app.rupture_observation_event;
|
||||
CREATE POLICY p_rupture_observation_select ON app.rupture_observation_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.rupture_observation_event.session_id)
|
||||
AND (
|
||||
(app.current_role_name() = 'learner' AND 'counselor' = ANY(visible_to))
|
||||
OR (app.current_role_name() = 'instructor' AND visible_to && ARRAY['evaluator','supervisor']::TEXT[])
|
||||
OR app.current_role_name() = 'admin'
|
||||
)
|
||||
)
|
||||
);
|
||||
CREATE POLICY p_rupture_observation_insert ON app.rupture_observation_event FOR INSERT WITH CHECK (
|
||||
(
|
||||
app.is_ai_context()
|
||||
AND current_setting('app.current_ai_view', true) = 'evaluator'
|
||||
AND ai_view = 'evaluator'
|
||||
AND event_kind <> 'human.corrected'
|
||||
AND 'evaluator' = ANY(visible_to)
|
||||
)
|
||||
OR (
|
||||
NOT app.is_ai_context()
|
||||
AND app.current_role_name() IN ('instructor','admin')
|
||||
AND ai_view = 'supervisor'
|
||||
AND event_kind = 'human.corrected'
|
||||
AND EXISTS (SELECT 1 FROM app.sessions s WHERE s.id = app.rupture_observation_event.session_id)
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS p_rupture_reconciliation_select ON app.rupture_reconciliation_revision;
|
||||
DROP POLICY IF EXISTS p_rupture_reconciliation_insert ON app.rupture_reconciliation_revision;
|
||||
CREATE POLICY p_rupture_reconciliation_select
|
||||
ON app.rupture_reconciliation_revision 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.rupture_reconciliation_revision.session_id)
|
||||
AND (
|
||||
(app.current_role_name() = 'learner' AND 'counselor' = ANY(visible_to))
|
||||
OR (app.current_role_name() = 'instructor' AND visible_to && ARRAY['evaluator','supervisor']::TEXT[])
|
||||
OR app.current_role_name() = 'admin'
|
||||
)
|
||||
)
|
||||
);
|
||||
CREATE POLICY p_rupture_reconciliation_insert
|
||||
ON app.rupture_reconciliation_revision FOR INSERT WITH CHECK (
|
||||
app.is_ai_context()
|
||||
AND current_setting('app.current_ai_view', true) = 'evaluator'
|
||||
AND 'evaluator' = ANY(visible_to)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS p_rupture_safety_select ON app.rupture_safety_reference;
|
||||
DROP POLICY IF EXISTS p_rupture_safety_insert ON app.rupture_safety_reference;
|
||||
CREATE POLICY p_rupture_safety_select ON app.rupture_safety_reference FOR SELECT USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM app.rupture_episode e
|
||||
WHERE e.episode_id = app.rupture_safety_reference.episode_id
|
||||
)
|
||||
);
|
||||
CREATE POLICY p_rupture_safety_insert ON app.rupture_safety_reference FOR INSERT WITH CHECK (
|
||||
app.is_ai_context()
|
||||
AND current_setting('app.current_ai_view', true) = 'evaluator'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM app.rupture_episode e
|
||||
WHERE e.episode_id = app.rupture_safety_reference.episode_id
|
||||
)
|
||||
);
|
||||
852
infra/db/init/10_deliberate_practice.sql
Normal file
852
infra/db/init/10_deliberate_practice.sql
Normal file
|
|
@ -0,0 +1,852 @@
|
|||
-- 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)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
822
infra/db/init/11_calibration_transfer.sql
Normal file
822
infra/db/init/11_calibration_transfer.sql
Normal file
|
|
@ -0,0 +1,822 @@
|
|||
-- Outcome & Alliance OS G5: Calibration Mirror & unseen-transfer ledgers.
|
||||
-- Prerequisites: 02_schema.sql, 04_audit_eval_rls.sql, 07_measurement_foundation.sql.
|
||||
|
||||
INSERT INTO app.measurement_instrument (
|
||||
instrument_id, instrument_version, name_ko, instrument_kind, construct,
|
||||
validation_basis, scoring_schema, metadata
|
||||
) VALUES
|
||||
(
|
||||
'calibration-mirror-g5', '1.0.0', '외부평가 전 자기보정',
|
||||
'training_metric', 'self_calibration',
|
||||
'외부평가 공개 전 자기예측과 독립 수행 관측의 차이를 역량별로 추적한다.',
|
||||
'{"prediction_probability":{"min":0,"max":1},"calibration_error":{"min":0,"max":1}}'::jsonb,
|
||||
'{"clinical_claim_allowed":false,"aggregate_total_forbidden":true}'::jsonb
|
||||
),
|
||||
(
|
||||
'unseen-transfer-g5', '1.0.0', '미지 사례 역량 전이',
|
||||
'training_metric', 'transfer',
|
||||
'훈련 문구와 분리된 합성 미지 사례에서 역량 유지와 subgroup drift를 추적한다.',
|
||||
'{"transfer_success_rate":{"min":0,"max":1}}'::jsonb,
|
||||
'{"clinical_claim_allowed":false,"data_classification":"synthetic_educational","aggregate_total_forbidden":true}'::jsonb
|
||||
)
|
||||
ON CONFLICT (instrument_id, instrument_version) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.calibration_prediction_history (
|
||||
history_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,
|
||||
competency_id TEXT NOT NULL CHECK (competency_id ~ '^competency\.[a-z0-9_.-]+$'),
|
||||
practice_block_id TEXT NOT NULL CHECK (practice_block_id ~ '^oas-g5-block-[a-z0-9-]+$'),
|
||||
scenario_variant_id TEXT NOT NULL CHECK (length(btrim(scenario_variant_id)) > 0),
|
||||
phrase_family_id TEXT NOT NULL CHECK (length(btrim(phrase_family_id)) > 0),
|
||||
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 'learner'
|
||||
CHECK (created_by_role IN ('learner','migration')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (learner_id, practice_block_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_calibration_prediction_history_learner
|
||||
ON app.calibration_prediction_history(learner_id, created_at, history_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.calibration_prediction_revision (
|
||||
prediction_revision_id UUID PRIMARY KEY,
|
||||
submission_id UUID NOT NULL UNIQUE,
|
||||
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
||||
history_id UUID NOT NULL REFERENCES app.calibration_prediction_history(history_id) ON DELETE RESTRICT,
|
||||
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,
|
||||
revision_no INT NOT NULL CHECK (revision_no >= 1),
|
||||
supersedes_prediction_revision_id UUID
|
||||
REFERENCES app.calibration_prediction_revision(prediction_revision_id) ON DELETE RESTRICT,
|
||||
predicted_success_probability DOUBLE PRECISION NOT NULL
|
||||
CHECK (predicted_success_probability BETWEEN 0 AND 1),
|
||||
confidence DOUBLE PRECISION NOT NULL CHECK (confidence BETWEEN 0 AND 1),
|
||||
recorded_sequence INT NOT NULL CHECK (recorded_sequence >= 1),
|
||||
revision_reason TEXT NOT NULL CHECK (length(btrim(revision_reason)) > 0),
|
||||
source_kind TEXT NOT NULL DEFAULT 'learner_reported'
|
||||
CHECK (source_kind = 'learner_reported'),
|
||||
perspective TEXT NOT NULL DEFAULT 'learner_self_report'
|
||||
CHECK (perspective = 'learner_self_report'),
|
||||
model_run_id UUID CHECK (model_run_id IS NULL),
|
||||
instrument_id TEXT NOT NULL,
|
||||
instrument_version TEXT NOT NULL,
|
||||
evidence_turn_ids UUID[] NOT NULL DEFAULT '{}',
|
||||
created_by_role TEXT NOT NULL DEFAULT 'learner' CHECK (created_by_role = 'learner'),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (history_id, revision_no),
|
||||
UNIQUE (history_id, recorded_sequence),
|
||||
UNIQUE (supersedes_prediction_revision_id),
|
||||
UNIQUE (prediction_revision_id, history_id, session_id, learner_id),
|
||||
FOREIGN KEY (instrument_id, instrument_version)
|
||||
REFERENCES app.measurement_instrument(instrument_id, instrument_version) ON DELETE RESTRICT,
|
||||
CHECK (prediction_revision_id IS DISTINCT FROM supersedes_prediction_revision_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_calibration_prediction_revision_latest
|
||||
ON app.calibration_prediction_revision(history_id, revision_no DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.calibration_prediction_lock (
|
||||
lock_id UUID PRIMARY KEY,
|
||||
submission_id UUID NOT NULL UNIQUE,
|
||||
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
||||
history_id UUID NOT NULL UNIQUE
|
||||
REFERENCES app.calibration_prediction_history(history_id) ON DELETE RESTRICT,
|
||||
prediction_revision_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,
|
||||
locked_sequence INT NOT NULL CHECK (locked_sequence >= 1),
|
||||
created_by_role TEXT NOT NULL DEFAULT 'learner' CHECK (created_by_role = 'learner'),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (prediction_revision_id, history_id, session_id, learner_id)
|
||||
REFERENCES app.calibration_prediction_revision(
|
||||
prediction_revision_id, history_id, session_id, learner_id
|
||||
) ON DELETE RESTRICT,
|
||||
UNIQUE (lock_id, history_id, session_id, learner_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.calibration_performance_observation (
|
||||
observation_id UUID PRIMARY KEY,
|
||||
submission_id UUID NOT NULL UNIQUE,
|
||||
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
||||
history_id UUID NOT NULL UNIQUE
|
||||
REFERENCES app.calibration_prediction_history(history_id) ON DELETE RESTRICT,
|
||||
prediction_lock_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,
|
||||
competency_id TEXT NOT NULL CHECK (competency_id ~ '^competency\.[a-z0-9_.-]+$'),
|
||||
practice_block_id TEXT NOT NULL CHECK (practice_block_id ~ '^oas-g5-block-[a-z0-9-]+$'),
|
||||
scenario_variant_id TEXT NOT NULL CHECK (length(btrim(scenario_variant_id)) > 0),
|
||||
phrase_family_id TEXT NOT NULL CHECK (length(btrim(phrase_family_id)) > 0),
|
||||
status TEXT NOT NULL CHECK (status IN ('passed','failed','insufficient_evidence')),
|
||||
source_kind TEXT NOT NULL CHECK (source_kind IN ('model_inferred','observed_runtime')),
|
||||
perspective TEXT NOT NULL CHECK (
|
||||
perspective IN ('independent_observer','runtime_observation')
|
||||
),
|
||||
model_run_id UUID REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT,
|
||||
instrument_id TEXT NOT NULL,
|
||||
instrument_version TEXT NOT NULL,
|
||||
uncertainty DOUBLE PRECISION NOT NULL CHECK (uncertainty BETWEEN 0 AND 1),
|
||||
evidence_turn_ids UUID[] NOT NULL DEFAULT '{}',
|
||||
counterevidence TEXT[] NOT NULL DEFAULT '{}',
|
||||
revealed_sequence INT NOT NULL CHECK (revealed_sequence >= 1),
|
||||
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(),
|
||||
FOREIGN KEY (prediction_lock_id, history_id, session_id, learner_id)
|
||||
REFERENCES app.calibration_prediction_lock(lock_id, history_id, session_id, learner_id)
|
||||
ON DELETE RESTRICT,
|
||||
FOREIGN KEY (instrument_id, instrument_version)
|
||||
REFERENCES app.measurement_instrument(instrument_id, instrument_version) ON DELETE RESTRICT,
|
||||
CHECK (
|
||||
(source_kind = 'model_inferred' AND perspective = 'independent_observer' AND model_run_id IS NOT NULL)
|
||||
OR (source_kind = 'observed_runtime' AND perspective = 'runtime_observation')
|
||||
),
|
||||
CHECK (
|
||||
(status = 'insufficient_evidence' AND uncertainty = 1 AND cardinality(evidence_turn_ids) = 0)
|
||||
OR (status IN ('passed','failed') AND cardinality(evidence_turn_ids) > 0)
|
||||
),
|
||||
CHECK (status <> 'failed' OR cardinality(counterevidence) > 0),
|
||||
UNIQUE (observation_id, session_id, learner_id, competency_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_calibration_observation_learner_competency
|
||||
ON app.calibration_performance_observation(learner_id, competency_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.calibration_assessment_snapshot (
|
||||
assessment_snapshot_id UUID PRIMARY KEY,
|
||||
submission_id UUID NOT NULL UNIQUE,
|
||||
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
||||
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_.-]+$'),
|
||||
snapshot_no INT NOT NULL CHECK (snapshot_no >= 1),
|
||||
supersedes_assessment_snapshot_id UUID
|
||||
REFERENCES app.calibration_assessment_snapshot(assessment_snapshot_id) ON DELETE RESTRICT,
|
||||
source_observation_ids UUID[] NOT NULL CHECK (cardinality(source_observation_ids) > 0),
|
||||
assessment_payload JSONB NOT NULL CHECK (jsonb_typeof(assessment_payload) = 'object'),
|
||||
model_run_id UUID NOT NULL REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT,
|
||||
instrument_id TEXT NOT NULL,
|
||||
instrument_version TEXT NOT NULL,
|
||||
evidence_turn_ids UUID[] 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(),
|
||||
FOREIGN KEY (instrument_id, instrument_version)
|
||||
REFERENCES app.measurement_instrument(instrument_id, instrument_version) ON DELETE RESTRICT,
|
||||
UNIQUE (learner_id, competency_id, snapshot_no),
|
||||
UNIQUE (supersedes_assessment_snapshot_id),
|
||||
UNIQUE (assessment_snapshot_id, session_id, learner_id, competency_id),
|
||||
CHECK (assessment_snapshot_id IS DISTINCT FROM supersedes_assessment_snapshot_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_calibration_assessment_latest
|
||||
ON app.calibration_assessment_snapshot(learner_id, competency_id, snapshot_no DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.calibration_metacognitive_prescription (
|
||||
prescription_id UUID PRIMARY KEY,
|
||||
assessment_snapshot_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,
|
||||
competency_id TEXT NOT NULL CHECK (competency_id ~ '^competency\.[a-z0-9_.-]+$'),
|
||||
prescription_payload JSONB NOT NULL CHECK (jsonb_typeof(prescription_payload) = 'object'),
|
||||
model_run_id UUID NOT NULL REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT,
|
||||
instrument_id TEXT NOT NULL,
|
||||
instrument_version TEXT NOT NULL,
|
||||
evidence_turn_ids UUID[] 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(),
|
||||
FOREIGN KEY (assessment_snapshot_id, session_id, learner_id, competency_id)
|
||||
REFERENCES app.calibration_assessment_snapshot(
|
||||
assessment_snapshot_id, session_id, learner_id, competency_id
|
||||
) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (instrument_id, instrument_version)
|
||||
REFERENCES app.measurement_instrument(instrument_id, instrument_version) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.calibration_transfer_suite (
|
||||
transfer_suite_record_id UUID PRIMARY KEY,
|
||||
submission_id UUID NOT NULL UNIQUE,
|
||||
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
||||
suite_key TEXT NOT NULL CHECK (suite_key ~ '^oas-g5-suite-[a-z0-9-]+$'),
|
||||
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,
|
||||
training_phrase_family_ids TEXT[] NOT NULL CHECK (cardinality(training_phrase_family_ids) > 0),
|
||||
model_run_id UUID NOT NULL REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT,
|
||||
instrument_id TEXT NOT NULL,
|
||||
instrument_version TEXT NOT NULL,
|
||||
data_classification TEXT NOT NULL DEFAULT 'synthetic_educational'
|
||||
CHECK (data_classification = 'synthetic_educational'),
|
||||
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE),
|
||||
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(),
|
||||
FOREIGN KEY (instrument_id, instrument_version)
|
||||
REFERENCES app.measurement_instrument(instrument_id, instrument_version) ON DELETE RESTRICT,
|
||||
UNIQUE (learner_id, suite_key),
|
||||
UNIQUE (transfer_suite_record_id, session_id, learner_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.calibration_transfer_trial (
|
||||
transfer_trial_record_id UUID PRIMARY KEY,
|
||||
transfer_suite_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,
|
||||
trial_key TEXT NOT NULL CHECK (trial_key ~ '^oas-g5-transfer-[a-z0-9-]+$'),
|
||||
competency_id TEXT NOT NULL CHECK (competency_id ~ '^competency\.[a-z0-9_.-]+$'),
|
||||
scenario_variant_id TEXT NOT NULL CHECK (length(btrim(scenario_variant_id)) > 0),
|
||||
scenario_novelty TEXT NOT NULL DEFAULT 'unseen_transfer'
|
||||
CHECK (scenario_novelty = 'unseen_transfer'),
|
||||
context_variant TEXT NOT NULL CHECK (length(btrim(context_variant)) > 0),
|
||||
relationship_style TEXT NOT NULL CHECK (
|
||||
relationship_style IN ('collaborative','withdrawn','confrontational','ambivalent')
|
||||
),
|
||||
difficulty_level INT NOT NULL CHECK (difficulty_level BETWEEN 1 AND 5),
|
||||
expression_variant TEXT NOT NULL CHECK (length(btrim(expression_variant)) > 0),
|
||||
synthetic_subgroup TEXT NOT NULL CHECK (synthetic_subgroup ~ '^synthetic-[a-z0-9-]+$'),
|
||||
scenario_family_id TEXT NOT NULL CHECK (length(btrim(scenario_family_id)) > 0),
|
||||
phrase_family_id TEXT NOT NULL CHECK (length(btrim(phrase_family_id)) > 0),
|
||||
status TEXT NOT NULL CHECK (status IN ('passed','failed','insufficient_evidence')),
|
||||
uncertainty DOUBLE PRECISION NOT NULL CHECK (uncertainty BETWEEN 0 AND 1),
|
||||
evidence_turn_ids UUID[] NOT NULL DEFAULT '{}',
|
||||
counterevidence TEXT[] NOT NULL DEFAULT '{}',
|
||||
model_run_id UUID NOT NULL REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT,
|
||||
instrument_id TEXT NOT NULL,
|
||||
instrument_version TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (transfer_suite_record_id, session_id, learner_id)
|
||||
REFERENCES app.calibration_transfer_suite(transfer_suite_record_id, session_id, learner_id)
|
||||
ON DELETE RESTRICT,
|
||||
FOREIGN KEY (instrument_id, instrument_version)
|
||||
REFERENCES app.measurement_instrument(instrument_id, instrument_version) ON DELETE RESTRICT,
|
||||
UNIQUE (transfer_suite_record_id, trial_key),
|
||||
UNIQUE (transfer_trial_record_id, transfer_suite_record_id, session_id, learner_id, competency_id),
|
||||
CHECK (
|
||||
(status = 'insufficient_evidence' AND uncertainty = 1 AND cardinality(evidence_turn_ids) = 0)
|
||||
OR (status IN ('passed','failed') AND cardinality(evidence_turn_ids) > 0)
|
||||
),
|
||||
CHECK (status <> 'failed' OR cardinality(counterevidence) > 0)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_calibration_transfer_trial_suite_competency
|
||||
ON app.calibration_transfer_trial(transfer_suite_record_id, competency_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.calibration_transfer_assessment (
|
||||
transfer_assessment_id UUID PRIMARY KEY,
|
||||
transfer_suite_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_.-]+$'),
|
||||
source_trial_ids UUID[] NOT NULL CHECK (cardinality(source_trial_ids) > 0),
|
||||
assessment_payload JSONB NOT NULL CHECK (jsonb_typeof(assessment_payload) = 'object'),
|
||||
evidence_turn_ids UUID[] NOT NULL DEFAULT '{}',
|
||||
model_run_id UUID NOT NULL REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT,
|
||||
instrument_id TEXT NOT NULL,
|
||||
instrument_version TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (transfer_suite_record_id, session_id, learner_id)
|
||||
REFERENCES app.calibration_transfer_suite(transfer_suite_record_id, session_id, learner_id)
|
||||
ON DELETE RESTRICT,
|
||||
FOREIGN KEY (instrument_id, instrument_version)
|
||||
REFERENCES app.measurement_instrument(instrument_id, instrument_version) ON DELETE RESTRICT,
|
||||
UNIQUE (transfer_suite_record_id, competency_id),
|
||||
UNIQUE (transfer_assessment_id, session_id, learner_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.calibration_subgroup_drift_report (
|
||||
drift_report_id UUID PRIMARY KEY,
|
||||
transfer_suite_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_.-]+$'),
|
||||
source_trial_ids UUID[] NOT NULL CHECK (cardinality(source_trial_ids) > 0),
|
||||
report_payload JSONB NOT NULL CHECK (jsonb_typeof(report_payload) = 'object'),
|
||||
model_run_id UUID NOT NULL REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT,
|
||||
instrument_id TEXT NOT NULL,
|
||||
instrument_version TEXT NOT NULL,
|
||||
data_classification TEXT NOT NULL DEFAULT 'synthetic_educational'
|
||||
CHECK (data_classification = 'synthetic_educational'),
|
||||
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (transfer_suite_record_id, session_id, learner_id)
|
||||
REFERENCES app.calibration_transfer_suite(transfer_suite_record_id, session_id, learner_id)
|
||||
ON DELETE RESTRICT,
|
||||
FOREIGN KEY (instrument_id, instrument_version)
|
||||
REFERENCES app.measurement_instrument(instrument_id, instrument_version) ON DELETE RESTRICT,
|
||||
UNIQUE (transfer_suite_record_id, competency_id),
|
||||
UNIQUE (drift_report_id, session_id, learner_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.calibration_teacher_review_event (
|
||||
review_id UUID PRIMARY KEY,
|
||||
submission_id UUID NOT NULL UNIQUE,
|
||||
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
||||
target_kind TEXT NOT NULL CHECK (
|
||||
target_kind IN ('calibration_assessment','transfer_assessment','drift_report')
|
||||
),
|
||||
target_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,
|
||||
review_no INT NOT NULL CHECK (review_no >= 1),
|
||||
supersedes_review_id UUID
|
||||
REFERENCES app.calibration_teacher_review_event(review_id) ON DELETE RESTRICT,
|
||||
disposition TEXT NOT NULL CHECK (
|
||||
disposition IN ('confirmed','corrected','needs_more_evidence')
|
||||
),
|
||||
correction_payload JSONB NOT NULL DEFAULT '{}'::JSONB
|
||||
CHECK (jsonb_typeof(correction_payload) = 'object'),
|
||||
review_reason TEXT NOT NULL CHECK (length(btrim(review_reason)) > 0),
|
||||
evidence_turn_ids UUID[] NOT NULL DEFAULT '{}',
|
||||
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 (target_kind, target_id, review_no),
|
||||
UNIQUE (supersedes_review_id),
|
||||
CHECK (review_id IS DISTINCT FROM supersedes_review_id),
|
||||
CHECK (disposition = 'corrected' OR correction_payload = '{}'::JSONB)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_calibration_teacher_review_target
|
||||
ON app.calibration_teacher_review_event(target_kind, target_id, review_no DESC);
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_calibration_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 'calibration row must match its session learner'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_calibration_evidence_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 'calibration evidence turns must belong to its session'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_calibration_payload_boundary()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
payload JSONB;
|
||||
BEGIN
|
||||
payload := COALESCE(
|
||||
to_jsonb(NEW)->'assessment_payload',
|
||||
to_jsonb(NEW)->'prescription_payload',
|
||||
to_jsonb(NEW)->'report_payload',
|
||||
to_jsonb(NEW)->'correction_payload',
|
||||
'{}'::JSONB
|
||||
);
|
||||
IF payload::TEXT ~* '"(raw_transcript|transcript|text_masked|utterance_text|quote|total_score|overall_score)"[[:space:]]*:' THEN
|
||||
RAISE EXCEPTION 'calibration payload cannot store transcript text or aggregate score'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_calibration_prediction_revision()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
history_row app.calibration_prediction_history%ROWTYPE;
|
||||
latest_id UUID;
|
||||
latest_no INT;
|
||||
latest_sequence INT;
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended(NEW.history_id::TEXT, 5));
|
||||
SELECT * INTO history_row FROM app.calibration_prediction_history WHERE history_id = NEW.history_id;
|
||||
IF history_row.history_id IS NULL
|
||||
OR history_row.session_id IS DISTINCT FROM NEW.session_id
|
||||
OR history_row.learner_id IS DISTINCT FROM NEW.learner_id THEN
|
||||
RAISE EXCEPTION 'prediction revision must match its history anchor'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM app.calibration_prediction_lock l WHERE l.history_id = NEW.history_id)
|
||||
OR EXISTS (SELECT 1 FROM app.calibration_performance_observation o WHERE o.history_id = NEW.history_id) THEN
|
||||
RAISE EXCEPTION 'self-prediction cannot be revised after lock or external reveal'
|
||||
USING ERRCODE = '55000';
|
||||
END IF;
|
||||
SELECT prediction_revision_id, revision_no, recorded_sequence
|
||||
INTO latest_id, latest_no, latest_sequence
|
||||
FROM app.calibration_prediction_revision
|
||||
WHERE history_id = NEW.history_id
|
||||
ORDER BY revision_no DESC
|
||||
LIMIT 1;
|
||||
IF latest_id IS NULL THEN
|
||||
IF NEW.revision_no <> 1 OR NEW.supersedes_prediction_revision_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'first self-prediction revision must start at one without supersedes'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
ELSIF NEW.revision_no <> latest_no + 1
|
||||
OR NEW.supersedes_prediction_revision_id IS DISTINCT FROM latest_id
|
||||
OR NEW.recorded_sequence <= latest_sequence THEN
|
||||
RAISE EXCEPTION 'self-prediction revision must be contiguous and supersede latest'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_calibration_prediction_lock()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
latest_id UUID;
|
||||
latest_sequence INT;
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended(NEW.history_id::TEXT, 5));
|
||||
SELECT prediction_revision_id, recorded_sequence INTO latest_id, latest_sequence
|
||||
FROM app.calibration_prediction_revision
|
||||
WHERE history_id = NEW.history_id
|
||||
ORDER BY revision_no DESC
|
||||
LIMIT 1;
|
||||
IF latest_id IS NULL OR NEW.prediction_revision_id IS DISTINCT FROM latest_id
|
||||
OR NEW.locked_sequence < latest_sequence THEN
|
||||
RAISE EXCEPTION 'prediction lock must target the latest revision at or after its sequence'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_calibration_observation_reveal()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
history_row app.calibration_prediction_history%ROWTYPE;
|
||||
lock_sequence INT;
|
||||
BEGIN
|
||||
SELECT * INTO history_row FROM app.calibration_prediction_history WHERE history_id = NEW.history_id;
|
||||
SELECT locked_sequence INTO lock_sequence
|
||||
FROM app.calibration_prediction_lock
|
||||
WHERE lock_id = NEW.prediction_lock_id AND history_id = NEW.history_id;
|
||||
IF history_row.history_id IS NULL OR lock_sequence IS NULL
|
||||
OR NEW.revealed_sequence <= lock_sequence
|
||||
OR NEW.session_id IS DISTINCT FROM history_row.session_id
|
||||
OR NEW.learner_id IS DISTINCT FROM history_row.learner_id
|
||||
OR NEW.competency_id IS DISTINCT FROM history_row.competency_id
|
||||
OR NEW.practice_block_id IS DISTINCT FROM history_row.practice_block_id
|
||||
OR NEW.scenario_variant_id IS DISTINCT FROM history_row.scenario_variant_id
|
||||
OR NEW.phrase_family_id IS DISTINCT FROM history_row.phrase_family_id THEN
|
||||
RAISE EXCEPTION 'external observation requires a matching locked prediction and later reveal sequence'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_calibration_assessment_chain()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
latest_id UUID;
|
||||
latest_no INT;
|
||||
matched_count INT;
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended(NEW.learner_id::TEXT || ':' || NEW.competency_id, 6));
|
||||
SELECT assessment_snapshot_id, snapshot_no INTO latest_id, latest_no
|
||||
FROM app.calibration_assessment_snapshot
|
||||
WHERE learner_id = NEW.learner_id AND competency_id = NEW.competency_id
|
||||
ORDER BY snapshot_no DESC LIMIT 1;
|
||||
IF latest_id IS NULL THEN
|
||||
IF NEW.snapshot_no <> 1 OR NEW.supersedes_assessment_snapshot_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'first calibration assessment snapshot must start at one'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
ELSIF NEW.snapshot_no <> latest_no + 1
|
||||
OR NEW.supersedes_assessment_snapshot_id IS DISTINCT FROM latest_id THEN
|
||||
RAISE EXCEPTION 'calibration assessment must supersede latest snapshot'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
SELECT count(DISTINCT o.observation_id) INTO matched_count
|
||||
FROM app.calibration_performance_observation o
|
||||
WHERE o.observation_id = ANY(NEW.source_observation_ids)
|
||||
AND o.learner_id = NEW.learner_id
|
||||
AND o.competency_id = NEW.competency_id;
|
||||
IF matched_count <> cardinality(NEW.source_observation_ids)
|
||||
OR NEW.assessment_payload->>'competency_id' IS DISTINCT FROM NEW.competency_id THEN
|
||||
RAISE EXCEPTION 'calibration assessment sources or competency do not match'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_calibration_transfer_children()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
matched_count INT;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME = 'calibration_transfer_assessment' THEN
|
||||
SELECT count(DISTINCT t.transfer_trial_record_id) INTO matched_count
|
||||
FROM app.calibration_transfer_trial t
|
||||
WHERE t.transfer_trial_record_id = ANY(NEW.source_trial_ids)
|
||||
AND t.transfer_suite_record_id = NEW.transfer_suite_record_id
|
||||
AND t.competency_id = NEW.competency_id;
|
||||
IF matched_count <> cardinality(NEW.source_trial_ids)
|
||||
OR NEW.assessment_payload->>'competency_id' IS DISTINCT FROM NEW.competency_id THEN
|
||||
RAISE EXCEPTION 'transfer assessment sources or competency do not match'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
ELSIF TG_TABLE_NAME = 'calibration_subgroup_drift_report' THEN
|
||||
SELECT count(DISTINCT t.transfer_trial_record_id) INTO matched_count
|
||||
FROM app.calibration_transfer_trial t
|
||||
WHERE t.transfer_trial_record_id = ANY(NEW.source_trial_ids)
|
||||
AND t.transfer_suite_record_id = NEW.transfer_suite_record_id
|
||||
AND t.competency_id = NEW.competency_id;
|
||||
IF matched_count <> cardinality(NEW.source_trial_ids)
|
||||
OR NEW.report_payload->>'competency_id' IS DISTINCT FROM NEW.competency_id THEN
|
||||
RAISE EXCEPTION 'subgroup drift sources or competency do not match'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_calibration_teacher_review()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
target_session UUID;
|
||||
target_learner UUID;
|
||||
latest_id UUID;
|
||||
latest_no INT;
|
||||
BEGIN
|
||||
IF NEW.target_kind = 'calibration_assessment' THEN
|
||||
SELECT session_id, learner_id INTO target_session, target_learner
|
||||
FROM app.calibration_assessment_snapshot WHERE assessment_snapshot_id = NEW.target_id;
|
||||
ELSIF NEW.target_kind = 'transfer_assessment' THEN
|
||||
SELECT session_id, learner_id INTO target_session, target_learner
|
||||
FROM app.calibration_transfer_assessment WHERE transfer_assessment_id = NEW.target_id;
|
||||
ELSE
|
||||
SELECT session_id, learner_id INTO target_session, target_learner
|
||||
FROM app.calibration_subgroup_drift_report WHERE drift_report_id = NEW.target_id;
|
||||
END IF;
|
||||
IF target_session IS NULL OR NEW.session_id IS DISTINCT FROM target_session
|
||||
OR NEW.learner_id IS DISTINCT FROM target_learner THEN
|
||||
RAISE EXCEPTION 'teacher review target is missing or mismatched'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended(NEW.target_kind || ':' || NEW.target_id::TEXT, 7));
|
||||
SELECT review_id, review_no INTO latest_id, latest_no
|
||||
FROM app.calibration_teacher_review_event
|
||||
WHERE target_kind = NEW.target_kind AND target_id = NEW.target_id
|
||||
ORDER BY review_no DESC LIMIT 1;
|
||||
IF latest_id IS NULL THEN
|
||||
IF NEW.review_no <> 1 OR NEW.supersedes_review_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'first teacher review must start at one'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
ELSIF NEW.review_no <> latest_no + 1 OR NEW.supersedes_review_id IS DISTINCT FROM latest_id THEN
|
||||
RAISE EXCEPTION 'teacher review must supersede latest review'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
table_name TEXT;
|
||||
BEGIN
|
||||
FOREACH table_name IN ARRAY ARRAY[
|
||||
'calibration_prediction_history','calibration_prediction_revision',
|
||||
'calibration_prediction_lock','calibration_performance_observation',
|
||||
'calibration_assessment_snapshot','calibration_metacognitive_prescription',
|
||||
'calibration_transfer_suite','calibration_transfer_trial',
|
||||
'calibration_transfer_assessment','calibration_subgroup_drift_report',
|
||||
'calibration_teacher_review_event'
|
||||
] LOOP
|
||||
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
|
||||
);
|
||||
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_calibration_session_learner()',
|
||||
table_name, table_name
|
||||
);
|
||||
EXECUTE format('ALTER TABLE app.%I ENABLE ROW LEVEL SECURITY', table_name);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
table_name TEXT;
|
||||
BEGIN
|
||||
FOREACH table_name IN ARRAY ARRAY[
|
||||
'calibration_prediction_revision','calibration_performance_observation',
|
||||
'calibration_assessment_snapshot','calibration_metacognitive_prescription',
|
||||
'calibration_transfer_trial','calibration_transfer_assessment',
|
||||
'calibration_teacher_review_event'
|
||||
] 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_calibration_evidence_ownership()',
|
||||
table_name, table_name
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_calibration_prediction_revision_contract
|
||||
ON app.calibration_prediction_revision;
|
||||
CREATE TRIGGER trg_calibration_prediction_revision_contract
|
||||
BEFORE INSERT ON app.calibration_prediction_revision
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_prediction_revision();
|
||||
DROP TRIGGER IF EXISTS trg_calibration_prediction_lock_contract
|
||||
ON app.calibration_prediction_lock;
|
||||
CREATE TRIGGER trg_calibration_prediction_lock_contract
|
||||
BEFORE INSERT ON app.calibration_prediction_lock
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_prediction_lock();
|
||||
DROP TRIGGER IF EXISTS trg_calibration_observation_reveal_contract
|
||||
ON app.calibration_performance_observation;
|
||||
CREATE TRIGGER trg_calibration_observation_reveal_contract
|
||||
BEFORE INSERT ON app.calibration_performance_observation
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_observation_reveal();
|
||||
DROP TRIGGER IF EXISTS trg_calibration_assessment_chain
|
||||
ON app.calibration_assessment_snapshot;
|
||||
CREATE TRIGGER trg_calibration_assessment_chain
|
||||
BEFORE INSERT ON app.calibration_assessment_snapshot
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_assessment_chain();
|
||||
DROP TRIGGER IF EXISTS trg_calibration_transfer_assessment_contract
|
||||
ON app.calibration_transfer_assessment;
|
||||
CREATE TRIGGER trg_calibration_transfer_assessment_contract
|
||||
BEFORE INSERT ON app.calibration_transfer_assessment
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_transfer_children();
|
||||
DROP TRIGGER IF EXISTS trg_calibration_subgroup_drift_contract
|
||||
ON app.calibration_subgroup_drift_report;
|
||||
CREATE TRIGGER trg_calibration_subgroup_drift_contract
|
||||
BEFORE INSERT ON app.calibration_subgroup_drift_report
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_transfer_children();
|
||||
DROP TRIGGER IF EXISTS trg_calibration_teacher_review_contract
|
||||
ON app.calibration_teacher_review_event;
|
||||
CREATE TRIGGER trg_calibration_teacher_review_contract
|
||||
BEFORE INSERT ON app.calibration_teacher_review_event
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_teacher_review();
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
table_name TEXT;
|
||||
BEGIN
|
||||
FOREACH table_name IN ARRAY ARRAY[
|
||||
'calibration_assessment_snapshot','calibration_metacognitive_prescription',
|
||||
'calibration_transfer_assessment','calibration_subgroup_drift_report',
|
||||
'calibration_teacher_review_event'
|
||||
] LOOP
|
||||
EXECUTE format('DROP TRIGGER IF EXISTS trg_%s_payload_boundary ON app.%I', table_name, table_name);
|
||||
EXECUTE format(
|
||||
'CREATE TRIGGER trg_%s_payload_boundary BEFORE INSERT ON app.%I '
|
||||
'FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_payload_boundary()',
|
||||
table_name, table_name
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Human SELECT: learner-self, teacher cohort, admin. AI SELECT/INSERT: evaluator only.
|
||||
DO $$
|
||||
DECLARE
|
||||
table_name TEXT;
|
||||
BEGIN
|
||||
FOREACH table_name IN ARRAY ARRAY[
|
||||
'calibration_prediction_history','calibration_prediction_revision',
|
||||
'calibration_prediction_lock','calibration_performance_observation',
|
||||
'calibration_assessment_snapshot','calibration_metacognitive_prescription',
|
||||
'calibration_transfer_suite','calibration_transfer_trial',
|
||||
'calibration_transfer_assessment','calibration_subgroup_drift_report'
|
||||
] LOOP
|
||||
EXECUTE format('DROP POLICY IF EXISTS p_%s_select 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) = ''evaluator'') OR '
|
||||
'(NOT app.is_ai_context() AND ('
|
||||
'app.current_role_name() = ''admin'' OR '
|
||||
'(app.current_role_name() = ''learner'' AND learner_id = app.current_uid()) OR '
|
||||
'(app.current_role_name() = ''instructor'' AND EXISTS ('
|
||||
'SELECT 1 FROM app.app_user u WHERE u.user_id = learner_id '
|
||||
'AND u.cohort = current_setting(''app.current_cohort'', true)))))'
|
||||
')',
|
||||
table_name, table_name
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
table_name TEXT;
|
||||
BEGIN
|
||||
FOREACH table_name IN ARRAY ARRAY[
|
||||
'calibration_prediction_history','calibration_prediction_revision','calibration_prediction_lock'
|
||||
] LOOP
|
||||
EXECUTE format('DROP POLICY IF EXISTS p_%s_insert ON app.%I', table_name, table_name);
|
||||
EXECUTE format(
|
||||
'CREATE POLICY p_%s_insert ON app.%I FOR INSERT WITH CHECK ('
|
||||
'NOT app.is_ai_context() AND app.current_role_name() = ''learner'' '
|
||||
'AND learner_id = app.current_uid())',
|
||||
table_name, table_name
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
table_name TEXT;
|
||||
BEGIN
|
||||
FOREACH table_name IN ARRAY ARRAY[
|
||||
'calibration_performance_observation','calibration_assessment_snapshot',
|
||||
'calibration_metacognitive_prescription','calibration_transfer_suite',
|
||||
'calibration_transfer_trial','calibration_transfer_assessment',
|
||||
'calibration_subgroup_drift_report'
|
||||
] LOOP
|
||||
EXECUTE format('DROP POLICY IF EXISTS p_%s_insert ON app.%I', 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;
|
||||
$$;
|
||||
|
||||
DROP POLICY IF EXISTS p_calibration_teacher_review_event_select
|
||||
ON app.calibration_teacher_review_event;
|
||||
DROP POLICY IF EXISTS p_calibration_teacher_review_event_insert
|
||||
ON app.calibration_teacher_review_event;
|
||||
CREATE POLICY p_calibration_teacher_review_event_select
|
||||
ON app.calibration_teacher_review_event FOR SELECT USING (
|
||||
(app.is_ai_context() AND current_setting('app.current_ai_view', true) = 'evaluator')
|
||||
OR (NOT app.is_ai_context() AND (
|
||||
app.current_role_name() = 'admin'
|
||||
OR (app.current_role_name() = 'learner' AND learner_id = app.current_uid())
|
||||
OR (app.current_role_name() = 'instructor' AND EXISTS (
|
||||
SELECT 1 FROM app.app_user u
|
||||
WHERE u.user_id = learner_id
|
||||
AND u.cohort = current_setting('app.current_cohort', true)
|
||||
))
|
||||
))
|
||||
);
|
||||
CREATE POLICY p_calibration_teacher_review_event_insert
|
||||
ON app.calibration_teacher_review_event 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)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
581
infra/db/init/12_supervision_research.sql
Normal file
581
infra/db/init/12_supervision_research.sql
Normal file
|
|
@ -0,0 +1,581 @@
|
|||
-- G6 Supervision & Research OS: immutable supervision queue and research evidence ledger.
|
||||
-- This schema stores metadata and UUID/hash provenance only. Transcript text and clinical claims
|
||||
-- are intentionally outside the contract.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.supervision_evidence_pointer (
|
||||
pointer_id UUID PRIMARY KEY,
|
||||
learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
||||
cohort_id TEXT NOT NULL CHECK (length(btrim(cohort_id)) > 0),
|
||||
consumer_view TEXT NOT NULL CHECK (consumer_view IN ('supervisor','research')),
|
||||
ledger TEXT NOT NULL CHECK (ledger IN (
|
||||
'measurement_event','outcome_trajectory_revision','rupture_observation_event',
|
||||
'rupture_reconciliation_revision','safety_event','calibration_assessment',
|
||||
'transfer_assessment','practice_attempt'
|
||||
)),
|
||||
event_id TEXT NOT NULL CHECK (length(btrim(event_id)) > 0),
|
||||
session_id UUID,
|
||||
route_hint TEXT NOT NULL CHECK (route_hint ~ '^/[a-zA-Z0-9_{}?&=./-]+$'),
|
||||
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (learner_id, consumer_view, ledger, event_id, route_hint)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.supervision_attention_snapshot (
|
||||
snapshot_id UUID PRIMARY KEY,
|
||||
submission_id UUID NOT NULL UNIQUE,
|
||||
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
||||
cohort_id TEXT NOT NULL CHECK (length(btrim(cohort_id)) > 0),
|
||||
item_count INT NOT NULL CHECK (item_count >= 1),
|
||||
source_pointer_ids UUID[] NOT NULL CHECK (cardinality(source_pointer_ids) >= 1),
|
||||
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE),
|
||||
created_by_role TEXT NOT NULL DEFAULT 'agent' CHECK (created_by_role = 'agent'),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.supervision_attention_item (
|
||||
item_id UUID PRIMARY KEY,
|
||||
snapshot_id UUID NOT NULL REFERENCES app.supervision_attention_snapshot(snapshot_id) ON DELETE RESTRICT,
|
||||
learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
||||
learner_ref TEXT NOT NULL CHECK (learner_ref ~ '^learner-[a-z0-9-]+$'),
|
||||
cohort_id TEXT NOT NULL CHECK (length(btrim(cohort_id)) > 0),
|
||||
queue_position INT NOT NULL CHECK (queue_position >= 1),
|
||||
primary_signal TEXT NOT NULL CHECK (primary_signal IN (
|
||||
'deterioration','unresolved_rupture','safety_boundary',
|
||||
'persistent_overconfidence','growth_stagnation','transfer_failure'
|
||||
)),
|
||||
oldest_active_sequence INT NOT NULL CHECK (oldest_active_sequence >= 1),
|
||||
drilldown_routes TEXT[] NOT NULL CHECK (
|
||||
cardinality(drilldown_routes) BETWEEN 1 AND 3
|
||||
),
|
||||
evidence_pointer_ids UUID[] NOT NULL CHECK (
|
||||
cardinality(evidence_pointer_ids) BETWEEN 1 AND 3
|
||||
),
|
||||
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (snapshot_id, learner_id),
|
||||
UNIQUE (snapshot_id, queue_position)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.supervision_attention_reason (
|
||||
reason_id UUID PRIMARY KEY,
|
||||
item_id UUID NOT NULL REFERENCES app.supervision_attention_item(item_id) ON DELETE RESTRICT,
|
||||
signal_id TEXT NOT NULL CHECK (signal_id ~ '^oas-g6-signal-[a-z0-9-]+$'),
|
||||
signal_type TEXT NOT NULL CHECK (signal_type IN (
|
||||
'deterioration','unresolved_rupture','safety_boundary',
|
||||
'persistent_overconfidence','growth_stagnation','transfer_failure'
|
||||
)),
|
||||
severity TEXT NOT NULL CHECK (severity IN ('high','moderate','low')),
|
||||
uncertainty DOUBLE PRECISION NOT NULL CHECK (uncertainty BETWEEN 0 AND 1),
|
||||
evidence_pointer_ids UUID[] NOT NULL CHECK (
|
||||
cardinality(evidence_pointer_ids) BETWEEN 1 AND 3
|
||||
),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (item_id, signal_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.supervision_teacher_ai_disagreement (
|
||||
disagreement_record_id UUID PRIMARY KEY,
|
||||
submission_id UUID NOT NULL UNIQUE,
|
||||
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
||||
disagreement_id TEXT NOT NULL UNIQUE CHECK (disagreement_id ~ '^oas-g6-disagreement-[a-z0-9-]+$'),
|
||||
learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
||||
cohort_id TEXT NOT NULL CHECK (length(btrim(cohort_id)) > 0),
|
||||
case_ref TEXT NOT NULL CHECK (length(btrim(case_ref)) > 0),
|
||||
competency_id TEXT NOT NULL CHECK (competency_id ~ '^competency\.[a-z0-9_.-]+$'),
|
||||
ai_label TEXT NOT NULL CHECK (length(btrim(ai_label)) > 0),
|
||||
teacher_label TEXT NOT NULL CHECK (length(btrim(teacher_label)) > 0),
|
||||
ai_model TEXT NOT NULL CHECK (length(btrim(ai_model)) > 0),
|
||||
prompt_version TEXT NOT NULL CHECK (length(btrim(prompt_version)) > 0),
|
||||
instrument_id TEXT NOT NULL CHECK (length(btrim(instrument_id)) > 0),
|
||||
instrument_version TEXT NOT NULL CHECK (length(btrim(instrument_version)) > 0),
|
||||
correction_reason_code TEXT NOT NULL CHECK (length(btrim(correction_reason_code)) > 0),
|
||||
ai_evidence_pointer_ids UUID[] NOT NULL CHECK (cardinality(ai_evidence_pointer_ids) >= 1),
|
||||
teacher_evidence_pointer_ids UUID[] NOT NULL CHECK (cardinality(teacher_evidence_pointer_ids) >= 1),
|
||||
created_by_uid UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
||||
raw_transcript_included BOOLEAN NOT NULL DEFAULT FALSE CHECK (raw_transcript_included = FALSE),
|
||||
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (ai_label <> teacher_label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.supervision_calibration_dataset_row (
|
||||
dataset_row_id UUID PRIMARY KEY,
|
||||
disagreement_record_id UUID NOT NULL UNIQUE
|
||||
REFERENCES app.supervision_teacher_ai_disagreement(disagreement_record_id) ON DELETE RESTRICT,
|
||||
learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
||||
cohort_id TEXT NOT NULL CHECK (length(btrim(cohort_id)) > 0),
|
||||
row_hash TEXT NOT NULL UNIQUE CHECK (row_hash ~ '^[a-f0-9]{64}$'),
|
||||
evidence_pointer_ids UUID[] NOT NULL CHECK (cardinality(evidence_pointer_ids) >= 2),
|
||||
raw_transcript_included BOOLEAN NOT NULL DEFAULT FALSE CHECK (raw_transcript_included = FALSE),
|
||||
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit.supervision_teacher_event (
|
||||
audit_event_id UUID PRIMARY KEY,
|
||||
disagreement_record_id UUID NOT NULL UNIQUE
|
||||
REFERENCES app.supervision_teacher_ai_disagreement(disagreement_record_id) ON DELETE RESTRICT,
|
||||
actor_uid UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
||||
learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
||||
cohort_id TEXT NOT NULL CHECK (length(btrim(cohort_id)) > 0),
|
||||
action TEXT NOT NULL CHECK (action = 'teacher_ai_disagreement.corrected'),
|
||||
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
||||
evidence_pointer_ids UUID[] NOT NULL CHECK (cardinality(evidence_pointer_ids) >= 2),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.supervision_curriculum_gap_snapshot (
|
||||
gap_snapshot_id UUID PRIMARY KEY,
|
||||
submission_id UUID NOT NULL UNIQUE,
|
||||
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
||||
cohort_id TEXT NOT NULL CHECK (length(btrim(cohort_id)) > 0),
|
||||
competency_id TEXT NOT NULL CHECK (competency_id ~ '^competency\.[a-z0-9_.-]+$'),
|
||||
gap_kind TEXT NOT NULL CHECK (gap_kind IN (
|
||||
'coverage','growth_stagnation','rupture_repair','transfer','calibration'
|
||||
)),
|
||||
status TEXT NOT NULL CHECK (status IN ('observed','monitoring','insufficient_evidence')),
|
||||
uncertainty DOUBLE PRECISION NOT NULL CHECK (uncertainty BETWEEN 0 AND 1),
|
||||
affected_learner_count INT NOT NULL CHECK (affected_learner_count >= 0),
|
||||
evidence_pointer_ids UUID[] NOT NULL,
|
||||
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (
|
||||
(status = 'insufficient_evidence' AND uncertainty = 1 AND cardinality(evidence_pointer_ids) = 0)
|
||||
OR (status <> 'insufficient_evidence' AND cardinality(evidence_pointer_ids) >= 1)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.supervision_evaluation_batch (
|
||||
batch_record_id UUID PRIMARY KEY,
|
||||
submission_id UUID NOT NULL UNIQUE,
|
||||
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
||||
batch_id TEXT NOT NULL UNIQUE CHECK (batch_id ~ '^oas-g6-batch-[a-z0-9-]+$'),
|
||||
cohort_id TEXT NOT NULL CHECK (length(btrim(cohort_id)) > 0),
|
||||
model_name TEXT NOT NULL CHECK (length(btrim(model_name)) > 0),
|
||||
prompt_version TEXT NOT NULL CHECK (length(btrim(prompt_version)) > 0),
|
||||
instrument_id TEXT NOT NULL CHECK (length(btrim(instrument_id)) > 0),
|
||||
instrument_version TEXT NOT NULL CHECK (length(btrim(instrument_version)) > 0),
|
||||
observation_count INT NOT NULL CHECK (observation_count >= 1),
|
||||
data_classification TEXT NOT NULL DEFAULT 'synthetic_educational'
|
||||
CHECK (data_classification = 'synthetic_educational'),
|
||||
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.supervision_evaluation_observation (
|
||||
observation_record_id UUID PRIMARY KEY,
|
||||
batch_record_id UUID NOT NULL REFERENCES app.supervision_evaluation_batch(batch_record_id) ON DELETE RESTRICT,
|
||||
cohort_id TEXT NOT NULL CHECK (length(btrim(cohort_id)) > 0),
|
||||
case_ref TEXT NOT NULL CHECK (length(btrim(case_ref)) > 0),
|
||||
competency_id TEXT NOT NULL CHECK (competency_id ~ '^competency\.[a-z0-9_.-]+$'),
|
||||
synthetic_subgroup TEXT NOT NULL CHECK (synthetic_subgroup ~ '^synthetic-[a-z0-9-]+$'),
|
||||
gold_label TEXT NOT NULL CHECK (length(btrim(gold_label)) > 0),
|
||||
predicted_label TEXT NOT NULL CHECK (length(btrim(predicted_label)) > 0),
|
||||
evidence_pointer_id UUID NOT NULL REFERENCES app.supervision_evidence_pointer(pointer_id) ON DELETE RESTRICT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (batch_record_id, case_ref, competency_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.supervision_drift_report (
|
||||
drift_report_id UUID PRIMARY KEY,
|
||||
submission_id UUID NOT NULL UNIQUE,
|
||||
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
||||
cohort_id TEXT NOT NULL CHECK (length(btrim(cohort_id)) > 0),
|
||||
baseline_batch_record_id UUID NOT NULL REFERENCES app.supervision_evaluation_batch(batch_record_id) ON DELETE RESTRICT,
|
||||
candidate_batch_record_id UUID NOT NULL REFERENCES app.supervision_evaluation_batch(batch_record_id) ON DELETE RESTRICT,
|
||||
matched_count INT NOT NULL CHECK (matched_count >= 0),
|
||||
status TEXT NOT NULL CHECK (status IN ('stable','drift_flagged','insufficient_evidence')),
|
||||
baseline_accuracy DOUBLE PRECISION CHECK (baseline_accuracy BETWEEN 0 AND 1),
|
||||
candidate_accuracy DOUBLE PRECISION CHECK (candidate_accuracy BETWEEN 0 AND 1),
|
||||
accuracy_delta DOUBLE PRECISION CHECK (accuracy_delta BETWEEN -1 AND 1),
|
||||
disagreement_case_refs TEXT[] NOT NULL DEFAULT '{}',
|
||||
alerts TEXT[] NOT NULL DEFAULT '{}',
|
||||
evidence_pointer_ids UUID[] NOT NULL CHECK (cardinality(evidence_pointer_ids) >= 1),
|
||||
data_classification TEXT NOT NULL DEFAULT 'synthetic_educational'
|
||||
CHECK (data_classification = 'synthetic_educational'),
|
||||
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (baseline_batch_record_id <> candidate_batch_record_id),
|
||||
CHECK (
|
||||
(status = 'insufficient_evidence' AND baseline_accuracy IS NULL
|
||||
AND candidate_accuracy IS NULL AND accuracy_delta IS NULL)
|
||||
OR (status <> 'insufficient_evidence' AND baseline_accuracy IS NOT NULL
|
||||
AND candidate_accuracy IS NOT NULL AND accuracy_delta IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.supervision_drift_subgroup_metric (
|
||||
subgroup_metric_id UUID PRIMARY KEY,
|
||||
drift_report_id UUID NOT NULL REFERENCES app.supervision_drift_report(drift_report_id) ON DELETE RESTRICT,
|
||||
cohort_id TEXT NOT NULL CHECK (length(btrim(cohort_id)) > 0),
|
||||
subgroup TEXT NOT NULL CHECK (length(btrim(subgroup)) > 0),
|
||||
matched_count INT NOT NULL CHECK (matched_count >= 0),
|
||||
baseline_accuracy DOUBLE PRECISION CHECK (baseline_accuracy BETWEEN 0 AND 1),
|
||||
candidate_accuracy DOUBLE PRECISION CHECK (candidate_accuracy BETWEEN 0 AND 1),
|
||||
accuracy_delta DOUBLE PRECISION CHECK (accuracy_delta BETWEEN -1 AND 1),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (drift_report_id, subgroup),
|
||||
CHECK (
|
||||
(baseline_accuracy IS NULL AND candidate_accuracy IS NULL AND accuracy_delta IS NULL)
|
||||
OR (baseline_accuracy IS NOT NULL AND candidate_accuracy IS NOT NULL AND accuracy_delta IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.supervision_phase3_manifest (
|
||||
manifest_id UUID PRIMARY KEY,
|
||||
submission_id UUID NOT NULL UNIQUE,
|
||||
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
||||
cohort_id TEXT NOT NULL CHECK (length(btrim(cohort_id)) > 0),
|
||||
schema_version TEXT NOT NULL CHECK (schema_version = 'vignette.phase3-outcome-evidence-manifest.v1'),
|
||||
artifact_count INT NOT NULL CHECK (artifact_count = 4),
|
||||
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.supervision_phase3_artifact (
|
||||
artifact_record_id UUID PRIMARY KEY,
|
||||
manifest_id UUID NOT NULL REFERENCES app.supervision_phase3_manifest(manifest_id) ON DELETE RESTRICT,
|
||||
cohort_id TEXT NOT NULL CHECK (length(btrim(cohort_id)) > 0),
|
||||
domain TEXT NOT NULL CHECK (domain IN ('alliance','rupture','transfer','calibration')),
|
||||
artifact_id TEXT NOT NULL CHECK (length(btrim(artifact_id)) > 0),
|
||||
schema_version TEXT NOT NULL CHECK (length(btrim(schema_version)) > 0),
|
||||
content_sha256 TEXT NOT NULL CHECK (content_sha256 ~ '^[a-f0-9]{64}$'),
|
||||
record_count INT NOT NULL CHECK (record_count >= 1),
|
||||
provenance_uri TEXT NOT NULL CHECK (provenance_uri ~ '^(repo|db|audit)://[a-zA-Z0-9_./:-]+$'),
|
||||
source_pointer_id UUID NOT NULL REFERENCES app.supervision_evidence_pointer(pointer_id) ON DELETE RESTRICT,
|
||||
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (manifest_id, domain)
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_supervision_evidence_pointer()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
found_session UUID;
|
||||
found_learner UUID;
|
||||
found_cohort TEXT;
|
||||
BEGIN
|
||||
IF NEW.ledger = 'measurement_event' THEN
|
||||
SELECT m.session_id, s.learner_id INTO found_session, found_learner
|
||||
FROM app.measurement_event m JOIN app.sessions s ON s.id = m.session_id
|
||||
WHERE m.measurement_id = NEW.event_id::UUID;
|
||||
ELSIF NEW.ledger = 'outcome_trajectory_revision' THEN
|
||||
SELECT anchor_session_id, learner_id INTO found_session, found_learner
|
||||
FROM app.outcome_trajectory_revision WHERE revision_id = NEW.event_id::UUID;
|
||||
ELSIF NEW.ledger = 'rupture_observation_event' THEN
|
||||
SELECT o.session_id, s.learner_id INTO found_session, found_learner
|
||||
FROM app.rupture_observation_event o JOIN app.sessions s ON s.id = o.session_id
|
||||
WHERE o.observation_id = NEW.event_id::UUID;
|
||||
ELSIF NEW.ledger = 'rupture_reconciliation_revision' THEN
|
||||
SELECT r.session_id, s.learner_id INTO found_session, found_learner
|
||||
FROM app.rupture_reconciliation_revision r JOIN app.sessions s ON s.id = r.session_id
|
||||
WHERE r.revision_id = NEW.event_id::UUID;
|
||||
ELSIF NEW.ledger = 'safety_event' THEN
|
||||
SELECT e.session_id, s.learner_id INTO found_session, found_learner
|
||||
FROM app.safety_events e JOIN app.sessions s ON s.id = e.session_id
|
||||
WHERE e.id = NEW.event_id::BIGINT;
|
||||
ELSIF NEW.ledger = 'calibration_assessment' THEN
|
||||
SELECT session_id, learner_id INTO found_session, found_learner
|
||||
FROM app.calibration_assessment_snapshot WHERE assessment_snapshot_id = NEW.event_id::UUID;
|
||||
ELSIF NEW.ledger = 'transfer_assessment' THEN
|
||||
SELECT session_id, learner_id INTO found_session, found_learner
|
||||
FROM app.calibration_transfer_assessment WHERE transfer_assessment_id = NEW.event_id::UUID;
|
||||
ELSE
|
||||
SELECT session_id, learner_id INTO found_session, found_learner
|
||||
FROM app.practice_attempt_evidence WHERE attempt_record_id = NEW.event_id::UUID;
|
||||
END IF;
|
||||
SELECT cohort INTO found_cohort FROM app.app_user WHERE user_id = found_learner;
|
||||
IF found_learner IS NULL OR NEW.learner_id IS DISTINCT FROM found_learner
|
||||
OR (NEW.session_id IS NOT NULL AND NEW.session_id IS DISTINCT FROM found_session)
|
||||
OR NEW.cohort_id IS DISTINCT FROM found_cohort THEN
|
||||
RAISE EXCEPTION 'evidence pointer target is missing or outside learner/cohort scope'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
EXCEPTION WHEN invalid_text_representation THEN
|
||||
RAISE EXCEPTION 'evidence pointer event id has invalid ledger type' USING ERRCODE = '23514';
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_supervision_pointer_array()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
ids UUID[];
|
||||
expected_view TEXT;
|
||||
expected_cohort TEXT;
|
||||
expected_learner UUID;
|
||||
matched_count INT;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME = 'supervision_attention_snapshot' THEN
|
||||
ids := NEW.source_pointer_ids; expected_view := 'supervisor'; expected_cohort := NEW.cohort_id;
|
||||
ELSIF TG_TABLE_NAME = 'supervision_attention_item' THEN
|
||||
ids := NEW.evidence_pointer_ids; expected_view := 'supervisor';
|
||||
expected_cohort := NEW.cohort_id; expected_learner := NEW.learner_id;
|
||||
ELSIF TG_TABLE_NAME = 'supervision_attention_reason' THEN
|
||||
ids := NEW.evidence_pointer_ids; expected_view := 'supervisor';
|
||||
ELSIF TG_TABLE_NAME = 'supervision_teacher_ai_disagreement' THEN
|
||||
ids := NEW.ai_evidence_pointer_ids || NEW.teacher_evidence_pointer_ids;
|
||||
expected_view := 'research'; expected_cohort := NEW.cohort_id; expected_learner := NEW.learner_id;
|
||||
ELSIF TG_TABLE_NAME = 'supervision_calibration_dataset_row' THEN
|
||||
ids := NEW.evidence_pointer_ids; expected_view := 'research';
|
||||
expected_cohort := NEW.cohort_id; expected_learner := NEW.learner_id;
|
||||
ELSIF TG_TABLE_NAME = 'supervision_curriculum_gap_snapshot' THEN
|
||||
ids := NEW.evidence_pointer_ids; expected_view := 'supervisor'; expected_cohort := NEW.cohort_id;
|
||||
ELSIF TG_TABLE_NAME = 'supervision_drift_report' THEN
|
||||
ids := NEW.evidence_pointer_ids; expected_view := 'research'; expected_cohort := NEW.cohort_id;
|
||||
ELSIF TG_TABLE_NAME = 'supervision_evaluation_observation' THEN
|
||||
ids := ARRAY[NEW.evidence_pointer_id]; expected_view := 'research'; expected_cohort := NEW.cohort_id;
|
||||
ELSIF TG_TABLE_NAME = 'supervision_phase3_artifact' THEN
|
||||
ids := ARRAY[NEW.source_pointer_id]; expected_view := 'research'; expected_cohort := NEW.cohort_id;
|
||||
ELSIF TG_TABLE_SCHEMA = 'audit' AND TG_TABLE_NAME = 'supervision_teacher_event' THEN
|
||||
ids := NEW.evidence_pointer_ids; expected_view := 'research';
|
||||
expected_cohort := NEW.cohort_id; expected_learner := NEW.learner_id;
|
||||
ELSE RETURN NEW;
|
||||
END IF;
|
||||
IF cardinality(ids) = 0 THEN RETURN NEW; END IF;
|
||||
SELECT count(DISTINCT p.pointer_id) INTO matched_count
|
||||
FROM app.supervision_evidence_pointer p
|
||||
WHERE p.pointer_id = ANY(ids)
|
||||
AND p.consumer_view = expected_view
|
||||
AND (expected_cohort IS NULL OR p.cohort_id = expected_cohort)
|
||||
AND (expected_learner IS NULL OR p.learner_id = expected_learner);
|
||||
IF matched_count <> cardinality(ARRAY(SELECT DISTINCT unnest(ids))) THEN
|
||||
RAISE EXCEPTION 'aggregate evidence pointer is missing or assigned to another AI view'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_supervision_attention_child()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE parent_item app.supervision_attention_item%ROWTYPE;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME = 'supervision_attention_item' THEN
|
||||
IF NOT EXISTS (SELECT 1 FROM app.supervision_attention_snapshot s
|
||||
WHERE s.snapshot_id = NEW.snapshot_id AND s.cohort_id = NEW.cohort_id) THEN
|
||||
RAISE EXCEPTION 'attention item cohort does not match snapshot' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM app.app_user u WHERE u.user_id = NEW.learner_id AND u.cohort = NEW.cohort_id) THEN
|
||||
RAISE EXCEPTION 'attention learner is outside snapshot cohort' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
ELSE
|
||||
SELECT * INTO parent_item FROM app.supervision_attention_item WHERE item_id = NEW.item_id;
|
||||
IF parent_item.item_id IS NULL OR NOT NEW.evidence_pointer_ids <@ parent_item.evidence_pointer_ids THEN
|
||||
RAISE EXCEPTION 'attention reason evidence must be within item drilldown set' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_supervision_teacher_actor()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM app.app_user u WHERE u.user_id = NEW.learner_id AND u.cohort = NEW.cohort_id) THEN
|
||||
RAISE EXCEPTION 'teacher correction learner is outside cohort' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF app.current_role_name() = 'instructor' AND NEW.created_by_uid IS DISTINCT FROM app.current_uid() THEN
|
||||
RAISE EXCEPTION 'teacher correction actor must match current user' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
DECLARE table_name TEXT;
|
||||
BEGIN
|
||||
FOREACH table_name IN ARRAY ARRAY[
|
||||
'supervision_evidence_pointer','supervision_attention_snapshot','supervision_attention_item',
|
||||
'supervision_attention_reason','supervision_teacher_ai_disagreement',
|
||||
'supervision_calibration_dataset_row','supervision_curriculum_gap_snapshot',
|
||||
'supervision_evaluation_batch','supervision_evaluation_observation',
|
||||
'supervision_drift_report','supervision_drift_subgroup_metric',
|
||||
'supervision_phase3_manifest','supervision_phase3_artifact'
|
||||
] LOOP
|
||||
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);
|
||||
EXECUTE format('ALTER TABLE app.%I ENABLE ROW LEVEL SECURITY', table_name);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_supervision_teacher_event_append_only ON audit.supervision_teacher_event;
|
||||
CREATE TRIGGER trg_supervision_teacher_event_append_only BEFORE UPDATE OR DELETE ON audit.supervision_teacher_event
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation();
|
||||
ALTER TABLE audit.supervision_teacher_event ENABLE ROW LEVEL SECURITY;
|
||||
DROP TRIGGER IF EXISTS trg_supervision_teacher_event_pointer_array ON audit.supervision_teacher_event;
|
||||
CREATE TRIGGER trg_supervision_teacher_event_pointer_array BEFORE INSERT ON audit.supervision_teacher_event
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_supervision_pointer_array();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_supervision_evidence_pointer_contract ON app.supervision_evidence_pointer;
|
||||
CREATE TRIGGER trg_supervision_evidence_pointer_contract BEFORE INSERT ON app.supervision_evidence_pointer
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_supervision_evidence_pointer();
|
||||
DROP TRIGGER IF EXISTS trg_supervision_attention_item_contract ON app.supervision_attention_item;
|
||||
CREATE TRIGGER trg_supervision_attention_item_contract BEFORE INSERT ON app.supervision_attention_item
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_supervision_attention_child();
|
||||
DROP TRIGGER IF EXISTS trg_supervision_attention_reason_contract ON app.supervision_attention_reason;
|
||||
CREATE TRIGGER trg_supervision_attention_reason_contract BEFORE INSERT ON app.supervision_attention_reason
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_supervision_attention_child();
|
||||
DROP TRIGGER IF EXISTS trg_supervision_teacher_ai_actor ON app.supervision_teacher_ai_disagreement;
|
||||
CREATE TRIGGER trg_supervision_teacher_ai_actor BEFORE INSERT ON app.supervision_teacher_ai_disagreement
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_supervision_teacher_actor();
|
||||
|
||||
DO $$
|
||||
DECLARE table_name TEXT;
|
||||
BEGIN
|
||||
FOREACH table_name IN ARRAY ARRAY[
|
||||
'supervision_attention_snapshot','supervision_attention_item','supervision_attention_reason',
|
||||
'supervision_teacher_ai_disagreement','supervision_calibration_dataset_row',
|
||||
'supervision_curriculum_gap_snapshot','supervision_evaluation_observation',
|
||||
'supervision_drift_report','supervision_phase3_artifact'
|
||||
] LOOP
|
||||
EXECUTE format('DROP TRIGGER IF EXISTS trg_%s_pointer_array ON app.%I', table_name, table_name);
|
||||
EXECUTE format('CREATE TRIGGER trg_%s_pointer_array BEFORE INSERT ON app.%I FOR EACH ROW EXECUTE FUNCTION audit.enforce_supervision_pointer_array()', table_name, table_name);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Evidence pointer is view-partitioned for AI and learner/cohort scoped for humans.
|
||||
DROP POLICY IF EXISTS p_supervision_evidence_pointer_select ON app.supervision_evidence_pointer;
|
||||
DROP POLICY IF EXISTS p_supervision_evidence_pointer_insert ON app.supervision_evidence_pointer;
|
||||
CREATE POLICY p_supervision_evidence_pointer_select ON app.supervision_evidence_pointer FOR SELECT USING (
|
||||
(app.is_ai_context() AND consumer_view = current_setting('app.current_ai_view', true))
|
||||
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 cohort_id = current_setting('app.current_cohort', true))
|
||||
))
|
||||
);
|
||||
CREATE POLICY p_supervision_evidence_pointer_insert ON app.supervision_evidence_pointer FOR INSERT WITH CHECK (
|
||||
(app.is_ai_context() AND consumer_view = current_setting('app.current_ai_view', true)
|
||||
AND consumer_view IN ('supervisor','research'))
|
||||
OR (NOT app.is_ai_context() AND app.current_role_name() IN ('instructor','admin')
|
||||
AND consumer_view = 'research'
|
||||
AND (app.current_role_name() = 'admin' OR cohort_id = current_setting('app.current_cohort', true)))
|
||||
);
|
||||
|
||||
-- Supervisor-only AI relations. Learners can see only their own queue rows, never cohort snapshots/gaps.
|
||||
DROP POLICY IF EXISTS p_supervision_attention_snapshot_select ON app.supervision_attention_snapshot;
|
||||
DROP POLICY IF EXISTS p_supervision_attention_snapshot_insert ON app.supervision_attention_snapshot;
|
||||
CREATE POLICY p_supervision_attention_snapshot_select ON app.supervision_attention_snapshot FOR SELECT USING (
|
||||
(app.is_ai_context() AND current_setting('app.current_ai_view', true) = 'supervisor')
|
||||
OR (NOT app.is_ai_context() AND (app.current_role_name() = 'admin'
|
||||
OR (app.current_role_name() = 'instructor' AND cohort_id = current_setting('app.current_cohort', true))))
|
||||
);
|
||||
CREATE POLICY p_supervision_attention_snapshot_insert ON app.supervision_attention_snapshot FOR INSERT WITH CHECK (
|
||||
app.is_ai_context() AND current_setting('app.current_ai_view', true) = 'supervisor'
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS p_supervision_attention_item_select ON app.supervision_attention_item;
|
||||
DROP POLICY IF EXISTS p_supervision_attention_item_insert ON app.supervision_attention_item;
|
||||
CREATE POLICY p_supervision_attention_item_select ON app.supervision_attention_item FOR SELECT USING (
|
||||
(app.is_ai_context() AND current_setting('app.current_ai_view', true) = 'supervisor')
|
||||
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 cohort_id = current_setting('app.current_cohort', true))))
|
||||
);
|
||||
CREATE POLICY p_supervision_attention_item_insert ON app.supervision_attention_item FOR INSERT WITH CHECK (
|
||||
app.is_ai_context() AND current_setting('app.current_ai_view', true) = 'supervisor'
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS p_supervision_attention_reason_select ON app.supervision_attention_reason;
|
||||
DROP POLICY IF EXISTS p_supervision_attention_reason_insert ON app.supervision_attention_reason;
|
||||
CREATE POLICY p_supervision_attention_reason_select ON app.supervision_attention_reason FOR SELECT USING (
|
||||
EXISTS (SELECT 1 FROM app.supervision_attention_item i WHERE i.item_id = item_id)
|
||||
);
|
||||
CREATE POLICY p_supervision_attention_reason_insert ON app.supervision_attention_reason FOR INSERT WITH CHECK (
|
||||
app.is_ai_context() AND current_setting('app.current_ai_view', true) = 'supervisor'
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS p_supervision_curriculum_gap_select ON app.supervision_curriculum_gap_snapshot;
|
||||
DROP POLICY IF EXISTS p_supervision_curriculum_gap_insert ON app.supervision_curriculum_gap_snapshot;
|
||||
CREATE POLICY p_supervision_curriculum_gap_select ON app.supervision_curriculum_gap_snapshot FOR SELECT USING (
|
||||
(app.is_ai_context() AND current_setting('app.current_ai_view', true) = 'supervisor')
|
||||
OR (NOT app.is_ai_context() AND (app.current_role_name() = 'admin'
|
||||
OR (app.current_role_name() = 'instructor' AND cohort_id = current_setting('app.current_cohort', true))))
|
||||
);
|
||||
CREATE POLICY p_supervision_curriculum_gap_insert ON app.supervision_curriculum_gap_snapshot FOR INSERT WITH CHECK (
|
||||
app.is_ai_context() AND current_setting('app.current_ai_view', true) = 'supervisor'
|
||||
);
|
||||
|
||||
-- Research-only AI relations, cohort-scoped for humans. Dataset/audit remain metadata-only.
|
||||
DO $$
|
||||
DECLARE table_name TEXT;
|
||||
BEGIN
|
||||
FOREACH table_name IN ARRAY ARRAY[
|
||||
'supervision_teacher_ai_disagreement','supervision_calibration_dataset_row',
|
||||
'supervision_evaluation_batch','supervision_evaluation_observation',
|
||||
'supervision_drift_report','supervision_drift_subgroup_metric',
|
||||
'supervision_phase3_manifest','supervision_phase3_artifact'
|
||||
] LOOP
|
||||
EXECUTE format('DROP POLICY IF EXISTS p_%s_select 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) = ''research'') OR ' ||
|
||||
'(NOT app.is_ai_context() AND (app.current_role_name() = ''admin'' OR ' ||
|
||||
'(app.current_role_name() = ''instructor'' AND cohort_id = current_setting(''app.current_cohort'', true))' ||
|
||||
CASE WHEN table_name IN ('supervision_teacher_ai_disagreement','supervision_calibration_dataset_row')
|
||||
THEN ' OR (app.current_role_name() = ''learner'' AND learner_id = app.current_uid())' ELSE '' END || ')))',
|
||||
table_name, table_name
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP POLICY IF EXISTS p_supervision_teacher_ai_disagreement_insert ON app.supervision_teacher_ai_disagreement;
|
||||
CREATE POLICY p_supervision_teacher_ai_disagreement_insert ON app.supervision_teacher_ai_disagreement 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 cohort_id = current_setting('app.current_cohort', true))
|
||||
)
|
||||
);
|
||||
DROP POLICY IF EXISTS p_supervision_calibration_dataset_row_insert ON app.supervision_calibration_dataset_row;
|
||||
CREATE POLICY p_supervision_calibration_dataset_row_insert ON app.supervision_calibration_dataset_row FOR INSERT WITH CHECK (
|
||||
NOT app.is_ai_context() AND (app.current_role_name() = 'admin'
|
||||
OR (app.current_role_name() = 'instructor' AND cohort_id = current_setting('app.current_cohort', true)))
|
||||
);
|
||||
|
||||
DO $$
|
||||
DECLARE table_name TEXT;
|
||||
BEGIN
|
||||
FOREACH table_name IN ARRAY ARRAY[
|
||||
'supervision_evaluation_batch','supervision_evaluation_observation',
|
||||
'supervision_drift_report','supervision_drift_subgroup_metric',
|
||||
'supervision_phase3_manifest','supervision_phase3_artifact'
|
||||
] LOOP
|
||||
EXECUTE format('DROP POLICY IF EXISTS p_%s_insert ON app.%I', 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) = ''research'')', table_name, table_name);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP POLICY IF EXISTS p_supervision_teacher_event_select ON audit.supervision_teacher_event;
|
||||
DROP POLICY IF EXISTS p_supervision_teacher_event_insert ON audit.supervision_teacher_event;
|
||||
CREATE POLICY p_supervision_teacher_event_select ON audit.supervision_teacher_event FOR SELECT USING (
|
||||
(app.is_ai_context() AND current_setting('app.current_ai_view', true) = 'research')
|
||||
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 cohort_id = current_setting('app.current_cohort', true))))
|
||||
);
|
||||
CREATE POLICY p_supervision_teacher_event_insert ON audit.supervision_teacher_event FOR INSERT WITH CHECK (
|
||||
NOT app.is_ai_context() AND (app.current_role_name() = 'admin'
|
||||
OR (app.current_role_name() = 'instructor' AND actor_uid = app.current_uid()
|
||||
AND cohort_id = current_setting('app.current_cohort', true)))
|
||||
);
|
||||
|
||||
DO $$
|
||||
DECLARE app_role TEXT;
|
||||
BEGIN
|
||||
FOREACH app_role IN ARRAY ARRAY['vignette_app','vignette'] LOOP
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = app_role) THEN
|
||||
EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app, audit TO %I', app_role);
|
||||
EXECUTE format('GRANT EXECUTE ON FUNCTION audit.enforce_supervision_evidence_pointer() TO %I', app_role);
|
||||
EXECUTE format('GRANT EXECUTE ON FUNCTION audit.enforce_supervision_pointer_array() TO %I', app_role);
|
||||
EXECUTE format('GRANT EXECUTE ON FUNCTION audit.enforce_supervision_attention_child() TO %I', app_role);
|
||||
EXECUTE format('GRANT EXECUTE ON FUNCTION audit.enforce_supervision_teacher_actor() TO %I', app_role);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
629
infra/db/init/13_multimodal_alliance.sql
Normal file
629
infra/db/init/13_multimodal_alliance.sql
Normal file
|
|
@ -0,0 +1,629 @@
|
|||
-- G7 Multimodal Alliance: consent-bound audio clock, independent modality
|
||||
-- measurements, calibrated fusion, and deletion tombstones.
|
||||
-- Raw transcript tokens and audio bytes are never stored in these relations.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.multimodal_ingestion_request (
|
||||
submission_id UUID PRIMARY KEY,
|
||||
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
||||
request_kind TEXT NOT NULL CHECK (request_kind IN (
|
||||
'consent','timeline','measurement_fusion','deletion_request','deletion_completion'
|
||||
)),
|
||||
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,
|
||||
result_id UUID NOT NULL,
|
||||
created_by_role TEXT NOT NULL CHECK (
|
||||
created_by_role IN ('learner','instructor','admin','evaluator')
|
||||
),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.multimodal_consent_snapshot (
|
||||
consent_snapshot_id UUID PRIMARY KEY,
|
||||
submission_id UUID NOT NULL UNIQUE
|
||||
REFERENCES app.multimodal_ingestion_request(submission_id) ON DELETE RESTRICT,
|
||||
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),
|
||||
consent_status TEXT NOT NULL CHECK (
|
||||
consent_status IN ('granted','withdrawn','not_granted')
|
||||
),
|
||||
retain_audio BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
retain_derived_features BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
transcript_retained BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
retention_days INT CHECK (retention_days BETWEEN 1 AND 3650),
|
||||
policy_version TEXT NOT NULL CHECK (length(btrim(policy_version)) > 0),
|
||||
reason_code TEXT CHECK (reason_code IS NULL OR length(btrim(reason_code)) > 0),
|
||||
created_by_uid UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (session_id, sequence_no),
|
||||
CHECK (
|
||||
(consent_status = 'granted' AND retain_derived_features AND retention_days IS NOT NULL)
|
||||
OR
|
||||
(consent_status <> 'granted' AND NOT retain_audio AND NOT retain_derived_features
|
||||
AND retention_days IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_multimodal_consent_latest
|
||||
ON app.multimodal_consent_snapshot(session_id, sequence_no DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.multimodal_audio_asset (
|
||||
audio_asset_id UUID PRIMARY KEY,
|
||||
timeline_submission_id UUID NOT NULL UNIQUE
|
||||
REFERENCES app.multimodal_ingestion_request(submission_id) ON DELETE RESTRICT,
|
||||
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,
|
||||
consent_snapshot_id UUID NOT NULL
|
||||
REFERENCES app.multimodal_consent_snapshot(consent_snapshot_id) ON DELETE RESTRICT,
|
||||
audio_ref TEXT NOT NULL CHECK (length(btrim(audio_ref)) BETWEEN 1 AND 300),
|
||||
audio_sha256 TEXT NOT NULL CHECK (audio_sha256 ~ '^[a-f0-9]{64}$'),
|
||||
media_type TEXT NOT NULL CHECK (media_type IN (
|
||||
'audio/wav','audio/webm','audio/ogg','audio/mpeg','audio/mp4'
|
||||
)),
|
||||
byte_size BIGINT NOT NULL CHECK (byte_size > 0 AND byte_size <= 524288000),
|
||||
duration_ms INT NOT NULL CHECK (duration_ms > 0),
|
||||
retained_until TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (retained_until > created_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.multimodal_audio_timeline (
|
||||
timeline_id UUID PRIMARY KEY,
|
||||
submission_id UUID NOT NULL UNIQUE
|
||||
REFERENCES app.multimodal_ingestion_request(submission_id) ON DELETE RESTRICT,
|
||||
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,
|
||||
consent_snapshot_id UUID NOT NULL
|
||||
REFERENCES app.multimodal_consent_snapshot(consent_snapshot_id) ON DELETE RESTRICT,
|
||||
audio_asset_id UUID REFERENCES app.multimodal_audio_asset(audio_asset_id) ON DELETE RESTRICT,
|
||||
audio_duration_ms INT NOT NULL CHECK (audio_duration_ms > 0),
|
||||
clock_version TEXT NOT NULL DEFAULT 'audio-ms-v1'
|
||||
CHECK (clock_version = 'audio-ms-v1'),
|
||||
word_count INT NOT NULL CHECK (word_count >= 0),
|
||||
event_count INT NOT NULL CHECK (event_count >= 0),
|
||||
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE
|
||||
CHECK (clinical_claim_allowed = FALSE),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_multimodal_timeline_session
|
||||
ON app.multimodal_audio_timeline(session_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.multimodal_word_timestamp (
|
||||
word_timestamp_id UUID PRIMARY KEY,
|
||||
timeline_id UUID NOT NULL
|
||||
REFERENCES app.multimodal_audio_timeline(timeline_id) ON DELETE RESTRICT,
|
||||
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,
|
||||
word_index INT NOT NULL CHECK (word_index >= 0),
|
||||
start_ms INT NOT NULL CHECK (start_ms >= 0),
|
||||
end_ms INT NOT NULL CHECK (end_ms > start_ms),
|
||||
speaker TEXT NOT NULL CHECK (speaker IN ('learner','client')),
|
||||
token_hash TEXT NOT NULL CHECK (token_hash ~ '^[a-f0-9]{64}$'),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (timeline_id, word_index)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_multimodal_word_clock
|
||||
ON app.multimodal_word_timestamp(timeline_id, start_ms, word_index);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.multimodal_voice_event (
|
||||
voice_event_record_id UUID PRIMARY KEY,
|
||||
timeline_id UUID NOT NULL
|
||||
REFERENCES app.multimodal_audio_timeline(timeline_id) ON DELETE RESTRICT,
|
||||
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,
|
||||
event_id TEXT NOT NULL CHECK (event_id ~ '^oas-g7-event-[a-z0-9-]+$'),
|
||||
event_type TEXT NOT NULL CHECK (event_type IN (
|
||||
'silence','overlap','interruption','prosody','pace','audio_quality'
|
||||
)),
|
||||
start_ms INT NOT NULL CHECK (start_ms >= 0),
|
||||
end_ms INT NOT NULL CHECK (end_ms > start_ms),
|
||||
actor TEXT NOT NULL CHECK (actor IN ('learner','client','both','channel')),
|
||||
observed_feature TEXT NOT NULL CHECK (length(btrim(observed_feature)) BETWEEN 1 AND 200),
|
||||
uncertainty DOUBLE PRECISION NOT NULL CHECK (uncertainty BETWEEN 0 AND 1),
|
||||
source TEXT NOT NULL CHECK (source IN (
|
||||
'observed_audio_runtime','stt_word_timestamps'
|
||||
)),
|
||||
claim_scope TEXT NOT NULL DEFAULT 'interaction_signal'
|
||||
CHECK (claim_scope = 'interaction_signal'),
|
||||
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE
|
||||
CHECK (clinical_claim_allowed = FALSE),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (timeline_id, event_id),
|
||||
CHECK (lower(observed_feature) !~
|
||||
'(diagnosis|depression|anxiety disorder|emotion is|feels |진단|우울증|불안장애|자살|감정은|감정이|기분은|기분이|슬프|화났|분노|불안해|느낀다)')
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_multimodal_event_clock
|
||||
ON app.multimodal_voice_event(timeline_id, start_ms, event_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.multimodal_axis_measurement (
|
||||
measurement_record_id UUID PRIMARY KEY,
|
||||
submission_id UUID NOT NULL
|
||||
REFERENCES app.multimodal_ingestion_request(submission_id) ON DELETE RESTRICT,
|
||||
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,
|
||||
measurement_id TEXT NOT NULL UNIQUE
|
||||
CHECK (measurement_id ~ '^oas-g7-measurement-[a-z0-9-]+$'),
|
||||
axis TEXT NOT NULL CHECK (axis IN ('goal','task','bond')),
|
||||
modality TEXT NOT NULL CHECK (modality IN ('text','voice')),
|
||||
status TEXT NOT NULL CHECK (status IN ('ready','missing','error')),
|
||||
value DOUBLE PRECISION CHECK (value BETWEEN 0 AND 1),
|
||||
confidence DOUBLE PRECISION CHECK (confidence BETWEEN 0 AND 1),
|
||||
uncertainty DOUBLE PRECISION NOT NULL CHECK (uncertainty BETWEEN 0 AND 1),
|
||||
evidence_refs TEXT[] NOT NULL DEFAULT '{}',
|
||||
model_run_id UUID,
|
||||
instrument_id TEXT NOT NULL CHECK (length(btrim(instrument_id)) > 0),
|
||||
instrument_version TEXT NOT NULL CHECK (length(btrim(instrument_version)) > 0),
|
||||
model_name TEXT NOT NULL CHECK (length(btrim(model_name)) > 0),
|
||||
prompt_version TEXT NOT NULL CHECK (length(btrim(prompt_version)) > 0),
|
||||
source_kind TEXT NOT NULL CHECK (source_kind IN (
|
||||
'model_inferred_text','model_inferred_voice'
|
||||
)),
|
||||
error_code TEXT CHECK (error_code IS NULL OR length(btrim(error_code)) > 0),
|
||||
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE
|
||||
CHECK (clinical_claim_allowed = FALSE),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (submission_id, axis, modality),
|
||||
CHECK ((modality = 'text' AND source_kind = 'model_inferred_text')
|
||||
OR (modality = 'voice' AND source_kind = 'model_inferred_voice')),
|
||||
CHECK (
|
||||
(status = 'ready' AND value IS NOT NULL AND confidence IS NOT NULL
|
||||
AND cardinality(evidence_refs) >= 1 AND model_run_id IS NOT NULL
|
||||
AND error_code IS NULL)
|
||||
OR
|
||||
(status = 'missing' AND value IS NULL AND confidence IS NULL AND error_code IS NULL)
|
||||
OR
|
||||
(status = 'error' AND value IS NULL AND confidence IS NULL
|
||||
AND uncertainty = 1 AND error_code IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_multimodal_measurement_session
|
||||
ON app.multimodal_axis_measurement(session_id, axis, modality, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.multimodal_fusion_decision (
|
||||
fusion_record_id UUID PRIMARY KEY,
|
||||
submission_id UUID NOT NULL UNIQUE
|
||||
REFERENCES app.multimodal_ingestion_request(submission_id) ON DELETE RESTRICT,
|
||||
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,
|
||||
axis TEXT NOT NULL CHECK (axis IN ('goal','task','bond')),
|
||||
status TEXT NOT NULL CHECK (status IN ('ready','missing','error')),
|
||||
value DOUBLE PRECISION CHECK (value BETWEEN 0 AND 1),
|
||||
uncertainty DOUBLE PRECISION NOT NULL CHECK (uncertainty BETWEEN 0 AND 1),
|
||||
modalities_used TEXT[] NOT NULL,
|
||||
measurement_ids TEXT[] NOT NULL,
|
||||
fusion_applied BOOLEAN NOT NULL,
|
||||
calibration_id TEXT CHECK (
|
||||
calibration_id IS NULL OR calibration_id ~ '^oas-g7-fusion-[a-z0-9-]+$'
|
||||
),
|
||||
benchmark_version TEXT NOT NULL CHECK (length(btrim(benchmark_version)) > 0),
|
||||
text_weight DOUBLE PRECISION NOT NULL CHECK (text_weight BETWEEN 0 AND 1),
|
||||
voice_weight DOUBLE PRECISION NOT NULL CHECK (voice_weight BETWEEN 0 AND 1),
|
||||
text_only_accuracy DOUBLE PRECISION NOT NULL CHECK (text_only_accuracy BETWEEN 0 AND 1),
|
||||
fused_accuracy DOUBLE PRECISION NOT NULL CHECK (fused_accuracy BETWEEN 0 AND 1),
|
||||
minimum_incremental_gain DOUBLE PRECISION NOT NULL CHECK (
|
||||
minimum_incremental_gain BETWEEN 0 AND 1
|
||||
),
|
||||
incremental_gain DOUBLE PRECISION NOT NULL CHECK (incremental_gain BETWEEN -1 AND 1),
|
||||
counterevidence TEXT[] NOT NULL DEFAULT '{}',
|
||||
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE
|
||||
CHECK (clinical_claim_allowed = FALSE),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (abs(text_weight + voice_weight - 1.0) <= 0.000000001),
|
||||
CHECK (abs(incremental_gain - (fused_accuracy - text_only_accuracy)) <= 0.000000001),
|
||||
CONSTRAINT ck_multimodal_fusion_status_value CHECK (
|
||||
(status = 'ready' AND value IS NOT NULL)
|
||||
OR (status <> 'ready' AND value IS NULL)
|
||||
),
|
||||
CHECK (
|
||||
(fusion_applied AND calibration_id IS NOT NULL
|
||||
AND modalities_used @> ARRAY['text','voice']::text[]
|
||||
AND cardinality(measurement_ids) = 2
|
||||
AND incremental_gain >= minimum_incremental_gain)
|
||||
OR
|
||||
(NOT fusion_applied AND calibration_id IS NULL
|
||||
AND modalities_used = ARRAY['text']::text[]
|
||||
AND cardinality(measurement_ids) = 1)
|
||||
)
|
||||
);
|
||||
|
||||
-- CREATE TABLE IF NOT EXISTS does not retrofit named checks in an existing dev DB.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'ck_multimodal_fusion_status_value'
|
||||
AND conrelid = 'app.multimodal_fusion_decision'::regclass
|
||||
) THEN
|
||||
ALTER TABLE app.multimodal_fusion_decision
|
||||
ADD CONSTRAINT ck_multimodal_fusion_status_value CHECK (
|
||||
(status = 'ready' AND value IS NOT NULL)
|
||||
OR (status <> 'ready' AND value IS NULL)
|
||||
);
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.multimodal_deletion_request (
|
||||
deletion_request_id UUID PRIMARY KEY,
|
||||
submission_id UUID NOT NULL UNIQUE
|
||||
REFERENCES app.multimodal_ingestion_request(submission_id) ON DELETE RESTRICT,
|
||||
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,
|
||||
scopes TEXT[] NOT NULL CHECK (
|
||||
cardinality(scopes) BETWEEN 1 AND 3
|
||||
AND scopes <@ ARRAY['audio','derived_features','transcript']::text[]
|
||||
),
|
||||
request_reason TEXT NOT NULL CHECK (request_reason IN (
|
||||
'learner_request','consent_withdrawal','retention_expired','admin_privacy_action'
|
||||
)),
|
||||
requested_by_uid UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_multimodal_deletion_pending
|
||||
ON app.multimodal_deletion_request(session_id, requested_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit.multimodal_deletion_tombstone (
|
||||
tombstone_id UUID PRIMARY KEY,
|
||||
completion_submission_id UUID NOT NULL
|
||||
REFERENCES app.multimodal_ingestion_request(submission_id) ON DELETE RESTRICT,
|
||||
deletion_request_id UUID NOT NULL
|
||||
REFERENCES app.multimodal_deletion_request(deletion_request_id) ON DELETE RESTRICT,
|
||||
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,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('audio','derived_features','transcript')),
|
||||
target_ref_hash TEXT NOT NULL CHECK (target_ref_hash ~ '^[a-f0-9]{64}$'),
|
||||
deletion_proof TEXT NOT NULL CHECK (length(btrim(deletion_proof)) BETWEEN 1 AND 300),
|
||||
actor_uid UUID REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
||||
actor_kind TEXT NOT NULL CHECK (actor_kind IN ('retention_worker','admin')),
|
||||
deleted_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (deletion_request_id, scope)
|
||||
);
|
||||
|
||||
-- Child rows must share their parent's one audio clock and ownership.
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_multimodal_clock_child()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE parent app.multimodal_audio_timeline%ROWTYPE;
|
||||
BEGIN
|
||||
SELECT * INTO parent FROM app.multimodal_audio_timeline WHERE timeline_id = NEW.timeline_id;
|
||||
IF parent.timeline_id IS NULL OR parent.session_id <> NEW.session_id
|
||||
OR parent.learner_id <> NEW.learner_id THEN
|
||||
RAISE EXCEPTION 'multimodal clock child does not match timeline ownership'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NEW.end_ms > parent.audio_duration_ms THEN
|
||||
RAISE EXCEPTION 'multimodal clock child exceeds audio duration'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_multimodal_consent_gate()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE consent app.multimodal_consent_snapshot%ROWTYPE;
|
||||
DECLARE owner UUID;
|
||||
BEGIN
|
||||
SELECT learner_id INTO owner FROM app.sessions WHERE id = NEW.session_id;
|
||||
IF owner IS NULL OR owner <> NEW.learner_id THEN
|
||||
RAISE EXCEPTION 'multimodal write does not match session owner'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
SELECT * INTO consent FROM app.multimodal_consent_snapshot
|
||||
WHERE session_id = NEW.session_id ORDER BY sequence_no DESC LIMIT 1;
|
||||
IF consent.consent_snapshot_id IS NULL OR consent.consent_status <> 'granted'
|
||||
OR consent.consent_snapshot_id <> NEW.consent_snapshot_id THEN
|
||||
RAISE EXCEPTION 'multimodal voice processing requires latest granted consent'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF TG_TABLE_NAME = 'multimodal_audio_asset' AND NOT consent.retain_audio THEN
|
||||
RAISE EXCEPTION 'raw audio retention was not granted' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF TG_TABLE_NAME = 'multimodal_audio_timeline' AND NOT consent.retain_derived_features THEN
|
||||
RAISE EXCEPTION 'derived feature retention was not granted' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_multimodal_consent_sequence()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE expected_sequence INT;
|
||||
DECLARE latest_status TEXT;
|
||||
DECLARE owner UUID;
|
||||
BEGIN
|
||||
SELECT learner_id INTO owner FROM app.sessions WHERE id = NEW.session_id;
|
||||
IF owner IS NULL OR owner <> NEW.learner_id OR owner <> NEW.created_by_uid THEN
|
||||
RAISE EXCEPTION 'multimodal consent actor must be the session learner'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
SELECT COALESCE(max(sequence_no), 0) + 1 INTO expected_sequence
|
||||
FROM app.multimodal_consent_snapshot WHERE session_id = NEW.session_id;
|
||||
SELECT consent_status INTO latest_status FROM app.multimodal_consent_snapshot
|
||||
WHERE session_id = NEW.session_id ORDER BY sequence_no DESC LIMIT 1;
|
||||
IF NEW.sequence_no <> expected_sequence THEN
|
||||
RAISE EXCEPTION 'multimodal consent sequence must be contiguous'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF latest_status = 'withdrawn' THEN
|
||||
RAISE EXCEPTION 'withdrawn multimodal consent is terminal for the session'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_multimodal_timeline_counts()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE timeline_row app.multimodal_audio_timeline%ROWTYPE;
|
||||
DECLARE actual_words INT;
|
||||
DECLARE actual_events INT;
|
||||
DECLARE min_word_index INT;
|
||||
DECLARE max_word_index INT;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME = 'multimodal_audio_timeline' THEN
|
||||
timeline_row := NEW;
|
||||
ELSE
|
||||
SELECT * INTO timeline_row FROM app.multimodal_audio_timeline
|
||||
WHERE timeline_id = NEW.timeline_id;
|
||||
END IF;
|
||||
SELECT count(*), min(word_index), max(word_index)
|
||||
INTO actual_words, min_word_index, max_word_index
|
||||
FROM app.multimodal_word_timestamp WHERE timeline_id = timeline_row.timeline_id;
|
||||
SELECT count(*) INTO actual_events
|
||||
FROM app.multimodal_voice_event WHERE timeline_id = timeline_row.timeline_id;
|
||||
IF actual_words <> timeline_row.word_count OR actual_events <> timeline_row.event_count THEN
|
||||
RAISE EXCEPTION 'multimodal timeline declared counts do not match clock children'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF actual_words > 0 AND (min_word_index <> 0 OR max_word_index <> actual_words - 1) THEN
|
||||
RAISE EXCEPTION 'multimodal word indices must be contiguous from zero'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_multimodal_fusion_contract()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE text_row app.multimodal_axis_measurement%ROWTYPE;
|
||||
DECLARE voice_row app.multimodal_axis_measurement%ROWTYPE;
|
||||
DECLARE latest_consent_status TEXT;
|
||||
BEGIN
|
||||
SELECT * INTO text_row FROM app.multimodal_axis_measurement
|
||||
WHERE submission_id = NEW.submission_id AND axis = NEW.axis AND modality = 'text';
|
||||
SELECT * INTO voice_row FROM app.multimodal_axis_measurement
|
||||
WHERE submission_id = NEW.submission_id AND axis = NEW.axis AND modality = 'voice';
|
||||
IF text_row.measurement_record_id IS NULL OR voice_row.measurement_record_id IS NULL
|
||||
OR text_row.session_id <> NEW.session_id OR voice_row.session_id <> NEW.session_id
|
||||
OR text_row.learner_id <> NEW.learner_id OR voice_row.learner_id <> NEW.learner_id THEN
|
||||
RAISE EXCEPTION 'fusion requires independent text and voice measurements for one owner and axis'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
SELECT consent_status INTO latest_consent_status
|
||||
FROM app.multimodal_consent_snapshot WHERE session_id = NEW.session_id
|
||||
ORDER BY sequence_no DESC LIMIT 1;
|
||||
IF voice_row.status = 'ready' AND latest_consent_status <> 'granted' THEN
|
||||
RAISE EXCEPTION 'ready voice measurement requires latest granted consent'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NEW.fusion_applied THEN
|
||||
IF NEW.status <> 'ready' OR NEW.value IS NULL
|
||||
OR text_row.status <> 'ready' OR voice_row.status <> 'ready'
|
||||
OR NOT NEW.measurement_ids @> ARRAY[text_row.measurement_id, voice_row.measurement_id]::text[]
|
||||
OR NEW.incremental_gain < NEW.minimum_incremental_gain THEN
|
||||
RAISE EXCEPTION 'fusion was applied without ready modalities and benchmark gain'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
ELSE
|
||||
IF NEW.status <> 'ready' OR NEW.value IS NULL OR text_row.status <> 'ready'
|
||||
OR NEW.measurement_ids <> ARRAY[text_row.measurement_id]::text[]
|
||||
OR abs(NEW.value - text_row.value) > 0.000000001
|
||||
OR (voice_row.status = 'ready'
|
||||
AND NEW.incremental_gain >= NEW.minimum_incremental_gain) THEN
|
||||
RAISE EXCEPTION 'text-only fallback does not match modality readiness or benchmark gate'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_multimodal_consent_sequence ON app.multimodal_consent_snapshot;
|
||||
CREATE TRIGGER trg_multimodal_consent_sequence BEFORE INSERT ON app.multimodal_consent_snapshot
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_multimodal_consent_sequence();
|
||||
DROP TRIGGER IF EXISTS trg_multimodal_audio_consent ON app.multimodal_audio_asset;
|
||||
CREATE TRIGGER trg_multimodal_audio_consent BEFORE INSERT ON app.multimodal_audio_asset
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_multimodal_consent_gate();
|
||||
DROP TRIGGER IF EXISTS trg_multimodal_timeline_consent ON app.multimodal_audio_timeline;
|
||||
CREATE TRIGGER trg_multimodal_timeline_consent BEFORE INSERT ON app.multimodal_audio_timeline
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_multimodal_consent_gate();
|
||||
DROP TRIGGER IF EXISTS trg_multimodal_word_clock ON app.multimodal_word_timestamp;
|
||||
CREATE TRIGGER trg_multimodal_word_clock BEFORE INSERT ON app.multimodal_word_timestamp
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_multimodal_clock_child();
|
||||
DROP TRIGGER IF EXISTS trg_multimodal_event_clock ON app.multimodal_voice_event;
|
||||
CREATE TRIGGER trg_multimodal_event_clock BEFORE INSERT ON app.multimodal_voice_event
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_multimodal_clock_child();
|
||||
DROP TRIGGER IF EXISTS trg_multimodal_timeline_counts ON app.multimodal_audio_timeline;
|
||||
CREATE CONSTRAINT TRIGGER trg_multimodal_timeline_counts
|
||||
AFTER INSERT ON app.multimodal_audio_timeline
|
||||
DEFERRABLE INITIALLY DEFERRED
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_multimodal_timeline_counts();
|
||||
DROP TRIGGER IF EXISTS trg_multimodal_word_counts ON app.multimodal_word_timestamp;
|
||||
CREATE CONSTRAINT TRIGGER trg_multimodal_word_counts
|
||||
AFTER INSERT ON app.multimodal_word_timestamp
|
||||
DEFERRABLE INITIALLY DEFERRED
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_multimodal_timeline_counts();
|
||||
DROP TRIGGER IF EXISTS trg_multimodal_event_counts ON app.multimodal_voice_event;
|
||||
CREATE CONSTRAINT TRIGGER trg_multimodal_event_counts
|
||||
AFTER INSERT ON app.multimodal_voice_event
|
||||
DEFERRABLE INITIALLY DEFERRED
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_multimodal_timeline_counts();
|
||||
DROP TRIGGER IF EXISTS trg_multimodal_fusion_contract ON app.multimodal_fusion_decision;
|
||||
CREATE TRIGGER trg_multimodal_fusion_contract
|
||||
BEFORE INSERT ON app.multimodal_fusion_decision
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_multimodal_fusion_contract();
|
||||
|
||||
-- Every ledger is immutable. Deletion is represented by an audit tombstone.
|
||||
DO $$
|
||||
DECLARE relation_name TEXT;
|
||||
BEGIN
|
||||
FOREACH relation_name IN ARRAY ARRAY[
|
||||
'multimodal_ingestion_request','multimodal_consent_snapshot','multimodal_audio_asset',
|
||||
'multimodal_audio_timeline','multimodal_word_timestamp','multimodal_voice_event',
|
||||
'multimodal_axis_measurement','multimodal_fusion_decision','multimodal_deletion_request'
|
||||
] LOOP
|
||||
EXECUTE format('DROP TRIGGER IF EXISTS trg_%s_append_only ON app.%I', relation_name, relation_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()', relation_name, relation_name);
|
||||
EXECUTE format('ALTER TABLE app.%I ENABLE ROW LEVEL SECURITY', relation_name);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
DROP TRIGGER IF EXISTS trg_multimodal_deletion_tombstone_append_only
|
||||
ON audit.multimodal_deletion_tombstone;
|
||||
CREATE TRIGGER trg_multimodal_deletion_tombstone_append_only
|
||||
BEFORE UPDATE OR DELETE ON audit.multimodal_deletion_tombstone
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation();
|
||||
ALTER TABLE audit.multimodal_deletion_tombstone ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Shared metadata access: learner self, instructor cohort, admin, evaluator ingestion.
|
||||
DO $$
|
||||
DECLARE relation_name TEXT;
|
||||
BEGIN
|
||||
FOREACH relation_name IN ARRAY ARRAY[
|
||||
'multimodal_ingestion_request','multimodal_consent_snapshot','multimodal_audio_timeline',
|
||||
'multimodal_word_timestamp','multimodal_voice_event','multimodal_axis_measurement',
|
||||
'multimodal_fusion_decision','multimodal_deletion_request'
|
||||
] LOOP
|
||||
EXECUTE format('DROP POLICY IF EXISTS p_%s_select ON app.%I', relation_name, relation_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) = ''evaluator'') OR ' ||
|
||||
'(NOT app.is_ai_context() AND (app.current_role_name() = ''admin'' OR ' ||
|
||||
'(app.current_role_name() = ''learner'' AND learner_id = app.current_uid()) OR ' ||
|
||||
'(app.current_role_name() = ''instructor'' AND EXISTS (' ||
|
||||
'SELECT 1 FROM app.app_user u WHERE u.user_id = learner_id ' ||
|
||||
'AND u.cohort = current_setting(''app.current_cohort'', true)))))' ||
|
||||
')',
|
||||
relation_name, relation_name
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Raw audio references are never instructor-visible.
|
||||
ALTER TABLE app.multimodal_audio_asset ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS p_multimodal_audio_asset_select ON app.multimodal_audio_asset;
|
||||
CREATE POLICY p_multimodal_audio_asset_select ON app.multimodal_audio_asset FOR SELECT USING (
|
||||
(app.is_ai_context() AND current_setting('app.current_ai_view', true) = 'evaluator')
|
||||
OR (NOT app.is_ai_context() AND (
|
||||
app.current_role_name() = 'admin'
|
||||
OR (app.current_role_name() = 'learner' AND learner_id = app.current_uid())
|
||||
))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS p_multimodal_human_ingestion_insert ON app.multimodal_ingestion_request;
|
||||
CREATE POLICY p_multimodal_human_ingestion_insert ON app.multimodal_ingestion_request
|
||||
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())
|
||||
OR app.current_role_name() = 'admin'
|
||||
))
|
||||
);
|
||||
DROP POLICY IF EXISTS p_multimodal_consent_insert ON app.multimodal_consent_snapshot;
|
||||
CREATE POLICY p_multimodal_consent_insert ON app.multimodal_consent_snapshot
|
||||
FOR INSERT WITH CHECK (
|
||||
NOT app.is_ai_context() AND app.current_role_name() = 'learner'
|
||||
AND learner_id = app.current_uid() AND created_by_uid = app.current_uid()
|
||||
);
|
||||
DROP POLICY IF EXISTS p_multimodal_deletion_request_insert ON app.multimodal_deletion_request;
|
||||
CREATE POLICY p_multimodal_deletion_request_insert ON app.multimodal_deletion_request
|
||||
FOR INSERT WITH CHECK (
|
||||
(app.is_ai_context()
|
||||
AND current_setting('app.current_ai_view', true) = 'evaluator'
|
||||
AND request_reason = 'retention_expired')
|
||||
OR (NOT app.is_ai_context() AND (
|
||||
(app.current_role_name() = 'learner' AND learner_id = app.current_uid()
|
||||
AND requested_by_uid = app.current_uid())
|
||||
OR app.current_role_name() = 'admin'
|
||||
))
|
||||
);
|
||||
|
||||
DO $$
|
||||
DECLARE relation_name TEXT;
|
||||
BEGIN
|
||||
FOREACH relation_name IN ARRAY ARRAY[
|
||||
'multimodal_audio_asset','multimodal_audio_timeline','multimodal_word_timestamp',
|
||||
'multimodal_voice_event','multimodal_axis_measurement','multimodal_fusion_decision'
|
||||
] LOOP
|
||||
EXECUTE format('DROP POLICY IF EXISTS p_%s_insert ON app.%I', relation_name, relation_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'')', relation_name, relation_name);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP POLICY IF EXISTS p_multimodal_tombstone_select ON audit.multimodal_deletion_tombstone;
|
||||
CREATE POLICY p_multimodal_tombstone_select ON audit.multimodal_deletion_tombstone
|
||||
FOR SELECT USING (
|
||||
(app.is_ai_context() AND current_setting('app.current_ai_view', true) = 'evaluator')
|
||||
OR (NOT app.is_ai_context() AND (
|
||||
app.current_role_name() = 'admin'
|
||||
OR (app.current_role_name() = 'learner' AND learner_id = app.current_uid())
|
||||
OR (app.current_role_name() = 'instructor' AND EXISTS (
|
||||
SELECT 1 FROM app.app_user u WHERE u.user_id = learner_id
|
||||
AND u.cohort = current_setting('app.current_cohort', true)
|
||||
))
|
||||
))
|
||||
);
|
||||
DROP POLICY IF EXISTS p_multimodal_tombstone_insert ON audit.multimodal_deletion_tombstone;
|
||||
CREATE POLICY p_multimodal_tombstone_insert ON audit.multimodal_deletion_tombstone
|
||||
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() = 'admin')
|
||||
);
|
||||
|
||||
-- Views separate raw storage handles from safe, observable metadata.
|
||||
CREATE OR REPLACE VIEW app.multimodal_raw_audio_access_v
|
||||
WITH (security_invoker = true, security_barrier = true) AS
|
||||
SELECT a.audio_asset_id, a.session_id, a.learner_id, a.audio_ref, a.audio_sha256,
|
||||
a.media_type, a.byte_size, a.duration_ms, a.retained_until, a.created_at
|
||||
FROM app.multimodal_audio_asset a
|
||||
WHERE a.retained_until > now()
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM audit.multimodal_deletion_tombstone t
|
||||
WHERE t.session_id = a.session_id AND t.scope = 'audio'
|
||||
);
|
||||
|
||||
CREATE OR REPLACE VIEW app.multimodal_session_metadata_v
|
||||
WITH (security_invoker = true, security_barrier = true) AS
|
||||
SELECT t.timeline_id, t.session_id, t.learner_id, t.audio_duration_ms,
|
||||
t.clock_version, t.word_count, t.event_count, t.created_at,
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM audit.multimodal_deletion_tombstone d
|
||||
WHERE d.session_id = t.session_id AND d.scope = 'derived_features'
|
||||
) AS derived_features_available
|
||||
FROM app.multimodal_audio_timeline t;
|
||||
|
||||
DO $$
|
||||
DECLARE app_role TEXT;
|
||||
BEGIN
|
||||
FOREACH app_role IN ARRAY ARRAY['vignette_app','vignette'] LOOP
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = app_role) THEN
|
||||
EXECUTE format('GRANT USAGE ON SCHEMA app, audit TO %I', app_role);
|
||||
EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app, audit TO %I', app_role);
|
||||
EXECUTE format('GRANT SELECT ON app.multimodal_raw_audio_access_v, app.multimodal_session_metadata_v TO %I', app_role);
|
||||
EXECUTE format('GRANT EXECUTE ON FUNCTION audit.enforce_multimodal_clock_child() TO %I', app_role);
|
||||
EXECUTE format('GRANT EXECUTE ON FUNCTION audit.enforce_multimodal_consent_gate() TO %I', app_role);
|
||||
EXECUTE format('GRANT EXECUTE ON FUNCTION audit.enforce_multimodal_consent_sequence() TO %I', app_role);
|
||||
EXECUTE format('GRANT EXECUTE ON FUNCTION audit.enforce_multimodal_timeline_counts() TO %I', app_role);
|
||||
EXECUTE format('GRANT EXECUTE ON FUNCTION audit.enforce_multimodal_fusion_contract() TO %I', app_role);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
794
infra/db/init/14_continuous_improvement.sql
Normal file
794
infra/db/init/14_continuous_improvement.sql
Normal file
|
|
@ -0,0 +1,794 @@
|
|||
-- G8 Autonomous Content & Continuous Improvement OS.
|
||||
-- Only synthetic replay/red-team/coverage-drift metadata is accepted. Every decision and
|
||||
-- lifecycle transition is immutable; qualification never silently promotes a candidate.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.ci_ingestion_submission (
|
||||
submission_id UUID PRIMARY KEY,
|
||||
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
||||
operation_kind TEXT NOT NULL CHECK (operation_kind IN (
|
||||
'content_pipeline','model_change_gate','release_gate','incident_dag',
|
||||
'human_approval','monitor_event'
|
||||
)),
|
||||
result_id UUID NOT NULL,
|
||||
data_classification TEXT NOT NULL DEFAULT 'synthetic_replay_red_team_coverage_drift'
|
||||
CHECK (data_classification = 'synthetic_replay_red_team_coverage_drift'),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.ci_source_artifact (
|
||||
source_record_id UUID PRIMARY KEY,
|
||||
source_id TEXT NOT NULL CHECK (source_id ~ '^oas-g8-source-[a-z0-9-]+$'),
|
||||
source_version TEXT NOT NULL CHECK (length(btrim(source_version)) > 0),
|
||||
content_sha256 TEXT NOT NULL CHECK (content_sha256 ~ '^[a-f0-9]{64}$'),
|
||||
provenance_uri TEXT NOT NULL CHECK (provenance_uri ~ '^(repo|db|audit)://[a-zA-Z0-9_./:-]+$'),
|
||||
usage_status TEXT NOT NULL CHECK (usage_status IN ('approved','restricted','rejected')),
|
||||
citation_label TEXT NOT NULL CHECK (length(btrim(citation_label)) > 0),
|
||||
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (source_id, source_version)
|
||||
);
|
||||
|
||||
-- Mutable scheduler control-plane. Source/configuration fields are immutable after
|
||||
-- enqueue; only lease/retry/result state may change. It never represents approval.
|
||||
CREATE TABLE IF NOT EXISTS app.ci_agentic_job (
|
||||
job_id UUID PRIMARY KEY,
|
||||
job_key TEXT NOT NULL UNIQUE CHECK (job_key ~ '^oas-g8-job-[a-z0-9-]+$'),
|
||||
source_packs JSONB NOT NULL CHECK (
|
||||
jsonb_typeof(source_packs) = 'array' AND jsonb_array_length(source_packs) >= 1
|
||||
),
|
||||
source_fingerprint TEXT NOT NULL CHECK (source_fingerprint ~ '^[a-f0-9]{64}$'),
|
||||
data_classification TEXT NOT NULL
|
||||
CHECK (data_classification = 'synthetic_replay_red_team_coverage_drift'),
|
||||
content_kind TEXT NOT NULL CHECK (content_kind IN ('case','rupture','practice','benchmark')),
|
||||
difficulty_level INT NOT NULL CHECK (difficulty_level BETWEEN 1 AND 5),
|
||||
variant_count INT NOT NULL CHECK (variant_count BETWEEN 3 AND 12),
|
||||
prompt_version TEXT NOT NULL CHECK (length(btrim(prompt_version)) > 0),
|
||||
trigger_kind TEXT NOT NULL CHECK (trigger_kind IN ('scheduled_repo_source','scheduled_incident')),
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending','processing','retry_wait','rejected','completed')),
|
||||
attempt_count INT NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
lease_started_at TIMESTAMPTZ,
|
||||
last_error_code TEXT,
|
||||
last_error_message TEXT CHECK (
|
||||
last_error_message IS NULL OR length(last_error_message) <= 500
|
||||
),
|
||||
result_submission_id UUID REFERENCES app.ci_ingestion_submission(submission_id) ON DELETE RESTRICT,
|
||||
result_qualification_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
CHECK (
|
||||
(status = 'completed') =
|
||||
(result_submission_id IS NOT NULL AND result_qualification_id IS NOT NULL AND completed_at IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ci_agentic_job_ready
|
||||
ON app.ci_agentic_job(status, next_attempt_at, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.ci_content_pipeline (
|
||||
pipeline_id UUID PRIMARY KEY,
|
||||
submission_id UUID NOT NULL UNIQUE
|
||||
REFERENCES app.ci_ingestion_submission(submission_id) ON DELETE RESTRICT,
|
||||
draft_id TEXT NOT NULL UNIQUE CHECK (draft_id ~ '^oas-g8-draft-[a-z0-9-]+$'),
|
||||
content_kind TEXT NOT NULL CHECK (content_kind IN ('case','rupture','practice','benchmark')),
|
||||
source_record_ids UUID[] NOT NULL CHECK (cardinality(source_record_ids) >= 1),
|
||||
generation_model TEXT NOT NULL CHECK (length(btrim(generation_model)) > 0),
|
||||
prompt_version TEXT NOT NULL CHECK (length(btrim(prompt_version)) > 0),
|
||||
prompt_sha256 TEXT NOT NULL CHECK (prompt_sha256 ~ '^[a-f0-9]{64}$'),
|
||||
payload_sha256 TEXT NOT NULL CHECK (payload_sha256 ~ '^[a-f0-9]{64}$'),
|
||||
draft_payload JSONB,
|
||||
synthetic_identity_id TEXT NOT NULL CHECK (synthetic_identity_id ~ '^synthetic-identity-[a-z0-9-]+$'),
|
||||
difficulty_level INT NOT NULL CHECK (difficulty_level BETWEEN 1 AND 5),
|
||||
hidden_answer_fingerprint TEXT NOT NULL CHECK (hidden_answer_fingerprint ~ '^[a-f0-9]{64}$'),
|
||||
visible_answer_overlap_tokens INT NOT NULL CHECK (visible_answer_overlap_tokens = 0),
|
||||
pii_findings INT NOT NULL CHECK (pii_findings = 0),
|
||||
unsupported_clinical_claims INT NOT NULL CHECK (unsupported_clinical_claims = 0),
|
||||
state TEXT NOT NULL DEFAULT 'pending_human_approval'
|
||||
CHECK (state = 'pending_human_approval'),
|
||||
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE app.ci_content_pipeline
|
||||
ADD COLUMN IF NOT EXISTS draft_payload JSONB;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'ci_content_pipeline_draft_payload_object'
|
||||
AND conrelid = 'app.ci_content_pipeline'::regclass
|
||||
) THEN
|
||||
ALTER TABLE app.ci_content_pipeline
|
||||
ADD CONSTRAINT ci_content_pipeline_draft_payload_object
|
||||
CHECK (draft_payload IS NULL OR jsonb_typeof(draft_payload) = 'object');
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.ci_red_team_review (
|
||||
review_record_id UUID PRIMARY KEY,
|
||||
pipeline_id UUID NOT NULL REFERENCES app.ci_content_pipeline(pipeline_id) ON DELETE RESTRICT,
|
||||
review_id TEXT NOT NULL UNIQUE CHECK (review_id ~ '^oas-g8-review-[a-z0-9-]+$'),
|
||||
reviewer_agent_id TEXT NOT NULL CHECK (length(btrim(reviewer_agent_id)) > 0),
|
||||
dimensions TEXT[] NOT NULL CHECK (
|
||||
cardinality(dimensions) >= 3
|
||||
AND dimensions <@ ARRAY['safety','identity','answer_leakage','cultural_bias','difficulty','pii','grounding']::TEXT[]
|
||||
),
|
||||
reviewed_payload_sha256 TEXT NOT NULL CHECK (reviewed_payload_sha256 ~ '^[a-f0-9]{64}$'),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (pipeline_id, reviewer_agent_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.ci_red_team_finding (
|
||||
finding_record_id UUID PRIMARY KEY,
|
||||
review_record_id UUID NOT NULL REFERENCES app.ci_red_team_review(review_record_id) ON DELETE RESTRICT,
|
||||
finding_id TEXT NOT NULL UNIQUE CHECK (finding_id ~ '^oas-g8-finding-[a-z0-9-]+$'),
|
||||
dimension TEXT NOT NULL CHECK (dimension IN (
|
||||
'safety','identity','answer_leakage','cultural_bias','difficulty','pii','grounding'
|
||||
)),
|
||||
severity TEXT NOT NULL CHECK (severity IN ('blocker','high','moderate','low')),
|
||||
finding_state TEXT NOT NULL CHECK (finding_state IN ('open','resolved','accepted_risk')),
|
||||
evidence_ref TEXT NOT NULL CHECK (length(btrim(evidence_ref)) > 0),
|
||||
remediation_ref TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (finding_state <> 'resolved' OR length(btrim(remediation_ref)) > 0),
|
||||
CHECK (finding_state <> 'accepted_risk' OR severity NOT IN ('blocker','high'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.ci_content_benchmark (
|
||||
benchmark_record_id UUID PRIMARY KEY,
|
||||
pipeline_id UUID NOT NULL UNIQUE REFERENCES app.ci_content_pipeline(pipeline_id) ON DELETE RESTRICT,
|
||||
benchmark_id TEXT NOT NULL UNIQUE CHECK (benchmark_id ~ '^oas-g8-benchmark-[a-z0-9-]+$'),
|
||||
variant_count INT NOT NULL CHECK (variant_count >= 3),
|
||||
variant_pass_rate DOUBLE PRECISION NOT NULL CHECK (variant_pass_rate BETWEEN 0 AND 1),
|
||||
answer_leakage_count INT NOT NULL CHECK (answer_leakage_count = 0),
|
||||
pii_finding_count INT NOT NULL CHECK (pii_finding_count = 0),
|
||||
unsupported_claim_count INT NOT NULL CHECK (unsupported_claim_count = 0),
|
||||
safety_failure_count INT NOT NULL CHECK (safety_failure_count = 0),
|
||||
reward_hacking_count INT NOT NULL CHECK (reward_hacking_count = 0),
|
||||
evidence_refs TEXT[] NOT NULL CHECK (cardinality(evidence_refs) >= 1),
|
||||
qualified BOOLEAN NOT NULL CHECK (qualified = (variant_pass_rate >= 0.85)),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.ci_content_qualification (
|
||||
qualification_id UUID PRIMARY KEY,
|
||||
pipeline_id UUID NOT NULL UNIQUE REFERENCES app.ci_content_pipeline(pipeline_id) ON DELETE RESTRICT,
|
||||
benchmark_record_id UUID NOT NULL UNIQUE REFERENCES app.ci_content_benchmark(benchmark_record_id) ON DELETE RESTRICT,
|
||||
catalog_entry_id TEXT NOT NULL UNIQUE CHECK (catalog_entry_id ~ '^oas-g8-catalog-[a-z0-9-]+$'),
|
||||
payload_sha256 TEXT NOT NULL CHECK (payload_sha256 ~ '^[a-f0-9]{64}$'),
|
||||
source_record_ids UUID[] NOT NULL CHECK (cardinality(source_record_ids) >= 1),
|
||||
review_record_ids UUID[] NOT NULL CHECK (cardinality(review_record_ids) >= 2),
|
||||
gate_state TEXT NOT NULL DEFAULT 'pending_human_approval'
|
||||
CHECK (gate_state = 'pending_human_approval'),
|
||||
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.ci_gate_artifact (
|
||||
artifact_record_id UUID PRIMARY KEY,
|
||||
owner_kind TEXT NOT NULL CHECK (owner_kind IN ('model_change_gate','release_gate')),
|
||||
owner_id UUID NOT NULL,
|
||||
artifact_kind TEXT NOT NULL CHECK (artifact_kind IN ('baseline','threshold','provenance','rollback')),
|
||||
artifact_id TEXT NOT NULL CHECK (length(btrim(artifact_id)) > 0),
|
||||
content_sha256 TEXT NOT NULL CHECK (content_sha256 ~ '^[a-f0-9]{64}$'),
|
||||
provenance_uri TEXT NOT NULL CHECK (provenance_uri ~ '^(repo|db|audit)://[a-zA-Z0-9_./:-]+$'),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (owner_kind, owner_id, artifact_kind, artifact_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.ci_model_calibration_snapshot (
|
||||
snapshot_record_id UUID PRIMARY KEY,
|
||||
gate_id UUID NOT NULL,
|
||||
snapshot_role TEXT NOT NULL CHECK (snapshot_role IN ('baseline','candidate')),
|
||||
snapshot_id TEXT NOT NULL CHECK (snapshot_id ~ '^oas-g8-model-snapshot-[a-z0-9-]+$'),
|
||||
model_name TEXT NOT NULL CHECK (length(btrim(model_name)) > 0),
|
||||
prompt_version TEXT NOT NULL CHECK (length(btrim(prompt_version)) > 0),
|
||||
benchmark_version TEXT NOT NULL CHECK (length(btrim(benchmark_version)) > 0),
|
||||
task_accuracy DOUBLE PRECISION NOT NULL CHECK (task_accuracy BETWEEN 0 AND 1),
|
||||
critical_miss_count INT NOT NULL CHECK (critical_miss_count >= 0),
|
||||
leakage_count INT NOT NULL CHECK (leakage_count >= 0),
|
||||
pii_count INT NOT NULL CHECK (pii_count >= 0),
|
||||
calibration_error DOUBLE PRECISION NOT NULL CHECK (calibration_error BETWEEN 0 AND 1),
|
||||
subgroup_max_gap DOUBLE PRECISION NOT NULL CHECK (subgroup_max_gap BETWEEN 0 AND 1),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (gate_id, snapshot_role),
|
||||
UNIQUE (gate_id, snapshot_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.ci_model_change_gate (
|
||||
gate_id UUID PRIMARY KEY,
|
||||
submission_id UUID NOT NULL UNIQUE REFERENCES app.ci_ingestion_submission(submission_id) ON DELETE RESTRICT,
|
||||
baseline_snapshot_record_id UUID NOT NULL UNIQUE
|
||||
REFERENCES app.ci_model_calibration_snapshot(snapshot_record_id) ON DELETE RESTRICT,
|
||||
candidate_snapshot_record_id UUID NOT NULL UNIQUE
|
||||
REFERENCES app.ci_model_calibration_snapshot(snapshot_record_id) ON DELETE RESTRICT,
|
||||
baseline_artifact_id UUID NOT NULL REFERENCES app.ci_gate_artifact(artifact_record_id) ON DELETE RESTRICT,
|
||||
threshold_artifact_id UUID NOT NULL REFERENCES app.ci_gate_artifact(artifact_record_id) ON DELETE RESTRICT,
|
||||
provenance_artifact_ids UUID[] NOT NULL CHECK (cardinality(provenance_artifact_ids) >= 1),
|
||||
rollback_artifact_id UUID NOT NULL REFERENCES app.ci_gate_artifact(artifact_record_id) ON DELETE RESTRICT,
|
||||
gate_decision TEXT NOT NULL CHECK (gate_decision IN ('promote','rollback','quarantine')),
|
||||
reasons TEXT[] NOT NULL CHECK (cardinality(reasons) >= 1),
|
||||
rollback_target_snapshot_id TEXT,
|
||||
state TEXT NOT NULL DEFAULT 'pending_human_approval' CHECK (state = 'pending_human_approval'),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (baseline_snapshot_record_id <> candidate_snapshot_record_id),
|
||||
CHECK ((gate_decision = 'rollback') = (rollback_target_snapshot_id IS NOT NULL))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.ci_release_gate (
|
||||
gate_id UUID PRIMARY KEY,
|
||||
submission_id UUID NOT NULL UNIQUE REFERENCES app.ci_ingestion_submission(submission_id) ON DELETE RESTRICT,
|
||||
release_id TEXT NOT NULL UNIQUE CHECK (release_id ~ '^oas-g8-release-[a-z0-9-]+$'),
|
||||
red_green_passed BOOLEAN NOT NULL,
|
||||
contract_passed BOOLEAN NOT NULL,
|
||||
e2e_passed BOOLEAN NOT NULL,
|
||||
runtime_proof_passed BOOLEAN NOT NULL,
|
||||
public_proof_passed BOOLEAN NOT NULL,
|
||||
ssot_synced BOOLEAN NOT NULL,
|
||||
evidence_refs TEXT[] NOT NULL,
|
||||
baseline_artifact_id UUID NOT NULL REFERENCES app.ci_gate_artifact(artifact_record_id) ON DELETE RESTRICT,
|
||||
threshold_artifact_id UUID NOT NULL REFERENCES app.ci_gate_artifact(artifact_record_id) ON DELETE RESTRICT,
|
||||
provenance_artifact_ids UUID[] NOT NULL CHECK (cardinality(provenance_artifact_ids) >= 1),
|
||||
rollback_artifact_id UUID NOT NULL REFERENCES app.ci_gate_artifact(artifact_record_id) ON DELETE RESTRICT,
|
||||
qualified BOOLEAN NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'pending_human_approval' CHECK (state = 'pending_human_approval'),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (qualified = (
|
||||
red_green_passed AND contract_passed AND e2e_passed AND runtime_proof_passed
|
||||
AND public_proof_passed AND ssot_synced AND cardinality(evidence_refs) >= 1
|
||||
))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit.ci_human_approval_event (
|
||||
approval_event_id UUID PRIMARY KEY,
|
||||
submission_id UUID NOT NULL UNIQUE REFERENCES app.ci_ingestion_submission(submission_id) ON DELETE RESTRICT,
|
||||
target_kind TEXT NOT NULL CHECK (target_kind IN ('content_qualification','model_change_gate','release_gate')),
|
||||
target_id UUID NOT NULL,
|
||||
decision TEXT NOT NULL CHECK (decision IN (
|
||||
'approve_content','approve_promotion','authorize_rollback','reject','keep_quarantine'
|
||||
)),
|
||||
actor_uid UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
||||
reason_code TEXT NOT NULL CHECK (length(btrim(reason_code)) > 0),
|
||||
evidence_refs TEXT[] NOT NULL CHECK (cardinality(evidence_refs) >= 1),
|
||||
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (target_kind, target_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.ci_catalog_entry (
|
||||
catalog_record_id UUID PRIMARY KEY,
|
||||
qualification_id UUID NOT NULL UNIQUE REFERENCES app.ci_content_qualification(qualification_id) ON DELETE RESTRICT,
|
||||
approval_event_id UUID NOT NULL UNIQUE REFERENCES audit.ci_human_approval_event(approval_event_id) ON DELETE RESTRICT,
|
||||
catalog_entry_id TEXT NOT NULL UNIQUE CHECK (catalog_entry_id ~ '^oas-g8-catalog-[a-z0-9-]+$'),
|
||||
payload_sha256 TEXT NOT NULL CHECK (payload_sha256 ~ '^[a-f0-9]{64}$'),
|
||||
source_record_ids UUID[] NOT NULL CHECK (cardinality(source_record_ids) >= 1),
|
||||
review_record_ids UUID[] NOT NULL CHECK (cardinality(review_record_ids) >= 2),
|
||||
benchmark_record_id UUID NOT NULL REFERENCES app.ci_content_benchmark(benchmark_record_id) ON DELETE RESTRICT,
|
||||
status TEXT NOT NULL DEFAULT 'approved' CHECK (status = 'approved'),
|
||||
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit.ci_lifecycle_event (
|
||||
lifecycle_event_id UUID PRIMARY KEY,
|
||||
submission_id UUID NOT NULL UNIQUE REFERENCES app.ci_ingestion_submission(submission_id) ON DELETE RESTRICT,
|
||||
target_kind TEXT NOT NULL CHECK (target_kind IN ('model_change_gate','release_gate')),
|
||||
target_id UUID NOT NULL,
|
||||
event_type TEXT NOT NULL CHECK (event_type IN ('promotion','rollback','monitor')),
|
||||
event_status TEXT NOT NULL CHECK (event_status IN (
|
||||
'approved','requested','executed','failed','healthy','drift_detected',
|
||||
'rollback_recommended','rollback_verified'
|
||||
)),
|
||||
approval_event_id UUID REFERENCES audit.ci_human_approval_event(approval_event_id) ON DELETE RESTRICT,
|
||||
artifact_record_id UUID REFERENCES app.ci_gate_artifact(artifact_record_id) ON DELETE RESTRICT,
|
||||
evidence_refs TEXT[] NOT NULL CHECK (cardinality(evidence_refs) >= 1),
|
||||
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
||||
executor_receipt_id TEXT,
|
||||
executor_evidence_refs TEXT[],
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (
|
||||
(event_type = 'monitor' AND approval_event_id IS NULL)
|
||||
OR (event_type IN ('promotion','rollback') AND approval_event_id IS NOT NULL)
|
||||
),
|
||||
CHECK (event_type <> 'rollback' OR artifact_record_id IS NOT NULL)
|
||||
);
|
||||
|
||||
-- Idempotent upgrade for databases that already created the G8 ledger. NOT VALID
|
||||
-- preserves legacy rows while still enforcing these fail-closed rules on every new row.
|
||||
ALTER TABLE audit.ci_lifecycle_event
|
||||
ADD COLUMN IF NOT EXISTS executor_receipt_id TEXT,
|
||||
ADD COLUMN IF NOT EXISTS executor_evidence_refs TEXT[];
|
||||
ALTER TABLE audit.ci_lifecycle_event
|
||||
DROP CONSTRAINT IF EXISTS ci_lifecycle_event_event_status_check;
|
||||
ALTER TABLE audit.ci_lifecycle_event
|
||||
ADD CONSTRAINT ci_lifecycle_event_event_status_check CHECK (event_status IN (
|
||||
'approved','requested','executed','failed','healthy','drift_detected',
|
||||
'rollback_recommended','rollback_verified'
|
||||
));
|
||||
ALTER TABLE audit.ci_lifecycle_event
|
||||
DROP CONSTRAINT IF EXISTS ci_lifecycle_event_status_by_type_check;
|
||||
ALTER TABLE audit.ci_lifecycle_event
|
||||
ADD CONSTRAINT ci_lifecycle_event_status_by_type_check CHECK (
|
||||
(event_type = 'promotion' AND event_status = 'approved')
|
||||
OR (event_type = 'rollback' AND event_status IN ('requested','executed','failed'))
|
||||
OR (event_type = 'monitor' AND event_status IN (
|
||||
'healthy','drift_detected','rollback_recommended','rollback_verified'
|
||||
))
|
||||
) NOT VALID;
|
||||
ALTER TABLE audit.ci_lifecycle_event
|
||||
DROP CONSTRAINT IF EXISTS ci_lifecycle_event_rollback_receipt_check;
|
||||
ALTER TABLE audit.ci_lifecycle_event
|
||||
ADD CONSTRAINT ci_lifecycle_event_rollback_receipt_check CHECK (
|
||||
event_type <> 'rollback'
|
||||
OR (
|
||||
event_status = 'executed'
|
||||
AND length(btrim(executor_receipt_id)) > 0
|
||||
AND cardinality(executor_evidence_refs) >= 1
|
||||
)
|
||||
OR (
|
||||
event_status IN ('requested','failed')
|
||||
AND executor_receipt_id IS NULL
|
||||
AND executor_evidence_refs IS NULL
|
||||
)
|
||||
) NOT VALID;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.ci_operational_incident (
|
||||
incident_record_id UUID PRIMARY KEY,
|
||||
submission_id UUID NOT NULL UNIQUE REFERENCES app.ci_ingestion_submission(submission_id) ON DELETE RESTRICT,
|
||||
incident_id TEXT NOT NULL UNIQUE CHECK (incident_id ~ '^oas-g8-incident-[a-z0-9-]+$'),
|
||||
error_fingerprint TEXT NOT NULL CHECK (error_fingerprint ~ '^[a-f0-9]{64}$'),
|
||||
affected_contract TEXT NOT NULL CHECK (length(btrim(affected_contract)) > 0),
|
||||
evidence_refs TEXT[] NOT NULL CHECK (cardinality(evidence_refs) >= 1),
|
||||
pii_included BOOLEAN NOT NULL DEFAULT FALSE CHECK (pii_included = FALSE),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.ci_regression_dag_node (
|
||||
node_record_id UUID PRIMARY KEY,
|
||||
incident_record_id UUID NOT NULL REFERENCES app.ci_operational_incident(incident_record_id) ON DELETE RESTRICT,
|
||||
node_id TEXT NOT NULL UNIQUE CHECK (node_id ~ '^oas-g8-node-[a-z0-9-]+$'),
|
||||
node_type TEXT NOT NULL CHECK (node_type IN ('reproduction_test','implementation','e2e','runtime_proof')),
|
||||
depends_on_record_ids UUID[] NOT NULL DEFAULT '{}',
|
||||
evidence_ref TEXT,
|
||||
node_status TEXT NOT NULL CHECK (node_status IN ('pending','passed','failed')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (incident_record_id, node_type)
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_ci_uuid_array()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE ids UUID[];
|
||||
DECLARE matched INT;
|
||||
DECLARE expected_sources UUID[];
|
||||
DECLARE expected_payload TEXT;
|
||||
DECLARE benchmark_ready BOOLEAN;
|
||||
DECLARE covered_dimensions TEXT[];
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME = 'ci_content_pipeline' THEN
|
||||
ids := NEW.source_record_ids;
|
||||
SELECT count(DISTINCT source_record_id) INTO matched FROM app.ci_source_artifact
|
||||
WHERE source_record_id = ANY(ids) AND usage_status = 'approved';
|
||||
ELSIF TG_TABLE_NAME = 'ci_content_qualification' THEN
|
||||
SELECT p.source_record_ids, p.payload_sha256
|
||||
INTO expected_sources, expected_payload
|
||||
FROM app.ci_content_pipeline p WHERE p.pipeline_id = NEW.pipeline_id;
|
||||
IF expected_sources IS NULL OR NEW.source_record_ids IS DISTINCT FROM expected_sources
|
||||
OR NEW.payload_sha256 IS DISTINCT FROM expected_payload THEN
|
||||
RAISE EXCEPTION 'qualification does not match immutable draft provenance' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
SELECT count(DISTINCT source_record_id) INTO matched FROM app.ci_source_artifact
|
||||
WHERE source_record_id = ANY(NEW.source_record_ids) AND usage_status = 'approved';
|
||||
IF matched <> cardinality(ARRAY(SELECT DISTINCT unnest(NEW.source_record_ids))) THEN
|
||||
RAISE EXCEPTION 'qualification source UUIDs are missing or not approved' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
SELECT count(DISTINCT review_record_id) INTO matched FROM app.ci_red_team_review
|
||||
WHERE review_record_id = ANY(NEW.review_record_ids) AND pipeline_id = NEW.pipeline_id;
|
||||
IF matched <> cardinality(ARRAY(SELECT DISTINCT unnest(NEW.review_record_ids))) THEN
|
||||
RAISE EXCEPTION 'qualification review UUIDs are missing or cross-pipeline' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
SELECT array_agg(DISTINCT dimension) INTO covered_dimensions
|
||||
FROM app.ci_red_team_review r
|
||||
CROSS JOIN LATERAL unnest(r.dimensions) AS dims(dimension)
|
||||
WHERE r.review_record_id = ANY(NEW.review_record_ids);
|
||||
IF NOT ARRAY['safety','identity','answer_leakage','cultural_bias','difficulty','pii','grounding']::TEXT[]
|
||||
<@ COALESCE(covered_dimensions, '{}'::TEXT[]) THEN
|
||||
RAISE EXCEPTION 'qualification red-team coverage is incomplete' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM app.ci_red_team_finding f
|
||||
WHERE f.review_record_id = ANY(NEW.review_record_ids)
|
||||
AND f.finding_state = 'open'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'qualification has unresolved red-team findings' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
SELECT b.qualified INTO benchmark_ready FROM app.ci_content_benchmark b
|
||||
WHERE b.benchmark_record_id = NEW.benchmark_record_id
|
||||
AND b.pipeline_id = NEW.pipeline_id;
|
||||
IF benchmark_ready IS DISTINCT FROM TRUE THEN
|
||||
RAISE EXCEPTION 'qualification benchmark is missing, cross-pipeline, or failed' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
ELSIF TG_TABLE_NAME = 'ci_model_change_gate' OR TG_TABLE_NAME = 'ci_release_gate' THEN
|
||||
ids := ARRAY[NEW.baseline_artifact_id,NEW.threshold_artifact_id,NEW.rollback_artifact_id]
|
||||
|| NEW.provenance_artifact_ids;
|
||||
SELECT count(DISTINCT artifact_record_id) INTO matched FROM app.ci_gate_artifact
|
||||
WHERE artifact_record_id = ANY(ids)
|
||||
AND owner_kind = replace(TG_TABLE_NAME::TEXT, 'ci_', '')
|
||||
AND owner_id = NEW.gate_id;
|
||||
IF matched <> cardinality(ARRAY(SELECT DISTINCT unnest(ids))) THEN
|
||||
RAISE EXCEPTION 'release gate artifacts are missing or assigned to another gate' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM app.ci_gate_artifact WHERE artifact_record_id = NEW.baseline_artifact_id AND artifact_kind = 'baseline')
|
||||
OR NOT EXISTS (SELECT 1 FROM app.ci_gate_artifact WHERE artifact_record_id = NEW.threshold_artifact_id AND artifact_kind = 'threshold')
|
||||
OR NOT EXISTS (SELECT 1 FROM app.ci_gate_artifact WHERE artifact_record_id = NEW.rollback_artifact_id AND artifact_kind = 'rollback')
|
||||
OR EXISTS (SELECT 1 FROM app.ci_gate_artifact WHERE artifact_record_id = ANY(NEW.provenance_artifact_ids) AND artifact_kind <> 'provenance') THEN
|
||||
RAISE EXCEPTION 'baseline/threshold/provenance/rollback artifact kinds are incomplete' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
ELSIF TG_TABLE_NAME = 'ci_regression_dag_node' THEN
|
||||
ids := NEW.depends_on_record_ids;
|
||||
IF cardinality(ids) = 0 THEN RETURN NEW; END IF;
|
||||
SELECT count(DISTINCT node_record_id) INTO matched FROM app.ci_regression_dag_node
|
||||
WHERE node_record_id = ANY(ids) AND incident_record_id = NEW.incident_record_id;
|
||||
ELSE
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
IF matched <> cardinality(ARRAY(SELECT DISTINCT unnest(ids))) THEN
|
||||
RAISE EXCEPTION 'UUID provenance array contains missing or cross-owner records' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_ci_review_contract()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE reviewed_dimensions TEXT[];
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME = 'ci_red_team_review' THEN
|
||||
IF NOT EXISTS (SELECT 1 FROM app.ci_content_pipeline p
|
||||
WHERE p.pipeline_id = NEW.pipeline_id AND p.payload_sha256 = NEW.reviewed_payload_sha256) THEN
|
||||
RAISE EXCEPTION 'red-team payload hash does not match draft' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
ELSE
|
||||
SELECT dimensions INTO reviewed_dimensions FROM app.ci_red_team_review
|
||||
WHERE review_record_id = NEW.review_record_id;
|
||||
IF reviewed_dimensions IS NULL OR NOT NEW.dimension = ANY(reviewed_dimensions) THEN
|
||||
RAISE EXCEPTION 'finding dimension was not reviewed' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_ci_human_approval()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE expected_decision TEXT;
|
||||
DECLARE qualified BOOLEAN;
|
||||
BEGIN
|
||||
IF app.current_role_name() <> 'admin' OR NEW.actor_uid IS DISTINCT FROM app.current_uid() THEN
|
||||
RAISE EXCEPTION 'continuous improvement approval requires current admin actor' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
IF NEW.target_kind = 'content_qualification' THEN
|
||||
SELECT 'approve_content', TRUE INTO expected_decision, qualified
|
||||
FROM app.ci_content_qualification WHERE qualification_id = NEW.target_id;
|
||||
ELSIF NEW.target_kind = 'model_change_gate' THEN
|
||||
SELECT CASE gate_decision WHEN 'promote' THEN 'approve_promotion'
|
||||
WHEN 'rollback' THEN 'authorize_rollback' ELSE 'keep_quarantine' END, TRUE
|
||||
INTO expected_decision, qualified FROM app.ci_model_change_gate WHERE gate_id = NEW.target_id;
|
||||
ELSE
|
||||
SELECT 'approve_promotion', g.qualified INTO expected_decision, qualified
|
||||
FROM app.ci_release_gate g WHERE gate_id = NEW.target_id;
|
||||
END IF;
|
||||
IF expected_decision IS NULL THEN
|
||||
RAISE EXCEPTION 'approval target does not exist' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NEW.decision = 'authorize_rollback'
|
||||
AND NEW.target_kind IN ('model_change_gate','release_gate')
|
||||
AND qualified THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
IF NEW.decision NOT IN ('reject', expected_decision)
|
||||
OR (NEW.decision = expected_decision AND NOT qualified) THEN
|
||||
RAISE EXCEPTION 'human decision cannot bypass computed fail-closed gate' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_ci_catalog_approval()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM app.ci_content_qualification q
|
||||
JOIN audit.ci_human_approval_event a
|
||||
ON a.approval_event_id = NEW.approval_event_id
|
||||
AND a.target_kind = 'content_qualification'
|
||||
AND a.target_id = q.qualification_id
|
||||
AND a.decision = 'approve_content'
|
||||
WHERE q.qualification_id = NEW.qualification_id
|
||||
AND q.catalog_entry_id = NEW.catalog_entry_id
|
||||
AND q.payload_sha256 = NEW.payload_sha256
|
||||
AND q.source_record_ids = NEW.source_record_ids
|
||||
AND q.review_record_ids = NEW.review_record_ids
|
||||
AND q.benchmark_record_id = NEW.benchmark_record_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'catalog promotion requires matching human approval' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_ci_lifecycle_event()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE gate_decision TEXT;
|
||||
DECLARE rollback_id UUID;
|
||||
BEGIN
|
||||
IF NEW.target_kind = 'model_change_gate' THEN
|
||||
SELECT g.gate_decision, g.rollback_artifact_id INTO gate_decision, rollback_id
|
||||
FROM app.ci_model_change_gate g WHERE g.gate_id = NEW.target_id;
|
||||
ELSE
|
||||
SELECT CASE WHEN g.qualified THEN 'promote' ELSE 'quarantine' END, g.rollback_artifact_id
|
||||
INTO gate_decision, rollback_id FROM app.ci_release_gate g WHERE g.gate_id = NEW.target_id;
|
||||
END IF;
|
||||
IF gate_decision IS NULL THEN
|
||||
RAISE EXCEPTION 'lifecycle target does not exist' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NEW.event_type = 'promotion' AND (
|
||||
gate_decision <> 'promote' OR NOT EXISTS (
|
||||
SELECT 1 FROM audit.ci_human_approval_event a
|
||||
WHERE a.approval_event_id = NEW.approval_event_id AND a.target_id = NEW.target_id
|
||||
AND a.target_kind = NEW.target_kind AND a.decision = 'approve_promotion'
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'silent or failed-gate promotion is forbidden' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NEW.event_type = 'rollback' AND (
|
||||
NEW.artifact_record_id IS DISTINCT FROM rollback_id OR NOT EXISTS (
|
||||
SELECT 1 FROM audit.ci_human_approval_event a
|
||||
WHERE a.approval_event_id = NEW.approval_event_id AND a.target_id = NEW.target_id
|
||||
AND a.target_kind = NEW.target_kind AND a.decision = 'authorize_rollback'
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'rollback requires human authorization and pinned artifact' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NEW.event_type = 'rollback' AND NEW.event_status = 'executed'
|
||||
AND (NULLIF(btrim(NEW.executor_receipt_id), '') IS NULL
|
||||
OR COALESCE(cardinality(NEW.executor_evidence_refs), 0) < 1) THEN
|
||||
RAISE EXCEPTION 'executed rollback requires executor receipt evidence' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NEW.event_type = 'monitor' AND NEW.event_status = 'rollback_verified'
|
||||
AND NOT EXISTS (SELECT 1 FROM audit.ci_lifecycle_event e
|
||||
WHERE e.target_kind = NEW.target_kind AND e.target_id = NEW.target_id
|
||||
AND e.event_type = 'rollback' AND e.event_status = 'executed') THEN
|
||||
RAISE EXCEPTION 'rollback verification requires an earlier executed rollback' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_ci_model_snapshot_gate()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE baseline app.ci_model_calibration_snapshot%ROWTYPE;
|
||||
DECLARE candidate app.ci_model_calibration_snapshot%ROWTYPE;
|
||||
DECLARE expected_decision TEXT;
|
||||
BEGIN
|
||||
SELECT * INTO baseline FROM app.ci_model_calibration_snapshot
|
||||
WHERE snapshot_record_id = NEW.baseline_snapshot_record_id
|
||||
AND gate_id = NEW.gate_id AND snapshot_role = 'baseline';
|
||||
SELECT * INTO candidate FROM app.ci_model_calibration_snapshot
|
||||
WHERE snapshot_record_id = NEW.candidate_snapshot_record_id
|
||||
AND gate_id = NEW.gate_id AND snapshot_role = 'candidate';
|
||||
IF baseline.snapshot_record_id IS NULL OR candidate.snapshot_record_id IS NULL THEN
|
||||
RAISE EXCEPTION 'model gate snapshots are missing or role-mismatched' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF candidate.critical_miss_count > 0 OR candidate.leakage_count > 0 OR candidate.pii_count > 0 THEN
|
||||
expected_decision := 'rollback';
|
||||
ELSIF candidate.task_accuracy < baseline.task_accuracy - 0.02
|
||||
OR candidate.calibration_error > baseline.calibration_error + 0.02
|
||||
OR candidate.subgroup_max_gap > baseline.subgroup_max_gap + 0.05 THEN
|
||||
expected_decision := 'quarantine';
|
||||
ELSE
|
||||
expected_decision := 'promote';
|
||||
END IF;
|
||||
IF NEW.gate_decision IS DISTINCT FROM expected_decision
|
||||
OR (expected_decision = 'rollback' AND NEW.rollback_target_snapshot_id IS DISTINCT FROM baseline.snapshot_id) THEN
|
||||
RAISE EXCEPTION 'model gate decision bypasses calibration baseline thresholds' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_ci_agentic_job_update()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF ROW(
|
||||
NEW.job_key, NEW.source_packs, NEW.source_fingerprint,
|
||||
NEW.data_classification, NEW.content_kind, NEW.difficulty_level,
|
||||
NEW.variant_count, NEW.prompt_version, NEW.trigger_kind, NEW.created_at
|
||||
) IS DISTINCT FROM ROW(
|
||||
OLD.job_key, OLD.source_packs, OLD.source_fingerprint,
|
||||
OLD.data_classification, OLD.content_kind, OLD.difficulty_level,
|
||||
OLD.variant_count, OLD.prompt_version, OLD.trigger_kind, OLD.created_at
|
||||
) THEN
|
||||
RAISE EXCEPTION 'agentic job source and configuration are immutable' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NOT (
|
||||
(OLD.status = 'pending' AND NEW.status = 'processing')
|
||||
OR (OLD.status = 'retry_wait' AND NEW.status = 'processing')
|
||||
OR (OLD.status = 'processing' AND NEW.status IN ('processing','retry_wait','rejected','completed'))
|
||||
) THEN
|
||||
RAISE EXCEPTION 'invalid agentic job state transition: % -> %', OLD.status, NEW.status
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NEW.status <> 'processing' AND NEW.lease_started_at IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'only processing agentic jobs may hold a lease' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_ci_content_pipeline_sources ON app.ci_content_pipeline;
|
||||
CREATE TRIGGER trg_ci_content_pipeline_sources BEFORE INSERT ON app.ci_content_pipeline
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_ci_uuid_array();
|
||||
DROP TRIGGER IF EXISTS trg_ci_agentic_job_update_contract ON app.ci_agentic_job;
|
||||
CREATE TRIGGER trg_ci_agentic_job_update_contract BEFORE UPDATE ON app.ci_agentic_job
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_ci_agentic_job_update();
|
||||
DROP TRIGGER IF EXISTS trg_ci_content_qualification_sources ON app.ci_content_qualification;
|
||||
CREATE TRIGGER trg_ci_content_qualification_sources BEFORE INSERT ON app.ci_content_qualification
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_ci_uuid_array();
|
||||
DROP TRIGGER IF EXISTS trg_ci_model_gate_artifacts ON app.ci_model_change_gate;
|
||||
CREATE TRIGGER trg_ci_model_gate_artifacts BEFORE INSERT ON app.ci_model_change_gate
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_ci_uuid_array();
|
||||
DROP TRIGGER IF EXISTS trg_ci_release_gate_artifacts ON app.ci_release_gate;
|
||||
CREATE TRIGGER trg_ci_release_gate_artifacts BEFORE INSERT ON app.ci_release_gate
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_ci_uuid_array();
|
||||
DROP TRIGGER IF EXISTS trg_ci_dag_dependencies ON app.ci_regression_dag_node;
|
||||
CREATE TRIGGER trg_ci_dag_dependencies BEFORE INSERT ON app.ci_regression_dag_node
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_ci_uuid_array();
|
||||
DROP TRIGGER IF EXISTS trg_ci_red_team_review_contract ON app.ci_red_team_review;
|
||||
CREATE TRIGGER trg_ci_red_team_review_contract BEFORE INSERT ON app.ci_red_team_review
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_ci_review_contract();
|
||||
DROP TRIGGER IF EXISTS trg_ci_red_team_finding_contract ON app.ci_red_team_finding;
|
||||
CREATE TRIGGER trg_ci_red_team_finding_contract BEFORE INSERT ON app.ci_red_team_finding
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_ci_review_contract();
|
||||
DROP TRIGGER IF EXISTS trg_ci_human_approval_contract ON audit.ci_human_approval_event;
|
||||
CREATE TRIGGER trg_ci_human_approval_contract BEFORE INSERT ON audit.ci_human_approval_event
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_ci_human_approval();
|
||||
DROP TRIGGER IF EXISTS trg_ci_catalog_approval ON app.ci_catalog_entry;
|
||||
CREATE TRIGGER trg_ci_catalog_approval BEFORE INSERT ON app.ci_catalog_entry
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_ci_catalog_approval();
|
||||
DROP TRIGGER IF EXISTS trg_ci_lifecycle_contract ON audit.ci_lifecycle_event;
|
||||
CREATE TRIGGER trg_ci_lifecycle_contract BEFORE INSERT ON audit.ci_lifecycle_event
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_ci_lifecycle_event();
|
||||
DROP TRIGGER IF EXISTS trg_ci_model_snapshot_contract ON app.ci_model_change_gate;
|
||||
CREATE TRIGGER trg_ci_model_snapshot_contract BEFORE INSERT ON app.ci_model_change_gate
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_ci_model_snapshot_gate();
|
||||
|
||||
DO $$
|
||||
DECLARE table_name TEXT;
|
||||
BEGIN
|
||||
FOREACH table_name IN ARRAY ARRAY[
|
||||
'ci_ingestion_submission','ci_source_artifact','ci_content_pipeline','ci_red_team_review',
|
||||
'ci_red_team_finding','ci_content_benchmark','ci_content_qualification','ci_gate_artifact',
|
||||
'ci_model_calibration_snapshot','ci_model_change_gate','ci_release_gate','ci_catalog_entry',
|
||||
'ci_operational_incident','ci_regression_dag_node'
|
||||
] LOOP
|
||||
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);
|
||||
EXECUTE format('ALTER TABLE app.%I ENABLE ROW LEVEL SECURITY', table_name);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
DECLARE table_name TEXT;
|
||||
BEGIN
|
||||
FOREACH table_name IN ARRAY ARRAY['ci_human_approval_event','ci_lifecycle_event'] LOOP
|
||||
EXECUTE format('DROP TRIGGER IF EXISTS trg_%s_append_only ON audit.%I', table_name, table_name);
|
||||
EXECUTE format('CREATE TRIGGER trg_%s_append_only BEFORE UPDATE OR DELETE ON audit.%I FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation()', table_name, table_name);
|
||||
EXECUTE format('ALTER TABLE audit.%I ENABLE ROW LEVEL SECURITY', table_name);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER TABLE app.ci_agentic_job ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS p_ci_agentic_job_select ON app.ci_agentic_job;
|
||||
DROP POLICY IF EXISTS p_ci_agentic_job_insert ON app.ci_agentic_job;
|
||||
DROP POLICY IF EXISTS p_ci_agentic_job_update ON app.ci_agentic_job;
|
||||
CREATE POLICY p_ci_agentic_job_select ON app.ci_agentic_job FOR SELECT USING (
|
||||
(app.is_ai_context() AND current_setting('app.current_ai_view',true) = 'research')
|
||||
OR (NOT app.is_ai_context() AND app.current_role_name() = 'admin')
|
||||
);
|
||||
CREATE POLICY p_ci_agentic_job_insert ON app.ci_agentic_job FOR INSERT WITH CHECK (
|
||||
app.is_ai_context()
|
||||
AND current_setting('app.current_ai_view',true) = 'research'
|
||||
AND data_classification = 'synthetic_replay_red_team_coverage_drift'
|
||||
AND status = 'pending'
|
||||
);
|
||||
CREATE POLICY p_ci_agentic_job_update ON app.ci_agentic_job FOR UPDATE
|
||||
USING (
|
||||
app.is_ai_context() AND current_setting('app.current_ai_view',true) = 'research'
|
||||
)
|
||||
WITH CHECK (
|
||||
app.is_ai_context()
|
||||
AND current_setting('app.current_ai_view',true) = 'research'
|
||||
AND data_classification = 'synthetic_replay_red_team_coverage_drift'
|
||||
);
|
||||
|
||||
-- Research automation can see/write candidates and evidence; only administrators approve.
|
||||
DO $$
|
||||
DECLARE table_name TEXT;
|
||||
BEGIN
|
||||
FOREACH table_name IN ARRAY ARRAY[
|
||||
'ci_ingestion_submission','ci_source_artifact','ci_content_pipeline','ci_red_team_review',
|
||||
'ci_red_team_finding','ci_content_benchmark','ci_content_qualification','ci_gate_artifact',
|
||||
'ci_model_calibration_snapshot','ci_model_change_gate','ci_release_gate',
|
||||
'ci_operational_incident','ci_regression_dag_node'
|
||||
] 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) = ''research'') OR (NOT app.is_ai_context() AND app.current_role_name() = ''admin''))', 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) = ''research'')', table_name, table_name);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP POLICY IF EXISTS p_ci_ingestion_submission_admin_insert ON app.ci_ingestion_submission;
|
||||
CREATE POLICY p_ci_ingestion_submission_admin_insert ON app.ci_ingestion_submission
|
||||
FOR INSERT WITH CHECK (
|
||||
NOT app.is_ai_context() AND app.current_role_name() = 'admin'
|
||||
AND operation_kind = 'human_approval'
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS p_ci_catalog_entry_select ON app.ci_catalog_entry;
|
||||
DROP POLICY IF EXISTS p_ci_catalog_entry_insert ON app.ci_catalog_entry;
|
||||
CREATE POLICY p_ci_catalog_entry_select ON app.ci_catalog_entry FOR SELECT USING (
|
||||
(app.is_ai_context() AND current_setting('app.current_ai_view',true) = 'research')
|
||||
OR (NOT app.is_ai_context() AND app.current_role_name() = 'admin')
|
||||
);
|
||||
CREATE POLICY p_ci_catalog_entry_insert ON app.ci_catalog_entry FOR INSERT WITH CHECK (
|
||||
NOT app.is_ai_context() AND app.current_role_name() = 'admin'
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS p_ci_human_approval_select ON audit.ci_human_approval_event;
|
||||
DROP POLICY IF EXISTS p_ci_human_approval_insert ON audit.ci_human_approval_event;
|
||||
CREATE POLICY p_ci_human_approval_select ON audit.ci_human_approval_event FOR SELECT USING (
|
||||
(app.is_ai_context() AND current_setting('app.current_ai_view',true) = 'research')
|
||||
OR (NOT app.is_ai_context() AND app.current_role_name() = 'admin')
|
||||
);
|
||||
CREATE POLICY p_ci_human_approval_insert ON audit.ci_human_approval_event FOR INSERT WITH CHECK (
|
||||
NOT app.is_ai_context() AND app.current_role_name() = 'admin' AND actor_uid = app.current_uid()
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS p_ci_lifecycle_select ON audit.ci_lifecycle_event;
|
||||
DROP POLICY IF EXISTS p_ci_lifecycle_monitor_insert ON audit.ci_lifecycle_event;
|
||||
DROP POLICY IF EXISTS p_ci_lifecycle_admin_insert ON audit.ci_lifecycle_event;
|
||||
CREATE POLICY p_ci_lifecycle_select ON audit.ci_lifecycle_event FOR SELECT USING (
|
||||
(app.is_ai_context() AND current_setting('app.current_ai_view',true) = 'research')
|
||||
OR (NOT app.is_ai_context() AND app.current_role_name() = 'admin')
|
||||
);
|
||||
CREATE POLICY p_ci_lifecycle_monitor_insert ON audit.ci_lifecycle_event FOR INSERT WITH CHECK (
|
||||
app.is_ai_context() AND current_setting('app.current_ai_view',true) = 'research'
|
||||
AND event_type = 'monitor'
|
||||
);
|
||||
CREATE POLICY p_ci_lifecycle_admin_insert ON audit.ci_lifecycle_event FOR INSERT WITH CHECK (
|
||||
NOT app.is_ai_context() AND app.current_role_name() = 'admin'
|
||||
AND event_type IN ('promotion','rollback')
|
||||
);
|
||||
|
||||
DO $$
|
||||
DECLARE app_role TEXT;
|
||||
BEGIN
|
||||
FOREACH app_role IN ARRAY ARRAY['vignette_app','vignette'] LOOP
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = app_role) THEN
|
||||
EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app, audit TO %I', app_role);
|
||||
EXECUTE format('GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA audit TO %I', app_role);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
189
infra/db/init/15_self_directed_practice_runtime.sql
Normal file
189
infra/db/init/15_self_directed_practice_runtime.sql
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
-- Outcome & Alliance OS G4/G5: cross-session self-directed practice runtime.
|
||||
-- A prescription is authored in one session; its observed attempt evidence belongs
|
||||
-- to a later completed session owned by the same learner.
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_practice_prescription_record_learner
|
||||
ON app.practice_prescription(prescription_record_id, learner_id);
|
||||
|
||||
ALTER TABLE app.practice_episode_submission
|
||||
DROP CONSTRAINT IF EXISTS practice_episode_prescription_fkey;
|
||||
ALTER TABLE app.practice_episode_submission
|
||||
ADD CONSTRAINT practice_episode_prescription_fkey
|
||||
FOREIGN KEY (prescription_record_id, learner_id)
|
||||
REFERENCES app.practice_prescription(prescription_record_id, learner_id)
|
||||
ON DELETE RESTRICT;
|
||||
|
||||
ALTER TABLE app.practice_curriculum_decision_event
|
||||
DROP CONSTRAINT IF EXISTS practice_decision_prescription_fkey;
|
||||
ALTER TABLE app.practice_curriculum_decision_event
|
||||
ADD CONSTRAINT practice_decision_prescription_fkey
|
||||
FOREIGN KEY (selected_prescription_record_id, learner_id)
|
||||
REFERENCES app.practice_prescription(prescription_record_id, learner_id)
|
||||
ON DELETE RESTRICT;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_practice_episode_transfer_gate()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
attempts JSONB;
|
||||
competency_key TEXT;
|
||||
has_familiar_pass BOOLEAN;
|
||||
has_unseen_pass BOOLEAN;
|
||||
has_transfer_collision BOOLEAN;
|
||||
claimed_prior_familiar INT;
|
||||
durable_prior_familiar INT;
|
||||
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;
|
||||
|
||||
competency_key := NEW.assessment_payload->>'competency_id';
|
||||
claimed_prior_familiar := COALESCE(
|
||||
(NEW.assessment_payload->>'prior_familiar_demonstrations')::INT,
|
||||
0
|
||||
);
|
||||
SELECT COALESCE((state->>'familiar_demonstrations')::INT, 0)
|
||||
INTO durable_prior_familiar
|
||||
FROM app.competency_graph_snapshot snapshot,
|
||||
jsonb_array_elements(COALESCE(snapshot.graph_payload->'states', '[]'::JSONB)) state
|
||||
WHERE snapshot.learner_id = NEW.learner_id
|
||||
AND state->>'competency_id' = competency_key
|
||||
ORDER BY snapshot.snapshot_no DESC
|
||||
LIMIT 1;
|
||||
durable_prior_familiar := COALESCE(durable_prior_familiar, 0);
|
||||
IF claimed_prior_familiar > durable_prior_familiar THEN
|
||||
RAISE EXCEPTION 'practice episode prior familiar claim exceeds durable graph'
|
||||
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 AND claimed_prior_familiar < 1)
|
||||
OR NOT has_unseen_pass
|
||||
OR has_transfer_collision
|
||||
) THEN
|
||||
RAISE EXCEPTION 'practice mastery requires durable 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;
|
||||
prescription_record UUID;
|
||||
competency_key TEXT;
|
||||
familiar_pass_exists BOOLEAN;
|
||||
durable_prior_familiar INT;
|
||||
repeated_template_exists BOOLEAN;
|
||||
repeated_variant_exists BOOLEAN;
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended(NEW.episode_submission_id::TEXT, 0));
|
||||
SELECT max(attempt.sequence_no) INTO latest_sequence
|
||||
FROM app.practice_attempt_evidence attempt
|
||||
WHERE attempt.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 episode.prescription_record_id, prescription.competency_id
|
||||
INTO prescription_record, competency_key
|
||||
FROM app.practice_episode_submission episode
|
||||
JOIN app.practice_prescription prescription
|
||||
ON prescription.prescription_record_id = episode.prescription_record_id
|
||||
AND prescription.learner_id = episode.learner_id
|
||||
WHERE episode.episode_submission_id = NEW.episode_submission_id
|
||||
AND episode.learner_id = NEW.learner_id;
|
||||
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM app.practice_attempt_evidence prior
|
||||
JOIN app.practice_episode_submission episode
|
||||
ON episode.episode_submission_id = prior.episode_submission_id
|
||||
WHERE episode.prescription_record_id = prescription_record
|
||||
AND prior.learner_id = NEW.learner_id
|
||||
AND prior.scenario_novelty = 'familiar'
|
||||
AND prior.outcome = 'passed'
|
||||
) INTO familiar_pass_exists;
|
||||
SELECT COALESCE((state->>'familiar_demonstrations')::INT, 0)
|
||||
INTO durable_prior_familiar
|
||||
FROM app.competency_graph_snapshot snapshot,
|
||||
jsonb_array_elements(COALESCE(snapshot.graph_payload->'states', '[]'::JSONB)) state
|
||||
WHERE snapshot.learner_id = NEW.learner_id
|
||||
AND state->>'competency_id' = competency_key
|
||||
ORDER BY snapshot.snapshot_no DESC
|
||||
LIMIT 1;
|
||||
familiar_pass_exists := familiar_pass_exists
|
||||
OR COALESCE(durable_prior_familiar, 0) > 0;
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM app.practice_attempt_evidence prior
|
||||
JOIN app.practice_episode_submission episode
|
||||
ON episode.episode_submission_id = prior.episode_submission_id
|
||||
WHERE episode.prescription_record_id = prescription_record
|
||||
AND prior.learner_id = NEW.learner_id
|
||||
AND prior.scenario_novelty = 'familiar'
|
||||
AND prior.utterance_template_id = NEW.utterance_template_id
|
||||
) INTO repeated_template_exists;
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM app.practice_attempt_evidence prior
|
||||
JOIN app.practice_episode_submission episode
|
||||
ON episode.episode_submission_id = prior.episode_submission_id
|
||||
WHERE episode.prescription_record_id = prescription_record
|
||||
AND prior.learner_id = NEW.learner_id
|
||||
AND prior.scenario_novelty = 'familiar'
|
||||
AND prior.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;
|
||||
$$;
|
||||
264
infra/db/init/16_calibration_transfer_actual_execution.sql
Normal file
264
infra/db/init/16_calibration_transfer_actual_execution.sql
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
-- Outcome & Alliance OS G5: completed-session transfer execution ledger.
|
||||
-- Prerequisites: 06_session_evaluation.sql, 07_measurement_foundation.sql,
|
||||
-- 11_calibration_transfer.sql, 15_self_directed_practice_runtime.sql.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.calibration_transfer_execution_event (
|
||||
execution_event_id UUID PRIMARY KEY,
|
||||
original_transfer_trial_record_id UUID NOT NULL
|
||||
REFERENCES app.calibration_transfer_trial(transfer_trial_record_id) ON DELETE RESTRICT,
|
||||
transfer_suite_record_id UUID NOT NULL,
|
||||
practice_session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT,
|
||||
learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
||||
competency_id TEXT NOT NULL CHECK (competency_id ~ '^competency\.[a-z0-9_.-]+$'),
|
||||
scenario_variant_id TEXT NOT NULL CHECK (length(btrim(scenario_variant_id)) > 0),
|
||||
scenario_novelty TEXT NOT NULL DEFAULT 'unseen_transfer'
|
||||
CHECK (scenario_novelty = 'unseen_transfer'),
|
||||
context_variant TEXT NOT NULL CHECK (length(btrim(context_variant)) > 0),
|
||||
relationship_style TEXT NOT NULL CHECK (
|
||||
relationship_style IN ('collaborative','withdrawn','confrontational','ambivalent')
|
||||
),
|
||||
difficulty_level INT NOT NULL CHECK (difficulty_level BETWEEN 1 AND 5),
|
||||
expression_variant TEXT NOT NULL CHECK (length(btrim(expression_variant)) > 0),
|
||||
synthetic_subgroup TEXT NOT NULL CHECK (synthetic_subgroup ~ '^synthetic-[a-z0-9-]+$'),
|
||||
scenario_family_id TEXT NOT NULL CHECK (length(btrim(scenario_family_id)) > 0),
|
||||
phrase_family_id TEXT NOT NULL CHECK (length(btrim(phrase_family_id)) > 0),
|
||||
training_phrase_collision BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
status TEXT NOT NULL CHECK (status IN ('passed','failed','insufficient_evidence')),
|
||||
uncertainty DOUBLE PRECISION NOT NULL CHECK (uncertainty BETWEEN 0 AND 1),
|
||||
evidence_turn_ids UUID[] NOT NULL DEFAULT '{}',
|
||||
normalized_evaluator_labels JSONB NOT NULL
|
||||
CHECK (jsonb_typeof(normalized_evaluator_labels) = 'object'),
|
||||
counterevidence TEXT[] NOT NULL DEFAULT '{}',
|
||||
source_kind TEXT NOT NULL DEFAULT 'model_inferred' CHECK (source_kind = 'model_inferred'),
|
||||
perspective TEXT NOT NULL DEFAULT 'independent_observer'
|
||||
CHECK (perspective = 'independent_observer'),
|
||||
model_run_id UUID NOT NULL REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT,
|
||||
instrument_id TEXT NOT NULL DEFAULT 'unseen-transfer-g5',
|
||||
instrument_version TEXT NOT NULL DEFAULT '1.0.0',
|
||||
observer_version TEXT NOT NULL CHECK (length(btrim(observer_version)) > 0),
|
||||
data_classification TEXT NOT NULL DEFAULT 'synthetic_educational'
|
||||
CHECK (data_classification = 'synthetic_educational'),
|
||||
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE),
|
||||
created_by_role TEXT NOT NULL DEFAULT 'learner' CHECK (created_by_role = 'learner'),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (original_transfer_trial_record_id, practice_session_id),
|
||||
UNIQUE (practice_session_id),
|
||||
CONSTRAINT calibration_transfer_execution_instrument_fkey
|
||||
FOREIGN KEY (instrument_id, instrument_version)
|
||||
REFERENCES app.measurement_instrument(instrument_id, instrument_version)
|
||||
ON DELETE RESTRICT,
|
||||
CONSTRAINT calibration_transfer_execution_instrument_check CHECK (
|
||||
instrument_id = 'unseen-transfer-g5' AND instrument_version = '1.0.0'
|
||||
),
|
||||
CHECK (
|
||||
(status = 'insufficient_evidence' AND uncertainty = 1 AND cardinality(evidence_turn_ids) = 0)
|
||||
OR (status IN ('passed','failed') AND cardinality(evidence_turn_ids) > 0)
|
||||
),
|
||||
CHECK (status <> 'failed' OR cardinality(counterevidence) > 0)
|
||||
);
|
||||
|
||||
-- This init file is also the forward owner migration for already-created
|
||||
-- development/NAS schemas. Preserve existing rows while adding the G0
|
||||
-- instrument provenance required by the current execution ledger.
|
||||
ALTER TABLE app.calibration_transfer_execution_event
|
||||
ADD COLUMN IF NOT EXISTS instrument_id TEXT DEFAULT 'unseen-transfer-g5';
|
||||
ALTER TABLE app.calibration_transfer_execution_event
|
||||
ADD COLUMN IF NOT EXISTS instrument_version TEXT DEFAULT '1.0.0';
|
||||
UPDATE app.calibration_transfer_execution_event
|
||||
SET instrument_id = 'unseen-transfer-g5'
|
||||
WHERE instrument_id IS NULL;
|
||||
UPDATE app.calibration_transfer_execution_event
|
||||
SET instrument_version = '1.0.0'
|
||||
WHERE instrument_version IS NULL;
|
||||
ALTER TABLE app.calibration_transfer_execution_event
|
||||
ALTER COLUMN instrument_id SET DEFAULT 'unseen-transfer-g5',
|
||||
ALTER COLUMN instrument_id SET NOT NULL,
|
||||
ALTER COLUMN instrument_version SET DEFAULT '1.0.0',
|
||||
ALTER COLUMN instrument_version SET NOT NULL;
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'calibration_transfer_execution_instrument_fkey'
|
||||
AND conrelid = 'app.calibration_transfer_execution_event'::regclass
|
||||
) THEN
|
||||
ALTER TABLE app.calibration_transfer_execution_event
|
||||
ADD CONSTRAINT calibration_transfer_execution_instrument_fkey
|
||||
FOREIGN KEY (instrument_id, instrument_version)
|
||||
REFERENCES app.measurement_instrument(instrument_id, instrument_version)
|
||||
ON DELETE RESTRICT;
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'calibration_transfer_execution_instrument_check'
|
||||
AND conrelid = 'app.calibration_transfer_execution_event'::regclass
|
||||
) THEN
|
||||
ALTER TABLE app.calibration_transfer_execution_event
|
||||
ADD CONSTRAINT calibration_transfer_execution_instrument_check CHECK (
|
||||
instrument_id = 'unseen-transfer-g5' AND instrument_version = '1.0.0'
|
||||
);
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_calibration_transfer_execution_learner_competency
|
||||
ON app.calibration_transfer_execution_event(learner_id, competency_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_calibration_transfer_execution_trial
|
||||
ON app.calibration_transfer_execution_event(original_transfer_trial_record_id);
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit.enforce_calibration_transfer_execution()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
original_row app.calibration_transfer_trial%ROWTYPE;
|
||||
training_phrases TEXT[];
|
||||
practice_learner UUID;
|
||||
practice_started TIMESTAMPTZ;
|
||||
practice_ended TIMESTAMPTZ;
|
||||
original_created TIMESTAMPTZ;
|
||||
evaluation_status TEXT;
|
||||
evaluation_scope TEXT;
|
||||
run_session UUID;
|
||||
run_role TEXT;
|
||||
run_status TEXT;
|
||||
BEGIN
|
||||
SELECT trial.*
|
||||
INTO original_row
|
||||
FROM app.calibration_transfer_trial trial
|
||||
WHERE trial.transfer_trial_record_id = NEW.original_transfer_trial_record_id;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'original transfer trial does not exist';
|
||||
END IF;
|
||||
SELECT suite.training_phrase_family_ids
|
||||
INTO training_phrases
|
||||
FROM app.calibration_transfer_suite suite
|
||||
WHERE suite.transfer_suite_record_id = original_row.transfer_suite_record_id;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'original transfer suite does not exist';
|
||||
END IF;
|
||||
original_created := original_row.created_at;
|
||||
|
||||
IF NEW.transfer_suite_record_id IS DISTINCT FROM original_row.transfer_suite_record_id
|
||||
OR NEW.learner_id IS DISTINCT FROM original_row.learner_id
|
||||
OR NEW.competency_id IS DISTINCT FROM original_row.competency_id
|
||||
OR NEW.scenario_variant_id IS DISTINCT FROM original_row.scenario_variant_id
|
||||
OR NEW.scenario_novelty IS DISTINCT FROM original_row.scenario_novelty
|
||||
OR NEW.context_variant IS DISTINCT FROM original_row.context_variant
|
||||
OR NEW.relationship_style IS DISTINCT FROM original_row.relationship_style
|
||||
OR NEW.difficulty_level IS DISTINCT FROM original_row.difficulty_level
|
||||
OR NEW.expression_variant IS DISTINCT FROM original_row.expression_variant
|
||||
OR NEW.synthetic_subgroup IS DISTINCT FROM original_row.synthetic_subgroup
|
||||
OR NEW.scenario_family_id IS DISTINCT FROM original_row.scenario_family_id
|
||||
OR NEW.phrase_family_id IS DISTINCT FROM original_row.phrase_family_id
|
||||
OR NEW.training_phrase_collision IS DISTINCT FROM
|
||||
(original_row.phrase_family_id = ANY(training_phrases)) THEN
|
||||
RAISE EXCEPTION 'actual transfer context must be copied from server ledger';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM app.calibration_prediction_history history
|
||||
JOIN app.calibration_prediction_lock prediction_lock
|
||||
ON prediction_lock.history_id = history.history_id
|
||||
WHERE history.learner_id = NEW.learner_id
|
||||
AND history.competency_id = NEW.competency_id
|
||||
AND prediction_lock.created_at <= original_created
|
||||
) THEN
|
||||
RAISE EXCEPTION 'self-prediction lock must precede transfer reveal';
|
||||
END IF;
|
||||
|
||||
SELECT session.learner_id, session.started_at, session.ended_at,
|
||||
evaluation.status, evaluation.scope
|
||||
INTO practice_learner, practice_started, practice_ended,
|
||||
evaluation_status, evaluation_scope
|
||||
FROM app.sessions session
|
||||
LEFT JOIN app.session_evaluation evaluation ON evaluation.session_id = session.id
|
||||
WHERE session.id = NEW.practice_session_id;
|
||||
IF NOT FOUND OR practice_learner IS DISTINCT FROM NEW.learner_id THEN
|
||||
RAISE EXCEPTION 'practice session must belong to learner';
|
||||
END IF;
|
||||
IF practice_ended IS NULL OR evaluation_status IS DISTINCT FROM 'ready'
|
||||
OR evaluation_scope IS DISTINCT FROM 'session_end' THEN
|
||||
RAISE EXCEPTION 'practice session must be ended with ready session_end evaluation';
|
||||
END IF;
|
||||
IF practice_started <= original_created THEN
|
||||
RAISE EXCEPTION 'practice session must start after original transfer trial';
|
||||
END IF;
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM unnest(NEW.evidence_turn_ids) evidence_turn_id
|
||||
LEFT JOIN app.turns turn_row ON turn_row.id = evidence_turn_id
|
||||
WHERE turn_row.id IS NULL OR turn_row.session_id <> NEW.practice_session_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'actual transfer evidence turns must belong to practice session';
|
||||
END IF;
|
||||
IF NOT (
|
||||
NEW.normalized_evaluator_labels
|
||||
?& ARRAY[
|
||||
'technique_codes','client_state_codes','appropriateness',
|
||||
'intent_deviation_dimensions','evaluator_error_count'
|
||||
]
|
||||
) OR EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_object_keys(NEW.normalized_evaluator_labels) AS keys(label_key)
|
||||
WHERE label_key <> ALL(ARRAY[
|
||||
'technique_codes','client_state_codes','appropriateness',
|
||||
'intent_deviation_dimensions','evaluator_error_count'
|
||||
])
|
||||
) THEN
|
||||
RAISE EXCEPTION 'actual transfer event accepts normalized evaluator labels only';
|
||||
END IF;
|
||||
|
||||
SELECT session_id, agent_role, status
|
||||
INTO run_session, run_role, run_status
|
||||
FROM audit.model_run
|
||||
WHERE model_run_id = NEW.model_run_id;
|
||||
IF NOT FOUND OR run_session IS DISTINCT FROM NEW.practice_session_id
|
||||
OR run_role IS DISTINCT FROM 'evaluator' OR run_status IS DISTINCT FROM 'ready' THEN
|
||||
RAISE EXCEPTION 'actual transfer requires ready evaluator model provenance';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_calibration_transfer_execution_contract
|
||||
ON app.calibration_transfer_execution_event;
|
||||
CREATE TRIGGER trg_calibration_transfer_execution_contract
|
||||
BEFORE INSERT ON app.calibration_transfer_execution_event
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_transfer_execution();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_calibration_transfer_execution_append_only
|
||||
ON app.calibration_transfer_execution_event;
|
||||
CREATE TRIGGER trg_calibration_transfer_execution_append_only
|
||||
BEFORE UPDATE OR DELETE ON app.calibration_transfer_execution_event
|
||||
FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation();
|
||||
|
||||
ALTER TABLE app.calibration_transfer_execution_event ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS p_calibration_transfer_execution_event_select
|
||||
ON app.calibration_transfer_execution_event;
|
||||
CREATE POLICY p_calibration_transfer_execution_event_select
|
||||
ON app.calibration_transfer_execution_event FOR SELECT USING (
|
||||
(app.is_ai_context() AND current_setting('app.current_ai_view', true) = 'evaluator')
|
||||
OR (NOT app.is_ai_context() AND (
|
||||
app.current_role_name() = 'admin'
|
||||
OR (app.current_role_name() = 'learner' AND learner_id = app.current_uid())
|
||||
OR (app.current_role_name() = 'instructor' AND EXISTS (
|
||||
SELECT 1 FROM app.app_user learner
|
||||
WHERE learner.user_id = learner_id
|
||||
AND learner.cohort = current_setting('app.current_cohort', true)
|
||||
))
|
||||
))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS p_calibration_transfer_execution_event_insert
|
||||
ON app.calibration_transfer_execution_event;
|
||||
CREATE POLICY p_calibration_transfer_execution_event_insert
|
||||
ON app.calibration_transfer_execution_event FOR INSERT WITH CHECK (
|
||||
app.is_ai_context()
|
||||
AND current_setting('app.current_ai_view', true) = 'evaluator'
|
||||
AND learner_id = app.current_uid()
|
||||
AND created_by_role = 'learner'
|
||||
);
|
||||
|
|
@ -14,10 +14,16 @@ services:
|
|||
- pgdata:/var/lib/postgresql/data
|
||||
- ./db/init:/docker-entrypoint-initdb.d:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-vignette_owner}"]
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- >-
|
||||
PGPASSWORD=$${APP_DB_PASSWORD} psql -U $${APP_DB_USER} -d $${POSTGRES_DB}
|
||||
-tAc "SELECT to_regclass('app.ci_regression_dag_node') IS NOT NULL"
|
||||
| grep -qx t
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 5m
|
||||
networks: [vignette]
|
||||
|
||||
api:
|
||||
|
|
@ -34,10 +40,39 @@ services:
|
|||
DATABASE_URL: postgresql://${APP_DB_USER:-vignette_app}:${APP_DB_PASSWORD}@db:5432/${POSTGRES_DB:-vignette}
|
||||
ENGINE_URL: ${ENGINE_URL:-http://host.docker.internal:9099}
|
||||
ENGINE_MODE: ${ENGINE_MODE:-claude_cli}
|
||||
ENGINE_GATEWAY_SHARED_SECRET: ${ENGINE_GATEWAY_SHARED_SECRET:-}
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:?set in .env}
|
||||
OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1}
|
||||
VIGNETTE_VOICE_STT_PROVIDER: ${VIGNETTE_VOICE_STT_PROVIDER:-openai}
|
||||
DEEPGRAM_API_KEY: ${DEEPGRAM_API_KEY:-}
|
||||
DEEPGRAM_STT_URL: ${DEEPGRAM_STT_URL:-wss://api.deepgram.com/v1/listen}
|
||||
DEEPGRAM_STT_MODEL: ${DEEPGRAM_STT_MODEL:-nova-3}
|
||||
DEEPGRAM_STT_LANGUAGE: ${DEEPGRAM_STT_LANGUAGE:-ko}
|
||||
DEEPGRAM_ENDPOINTING_MS: ${DEEPGRAM_ENDPOINTING_MS:-300}
|
||||
DEEPGRAM_UTTERANCE_END_MS: ${DEEPGRAM_UTTERANCE_END_MS:-1200}
|
||||
DEEPGRAM_KEEPALIVE_SECONDS: ${DEEPGRAM_KEEPALIVE_SECONDS:-4}
|
||||
DEEPGRAM_FINALIZE_TIMEOUT_SECONDS: ${DEEPGRAM_FINALIZE_TIMEOUT_SECONDS:-15}
|
||||
DEEPGRAM_MIP_OPT_OUT: ${DEEPGRAM_MIP_OPT_OUT:-true}
|
||||
SESSION_SECRET: ${SESSION_SECRET:?set in .env}
|
||||
VIGNETTE_RUPTURE_INTERNAL_TOKEN: ${VIGNETTE_RUPTURE_INTERNAL_TOKEN:?set in .env}
|
||||
VIGNETTE_PRACTICE_INTERNAL_TOKEN: ${VIGNETTE_PRACTICE_INTERNAL_TOKEN:?set in .env}
|
||||
VIGNETTE_CALIBRATION_TRANSFER_INTERNAL_TOKEN: ${VIGNETTE_CALIBRATION_TRANSFER_INTERNAL_TOKEN:?set in .env}
|
||||
VIGNETTE_SUPERVISION_RESEARCH_INTERNAL_TOKEN: ${VIGNETTE_SUPERVISION_RESEARCH_INTERNAL_TOKEN:?set in .env}
|
||||
VIGNETTE_MULTIMODAL_ALLIANCE_INTERNAL_TOKEN: ${VIGNETTE_MULTIMODAL_ALLIANCE_INTERNAL_TOKEN:?set in .env}
|
||||
VIGNETTE_CONTINUOUS_IMPROVEMENT_INTERNAL_TOKEN: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_INTERNAL_TOKEN:?set in .env}
|
||||
VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_ENABLED: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_ENABLED:-false}
|
||||
VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_INTERVAL_SECONDS: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_INTERVAL_SECONDS:-3600}
|
||||
VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_STARTUP_DELAY_SECONDS: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_STARTUP_DELAY_SECONDS:-30}
|
||||
VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_RETRY_DELAY_SECONDS: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_RETRY_DELAY_SECONDS:-300}
|
||||
VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_LEASE_TIMEOUT_SECONDS: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_LEASE_TIMEOUT_SECONDS:-1800}
|
||||
VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_ENGINE_TIMEOUT_SECONDS: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_ENGINE_TIMEOUT_SECONDS:-300}
|
||||
VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_BATCH_SIZE: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_BATCH_SIZE:-1}
|
||||
VIGNETTE_CONTINUOUS_IMPROVEMENT_DRIFT_TRIGGER_ENABLED: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_DRIFT_TRIGGER_ENABLED:-false}
|
||||
VIGNETTE_CONTINUOUS_IMPROVEMENT_ROLLBACK_EXECUTOR_ENABLED: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_ROLLBACK_EXECUTOR_ENABLED:-false}
|
||||
VIGNETTE_CONTINUOUS_IMPROVEMENT_ROLLBACK_EXECUTOR_ENDPOINT: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_ROLLBACK_EXECUTOR_ENDPOINT:-}
|
||||
VIGNETTE_CONTINUOUS_IMPROVEMENT_ROLLBACK_EXECUTOR_TOKEN: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_ROLLBACK_EXECUTOR_TOKEN:-}
|
||||
VIGNETTE_CONTINUOUS_IMPROVEMENT_ROLLBACK_EXECUTOR_TIMEOUT_SECONDS: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_ROLLBACK_EXECUTOR_TIMEOUT_SECONDS:-30}
|
||||
OAUTH_GOOGLE_CLIENT_ID: ${OAUTH_GOOGLE_CLIENT_ID:?set in .env}
|
||||
OAUTH_GOOGLE_CLIENT_SECRET: ${OAUTH_GOOGLE_CLIENT_SECRET:?set in .env}
|
||||
OAUTH_REDIRECT_URI: ${OAUTH_REDIRECT_URI:-https://api-vignette.chanpaca.net/auth/callback}
|
||||
|
|
@ -51,7 +86,7 @@ services:
|
|||
ALLOW_SEED_PERSONA_FALLBACK: ${ALLOW_SEED_PERSONA_FALLBACK:-false}
|
||||
EVALUATOR_GOLDEN_FEWSHOT_ENABLED: ${EVALUATOR_GOLDEN_FEWSHOT_ENABLED:-false}
|
||||
FRONTEND_BASE_URL: ${FRONTEND_BASE_URL:-https://vignette.chanpaca.net}
|
||||
FRONTEND_ORIGIN_MAP: '${FRONTEND_ORIGIN_MAP:-{"api-vignette.chanpaca.net":"https://vignette.chanpaca.net","api-vnet.18ka.net":"https://vnet.18ka.net"}}'
|
||||
FRONTEND_ORIGIN_MAP: '${FRONTEND_ORIGIN_MAP:?set FRONTEND_ORIGIN_MAP in .env}'
|
||||
CORS_ORIGINS: '${CORS_ORIGINS:-["https://vignette.chanpaca.net","https://vnet.18ka.net","https://vignette-b1q.pages.dev"]}'
|
||||
USER_UPLOAD_DIR: ${USER_UPLOAD_DIR:-/app/uploads}
|
||||
NOTIFICATION_EMAIL_PROVIDER: ${NOTIFICATION_EMAIL_PROVIDER:-disabled}
|
||||
|
|
@ -70,7 +105,14 @@ services:
|
|||
volumes:
|
||||
- apiuploads:/app/uploads
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- >-
|
||||
import json, urllib.request;
|
||||
payload=json.load(urllib.request.urlopen('http://localhost:8000/health'));
|
||||
assert payload.get('status') == 'ok' and payload.get('db') is True and payload.get('engine') is True
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue