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:
parent
93dd8f82d7
commit
16e791e044
390 changed files with 243188 additions and 499 deletions
634
infra/db/init/09_rupture_repair.sql
Normal file
634
infra/db/init/09_rupture_repair.sql
Normal file
|
|
@ -0,0 +1,634 @@
|
|||
-- 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
|
||||
)
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue