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:
Yun Chan 2026-08-08 01:30:53 +09:00
parent 93dd8f82d7
commit 16e791e044
390 changed files with 243188 additions and 499 deletions

View file

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