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