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