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 산출물은 커밋에서 제외했다.
634 lines
26 KiB
PL/PgSQL
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
|
|
)
|
|
);
|