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
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;
|
||||
$$;
|
||||
Loading…
Add table
Add a link
Reference in a new issue