G0~G8 성과·동맹 측정 OS 작업 일괄 고정

8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본
폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을
git 이력으로 고정하는 것이 목적이다.

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

engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
This commit is contained in:
Yun Chan 2026-08-08 01:30:53 +09:00
parent 93dd8f82d7
commit 16e791e044
390 changed files with 243188 additions and 499 deletions

View file

@ -0,0 +1,553 @@
-- Outcome & Alliance OS G2: synthetic expected arcs, longitudinal revisions,
-- observation provenance, and role-safe relationship memory.
-- Prerequisites: 02_schema.sql, 04_audit_eval_rls.sql, 07_measurement_foundation.sql.
CREATE SCHEMA IF NOT EXISTS audit;
CREATE SCHEMA IF NOT EXISTS ds;
-- The expected arc is an educational synthetic reference, never a clinical norm.
CREATE TABLE IF NOT EXISTS ds.synthetic_outcome_arc (
arc_id TEXT PRIMARY KEY,
schema_version TEXT NOT NULL
CHECK (schema_version = 'vignette.synthetic-outcome-arc.v1'),
title_ko TEXT NOT NULL CHECK (length(btrim(title_ko)) > 0),
data_classification TEXT NOT NULL
CHECK (data_classification = 'synthetic_educational'),
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE
CHECK (clinical_claim_allowed = FALSE),
provenance_note TEXT NOT NULL CHECK (length(btrim(provenance_note)) >= 20),
expected_arc JSONB NOT NULL CHECK (
jsonb_typeof(expected_arc) = 'object'
AND expected_arc->>'data_classification' = 'synthetic_educational'
AND (expected_arc->>'clinical_claim_allowed')::boolean = FALSE
AND jsonb_array_length(expected_arc->'distributions') = 15
),
content_hash TEXT NOT NULL UNIQUE CHECK (content_hash ~ '^[a-f0-9]{64}$'),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO ds.synthetic_outcome_arc (
arc_id, schema_version, title_ko, data_classification,
clinical_claim_allowed, provenance_note, expected_arc, content_hash
) VALUES (
'oas-g2-arc-001',
'vignette.synthetic-outcome-arc.v1',
'교육용 초기 5회기 기대 궤적',
'synthetic_educational',
FALSE,
'교육용 합성 사례의 결정론 테스트 분포이며 실제 내담자, 임상 규준, 치료 효과 또는 진단 예측을 나타내지 않는다.',
$arc${
"schema_version":"vignette.synthetic-outcome-arc.v1",
"arc_id":"oas-g2-arc-001",
"title_ko":"교육용 초기 5회기 기대 궤적",
"data_classification":"synthetic_educational",
"clinical_claim_allowed":false,
"provenance_note":"교육용 합성 사례의 결정론 테스트 분포이며 실제 내담자, 임상 규준, 치료 효과 또는 진단 예측을 나타내지 않는다.",
"distributions":[
{"session_no":1,"axis":"distress_load","mean":0.70,"standard_deviation":0.10,"lower_reference":0.50,"upper_reference":0.90,"sample_size":200,"expected_direction":"lower_is_better"},
{"session_no":1,"axis":"daily_functioning","mean":0.30,"standard_deviation":0.10,"lower_reference":0.10,"upper_reference":0.50,"sample_size":200,"expected_direction":"higher_is_better"},
{"session_no":1,"axis":"learning_engagement","mean":0.40,"standard_deviation":0.10,"lower_reference":0.20,"upper_reference":0.60,"sample_size":200,"expected_direction":"higher_is_better"},
{"session_no":2,"axis":"distress_load","mean":0.62,"standard_deviation":0.10,"lower_reference":0.42,"upper_reference":0.82,"sample_size":200,"expected_direction":"lower_is_better"},
{"session_no":2,"axis":"daily_functioning","mean":0.40,"standard_deviation":0.10,"lower_reference":0.20,"upper_reference":0.60,"sample_size":200,"expected_direction":"higher_is_better"},
{"session_no":2,"axis":"learning_engagement","mean":0.48,"standard_deviation":0.10,"lower_reference":0.28,"upper_reference":0.68,"sample_size":200,"expected_direction":"higher_is_better"},
{"session_no":3,"axis":"distress_load","mean":0.54,"standard_deviation":0.10,"lower_reference":0.34,"upper_reference":0.74,"sample_size":200,"expected_direction":"lower_is_better"},
{"session_no":3,"axis":"daily_functioning","mean":0.50,"standard_deviation":0.10,"lower_reference":0.30,"upper_reference":0.70,"sample_size":200,"expected_direction":"higher_is_better"},
{"session_no":3,"axis":"learning_engagement","mean":0.56,"standard_deviation":0.10,"lower_reference":0.36,"upper_reference":0.76,"sample_size":200,"expected_direction":"higher_is_better"},
{"session_no":4,"axis":"distress_load","mean":0.46,"standard_deviation":0.10,"lower_reference":0.26,"upper_reference":0.66,"sample_size":200,"expected_direction":"lower_is_better"},
{"session_no":4,"axis":"daily_functioning","mean":0.60,"standard_deviation":0.10,"lower_reference":0.40,"upper_reference":0.80,"sample_size":200,"expected_direction":"higher_is_better"},
{"session_no":4,"axis":"learning_engagement","mean":0.64,"standard_deviation":0.10,"lower_reference":0.44,"upper_reference":0.84,"sample_size":200,"expected_direction":"higher_is_better"},
{"session_no":5,"axis":"distress_load","mean":0.38,"standard_deviation":0.10,"lower_reference":0.18,"upper_reference":0.58,"sample_size":200,"expected_direction":"lower_is_better"},
{"session_no":5,"axis":"daily_functioning","mean":0.68,"standard_deviation":0.10,"lower_reference":0.48,"upper_reference":0.88,"sample_size":200,"expected_direction":"higher_is_better"},
{"session_no":5,"axis":"learning_engagement","mean":0.72,"standard_deviation":0.10,"lower_reference":0.52,"upper_reference":0.92,"sample_size":200,"expected_direction":"higher_is_better"}
]
}$arc$::jsonb,
'4a813340ededc4624e1607308579d0c2cfff23024ac68de0cd3c8d4b8706ab8f'
)
ON CONFLICT (arc_id) DO NOTHING;
INSERT INTO app.measurement_instrument (
instrument_id, instrument_version, name_ko, instrument_kind, construct,
validation_basis, scoring_schema, metadata
) VALUES (
'vignette-session-outcome', '1.0.0', '교육용 회기 성과 3축 관측',
'training_metric', 'session_outcome',
'교육용 합성 사례에서 부담감·일상 기능·학습 참여를 축별로 기록한다. 임상 척도나 치료 효과 지표가 아니다.',
'{"dimensions":["distress_load","daily_functioning","learning_engagement"],"min":0,"max":1}'::jsonb,
'{"clinical_claim_allowed":false,"data_classification":"synthetic_educational"}'::jsonb
), (
'vignette-session-outcome-checkin', '1.0.0', '학습자 회기 성과 3축 체크인',
'training_metric', 'session_outcome',
'수련생이 종료된 합성 회기를 돌아보며 기록하는 부담감·일상 기능·학습 참여 자기보고. 임상 척도나 치료 효과 지표가 아니다.',
'{"dimensions":["distress_load","daily_functioning","learning_engagement"],"min":0,"max":1}'::jsonb,
'{"clinical_claim_allowed":false,"data_classification":"synthetic_educational","perspective":"learner_self_report"}'::jsonb
)
ON CONFLICT (instrument_id, instrument_version) DO NOTHING;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_measurement_event_outcome_submission_metadata'
AND conrelid = 'app.measurement_event'::regclass
) THEN
ALTER TABLE app.measurement_event
ADD CONSTRAINT ck_measurement_event_outcome_submission_metadata CHECK (
NOT (
construct = 'session_outcome'
AND source_kind = 'learner_reported'
AND instrument_id = 'vignette-session-outcome-checkin'
)
OR (
instrument_version = '1.0.0'
AND perspective = 'learner_self_report'
AND dimension IN ('distress_load','daily_functioning','learning_engagement')
AND metadata ?& ARRAY['submission_id','submission_hash']
AND metadata->>'submission_id' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
AND metadata->>'submission_hash' ~ '^[a-f0-9]{64}$'
)
) NOT VALID;
ALTER TABLE app.measurement_event
VALIDATE CONSTRAINT ck_measurement_event_outcome_submission_metadata;
END IF;
END;
$$;
CREATE UNIQUE INDEX IF NOT EXISTS uq_measurement_event_outcome_submission_axis
ON app.measurement_event (
session_id,
(metadata->>'submission_id'),
dimension
)
WHERE construct = 'session_outcome'
AND source_kind = 'learner_reported'
AND perspective = 'learner_self_report'
AND instrument_id = 'vignette-session-outcome-checkin'
AND instrument_version = '1.0.0';
CREATE OR REPLACE FUNCTION audit.enforce_outcome_submission_turn_ownership()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NOT (
NEW.construct = 'session_outcome'
AND NEW.source_kind = 'learner_reported'
AND NEW.perspective = 'learner_self_report'
AND NEW.instrument_id = 'vignette-session-outcome-checkin'
) THEN
RETURN NEW;
END IF;
IF cardinality(NEW.evidence_turn_ids) > 0 AND (
SELECT count(DISTINCT t.id)
FROM app.turns t
WHERE t.id = ANY(NEW.evidence_turn_ids)
AND t.session_id = NEW.session_id
) <> cardinality(NEW.evidence_turn_ids) THEN
RAISE EXCEPTION 'outcome submission evidence turns must belong to its session'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_outcome_submission_turn_ownership
ON app.measurement_event;
CREATE TRIGGER trg_outcome_submission_turn_ownership
BEFORE INSERT ON app.measurement_event
FOR EACH ROW EXECUTE FUNCTION audit.enforce_outcome_submission_turn_ownership();
-- Every computation is a new immutable revision. A recomputation never overwrites
-- the previous assessment, even when the evidence fingerprint is unchanged.
CREATE TABLE IF NOT EXISTS app.outcome_trajectory_revision (
revision_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
anchor_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,
expected_arc_id TEXT NOT NULL REFERENCES ds.synthetic_outcome_arc(arc_id) ON DELETE RESTRICT,
revision_no INT NOT NULL CHECK (revision_no >= 1),
supersedes_revision_id UUID REFERENCES app.outcome_trajectory_revision(revision_id) ON DELETE RESTRICT,
source_fingerprint TEXT NOT NULL CHECK (source_fingerprint ~ '^[a-f0-9]{64}$'),
assessment JSONB NOT NULL CHECK (
jsonb_typeof(assessment) = 'object'
AND assessment->>'schema_version' = 'vignette.outcome-trajectory-assessment.v1'
AND assessment->>'data_classification' = 'synthetic_educational'
AND (assessment->>'clinical_claim_allowed')::boolean = FALSE
),
observation_count INT NOT NULL CHECK (observation_count BETWEEN 3 AND 15),
missing_observation_count INT NOT NULL CHECK (
missing_observation_count BETWEEN 0 AND observation_count
),
recompute_reason TEXT NOT NULL CHECK (length(btrim(recompute_reason)) > 0),
computed_by UUID REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
computed_role TEXT NOT NULL CHECK (
computed_role IN ('learner','instructor','admin','agent','migration')
),
computed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (case_id, revision_no),
UNIQUE (supersedes_revision_id),
CHECK (revision_id IS DISTINCT FROM supersedes_revision_id)
);
CREATE INDEX IF NOT EXISTS idx_outcome_trajectory_case_revision
ON app.outcome_trajectory_revision(case_id, revision_no DESC);
CREATE INDEX IF NOT EXISTS idx_outcome_trajectory_anchor
ON app.outcome_trajectory_revision(anchor_session_id, revision_no DESC);
CREATE TABLE IF NOT EXISTS app.outcome_trajectory_observation (
observation_snapshot_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
revision_id UUID NOT NULL REFERENCES app.outcome_trajectory_revision(revision_id) ON DELETE RESTRICT,
measurement_id UUID REFERENCES app.measurement_event(measurement_id) ON DELETE RESTRICT,
session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT,
session_no INT NOT NULL CHECK (session_no BETWEEN 1 AND 5),
axis TEXT NOT NULL CHECK (
axis IN ('distress_load','daily_functioning','learning_engagement')
),
status TEXT NOT NULL CHECK (status IN ('observed','missing','error')),
value DOUBLE PRECISION CHECK (value BETWEEN 0 AND 1),
raw_value DOUBLE PRECISION,
scale_min DOUBLE PRECISION,
scale_max DOUBLE PRECISION,
confidence DOUBLE PRECISION CHECK (confidence BETWEEN 0 AND 1),
source_kind TEXT NOT NULL CHECK (
source_kind IN (
'simulated_state','model_inferred','agent_reported',
'learner_reported','human_rated','observed_runtime'
)
),
perspective TEXT NOT NULL CHECK (
perspective IN (
'client_agent_report','learner_self_report','independent_observer',
'supervisor_human','client_simulation','runtime_observation'
)
),
instrument_id TEXT NOT NULL,
instrument_version TEXT NOT NULL,
model_run_id UUID REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT,
evidence_turn_ids UUID[] NOT NULL DEFAULT '{}',
missing_reason TEXT,
source_created_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (revision_id, session_no, axis),
CHECK (
(status = 'observed'
AND measurement_id IS NOT NULL
AND value IS NOT NULL
AND raw_value IS NOT NULL
AND scale_min IS NOT NULL
AND scale_max IS NOT NULL
AND scale_max > scale_min
AND confidence IS NOT NULL
AND missing_reason IS NULL)
OR
(status IN ('missing','error')
AND value IS NULL
AND raw_value IS NULL
AND confidence IS NULL
AND missing_reason IS NOT NULL)
),
CHECK (
(source_kind = 'simulated_state' AND perspective = 'client_simulation')
OR (source_kind = 'model_inferred' AND perspective = 'independent_observer')
OR (source_kind = 'agent_reported' AND perspective = 'client_agent_report')
OR (source_kind = 'learner_reported' AND perspective = 'learner_self_report')
OR (source_kind = 'human_rated' AND perspective = 'supervisor_human')
OR (source_kind = 'observed_runtime' AND perspective = 'runtime_observation')
),
CHECK (source_kind NOT IN ('model_inferred','agent_reported') OR model_run_id IS NOT NULL)
);
CREATE INDEX IF NOT EXISTS idx_outcome_observation_revision
ON app.outcome_trajectory_observation(revision_id, session_no, axis);
CREATE INDEX IF NOT EXISTS idx_outcome_observation_measurement
ON app.outcome_trajectory_observation(measurement_id)
WHERE measurement_id IS NOT NULL;
-- Relationship events contain no role-private sentences. Role-specific summaries
-- live in the projection table so RLS protects the text itself, not only the row.
CREATE TABLE IF NOT EXISTS app.relationship_memory_event (
memory_event_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,
event_type TEXT NOT NULL CHECK (
event_type IN (
'goal_agreement','task_agreement','rupture_withdrawal',
'rupture_confrontation','repair_attempt','repair_confirmed','unresolved_rupture'
)
),
resolves_event_id UUID REFERENCES app.relationship_memory_event(memory_event_id) ON DELETE RESTRICT,
visible_to TEXT[] NOT NULL CHECK (
cardinality(visible_to) > 0
AND visible_to <@ ARRAY['client','counselor','evaluator','supervisor','research']::TEXT[]
),
evidence_turn_ids UUID[] NOT NULL CHECK (cardinality(evidence_turn_ids) > 0),
source_kind TEXT NOT NULL CHECK (
source_kind IN ('model_inferred','agent_reported','human_rated','observed_runtime')
),
model_run_id UUID REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT,
created_by UUID REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (memory_event_id IS DISTINCT FROM resolves_event_id),
CHECK (resolves_event_id IS NULL OR event_type = 'repair_confirmed'),
CHECK (source_kind NOT IN ('model_inferred','agent_reported') OR model_run_id IS NOT NULL)
);
CREATE INDEX IF NOT EXISTS idx_relationship_memory_case_created
ON app.relationship_memory_event(case_id, created_at, memory_event_id);
CREATE INDEX IF NOT EXISTS idx_relationship_memory_session
ON app.relationship_memory_event(session_id, created_at);
CREATE TABLE IF NOT EXISTS app.relationship_memory_projection (
projection_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
memory_event_id UUID NOT NULL REFERENCES app.relationship_memory_event(memory_event_id) ON DELETE RESTRICT,
ai_view TEXT NOT NULL CHECK (
ai_view IN ('client','counselor','evaluator','supervisor','research')
),
summary TEXT NOT NULL CHECK (length(btrim(summary)) > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (memory_event_id, ai_view)
);
CREATE INDEX IF NOT EXISTS idx_relationship_projection_view
ON app.relationship_memory_projection(ai_view, memory_event_id);
CREATE OR REPLACE FUNCTION audit.enforce_relationship_memory_link()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
resolved_case UUID;
resolved_type TEXT;
BEGIN
IF (
SELECT count(DISTINCT t.id)
FROM app.turns t
WHERE t.id = ANY(NEW.evidence_turn_ids)
AND t.session_id = NEW.session_id
) <> cardinality(NEW.evidence_turn_ids) THEN
RAISE EXCEPTION 'relationship memory evidence turns must belong to its session'
USING ERRCODE = '23514';
END IF;
IF NEW.resolves_event_id IS NULL THEN
RETURN NEW;
END IF;
SELECT case_id, event_type INTO resolved_case, resolved_type
FROM app.relationship_memory_event
WHERE memory_event_id = NEW.resolves_event_id;
IF resolved_case IS NULL OR resolved_case IS DISTINCT FROM NEW.case_id THEN
RAISE EXCEPTION 'relationship repair must reference an event in the same case'
USING ERRCODE = '23514';
END IF;
IF resolved_type NOT IN (
'rupture_withdrawal','rupture_confrontation','unresolved_rupture','repair_attempt'
) THEN
RAISE EXCEPTION 'relationship repair must resolve a rupture or repair attempt'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION audit.enforce_relationship_projection_view()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM app.relationship_memory_event e
WHERE e.memory_event_id = NEW.memory_event_id
AND NEW.ai_view = ANY(e.visible_to)
) THEN
RAISE EXCEPTION 'relationship projection view must be declared by its event'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_outcome_trajectory_revision_append_only
ON app.outcome_trajectory_revision;
CREATE TRIGGER trg_outcome_trajectory_revision_append_only
BEFORE UPDATE OR DELETE ON app.outcome_trajectory_revision
FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation();
DROP TRIGGER IF EXISTS trg_outcome_trajectory_observation_append_only
ON app.outcome_trajectory_observation;
CREATE TRIGGER trg_outcome_trajectory_observation_append_only
BEFORE UPDATE OR DELETE ON app.outcome_trajectory_observation
FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation();
DROP TRIGGER IF EXISTS trg_synthetic_outcome_arc_append_only
ON ds.synthetic_outcome_arc;
CREATE TRIGGER trg_synthetic_outcome_arc_append_only
BEFORE UPDATE OR DELETE ON ds.synthetic_outcome_arc
FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation();
DROP TRIGGER IF EXISTS trg_relationship_memory_link
ON app.relationship_memory_event;
CREATE TRIGGER trg_relationship_memory_link
BEFORE INSERT ON app.relationship_memory_event
FOR EACH ROW EXECUTE FUNCTION audit.enforce_relationship_memory_link();
DROP TRIGGER IF EXISTS trg_relationship_memory_event_append_only
ON app.relationship_memory_event;
CREATE TRIGGER trg_relationship_memory_event_append_only
BEFORE UPDATE OR DELETE ON app.relationship_memory_event
FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation();
DROP TRIGGER IF EXISTS trg_relationship_projection_view
ON app.relationship_memory_projection;
CREATE TRIGGER trg_relationship_projection_view
BEFORE INSERT ON app.relationship_memory_projection
FOR EACH ROW EXECUTE FUNCTION audit.enforce_relationship_projection_view();
DROP TRIGGER IF EXISTS trg_relationship_memory_projection_append_only
ON app.relationship_memory_projection;
CREATE TRIGGER trg_relationship_memory_projection_append_only
BEFORE UPDATE OR DELETE ON app.relationship_memory_projection
FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation();
ALTER TABLE ds.synthetic_outcome_arc ENABLE ROW LEVEL SECURITY;
ALTER TABLE app.outcome_trajectory_revision ENABLE ROW LEVEL SECURITY;
ALTER TABLE app.outcome_trajectory_observation ENABLE ROW LEVEL SECURITY;
ALTER TABLE app.relationship_memory_event ENABLE ROW LEVEL SECURITY;
ALTER TABLE app.relationship_memory_projection ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_synthetic_outcome_arc_select ON ds.synthetic_outcome_arc;
DROP POLICY IF EXISTS p_synthetic_outcome_arc_insert ON ds.synthetic_outcome_arc;
CREATE POLICY p_synthetic_outcome_arc_select ON ds.synthetic_outcome_arc
FOR SELECT USING (true);
CREATE POLICY p_synthetic_outcome_arc_insert ON ds.synthetic_outcome_arc
FOR INSERT WITH CHECK (
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
);
DROP POLICY IF EXISTS p_outcome_trajectory_revision_select
ON app.outcome_trajectory_revision;
DROP POLICY IF EXISTS p_outcome_trajectory_revision_insert
ON app.outcome_trajectory_revision;
CREATE POLICY p_outcome_trajectory_revision_select ON app.outcome_trajectory_revision
FOR SELECT USING (
app.is_ai_context()
OR EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.outcome_trajectory_revision.anchor_session_id
)
);
CREATE POLICY p_outcome_trajectory_revision_insert ON app.outcome_trajectory_revision
FOR INSERT WITH CHECK (
app.is_ai_context()
OR (
EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.outcome_trajectory_revision.anchor_session_id
AND s.case_id = app.outcome_trajectory_revision.case_id
AND s.learner_id = app.outcome_trajectory_revision.learner_id
)
AND (
app.current_role_name() IN ('admin','instructor')
OR (
app.current_role_name() = 'learner'
AND learner_id = app.current_uid()
AND computed_by = app.current_uid()
)
)
)
);
DROP POLICY IF EXISTS p_outcome_trajectory_observation_select
ON app.outcome_trajectory_observation;
DROP POLICY IF EXISTS p_outcome_trajectory_observation_insert
ON app.outcome_trajectory_observation;
CREATE POLICY p_outcome_trajectory_observation_select
ON app.outcome_trajectory_observation
FOR SELECT USING (
EXISTS (
SELECT 1 FROM app.outcome_trajectory_revision r
WHERE r.revision_id = app.outcome_trajectory_observation.revision_id
)
);
CREATE POLICY p_outcome_trajectory_observation_insert
ON app.outcome_trajectory_observation
FOR INSERT WITH CHECK (
EXISTS (
SELECT 1 FROM app.outcome_trajectory_revision r
WHERE r.revision_id = app.outcome_trajectory_observation.revision_id
AND r.case_id = (
SELECT s.case_id FROM app.sessions s
WHERE s.id = app.outcome_trajectory_observation.session_id
)
)
);
DROP POLICY IF EXISTS p_relationship_memory_event_select
ON app.relationship_memory_event;
DROP POLICY IF EXISTS p_relationship_memory_event_insert
ON app.relationship_memory_event;
CREATE POLICY p_relationship_memory_event_select ON app.relationship_memory_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.relationship_memory_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_relationship_memory_event_insert ON app.relationship_memory_event
FOR INSERT WITH CHECK (
app.is_ai_context()
OR (
app.current_role_name() IN ('admin','instructor')
AND EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.relationship_memory_event.session_id
AND s.case_id = app.relationship_memory_event.case_id
)
)
);
DROP POLICY IF EXISTS p_relationship_memory_projection_select
ON app.relationship_memory_projection;
DROP POLICY IF EXISTS p_relationship_memory_projection_insert
ON app.relationship_memory_projection;
CREATE POLICY p_relationship_memory_projection_select
ON app.relationship_memory_projection
FOR SELECT USING (
(
app.is_ai_context()
AND ai_view = current_setting('app.current_ai_view', true)
)
OR (
NOT app.is_ai_context()
AND EXISTS (
SELECT 1 FROM app.relationship_memory_event e
WHERE e.memory_event_id = app.relationship_memory_projection.memory_event_id
)
AND (
(app.current_role_name() = 'learner' AND ai_view = 'counselor')
OR (app.current_role_name() = 'instructor' AND ai_view IN ('evaluator','supervisor'))
OR app.current_role_name() = 'admin'
)
)
);
CREATE POLICY p_relationship_memory_projection_insert
ON app.relationship_memory_projection
FOR INSERT WITH CHECK (
app.is_ai_context()
OR (
app.current_role_name() IN ('admin','instructor')
AND EXISTS (
SELECT 1 FROM app.relationship_memory_event e
WHERE e.memory_event_id = app.relationship_memory_projection.memory_event_id
)
)
);