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 산출물은 커밋에서 제외했다.
581 lines
32 KiB
PL/PgSQL
581 lines
32 KiB
PL/PgSQL
-- G6 Supervision & Research OS: immutable supervision queue and research evidence ledger.
|
|
-- This schema stores metadata and UUID/hash provenance only. Transcript text and clinical claims
|
|
-- are intentionally outside the contract.
|
|
|
|
CREATE TABLE IF NOT EXISTS app.supervision_evidence_pointer (
|
|
pointer_id UUID PRIMARY KEY,
|
|
learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
|
cohort_id TEXT NOT NULL CHECK (length(btrim(cohort_id)) > 0),
|
|
consumer_view TEXT NOT NULL CHECK (consumer_view IN ('supervisor','research')),
|
|
ledger TEXT NOT NULL CHECK (ledger IN (
|
|
'measurement_event','outcome_trajectory_revision','rupture_observation_event',
|
|
'rupture_reconciliation_revision','safety_event','calibration_assessment',
|
|
'transfer_assessment','practice_attempt'
|
|
)),
|
|
event_id TEXT NOT NULL CHECK (length(btrim(event_id)) > 0),
|
|
session_id UUID,
|
|
route_hint TEXT NOT NULL CHECK (route_hint ~ '^/[a-zA-Z0-9_{}?&=./-]+$'),
|
|
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
UNIQUE (learner_id, consumer_view, ledger, event_id, route_hint)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.supervision_attention_snapshot (
|
|
snapshot_id UUID PRIMARY KEY,
|
|
submission_id UUID NOT NULL UNIQUE,
|
|
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
|
cohort_id TEXT NOT NULL CHECK (length(btrim(cohort_id)) > 0),
|
|
item_count INT NOT NULL CHECK (item_count >= 1),
|
|
source_pointer_ids UUID[] NOT NULL CHECK (cardinality(source_pointer_ids) >= 1),
|
|
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE),
|
|
created_by_role TEXT NOT NULL DEFAULT 'agent' CHECK (created_by_role = 'agent'),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.supervision_attention_item (
|
|
item_id UUID PRIMARY KEY,
|
|
snapshot_id UUID NOT NULL REFERENCES app.supervision_attention_snapshot(snapshot_id) ON DELETE RESTRICT,
|
|
learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
|
learner_ref TEXT NOT NULL CHECK (learner_ref ~ '^learner-[a-z0-9-]+$'),
|
|
cohort_id TEXT NOT NULL CHECK (length(btrim(cohort_id)) > 0),
|
|
queue_position INT NOT NULL CHECK (queue_position >= 1),
|
|
primary_signal TEXT NOT NULL CHECK (primary_signal IN (
|
|
'deterioration','unresolved_rupture','safety_boundary',
|
|
'persistent_overconfidence','growth_stagnation','transfer_failure'
|
|
)),
|
|
oldest_active_sequence INT NOT NULL CHECK (oldest_active_sequence >= 1),
|
|
drilldown_routes TEXT[] NOT NULL CHECK (
|
|
cardinality(drilldown_routes) BETWEEN 1 AND 3
|
|
),
|
|
evidence_pointer_ids UUID[] NOT NULL CHECK (
|
|
cardinality(evidence_pointer_ids) BETWEEN 1 AND 3
|
|
),
|
|
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
UNIQUE (snapshot_id, learner_id),
|
|
UNIQUE (snapshot_id, queue_position)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.supervision_attention_reason (
|
|
reason_id UUID PRIMARY KEY,
|
|
item_id UUID NOT NULL REFERENCES app.supervision_attention_item(item_id) ON DELETE RESTRICT,
|
|
signal_id TEXT NOT NULL CHECK (signal_id ~ '^oas-g6-signal-[a-z0-9-]+$'),
|
|
signal_type TEXT NOT NULL CHECK (signal_type IN (
|
|
'deterioration','unresolved_rupture','safety_boundary',
|
|
'persistent_overconfidence','growth_stagnation','transfer_failure'
|
|
)),
|
|
severity TEXT NOT NULL CHECK (severity IN ('high','moderate','low')),
|
|
uncertainty DOUBLE PRECISION NOT NULL CHECK (uncertainty BETWEEN 0 AND 1),
|
|
evidence_pointer_ids UUID[] NOT NULL CHECK (
|
|
cardinality(evidence_pointer_ids) BETWEEN 1 AND 3
|
|
),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
UNIQUE (item_id, signal_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.supervision_teacher_ai_disagreement (
|
|
disagreement_record_id UUID PRIMARY KEY,
|
|
submission_id UUID NOT NULL UNIQUE,
|
|
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
|
disagreement_id TEXT NOT NULL UNIQUE CHECK (disagreement_id ~ '^oas-g6-disagreement-[a-z0-9-]+$'),
|
|
learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
|
cohort_id TEXT NOT NULL CHECK (length(btrim(cohort_id)) > 0),
|
|
case_ref TEXT NOT NULL CHECK (length(btrim(case_ref)) > 0),
|
|
competency_id TEXT NOT NULL CHECK (competency_id ~ '^competency\.[a-z0-9_.-]+$'),
|
|
ai_label TEXT NOT NULL CHECK (length(btrim(ai_label)) > 0),
|
|
teacher_label TEXT NOT NULL CHECK (length(btrim(teacher_label)) > 0),
|
|
ai_model TEXT NOT NULL CHECK (length(btrim(ai_model)) > 0),
|
|
prompt_version TEXT NOT NULL CHECK (length(btrim(prompt_version)) > 0),
|
|
instrument_id TEXT NOT NULL CHECK (length(btrim(instrument_id)) > 0),
|
|
instrument_version TEXT NOT NULL CHECK (length(btrim(instrument_version)) > 0),
|
|
correction_reason_code TEXT NOT NULL CHECK (length(btrim(correction_reason_code)) > 0),
|
|
ai_evidence_pointer_ids UUID[] NOT NULL CHECK (cardinality(ai_evidence_pointer_ids) >= 1),
|
|
teacher_evidence_pointer_ids UUID[] NOT NULL CHECK (cardinality(teacher_evidence_pointer_ids) >= 1),
|
|
created_by_uid UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
|
raw_transcript_included BOOLEAN NOT NULL DEFAULT FALSE CHECK (raw_transcript_included = FALSE),
|
|
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CHECK (ai_label <> teacher_label)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.supervision_calibration_dataset_row (
|
|
dataset_row_id UUID PRIMARY KEY,
|
|
disagreement_record_id UUID NOT NULL UNIQUE
|
|
REFERENCES app.supervision_teacher_ai_disagreement(disagreement_record_id) ON DELETE RESTRICT,
|
|
learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
|
cohort_id TEXT NOT NULL CHECK (length(btrim(cohort_id)) > 0),
|
|
row_hash TEXT NOT NULL UNIQUE CHECK (row_hash ~ '^[a-f0-9]{64}$'),
|
|
evidence_pointer_ids UUID[] NOT NULL CHECK (cardinality(evidence_pointer_ids) >= 2),
|
|
raw_transcript_included BOOLEAN NOT NULL DEFAULT FALSE CHECK (raw_transcript_included = FALSE),
|
|
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS audit.supervision_teacher_event (
|
|
audit_event_id UUID PRIMARY KEY,
|
|
disagreement_record_id UUID NOT NULL UNIQUE
|
|
REFERENCES app.supervision_teacher_ai_disagreement(disagreement_record_id) ON DELETE RESTRICT,
|
|
actor_uid UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
|
learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
|
cohort_id TEXT NOT NULL CHECK (length(btrim(cohort_id)) > 0),
|
|
action TEXT NOT NULL CHECK (action = 'teacher_ai_disagreement.corrected'),
|
|
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
|
evidence_pointer_ids UUID[] NOT NULL CHECK (cardinality(evidence_pointer_ids) >= 2),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.supervision_curriculum_gap_snapshot (
|
|
gap_snapshot_id UUID PRIMARY KEY,
|
|
submission_id UUID NOT NULL UNIQUE,
|
|
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
|
cohort_id TEXT NOT NULL CHECK (length(btrim(cohort_id)) > 0),
|
|
competency_id TEXT NOT NULL CHECK (competency_id ~ '^competency\.[a-z0-9_.-]+$'),
|
|
gap_kind TEXT NOT NULL CHECK (gap_kind IN (
|
|
'coverage','growth_stagnation','rupture_repair','transfer','calibration'
|
|
)),
|
|
status TEXT NOT NULL CHECK (status IN ('observed','monitoring','insufficient_evidence')),
|
|
uncertainty DOUBLE PRECISION NOT NULL CHECK (uncertainty BETWEEN 0 AND 1),
|
|
affected_learner_count INT NOT NULL CHECK (affected_learner_count >= 0),
|
|
evidence_pointer_ids UUID[] NOT NULL,
|
|
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CHECK (
|
|
(status = 'insufficient_evidence' AND uncertainty = 1 AND cardinality(evidence_pointer_ids) = 0)
|
|
OR (status <> 'insufficient_evidence' AND cardinality(evidence_pointer_ids) >= 1)
|
|
)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.supervision_evaluation_batch (
|
|
batch_record_id UUID PRIMARY KEY,
|
|
submission_id UUID NOT NULL UNIQUE,
|
|
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
|
batch_id TEXT NOT NULL UNIQUE CHECK (batch_id ~ '^oas-g6-batch-[a-z0-9-]+$'),
|
|
cohort_id TEXT NOT NULL CHECK (length(btrim(cohort_id)) > 0),
|
|
model_name TEXT NOT NULL CHECK (length(btrim(model_name)) > 0),
|
|
prompt_version TEXT NOT NULL CHECK (length(btrim(prompt_version)) > 0),
|
|
instrument_id TEXT NOT NULL CHECK (length(btrim(instrument_id)) > 0),
|
|
instrument_version TEXT NOT NULL CHECK (length(btrim(instrument_version)) > 0),
|
|
observation_count INT NOT NULL CHECK (observation_count >= 1),
|
|
data_classification TEXT NOT NULL DEFAULT 'synthetic_educational'
|
|
CHECK (data_classification = 'synthetic_educational'),
|
|
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.supervision_evaluation_observation (
|
|
observation_record_id UUID PRIMARY KEY,
|
|
batch_record_id UUID NOT NULL REFERENCES app.supervision_evaluation_batch(batch_record_id) ON DELETE RESTRICT,
|
|
cohort_id TEXT NOT NULL CHECK (length(btrim(cohort_id)) > 0),
|
|
case_ref TEXT NOT NULL CHECK (length(btrim(case_ref)) > 0),
|
|
competency_id TEXT NOT NULL CHECK (competency_id ~ '^competency\.[a-z0-9_.-]+$'),
|
|
synthetic_subgroup TEXT NOT NULL CHECK (synthetic_subgroup ~ '^synthetic-[a-z0-9-]+$'),
|
|
gold_label TEXT NOT NULL CHECK (length(btrim(gold_label)) > 0),
|
|
predicted_label TEXT NOT NULL CHECK (length(btrim(predicted_label)) > 0),
|
|
evidence_pointer_id UUID NOT NULL REFERENCES app.supervision_evidence_pointer(pointer_id) ON DELETE RESTRICT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
UNIQUE (batch_record_id, case_ref, competency_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.supervision_drift_report (
|
|
drift_report_id UUID PRIMARY KEY,
|
|
submission_id UUID NOT NULL UNIQUE,
|
|
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
|
cohort_id TEXT NOT NULL CHECK (length(btrim(cohort_id)) > 0),
|
|
baseline_batch_record_id UUID NOT NULL REFERENCES app.supervision_evaluation_batch(batch_record_id) ON DELETE RESTRICT,
|
|
candidate_batch_record_id UUID NOT NULL REFERENCES app.supervision_evaluation_batch(batch_record_id) ON DELETE RESTRICT,
|
|
matched_count INT NOT NULL CHECK (matched_count >= 0),
|
|
status TEXT NOT NULL CHECK (status IN ('stable','drift_flagged','insufficient_evidence')),
|
|
baseline_accuracy DOUBLE PRECISION CHECK (baseline_accuracy BETWEEN 0 AND 1),
|
|
candidate_accuracy DOUBLE PRECISION CHECK (candidate_accuracy BETWEEN 0 AND 1),
|
|
accuracy_delta DOUBLE PRECISION CHECK (accuracy_delta BETWEEN -1 AND 1),
|
|
disagreement_case_refs TEXT[] NOT NULL DEFAULT '{}',
|
|
alerts TEXT[] NOT NULL DEFAULT '{}',
|
|
evidence_pointer_ids UUID[] NOT NULL CHECK (cardinality(evidence_pointer_ids) >= 1),
|
|
data_classification TEXT NOT NULL DEFAULT 'synthetic_educational'
|
|
CHECK (data_classification = 'synthetic_educational'),
|
|
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CHECK (baseline_batch_record_id <> candidate_batch_record_id),
|
|
CHECK (
|
|
(status = 'insufficient_evidence' AND baseline_accuracy IS NULL
|
|
AND candidate_accuracy IS NULL AND accuracy_delta IS NULL)
|
|
OR (status <> 'insufficient_evidence' AND baseline_accuracy IS NOT NULL
|
|
AND candidate_accuracy IS NOT NULL AND accuracy_delta IS NOT NULL)
|
|
)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.supervision_drift_subgroup_metric (
|
|
subgroup_metric_id UUID PRIMARY KEY,
|
|
drift_report_id UUID NOT NULL REFERENCES app.supervision_drift_report(drift_report_id) ON DELETE RESTRICT,
|
|
cohort_id TEXT NOT NULL CHECK (length(btrim(cohort_id)) > 0),
|
|
subgroup TEXT NOT NULL CHECK (length(btrim(subgroup)) > 0),
|
|
matched_count INT NOT NULL CHECK (matched_count >= 0),
|
|
baseline_accuracy DOUBLE PRECISION CHECK (baseline_accuracy BETWEEN 0 AND 1),
|
|
candidate_accuracy DOUBLE PRECISION CHECK (candidate_accuracy BETWEEN 0 AND 1),
|
|
accuracy_delta DOUBLE PRECISION CHECK (accuracy_delta BETWEEN -1 AND 1),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
UNIQUE (drift_report_id, subgroup),
|
|
CHECK (
|
|
(baseline_accuracy IS NULL AND candidate_accuracy IS NULL AND accuracy_delta IS NULL)
|
|
OR (baseline_accuracy IS NOT NULL AND candidate_accuracy IS NOT NULL AND accuracy_delta IS NOT NULL)
|
|
)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.supervision_phase3_manifest (
|
|
manifest_id UUID PRIMARY KEY,
|
|
submission_id UUID NOT NULL UNIQUE,
|
|
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
|
cohort_id TEXT NOT NULL CHECK (length(btrim(cohort_id)) > 0),
|
|
schema_version TEXT NOT NULL CHECK (schema_version = 'vignette.phase3-outcome-evidence-manifest.v1'),
|
|
artifact_count INT NOT NULL CHECK (artifact_count = 4),
|
|
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.supervision_phase3_artifact (
|
|
artifact_record_id UUID PRIMARY KEY,
|
|
manifest_id UUID NOT NULL REFERENCES app.supervision_phase3_manifest(manifest_id) ON DELETE RESTRICT,
|
|
cohort_id TEXT NOT NULL CHECK (length(btrim(cohort_id)) > 0),
|
|
domain TEXT NOT NULL CHECK (domain IN ('alliance','rupture','transfer','calibration')),
|
|
artifact_id TEXT NOT NULL CHECK (length(btrim(artifact_id)) > 0),
|
|
schema_version TEXT NOT NULL CHECK (length(btrim(schema_version)) > 0),
|
|
content_sha256 TEXT NOT NULL CHECK (content_sha256 ~ '^[a-f0-9]{64}$'),
|
|
record_count INT NOT NULL CHECK (record_count >= 1),
|
|
provenance_uri TEXT NOT NULL CHECK (provenance_uri ~ '^(repo|db|audit)://[a-zA-Z0-9_./:-]+$'),
|
|
source_pointer_id UUID NOT NULL REFERENCES app.supervision_evidence_pointer(pointer_id) ON DELETE RESTRICT,
|
|
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
UNIQUE (manifest_id, domain)
|
|
);
|
|
|
|
CREATE OR REPLACE FUNCTION audit.enforce_supervision_evidence_pointer()
|
|
RETURNS trigger LANGUAGE plpgsql AS $$
|
|
DECLARE
|
|
found_session UUID;
|
|
found_learner UUID;
|
|
found_cohort TEXT;
|
|
BEGIN
|
|
IF NEW.ledger = 'measurement_event' THEN
|
|
SELECT m.session_id, s.learner_id INTO found_session, found_learner
|
|
FROM app.measurement_event m JOIN app.sessions s ON s.id = m.session_id
|
|
WHERE m.measurement_id = NEW.event_id::UUID;
|
|
ELSIF NEW.ledger = 'outcome_trajectory_revision' THEN
|
|
SELECT anchor_session_id, learner_id INTO found_session, found_learner
|
|
FROM app.outcome_trajectory_revision WHERE revision_id = NEW.event_id::UUID;
|
|
ELSIF NEW.ledger = 'rupture_observation_event' THEN
|
|
SELECT o.session_id, s.learner_id INTO found_session, found_learner
|
|
FROM app.rupture_observation_event o JOIN app.sessions s ON s.id = o.session_id
|
|
WHERE o.observation_id = NEW.event_id::UUID;
|
|
ELSIF NEW.ledger = 'rupture_reconciliation_revision' THEN
|
|
SELECT r.session_id, s.learner_id INTO found_session, found_learner
|
|
FROM app.rupture_reconciliation_revision r JOIN app.sessions s ON s.id = r.session_id
|
|
WHERE r.revision_id = NEW.event_id::UUID;
|
|
ELSIF NEW.ledger = 'safety_event' THEN
|
|
SELECT e.session_id, s.learner_id INTO found_session, found_learner
|
|
FROM app.safety_events e JOIN app.sessions s ON s.id = e.session_id
|
|
WHERE e.id = NEW.event_id::BIGINT;
|
|
ELSIF NEW.ledger = 'calibration_assessment' THEN
|
|
SELECT session_id, learner_id INTO found_session, found_learner
|
|
FROM app.calibration_assessment_snapshot WHERE assessment_snapshot_id = NEW.event_id::UUID;
|
|
ELSIF NEW.ledger = 'transfer_assessment' THEN
|
|
SELECT session_id, learner_id INTO found_session, found_learner
|
|
FROM app.calibration_transfer_assessment WHERE transfer_assessment_id = NEW.event_id::UUID;
|
|
ELSE
|
|
SELECT session_id, learner_id INTO found_session, found_learner
|
|
FROM app.practice_attempt_evidence WHERE attempt_record_id = NEW.event_id::UUID;
|
|
END IF;
|
|
SELECT cohort INTO found_cohort FROM app.app_user WHERE user_id = found_learner;
|
|
IF found_learner IS NULL OR NEW.learner_id IS DISTINCT FROM found_learner
|
|
OR (NEW.session_id IS NOT NULL AND NEW.session_id IS DISTINCT FROM found_session)
|
|
OR NEW.cohort_id IS DISTINCT FROM found_cohort THEN
|
|
RAISE EXCEPTION 'evidence pointer target is missing or outside learner/cohort scope'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
RETURN NEW;
|
|
EXCEPTION WHEN invalid_text_representation THEN
|
|
RAISE EXCEPTION 'evidence pointer event id has invalid ledger type' USING ERRCODE = '23514';
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION audit.enforce_supervision_pointer_array()
|
|
RETURNS trigger LANGUAGE plpgsql AS $$
|
|
DECLARE
|
|
ids UUID[];
|
|
expected_view TEXT;
|
|
expected_cohort TEXT;
|
|
expected_learner UUID;
|
|
matched_count INT;
|
|
BEGIN
|
|
IF TG_TABLE_NAME = 'supervision_attention_snapshot' THEN
|
|
ids := NEW.source_pointer_ids; expected_view := 'supervisor'; expected_cohort := NEW.cohort_id;
|
|
ELSIF TG_TABLE_NAME = 'supervision_attention_item' THEN
|
|
ids := NEW.evidence_pointer_ids; expected_view := 'supervisor';
|
|
expected_cohort := NEW.cohort_id; expected_learner := NEW.learner_id;
|
|
ELSIF TG_TABLE_NAME = 'supervision_attention_reason' THEN
|
|
ids := NEW.evidence_pointer_ids; expected_view := 'supervisor';
|
|
ELSIF TG_TABLE_NAME = 'supervision_teacher_ai_disagreement' THEN
|
|
ids := NEW.ai_evidence_pointer_ids || NEW.teacher_evidence_pointer_ids;
|
|
expected_view := 'research'; expected_cohort := NEW.cohort_id; expected_learner := NEW.learner_id;
|
|
ELSIF TG_TABLE_NAME = 'supervision_calibration_dataset_row' THEN
|
|
ids := NEW.evidence_pointer_ids; expected_view := 'research';
|
|
expected_cohort := NEW.cohort_id; expected_learner := NEW.learner_id;
|
|
ELSIF TG_TABLE_NAME = 'supervision_curriculum_gap_snapshot' THEN
|
|
ids := NEW.evidence_pointer_ids; expected_view := 'supervisor'; expected_cohort := NEW.cohort_id;
|
|
ELSIF TG_TABLE_NAME = 'supervision_drift_report' THEN
|
|
ids := NEW.evidence_pointer_ids; expected_view := 'research'; expected_cohort := NEW.cohort_id;
|
|
ELSIF TG_TABLE_NAME = 'supervision_evaluation_observation' THEN
|
|
ids := ARRAY[NEW.evidence_pointer_id]; expected_view := 'research'; expected_cohort := NEW.cohort_id;
|
|
ELSIF TG_TABLE_NAME = 'supervision_phase3_artifact' THEN
|
|
ids := ARRAY[NEW.source_pointer_id]; expected_view := 'research'; expected_cohort := NEW.cohort_id;
|
|
ELSIF TG_TABLE_SCHEMA = 'audit' AND TG_TABLE_NAME = 'supervision_teacher_event' THEN
|
|
ids := NEW.evidence_pointer_ids; expected_view := 'research';
|
|
expected_cohort := NEW.cohort_id; expected_learner := NEW.learner_id;
|
|
ELSE RETURN NEW;
|
|
END IF;
|
|
IF cardinality(ids) = 0 THEN RETURN NEW; END IF;
|
|
SELECT count(DISTINCT p.pointer_id) INTO matched_count
|
|
FROM app.supervision_evidence_pointer p
|
|
WHERE p.pointer_id = ANY(ids)
|
|
AND p.consumer_view = expected_view
|
|
AND (expected_cohort IS NULL OR p.cohort_id = expected_cohort)
|
|
AND (expected_learner IS NULL OR p.learner_id = expected_learner);
|
|
IF matched_count <> cardinality(ARRAY(SELECT DISTINCT unnest(ids))) THEN
|
|
RAISE EXCEPTION 'aggregate evidence pointer is missing or assigned to another AI view'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION audit.enforce_supervision_attention_child()
|
|
RETURNS trigger LANGUAGE plpgsql AS $$
|
|
DECLARE parent_item app.supervision_attention_item%ROWTYPE;
|
|
BEGIN
|
|
IF TG_TABLE_NAME = 'supervision_attention_item' THEN
|
|
IF NOT EXISTS (SELECT 1 FROM app.supervision_attention_snapshot s
|
|
WHERE s.snapshot_id = NEW.snapshot_id AND s.cohort_id = NEW.cohort_id) THEN
|
|
RAISE EXCEPTION 'attention item cohort does not match snapshot' USING ERRCODE = '23514';
|
|
END IF;
|
|
IF NOT EXISTS (SELECT 1 FROM app.app_user u WHERE u.user_id = NEW.learner_id AND u.cohort = NEW.cohort_id) THEN
|
|
RAISE EXCEPTION 'attention learner is outside snapshot cohort' USING ERRCODE = '23514';
|
|
END IF;
|
|
ELSE
|
|
SELECT * INTO parent_item FROM app.supervision_attention_item WHERE item_id = NEW.item_id;
|
|
IF parent_item.item_id IS NULL OR NOT NEW.evidence_pointer_ids <@ parent_item.evidence_pointer_ids THEN
|
|
RAISE EXCEPTION 'attention reason evidence must be within item drilldown set' USING ERRCODE = '23514';
|
|
END IF;
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION audit.enforce_supervision_teacher_actor()
|
|
RETURNS trigger LANGUAGE plpgsql AS $$
|
|
BEGIN
|
|
IF NOT EXISTS (SELECT 1 FROM app.app_user u WHERE u.user_id = NEW.learner_id AND u.cohort = NEW.cohort_id) THEN
|
|
RAISE EXCEPTION 'teacher correction learner is outside cohort' USING ERRCODE = '23514';
|
|
END IF;
|
|
IF app.current_role_name() = 'instructor' AND NEW.created_by_uid IS DISTINCT FROM app.current_uid() THEN
|
|
RAISE EXCEPTION 'teacher correction actor must match current user' USING ERRCODE = '23514';
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
DO $$
|
|
DECLARE table_name TEXT;
|
|
BEGIN
|
|
FOREACH table_name IN ARRAY ARRAY[
|
|
'supervision_evidence_pointer','supervision_attention_snapshot','supervision_attention_item',
|
|
'supervision_attention_reason','supervision_teacher_ai_disagreement',
|
|
'supervision_calibration_dataset_row','supervision_curriculum_gap_snapshot',
|
|
'supervision_evaluation_batch','supervision_evaluation_observation',
|
|
'supervision_drift_report','supervision_drift_subgroup_metric',
|
|
'supervision_phase3_manifest','supervision_phase3_artifact'
|
|
] LOOP
|
|
EXECUTE format('DROP TRIGGER IF EXISTS trg_%s_append_only ON app.%I', table_name, table_name);
|
|
EXECUTE format('CREATE TRIGGER trg_%s_append_only BEFORE UPDATE OR DELETE ON app.%I FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation()', table_name, table_name);
|
|
EXECUTE format('ALTER TABLE app.%I ENABLE ROW LEVEL SECURITY', table_name);
|
|
END LOOP;
|
|
END;
|
|
$$;
|
|
|
|
DROP TRIGGER IF EXISTS trg_supervision_teacher_event_append_only ON audit.supervision_teacher_event;
|
|
CREATE TRIGGER trg_supervision_teacher_event_append_only BEFORE UPDATE OR DELETE ON audit.supervision_teacher_event
|
|
FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation();
|
|
ALTER TABLE audit.supervision_teacher_event ENABLE ROW LEVEL SECURITY;
|
|
DROP TRIGGER IF EXISTS trg_supervision_teacher_event_pointer_array ON audit.supervision_teacher_event;
|
|
CREATE TRIGGER trg_supervision_teacher_event_pointer_array BEFORE INSERT ON audit.supervision_teacher_event
|
|
FOR EACH ROW EXECUTE FUNCTION audit.enforce_supervision_pointer_array();
|
|
|
|
DROP TRIGGER IF EXISTS trg_supervision_evidence_pointer_contract ON app.supervision_evidence_pointer;
|
|
CREATE TRIGGER trg_supervision_evidence_pointer_contract BEFORE INSERT ON app.supervision_evidence_pointer
|
|
FOR EACH ROW EXECUTE FUNCTION audit.enforce_supervision_evidence_pointer();
|
|
DROP TRIGGER IF EXISTS trg_supervision_attention_item_contract ON app.supervision_attention_item;
|
|
CREATE TRIGGER trg_supervision_attention_item_contract BEFORE INSERT ON app.supervision_attention_item
|
|
FOR EACH ROW EXECUTE FUNCTION audit.enforce_supervision_attention_child();
|
|
DROP TRIGGER IF EXISTS trg_supervision_attention_reason_contract ON app.supervision_attention_reason;
|
|
CREATE TRIGGER trg_supervision_attention_reason_contract BEFORE INSERT ON app.supervision_attention_reason
|
|
FOR EACH ROW EXECUTE FUNCTION audit.enforce_supervision_attention_child();
|
|
DROP TRIGGER IF EXISTS trg_supervision_teacher_ai_actor ON app.supervision_teacher_ai_disagreement;
|
|
CREATE TRIGGER trg_supervision_teacher_ai_actor BEFORE INSERT ON app.supervision_teacher_ai_disagreement
|
|
FOR EACH ROW EXECUTE FUNCTION audit.enforce_supervision_teacher_actor();
|
|
|
|
DO $$
|
|
DECLARE table_name TEXT;
|
|
BEGIN
|
|
FOREACH table_name IN ARRAY ARRAY[
|
|
'supervision_attention_snapshot','supervision_attention_item','supervision_attention_reason',
|
|
'supervision_teacher_ai_disagreement','supervision_calibration_dataset_row',
|
|
'supervision_curriculum_gap_snapshot','supervision_evaluation_observation',
|
|
'supervision_drift_report','supervision_phase3_artifact'
|
|
] LOOP
|
|
EXECUTE format('DROP TRIGGER IF EXISTS trg_%s_pointer_array ON app.%I', table_name, table_name);
|
|
EXECUTE format('CREATE TRIGGER trg_%s_pointer_array BEFORE INSERT ON app.%I FOR EACH ROW EXECUTE FUNCTION audit.enforce_supervision_pointer_array()', table_name, table_name);
|
|
END LOOP;
|
|
END;
|
|
$$;
|
|
|
|
-- Evidence pointer is view-partitioned for AI and learner/cohort scoped for humans.
|
|
DROP POLICY IF EXISTS p_supervision_evidence_pointer_select ON app.supervision_evidence_pointer;
|
|
DROP POLICY IF EXISTS p_supervision_evidence_pointer_insert ON app.supervision_evidence_pointer;
|
|
CREATE POLICY p_supervision_evidence_pointer_select ON app.supervision_evidence_pointer FOR SELECT USING (
|
|
(app.is_ai_context() AND consumer_view = current_setting('app.current_ai_view', true))
|
|
OR (NOT app.is_ai_context() AND (
|
|
app.current_role_name() = 'admin'
|
|
OR (app.current_role_name() = 'learner' AND learner_id = app.current_uid())
|
|
OR (app.current_role_name() = 'instructor' AND cohort_id = current_setting('app.current_cohort', true))
|
|
))
|
|
);
|
|
CREATE POLICY p_supervision_evidence_pointer_insert ON app.supervision_evidence_pointer FOR INSERT WITH CHECK (
|
|
(app.is_ai_context() AND consumer_view = current_setting('app.current_ai_view', true)
|
|
AND consumer_view IN ('supervisor','research'))
|
|
OR (NOT app.is_ai_context() AND app.current_role_name() IN ('instructor','admin')
|
|
AND consumer_view = 'research'
|
|
AND (app.current_role_name() = 'admin' OR cohort_id = current_setting('app.current_cohort', true)))
|
|
);
|
|
|
|
-- Supervisor-only AI relations. Learners can see only their own queue rows, never cohort snapshots/gaps.
|
|
DROP POLICY IF EXISTS p_supervision_attention_snapshot_select ON app.supervision_attention_snapshot;
|
|
DROP POLICY IF EXISTS p_supervision_attention_snapshot_insert ON app.supervision_attention_snapshot;
|
|
CREATE POLICY p_supervision_attention_snapshot_select ON app.supervision_attention_snapshot FOR SELECT USING (
|
|
(app.is_ai_context() AND current_setting('app.current_ai_view', true) = 'supervisor')
|
|
OR (NOT app.is_ai_context() AND (app.current_role_name() = 'admin'
|
|
OR (app.current_role_name() = 'instructor' AND cohort_id = current_setting('app.current_cohort', true))))
|
|
);
|
|
CREATE POLICY p_supervision_attention_snapshot_insert ON app.supervision_attention_snapshot FOR INSERT WITH CHECK (
|
|
app.is_ai_context() AND current_setting('app.current_ai_view', true) = 'supervisor'
|
|
);
|
|
|
|
DROP POLICY IF EXISTS p_supervision_attention_item_select ON app.supervision_attention_item;
|
|
DROP POLICY IF EXISTS p_supervision_attention_item_insert ON app.supervision_attention_item;
|
|
CREATE POLICY p_supervision_attention_item_select ON app.supervision_attention_item FOR SELECT USING (
|
|
(app.is_ai_context() AND current_setting('app.current_ai_view', true) = 'supervisor')
|
|
OR (NOT app.is_ai_context() AND (app.current_role_name() = 'admin'
|
|
OR (app.current_role_name() = 'learner' AND learner_id = app.current_uid())
|
|
OR (app.current_role_name() = 'instructor' AND cohort_id = current_setting('app.current_cohort', true))))
|
|
);
|
|
CREATE POLICY p_supervision_attention_item_insert ON app.supervision_attention_item FOR INSERT WITH CHECK (
|
|
app.is_ai_context() AND current_setting('app.current_ai_view', true) = 'supervisor'
|
|
);
|
|
|
|
DROP POLICY IF EXISTS p_supervision_attention_reason_select ON app.supervision_attention_reason;
|
|
DROP POLICY IF EXISTS p_supervision_attention_reason_insert ON app.supervision_attention_reason;
|
|
CREATE POLICY p_supervision_attention_reason_select ON app.supervision_attention_reason FOR SELECT USING (
|
|
EXISTS (SELECT 1 FROM app.supervision_attention_item i WHERE i.item_id = item_id)
|
|
);
|
|
CREATE POLICY p_supervision_attention_reason_insert ON app.supervision_attention_reason FOR INSERT WITH CHECK (
|
|
app.is_ai_context() AND current_setting('app.current_ai_view', true) = 'supervisor'
|
|
);
|
|
|
|
DROP POLICY IF EXISTS p_supervision_curriculum_gap_select ON app.supervision_curriculum_gap_snapshot;
|
|
DROP POLICY IF EXISTS p_supervision_curriculum_gap_insert ON app.supervision_curriculum_gap_snapshot;
|
|
CREATE POLICY p_supervision_curriculum_gap_select ON app.supervision_curriculum_gap_snapshot FOR SELECT USING (
|
|
(app.is_ai_context() AND current_setting('app.current_ai_view', true) = 'supervisor')
|
|
OR (NOT app.is_ai_context() AND (app.current_role_name() = 'admin'
|
|
OR (app.current_role_name() = 'instructor' AND cohort_id = current_setting('app.current_cohort', true))))
|
|
);
|
|
CREATE POLICY p_supervision_curriculum_gap_insert ON app.supervision_curriculum_gap_snapshot FOR INSERT WITH CHECK (
|
|
app.is_ai_context() AND current_setting('app.current_ai_view', true) = 'supervisor'
|
|
);
|
|
|
|
-- Research-only AI relations, cohort-scoped for humans. Dataset/audit remain metadata-only.
|
|
DO $$
|
|
DECLARE table_name TEXT;
|
|
BEGIN
|
|
FOREACH table_name IN ARRAY ARRAY[
|
|
'supervision_teacher_ai_disagreement','supervision_calibration_dataset_row',
|
|
'supervision_evaluation_batch','supervision_evaluation_observation',
|
|
'supervision_drift_report','supervision_drift_subgroup_metric',
|
|
'supervision_phase3_manifest','supervision_phase3_artifact'
|
|
] LOOP
|
|
EXECUTE format('DROP POLICY IF EXISTS p_%s_select ON app.%I', table_name, table_name);
|
|
EXECUTE format(
|
|
'CREATE POLICY p_%s_select ON app.%I FOR SELECT USING (' ||
|
|
'(app.is_ai_context() AND current_setting(''app.current_ai_view'', true) = ''research'') OR ' ||
|
|
'(NOT app.is_ai_context() AND (app.current_role_name() = ''admin'' OR ' ||
|
|
'(app.current_role_name() = ''instructor'' AND cohort_id = current_setting(''app.current_cohort'', true))' ||
|
|
CASE WHEN table_name IN ('supervision_teacher_ai_disagreement','supervision_calibration_dataset_row')
|
|
THEN ' OR (app.current_role_name() = ''learner'' AND learner_id = app.current_uid())' ELSE '' END || ')))',
|
|
table_name, table_name
|
|
);
|
|
END LOOP;
|
|
END;
|
|
$$;
|
|
|
|
DROP POLICY IF EXISTS p_supervision_teacher_ai_disagreement_insert ON app.supervision_teacher_ai_disagreement;
|
|
CREATE POLICY p_supervision_teacher_ai_disagreement_insert ON app.supervision_teacher_ai_disagreement FOR INSERT WITH CHECK (
|
|
NOT app.is_ai_context() AND (
|
|
app.current_role_name() = 'admin'
|
|
OR (app.current_role_name() = 'instructor' AND created_by_uid = app.current_uid()
|
|
AND cohort_id = current_setting('app.current_cohort', true))
|
|
)
|
|
);
|
|
DROP POLICY IF EXISTS p_supervision_calibration_dataset_row_insert ON app.supervision_calibration_dataset_row;
|
|
CREATE POLICY p_supervision_calibration_dataset_row_insert ON app.supervision_calibration_dataset_row FOR INSERT WITH CHECK (
|
|
NOT app.is_ai_context() AND (app.current_role_name() = 'admin'
|
|
OR (app.current_role_name() = 'instructor' AND cohort_id = current_setting('app.current_cohort', true)))
|
|
);
|
|
|
|
DO $$
|
|
DECLARE table_name TEXT;
|
|
BEGIN
|
|
FOREACH table_name IN ARRAY ARRAY[
|
|
'supervision_evaluation_batch','supervision_evaluation_observation',
|
|
'supervision_drift_report','supervision_drift_subgroup_metric',
|
|
'supervision_phase3_manifest','supervision_phase3_artifact'
|
|
] LOOP
|
|
EXECUTE format('DROP POLICY IF EXISTS p_%s_insert ON app.%I', table_name, table_name);
|
|
EXECUTE format('CREATE POLICY p_%s_insert ON app.%I FOR INSERT WITH CHECK (app.is_ai_context() AND current_setting(''app.current_ai_view'', true) = ''research'')', table_name, table_name);
|
|
END LOOP;
|
|
END;
|
|
$$;
|
|
|
|
DROP POLICY IF EXISTS p_supervision_teacher_event_select ON audit.supervision_teacher_event;
|
|
DROP POLICY IF EXISTS p_supervision_teacher_event_insert ON audit.supervision_teacher_event;
|
|
CREATE POLICY p_supervision_teacher_event_select ON audit.supervision_teacher_event FOR SELECT USING (
|
|
(app.is_ai_context() AND current_setting('app.current_ai_view', true) = 'research')
|
|
OR (NOT app.is_ai_context() AND (app.current_role_name() = 'admin'
|
|
OR (app.current_role_name() = 'learner' AND learner_id = app.current_uid())
|
|
OR (app.current_role_name() = 'instructor' AND cohort_id = current_setting('app.current_cohort', true))))
|
|
);
|
|
CREATE POLICY p_supervision_teacher_event_insert ON audit.supervision_teacher_event FOR INSERT WITH CHECK (
|
|
NOT app.is_ai_context() AND (app.current_role_name() = 'admin'
|
|
OR (app.current_role_name() = 'instructor' AND actor_uid = app.current_uid()
|
|
AND cohort_id = current_setting('app.current_cohort', true)))
|
|
);
|
|
|
|
DO $$
|
|
DECLARE app_role TEXT;
|
|
BEGIN
|
|
FOREACH app_role IN ARRAY ARRAY['vignette_app','vignette'] LOOP
|
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = app_role) THEN
|
|
EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app, audit TO %I', app_role);
|
|
EXECUTE format('GRANT EXECUTE ON FUNCTION audit.enforce_supervision_evidence_pointer() TO %I', app_role);
|
|
EXECUTE format('GRANT EXECUTE ON FUNCTION audit.enforce_supervision_pointer_array() TO %I', app_role);
|
|
EXECUTE format('GRANT EXECUTE ON FUNCTION audit.enforce_supervision_attention_child() TO %I', app_role);
|
|
EXECUTE format('GRANT EXECUTE ON FUNCTION audit.enforce_supervision_teacher_actor() TO %I', app_role);
|
|
END IF;
|
|
END LOOP;
|
|
END;
|
|
$$;
|