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