vignette/infra/db/init/13_multimodal_alliance.sql
Yun Chan 16e791e044 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 산출물은 커밋에서 제외했다.
2026-08-08 01:30:53 +09:00

629 lines
30 KiB
PL/PgSQL

-- 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;
$$;