vignette/infra/db/init/09_rupture_repair.sql
Yun Chan 16e791e044 G0~G8 성과·동맹 측정 OS 작업 일괄 고정
8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본
폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을
git 이력으로 고정하는 것이 목적이다.

- contracts/routes/services: measurement, outcome_trajectory, rupture_repair,
  deliberate_practice, calibration_transfer, supervision_research,
  multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트
- infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행)
- apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가
- docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG
- scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트

engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
2026-08-08 01:30:53 +09:00

634 lines
26 KiB
PL/PgSQL

-- Outcome & Alliance OS G3: append-only rupture/repair evidence and reconciliation ledger.
-- Prerequisites: 02_schema.sql, 04_audit_eval_rls.sql, 07_measurement_foundation.sql.
CREATE TABLE IF NOT EXISTS app.rupture_episode (
episode_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT,
case_id UUID NOT NULL,
learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
episode_key TEXT NOT NULL CHECK (length(btrim(episode_key)) > 0),
visible_to TEXT[] NOT NULL DEFAULT '{counselor,evaluator,supervisor,research}'
CHECK (
cardinality(visible_to) > 0
AND visible_to <@ ARRAY['counselor','evaluator','supervisor','research']::TEXT[]
),
created_by_uid UUID REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
created_by_role TEXT NOT NULL CHECK (
created_by_role IN ('agent','instructor','admin','migration')
),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (session_id, episode_key),
UNIQUE (episode_id, session_id),
UNIQUE (episode_id, case_id, learner_id)
);
CREATE INDEX IF NOT EXISTS idx_rupture_episode_session_created
ON app.rupture_episode(session_id, created_at, episode_id);
CREATE INDEX IF NOT EXISTS idx_rupture_episode_case_learner
ON app.rupture_episode(case_id, learner_id, created_at, episode_id);
CREATE TABLE IF NOT EXISTS app.rupture_observation_event (
observation_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
episode_id UUID NOT NULL REFERENCES app.rupture_episode(episode_id) ON DELETE RESTRICT,
session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT,
sequence_no INT NOT NULL CHECK (sequence_no >= 1),
idempotency_key UUID NOT NULL,
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
event_kind TEXT NOT NULL CHECK (
event_kind IN (
'rupture.detected','rupture.recognized','rupture.missed',
'repair.attempted','repair.partial','repair.resolved','repair.missed',
'human.corrected'
)
),
from_state TEXT CHECK (
from_state IS NULL OR from_state IN (
'onset','recognized','repair_attempted','missed','partial','resolved'
)
),
to_state TEXT NOT NULL CHECK (
to_state IN ('onset','recognized','repair_attempted','missed','partial','resolved')
),
rupture_type TEXT NOT NULL CHECK (
rupture_type IN (
'withdrawal','confrontation','goal_mismatch','task_mismatch',
'empathic_miss','cultural_miss','boundary_tension',
'premature_advice','over_disclosure'
)
),
source_kind TEXT NOT NULL CHECK (
source_kind IN ('model_inferred','observed_runtime','human_rated')
),
perspective TEXT NOT NULL CHECK (
perspective IN ('independent_observer','runtime_observation','supervisor_human')
),
ai_view TEXT NOT NULL CHECK (ai_view IN ('evaluator','supervisor')),
confidence DOUBLE PRECISION CHECK (confidence BETWEEN 0 AND 1),
uncertainty DOUBLE PRECISION NOT NULL CHECK (uncertainty BETWEEN 0 AND 1),
evidence_turn_ids UUID[] NOT NULL CHECK (cardinality(evidence_turn_ids) > 0),
counterevidence TEXT[] NOT NULL DEFAULT '{}',
model_run_id UUID REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT,
supersedes_observation_id UUID
REFERENCES app.rupture_observation_event(observation_id) ON DELETE RESTRICT,
correction_reason TEXT,
visible_to TEXT[] NOT NULL DEFAULT '{counselor,evaluator,supervisor,research}'
CHECK (
cardinality(visible_to) > 0
AND visible_to <@ ARRAY['counselor','evaluator','supervisor','research']::TEXT[]
),
created_by_uid UUID REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
created_by_role TEXT NOT NULL CHECK (
created_by_role IN ('agent','instructor','admin','migration')
),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (session_id, idempotency_key),
UNIQUE (supersedes_observation_id),
CONSTRAINT rupture_observation_episode_session_fkey
FOREIGN KEY (episode_id, session_id)
REFERENCES app.rupture_episode(episode_id, session_id) ON DELETE RESTRICT,
CHECK (observation_id IS DISTINCT FROM supersedes_observation_id),
CHECK (
(source_kind = 'model_inferred' AND perspective = 'independent_observer' AND model_run_id IS NOT NULL)
OR (source_kind = 'observed_runtime' AND perspective = 'runtime_observation')
OR (source_kind = 'human_rated' AND perspective = 'supervisor_human')
),
CHECK (
(event_kind = 'human.corrected'
AND source_kind = 'human_rated'
AND ai_view = 'supervisor'
AND supersedes_observation_id IS NOT NULL
AND correction_reason IS NOT NULL
AND length(btrim(correction_reason)) > 0)
OR
(event_kind <> 'human.corrected'
AND ai_view = 'evaluator'
AND supersedes_observation_id IS NULL
AND correction_reason IS NULL)
)
);
-- Development databases may have received an earlier G3 draft. Temporarily
-- remove its append guard so sequence numbers can be backfilled idempotently.
DROP TRIGGER IF EXISTS trg_rupture_observation_append_only ON app.rupture_observation_event;
ALTER TABLE app.rupture_observation_event
ADD COLUMN IF NOT EXISTS sequence_no INT;
WITH ranked AS (
SELECT observation_id,
row_number() OVER (
PARTITION BY episode_id ORDER BY created_at, observation_id
) AS sequence_no
FROM app.rupture_observation_event
WHERE sequence_no IS NULL
)
UPDATE app.rupture_observation_event target
SET sequence_no = ranked.sequence_no
FROM ranked
WHERE target.observation_id = ranked.observation_id;
ALTER TABLE app.rupture_observation_event
ALTER COLUMN sequence_no SET NOT NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_rupture_observation_sequence'
AND conrelid = 'app.rupture_observation_event'::regclass
) THEN
ALTER TABLE app.rupture_observation_event
ADD CONSTRAINT ck_rupture_observation_sequence
CHECK (sequence_no >= 1) NOT VALID;
ALTER TABLE app.rupture_observation_event
VALIDATE CONSTRAINT ck_rupture_observation_sequence;
END IF;
END;
$$;
CREATE UNIQUE INDEX IF NOT EXISTS uq_rupture_observation_episode_sequence
ON app.rupture_observation_event(episode_id, sequence_no);
CREATE INDEX IF NOT EXISTS idx_rupture_observation_episode_created
ON app.rupture_observation_event(episode_id, sequence_no);
CREATE INDEX IF NOT EXISTS idx_rupture_observation_model_run
ON app.rupture_observation_event(model_run_id)
WHERE model_run_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS app.rupture_reconciliation_revision (
revision_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
episode_id UUID NOT NULL REFERENCES app.rupture_episode(episode_id) ON DELETE RESTRICT,
session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT,
revision_no INT NOT NULL CHECK (revision_no >= 1),
idempotency_key UUID NOT NULL,
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
supersedes_revision_id UUID
REFERENCES app.rupture_reconciliation_revision(revision_id) ON DELETE RESTRICT,
fast_warning_observation_id UUID NOT NULL
REFERENCES app.rupture_observation_event(observation_id) ON DELETE RESTRICT,
deep_observation_id UUID
REFERENCES app.rupture_observation_event(observation_id) ON DELETE RESTRICT,
fast_warning_id TEXT NOT NULL CHECK (length(btrim(fast_warning_id)) > 0),
provisional_status TEXT NOT NULL CHECK (provisional_status IN ('missed','partial')),
deep_status TEXT NOT NULL CHECK (
deep_status IN ('missed','partial','resolved','not_applicable','insufficient_evidence')
),
disposition TEXT NOT NULL CHECK (
disposition IN ('confirmed','superseded_resolved','superseded_partial','dismissed')
),
uncertainty DOUBLE PRECISION NOT NULL CHECK (uncertainty BETWEEN 0 AND 1),
evidence_turn_ids UUID[] NOT NULL DEFAULT '{}',
counterevidence TEXT[] NOT NULL DEFAULT '{}',
model_run_id UUID NOT NULL REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT,
visible_to TEXT[] NOT NULL DEFAULT '{counselor,evaluator,supervisor,research}'
CHECK (
cardinality(visible_to) > 0
AND visible_to <@ ARRAY['counselor','evaluator','supervisor','research']::TEXT[]
),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (episode_id, revision_no),
UNIQUE (session_id, idempotency_key),
UNIQUE (supersedes_revision_id),
CONSTRAINT rupture_reconciliation_episode_session_fkey
FOREIGN KEY (episode_id, session_id)
REFERENCES app.rupture_episode(episode_id, session_id) ON DELETE RESTRICT,
CHECK (revision_id IS DISTINCT FROM supersedes_revision_id),
CHECK (
(disposition = 'confirmed' AND deep_status = provisional_status)
OR (disposition = 'superseded_resolved' AND deep_status = 'resolved')
OR (disposition = 'superseded_partial' AND deep_status = 'partial')
OR (disposition = 'dismissed' AND deep_status = 'not_applicable')
)
);
CREATE INDEX IF NOT EXISTS idx_rupture_reconciliation_episode_revision
ON app.rupture_reconciliation_revision(episode_id, revision_no DESC);
CREATE INDEX IF NOT EXISTS idx_rupture_reconciliation_model_run
ON app.rupture_reconciliation_revision(model_run_id);
-- Safety remains a reference-only side ledger. It deliberately has no status,
-- confidence, weight, or aggregate columns that could affect rupture resolution.
CREATE TABLE IF NOT EXISTS app.rupture_safety_reference (
reference_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
episode_id UUID NOT NULL REFERENCES app.rupture_episode(episode_id) ON DELETE RESTRICT,
session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT,
safety_event_id BIGINT NOT NULL REFERENCES app.safety_events(id) ON DELETE RESTRICT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (episode_id, safety_event_id),
CONSTRAINT rupture_safety_episode_session_fkey
FOREIGN KEY (episode_id, session_id)
REFERENCES app.rupture_episode(episode_id, session_id) ON DELETE RESTRICT
);
CREATE INDEX IF NOT EXISTS idx_rupture_safety_session
ON app.rupture_safety_reference(session_id, episode_id);
CREATE OR REPLACE FUNCTION audit.enforce_rupture_episode_anchor()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
anchor_case UUID;
anchor_learner UUID;
BEGIN
SELECT s.case_id, s.learner_id INTO anchor_case, anchor_learner
FROM app.sessions s
WHERE s.id = NEW.session_id;
IF anchor_case IS NULL OR anchor_learner IS NULL
OR anchor_case IS DISTINCT FROM NEW.case_id
OR anchor_learner IS DISTINCT FROM NEW.learner_id THEN
RAISE EXCEPTION 'rupture episode must match its session case and learner'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION audit.rupture_state_rank(state_name TEXT)
RETURNS INT
LANGUAGE sql
IMMUTABLE
AS $$
SELECT CASE state_name
WHEN 'onset' THEN 1
WHEN 'recognized' THEN 2
WHEN 'repair_attempted' THEN 3
WHEN 'missed' THEN 4
WHEN 'partial' THEN 5
WHEN 'resolved' THEN 6
ELSE 0
END
$$;
CREATE OR REPLACE FUNCTION audit.enforce_rupture_observation()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
existing_hash TEXT;
existing_sequence INT;
anchor_session UUID;
latest_state TEXT;
latest_sequence INT;
target_episode UUID;
target_state TEXT;
model_session UUID;
transition_ok BOOLEAN := FALSE;
BEGIN
PERFORM pg_advisory_xact_lock(hashtextextended(NEW.episode_id::text, 0));
SELECT e.content_hash, e.sequence_no INTO existing_hash, existing_sequence
FROM app.rupture_observation_event e
WHERE e.session_id = NEW.session_id
AND e.idempotency_key = NEW.idempotency_key;
IF existing_hash IS NOT NULL THEN
IF existing_hash IS DISTINCT FROM NEW.content_hash THEN
RAISE EXCEPTION 'rupture observation idempotency key reused with different content'
USING ERRCODE = '23505';
END IF;
NEW.sequence_no := existing_sequence;
RETURN NEW;
END IF;
SELECT e.session_id INTO anchor_session
FROM app.rupture_episode e
WHERE e.episode_id = NEW.episode_id;
IF anchor_session IS NULL OR anchor_session IS DISTINCT FROM NEW.session_id THEN
RAISE EXCEPTION 'rupture observation must match its episode session'
USING ERRCODE = '23514';
END IF;
IF (
SELECT count(DISTINCT t.id)
FROM app.turns t
WHERE t.session_id = NEW.session_id
AND t.id = ANY(NEW.evidence_turn_ids)
) <> cardinality(NEW.evidence_turn_ids) THEN
RAISE EXCEPTION 'rupture evidence turns must belong to its session'
USING ERRCODE = '23514';
END IF;
IF NEW.model_run_id IS NOT NULL THEN
SELECT mr.session_id INTO model_session
FROM audit.model_run mr
WHERE mr.model_run_id = NEW.model_run_id;
IF model_session IS NULL OR model_session IS DISTINCT FROM NEW.session_id THEN
RAISE EXCEPTION 'rupture model_run must belong to its session'
USING ERRCODE = '23514';
END IF;
END IF;
SELECT e.to_state, e.sequence_no INTO latest_state, latest_sequence
FROM app.rupture_observation_event e
WHERE e.episode_id = NEW.episode_id
ORDER BY e.sequence_no DESC
LIMIT 1;
NEW.sequence_no := COALESCE(latest_sequence, 0) + 1;
IF NEW.event_kind = 'human.corrected' THEN
SELECT e.episode_id, e.to_state INTO target_episode, target_state
FROM app.rupture_observation_event e
WHERE e.observation_id = NEW.supersedes_observation_id;
IF target_episode IS NULL OR target_episode IS DISTINCT FROM NEW.episode_id THEN
RAISE EXCEPTION 'human correction must supersede an observation in the same episode'
USING ERRCODE = '23514';
END IF;
IF NEW.from_state IS DISTINCT FROM target_state THEN
RAISE EXCEPTION 'human correction from_state must match the superseded observation'
USING ERRCODE = '23514';
END IF;
IF audit.rupture_state_rank(NEW.to_state) < audit.rupture_state_rank(target_state)
OR audit.rupture_state_rank(NEW.to_state) < audit.rupture_state_rank(latest_state)
OR (target_state = 'resolved' AND NEW.to_state <> 'resolved') THEN
RAISE EXCEPTION 'human correction cannot reverse rupture resolution'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END IF;
IF latest_state IS NULL THEN
transition_ok := NEW.event_kind = 'rupture.detected'
AND NEW.from_state IS NULL AND NEW.to_state = 'onset';
ELSE
transition_ok := NEW.from_state = latest_state AND (
(latest_state = 'onset' AND NEW.event_kind = 'rupture.recognized' AND NEW.to_state = 'recognized')
OR (latest_state IN ('onset','recognized') AND NEW.event_kind = 'rupture.missed' AND NEW.to_state = 'missed')
OR (latest_state IN ('recognized','partial') AND NEW.event_kind = 'repair.attempted' AND NEW.to_state = 'repair_attempted')
OR (latest_state = 'repair_attempted' AND NEW.event_kind = 'repair.missed' AND NEW.to_state = 'missed')
OR (latest_state = 'repair_attempted' AND NEW.event_kind = 'repair.partial' AND NEW.to_state = 'partial')
OR (latest_state = 'repair_attempted' AND NEW.event_kind = 'repair.resolved' AND NEW.to_state = 'resolved')
);
END IF;
IF NOT transition_ok THEN
RAISE EXCEPTION 'invalid or backward rupture lifecycle transition: % -> % (%)',
latest_state, NEW.to_state, NEW.event_kind
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION audit.reconciliation_status_rank(status_name TEXT)
RETURNS INT
LANGUAGE sql
IMMUTABLE
AS $$
SELECT CASE status_name
WHEN 'insufficient_evidence' THEN 0
WHEN 'not_applicable' THEN 1
WHEN 'missed' THEN 2
WHEN 'partial' THEN 3
WHEN 'resolved' THEN 4
ELSE -1
END
$$;
CREATE OR REPLACE FUNCTION audit.enforce_rupture_reconciliation()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
existing_hash TEXT;
anchor_session UUID;
fast_episode UUID;
deep_episode UUID;
model_session UUID;
prior_episode UUID;
prior_revision_no INT;
prior_deep_status TEXT;
latest_revision_no INT;
BEGIN
PERFORM pg_advisory_xact_lock(hashtextextended(NEW.episode_id::text, 1));
SELECT r.content_hash INTO existing_hash
FROM app.rupture_reconciliation_revision r
WHERE r.session_id = NEW.session_id
AND r.idempotency_key = NEW.idempotency_key;
IF existing_hash IS NOT NULL THEN
IF existing_hash IS DISTINCT FROM NEW.content_hash THEN
RAISE EXCEPTION 'rupture reconciliation idempotency key reused with different content'
USING ERRCODE = '23505';
END IF;
RETURN NEW;
END IF;
SELECT e.session_id INTO anchor_session
FROM app.rupture_episode e
WHERE e.episode_id = NEW.episode_id;
IF anchor_session IS NULL OR anchor_session IS DISTINCT FROM NEW.session_id THEN
RAISE EXCEPTION 'reconciliation must match its episode session'
USING ERRCODE = '23514';
END IF;
SELECT o.episode_id INTO fast_episode
FROM app.rupture_observation_event o
WHERE o.observation_id = NEW.fast_warning_observation_id;
IF fast_episode IS NULL OR fast_episode IS DISTINCT FROM NEW.episode_id THEN
RAISE EXCEPTION 'fast warning observation must belong to the reconciled episode'
USING ERRCODE = '23514';
END IF;
IF NEW.deep_observation_id IS NOT NULL THEN
SELECT o.episode_id INTO deep_episode
FROM app.rupture_observation_event o
WHERE o.observation_id = NEW.deep_observation_id;
IF deep_episode IS NULL OR deep_episode IS DISTINCT FROM NEW.episode_id THEN
RAISE EXCEPTION 'deep observation must belong to the reconciled episode'
USING ERRCODE = '23514';
END IF;
END IF;
IF cardinality(NEW.evidence_turn_ids) > 0 AND (
SELECT count(DISTINCT t.id)
FROM app.turns t
WHERE t.session_id = NEW.session_id
AND t.id = ANY(NEW.evidence_turn_ids)
) <> cardinality(NEW.evidence_turn_ids) THEN
RAISE EXCEPTION 'reconciliation evidence turns must belong to its session'
USING ERRCODE = '23514';
END IF;
SELECT mr.session_id INTO model_session
FROM audit.model_run mr
WHERE mr.model_run_id = NEW.model_run_id;
IF model_session IS NULL OR model_session IS DISTINCT FROM NEW.session_id THEN
RAISE EXCEPTION 'reconciliation model_run must belong to its session'
USING ERRCODE = '23514';
END IF;
SELECT max(r.revision_no) INTO latest_revision_no
FROM app.rupture_reconciliation_revision r
WHERE r.episode_id = NEW.episode_id;
IF NEW.supersedes_revision_id IS NULL THEN
IF latest_revision_no IS NOT NULL OR NEW.revision_no <> 1 THEN
RAISE EXCEPTION 'first reconciliation revision must be revision 1 without supersedes'
USING ERRCODE = '23514';
END IF;
ELSE
SELECT r.episode_id, r.revision_no, r.deep_status
INTO prior_episode, prior_revision_no, prior_deep_status
FROM app.rupture_reconciliation_revision r
WHERE r.revision_id = NEW.supersedes_revision_id;
IF prior_episode IS NULL OR prior_episode IS DISTINCT FROM NEW.episode_id
OR prior_revision_no IS DISTINCT FROM latest_revision_no
OR NEW.revision_no <> prior_revision_no + 1 THEN
RAISE EXCEPTION 'reconciliation must supersede the latest revision in order'
USING ERRCODE = '23514';
END IF;
IF audit.reconciliation_status_rank(NEW.deep_status)
< audit.reconciliation_status_rank(prior_deep_status)
OR (prior_deep_status = 'resolved' AND NEW.deep_status <> 'resolved') THEN
RAISE EXCEPTION 'reconciliation revision cannot reverse a resolved outcome'
USING ERRCODE = '23514';
END IF;
END IF;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION audit.enforce_rupture_safety_reference()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
episode_session UUID;
safety_session UUID;
BEGIN
SELECT e.session_id INTO episode_session
FROM app.rupture_episode e WHERE e.episode_id = NEW.episode_id;
SELECT s.session_id INTO safety_session
FROM app.safety_events s WHERE s.id = NEW.safety_event_id;
IF episode_session IS NULL OR safety_session IS NULL
OR NEW.session_id IS DISTINCT FROM episode_session
OR NEW.session_id IS DISTINCT FROM safety_session THEN
RAISE EXCEPTION 'rupture safety reference must stay inside one session'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_rupture_episode_anchor ON app.rupture_episode;
CREATE TRIGGER trg_rupture_episode_anchor
BEFORE INSERT ON app.rupture_episode
FOR EACH ROW EXECUTE FUNCTION audit.enforce_rupture_episode_anchor();
DROP TRIGGER IF EXISTS trg_rupture_episode_append_only ON app.rupture_episode;
CREATE TRIGGER trg_rupture_episode_append_only
BEFORE UPDATE OR DELETE ON app.rupture_episode
FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation();
DROP TRIGGER IF EXISTS trg_rupture_observation_contract ON app.rupture_observation_event;
CREATE TRIGGER trg_rupture_observation_contract
BEFORE INSERT ON app.rupture_observation_event
FOR EACH ROW EXECUTE FUNCTION audit.enforce_rupture_observation();
DROP TRIGGER IF EXISTS trg_rupture_observation_append_only ON app.rupture_observation_event;
CREATE TRIGGER trg_rupture_observation_append_only
BEFORE UPDATE OR DELETE ON app.rupture_observation_event
FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation();
DROP TRIGGER IF EXISTS trg_rupture_reconciliation_contract ON app.rupture_reconciliation_revision;
CREATE TRIGGER trg_rupture_reconciliation_contract
BEFORE INSERT ON app.rupture_reconciliation_revision
FOR EACH ROW EXECUTE FUNCTION audit.enforce_rupture_reconciliation();
DROP TRIGGER IF EXISTS trg_rupture_reconciliation_append_only ON app.rupture_reconciliation_revision;
CREATE TRIGGER trg_rupture_reconciliation_append_only
BEFORE UPDATE OR DELETE ON app.rupture_reconciliation_revision
FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation();
DROP TRIGGER IF EXISTS trg_rupture_safety_contract ON app.rupture_safety_reference;
CREATE TRIGGER trg_rupture_safety_contract
BEFORE INSERT ON app.rupture_safety_reference
FOR EACH ROW EXECUTE FUNCTION audit.enforce_rupture_safety_reference();
DROP TRIGGER IF EXISTS trg_rupture_safety_append_only ON app.rupture_safety_reference;
CREATE TRIGGER trg_rupture_safety_append_only
BEFORE UPDATE OR DELETE ON app.rupture_safety_reference
FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation();
ALTER TABLE app.rupture_episode ENABLE ROW LEVEL SECURITY;
ALTER TABLE app.rupture_observation_event ENABLE ROW LEVEL SECURITY;
ALTER TABLE app.rupture_reconciliation_revision ENABLE ROW LEVEL SECURITY;
ALTER TABLE app.rupture_safety_reference ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_rupture_episode_select ON app.rupture_episode;
DROP POLICY IF EXISTS p_rupture_episode_insert ON app.rupture_episode;
CREATE POLICY p_rupture_episode_select ON app.rupture_episode FOR SELECT USING (
(app.is_ai_context() AND current_setting('app.current_ai_view', true) = ANY(visible_to))
OR (
NOT app.is_ai_context()
AND EXISTS (SELECT 1 FROM app.sessions s WHERE s.id = app.rupture_episode.session_id)
AND (
(app.current_role_name() = 'learner' AND learner_id = app.current_uid() AND 'counselor' = ANY(visible_to))
OR (app.current_role_name() = 'instructor' AND visible_to && ARRAY['evaluator','supervisor']::TEXT[])
OR app.current_role_name() = 'admin'
)
)
);
CREATE POLICY p_rupture_episode_insert ON app.rupture_episode FOR INSERT WITH CHECK (
app.is_ai_context()
AND current_setting('app.current_ai_view', true) = 'evaluator'
AND 'evaluator' = ANY(visible_to)
AND created_by_role = 'agent'
);
DROP POLICY IF EXISTS p_rupture_observation_select ON app.rupture_observation_event;
DROP POLICY IF EXISTS p_rupture_observation_insert ON app.rupture_observation_event;
CREATE POLICY p_rupture_observation_select ON app.rupture_observation_event FOR SELECT USING (
(app.is_ai_context() AND current_setting('app.current_ai_view', true) = ANY(visible_to))
OR (
NOT app.is_ai_context()
AND EXISTS (SELECT 1 FROM app.sessions s WHERE s.id = app.rupture_observation_event.session_id)
AND (
(app.current_role_name() = 'learner' AND 'counselor' = ANY(visible_to))
OR (app.current_role_name() = 'instructor' AND visible_to && ARRAY['evaluator','supervisor']::TEXT[])
OR app.current_role_name() = 'admin'
)
)
);
CREATE POLICY p_rupture_observation_insert ON app.rupture_observation_event FOR INSERT WITH CHECK (
(
app.is_ai_context()
AND current_setting('app.current_ai_view', true) = 'evaluator'
AND ai_view = 'evaluator'
AND event_kind <> 'human.corrected'
AND 'evaluator' = ANY(visible_to)
)
OR (
NOT app.is_ai_context()
AND app.current_role_name() IN ('instructor','admin')
AND ai_view = 'supervisor'
AND event_kind = 'human.corrected'
AND EXISTS (SELECT 1 FROM app.sessions s WHERE s.id = app.rupture_observation_event.session_id)
)
);
DROP POLICY IF EXISTS p_rupture_reconciliation_select ON app.rupture_reconciliation_revision;
DROP POLICY IF EXISTS p_rupture_reconciliation_insert ON app.rupture_reconciliation_revision;
CREATE POLICY p_rupture_reconciliation_select
ON app.rupture_reconciliation_revision FOR SELECT USING (
(app.is_ai_context() AND current_setting('app.current_ai_view', true) = ANY(visible_to))
OR (
NOT app.is_ai_context()
AND EXISTS (SELECT 1 FROM app.sessions s WHERE s.id = app.rupture_reconciliation_revision.session_id)
AND (
(app.current_role_name() = 'learner' AND 'counselor' = ANY(visible_to))
OR (app.current_role_name() = 'instructor' AND visible_to && ARRAY['evaluator','supervisor']::TEXT[])
OR app.current_role_name() = 'admin'
)
)
);
CREATE POLICY p_rupture_reconciliation_insert
ON app.rupture_reconciliation_revision FOR INSERT WITH CHECK (
app.is_ai_context()
AND current_setting('app.current_ai_view', true) = 'evaluator'
AND 'evaluator' = ANY(visible_to)
);
DROP POLICY IF EXISTS p_rupture_safety_select ON app.rupture_safety_reference;
DROP POLICY IF EXISTS p_rupture_safety_insert ON app.rupture_safety_reference;
CREATE POLICY p_rupture_safety_select ON app.rupture_safety_reference FOR SELECT USING (
EXISTS (
SELECT 1 FROM app.rupture_episode e
WHERE e.episode_id = app.rupture_safety_reference.episode_id
)
);
CREATE POLICY p_rupture_safety_insert ON app.rupture_safety_reference FOR INSERT WITH CHECK (
app.is_ai_context()
AND current_setting('app.current_ai_view', true) = 'evaluator'
AND EXISTS (
SELECT 1 FROM app.rupture_episode e
WHERE e.episode_id = app.rupture_safety_reference.episode_id
)
);