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 산출물은 커밋에서 제외했다.
852 lines
36 KiB
PL/PgSQL
852 lines
36 KiB
PL/PgSQL
-- Outcome & Alliance OS G4: append-only deliberate-practice ledger.
|
|
-- Prerequisites: 02_schema.sql, 04_audit_eval_rls.sql, 07_measurement_foundation.sql.
|
|
|
|
CREATE TABLE IF NOT EXISTS app.practice_prescription_submission (
|
|
submission_id UUID PRIMARY KEY,
|
|
session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT,
|
|
learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
|
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
|
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_role TEXT NOT NULL DEFAULT 'agent'
|
|
CHECK (created_by_role IN ('agent','migration')),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
UNIQUE (submission_id, session_id, learner_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_practice_prescription_submission_learner
|
|
ON app.practice_prescription_submission(learner_id, created_at, submission_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.practice_coaching_card (
|
|
coaching_card_record_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
submission_id UUID NOT NULL,
|
|
session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT,
|
|
learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
|
card_key TEXT NOT NULL CHECK (length(btrim(card_key)) > 0),
|
|
scene_id TEXT NOT NULL CHECK (length(btrim(scene_id)) > 0),
|
|
coach_claim TEXT NOT NULL CHECK (length(btrim(coach_claim)) >= 10),
|
|
card_payload JSONB NOT NULL CHECK (jsonb_typeof(card_payload) = 'object'),
|
|
evidence_turn_ids UUID[] NOT NULL CHECK (cardinality(evidence_turn_ids) > 0),
|
|
source_refs TEXT[] NOT NULL CHECK (cardinality(source_refs) > 0),
|
|
uncertainty DOUBLE PRECISION NOT NULL CHECK (uncertainty BETWEEN 0 AND 1),
|
|
counterevidence TEXT[] NOT NULL DEFAULT '{}',
|
|
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 (submission_id, card_key),
|
|
UNIQUE (coaching_card_record_id, submission_id, session_id, learner_id),
|
|
CONSTRAINT practice_card_submission_fkey
|
|
FOREIGN KEY (submission_id, session_id, learner_id)
|
|
REFERENCES app.practice_prescription_submission(submission_id, session_id, learner_id)
|
|
ON DELETE RESTRICT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_practice_coaching_card_session
|
|
ON app.practice_coaching_card(session_id, created_at, coaching_card_record_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.practice_prescription (
|
|
prescription_record_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
prescription_key TEXT NOT NULL CHECK (length(btrim(prescription_key)) > 0),
|
|
submission_id UUID NOT NULL,
|
|
coaching_card_record_id UUID NOT NULL,
|
|
session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT,
|
|
learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
|
competency_id TEXT NOT NULL CHECK (competency_id ~ '^competency\.[a-z0-9_.-]+$'),
|
|
criterion_id TEXT NOT NULL CHECK (criterion_id ~ '^criterion\.[a-z0-9_.-]+$'),
|
|
observable_behavior TEXT NOT NULL CHECK (length(btrim(observable_behavior)) >= 10),
|
|
activity_mode TEXT NOT NULL CHECK (
|
|
activity_mode IN ('replay','branch','constrained_response','voice_retry','difficulty_ladder')
|
|
),
|
|
scenario_variant_id TEXT NOT NULL CHECK (length(btrim(scenario_variant_id)) > 0),
|
|
scenario_novelty TEXT NOT NULL CHECK (scenario_novelty IN ('familiar','unseen_transfer')),
|
|
difficulty_level INT NOT NULL CHECK (difficulty_level BETWEEN 1 AND 5),
|
|
prescription_payload JSONB NOT NULL CHECK (jsonb_typeof(prescription_payload) = 'object'),
|
|
evidence_turn_ids UUID[] NOT NULL CHECK (cardinality(evidence_turn_ids) > 0),
|
|
uncertainty DOUBLE PRECISION NOT NULL CHECK (uncertainty BETWEEN 0 AND 1),
|
|
counterevidence TEXT[] NOT NULL DEFAULT '{}',
|
|
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 (learner_id, prescription_key),
|
|
UNIQUE (prescription_record_id, session_id, learner_id),
|
|
CONSTRAINT practice_prescription_card_fkey
|
|
FOREIGN KEY (coaching_card_record_id, submission_id, session_id, learner_id)
|
|
REFERENCES app.practice_coaching_card(
|
|
coaching_card_record_id, submission_id, session_id, learner_id
|
|
) ON DELETE RESTRICT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_practice_prescription_learner_competency
|
|
ON app.practice_prescription(learner_id, competency_id, created_at);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.practice_episode_submission (
|
|
episode_submission_id UUID PRIMARY KEY,
|
|
episode_key TEXT NOT NULL CHECK (length(btrim(episode_key)) > 0),
|
|
prescription_record_id UUID NOT NULL,
|
|
session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT,
|
|
learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
|
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
|
assessment_payload JSONB NOT NULL CHECK (jsonb_typeof(assessment_payload) = 'object'),
|
|
progress TEXT NOT NULL CHECK (progress IN ('practicing','transfer_pending','mastered')),
|
|
mastery_allowed BOOLEAN NOT NULL,
|
|
mastery_blockers TEXT[] NOT NULL DEFAULT '{}',
|
|
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 '{}',
|
|
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_role TEXT NOT NULL CHECK (created_by_role IN ('learner','agent','migration')),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
UNIQUE (learner_id, episode_key),
|
|
UNIQUE (episode_submission_id, session_id, learner_id),
|
|
CONSTRAINT practice_episode_prescription_fkey
|
|
FOREIGN KEY (prescription_record_id, session_id, learner_id)
|
|
REFERENCES app.practice_prescription(prescription_record_id, session_id, learner_id)
|
|
ON DELETE RESTRICT,
|
|
CHECK (
|
|
(progress = 'mastered' AND mastery_allowed AND cardinality(mastery_blockers) = 0)
|
|
OR (progress <> 'mastered' AND NOT mastery_allowed)
|
|
)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_practice_episode_learner_created
|
|
ON app.practice_episode_submission(learner_id, created_at, episode_submission_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.practice_attempt_evidence (
|
|
attempt_record_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
attempt_key TEXT NOT NULL CHECK (length(btrim(attempt_key)) > 0),
|
|
episode_submission_id UUID NOT NULL,
|
|
session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT,
|
|
learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
|
sequence_no INT NOT NULL CHECK (sequence_no >= 1),
|
|
scenario_variant_id TEXT NOT NULL CHECK (length(btrim(scenario_variant_id)) > 0),
|
|
scenario_novelty TEXT NOT NULL CHECK (scenario_novelty IN ('familiar','unseen_transfer')),
|
|
difficulty_level INT NOT NULL CHECK (difficulty_level BETWEEN 1 AND 5),
|
|
criterion_status TEXT NOT NULL CHECK (criterion_status IN ('observed','not_observed','error')),
|
|
client_response TEXT CHECK (
|
|
client_response IS NULL OR client_response IN (
|
|
'rejecting','withdrawn','compliance_only','mixed','engaged','explicit_alignment'
|
|
)
|
|
),
|
|
outcome TEXT NOT NULL CHECK (outcome IN ('passed','needs_retry','insufficient_evidence')),
|
|
utterance_template_id TEXT,
|
|
learner_claimed_success BOOLEAN NOT NULL DEFAULT FALSE,
|
|
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 '{}',
|
|
attempt_payload JSONB NOT NULL CHECK (jsonb_typeof(attempt_payload) = 'object'),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
UNIQUE (episode_submission_id, attempt_key),
|
|
UNIQUE (episode_submission_id, sequence_no),
|
|
UNIQUE (attempt_record_id, episode_submission_id, session_id, learner_id),
|
|
CONSTRAINT practice_attempt_episode_fkey
|
|
FOREIGN KEY (episode_submission_id, session_id, learner_id)
|
|
REFERENCES app.practice_episode_submission(episode_submission_id, session_id, learner_id)
|
|
ON DELETE RESTRICT,
|
|
CHECK (
|
|
outcome <> 'passed'
|
|
OR (
|
|
criterion_status = 'observed'
|
|
AND client_response IN ('engaged','explicit_alignment')
|
|
AND uncertainty <= 0.5
|
|
)
|
|
)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_practice_attempt_episode_sequence
|
|
ON app.practice_attempt_evidence(episode_submission_id, sequence_no);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.competency_graph_snapshot (
|
|
snapshot_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
|
session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT,
|
|
snapshot_no INT NOT NULL CHECK (snapshot_no >= 1),
|
|
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
|
supersedes_snapshot_id UUID
|
|
REFERENCES app.competency_graph_snapshot(snapshot_id) ON DELETE RESTRICT,
|
|
source_prescription_submission_id UUID
|
|
REFERENCES app.practice_prescription_submission(submission_id) ON DELETE RESTRICT,
|
|
source_episode_submission_id UUID
|
|
REFERENCES app.practice_episode_submission(episode_submission_id) ON DELETE RESTRICT,
|
|
graph_payload JSONB NOT NULL CHECK (jsonb_typeof(graph_payload) = 'object'),
|
|
evidence_turn_ids UUID[] NOT NULL DEFAULT '{}',
|
|
created_by_role TEXT NOT NULL CHECK (created_by_role IN ('learner','agent','migration')),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
UNIQUE (learner_id, snapshot_no),
|
|
UNIQUE (supersedes_snapshot_id),
|
|
UNIQUE (source_prescription_submission_id),
|
|
UNIQUE (source_episode_submission_id),
|
|
UNIQUE (snapshot_id, learner_id, session_id),
|
|
CHECK (snapshot_id IS DISTINCT FROM supersedes_snapshot_id),
|
|
CHECK (
|
|
(source_prescription_submission_id IS NOT NULL)::INT
|
|
+ (source_episode_submission_id IS NOT NULL)::INT = 1
|
|
)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_competency_graph_snapshot_latest
|
|
ON app.competency_graph_snapshot(learner_id, snapshot_no DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.practice_curriculum_decision_event (
|
|
decision_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
source_snapshot_id UUID NOT NULL,
|
|
selected_prescription_record_id UUID NOT NULL,
|
|
learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
|
session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT,
|
|
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
|
decision_payload JSONB NOT NULL CHECK (jsonb_typeof(decision_payload) = 'object'),
|
|
created_by_role TEXT NOT NULL CHECK (created_by_role IN ('learner','agent','migration')),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
UNIQUE (source_snapshot_id),
|
|
CONSTRAINT practice_decision_snapshot_fkey
|
|
FOREIGN KEY (source_snapshot_id, learner_id, session_id)
|
|
REFERENCES app.competency_graph_snapshot(snapshot_id, learner_id, session_id)
|
|
ON DELETE RESTRICT,
|
|
CONSTRAINT practice_decision_prescription_fkey
|
|
FOREIGN KEY (selected_prescription_record_id, session_id, learner_id)
|
|
REFERENCES app.practice_prescription(prescription_record_id, session_id, learner_id)
|
|
ON DELETE RESTRICT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_practice_decision_learner_created
|
|
ON app.practice_curriculum_decision_event(learner_id, created_at, decision_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.practice_teacher_correction (
|
|
correction_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
submission_id UUID NOT NULL UNIQUE,
|
|
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
|
attempt_record_id UUID NOT NULL,
|
|
episode_submission_id UUID NOT NULL,
|
|
session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE RESTRICT,
|
|
learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
|
correction_no INT NOT NULL CHECK (correction_no >= 1),
|
|
supersedes_correction_id UUID
|
|
REFERENCES app.practice_teacher_correction(correction_id) ON DELETE RESTRICT,
|
|
corrected_outcome TEXT NOT NULL CHECK (
|
|
corrected_outcome IN ('passed','needs_retry','insufficient_evidence')
|
|
),
|
|
correction_reason TEXT NOT NULL CHECK (length(btrim(correction_reason)) > 0),
|
|
evidence_turn_ids UUID[] NOT NULL CHECK (cardinality(evidence_turn_ids) > 0),
|
|
counterevidence TEXT[] NOT NULL DEFAULT '{}',
|
|
created_by_uid UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
|
created_by_role TEXT NOT NULL CHECK (created_by_role IN ('instructor','admin')),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
UNIQUE (attempt_record_id, correction_no),
|
|
UNIQUE (supersedes_correction_id),
|
|
CHECK (correction_id IS DISTINCT FROM supersedes_correction_id),
|
|
CONSTRAINT practice_correction_attempt_fkey
|
|
FOREIGN KEY (attempt_record_id, episode_submission_id, session_id, learner_id)
|
|
REFERENCES app.practice_attempt_evidence(
|
|
attempt_record_id, episode_submission_id, session_id, learner_id
|
|
) ON DELETE RESTRICT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_practice_teacher_correction_attempt
|
|
ON app.practice_teacher_correction(attempt_record_id, correction_no DESC);
|
|
|
|
CREATE OR REPLACE FUNCTION audit.enforce_practice_session_learner()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
anchor_learner UUID;
|
|
BEGIN
|
|
SELECT s.learner_id INTO anchor_learner
|
|
FROM app.sessions s
|
|
WHERE s.id = NEW.session_id;
|
|
IF anchor_learner IS NULL OR anchor_learner IS DISTINCT FROM NEW.learner_id THEN
|
|
RAISE EXCEPTION 'practice row must match its session learner'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION audit.enforce_practice_evidence_turn_ownership()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
BEGIN
|
|
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 'practice evidence turns must belong to its session'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION audit.enforce_practice_episode_transfer_gate()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
attempts JSONB;
|
|
has_familiar_pass BOOLEAN;
|
|
has_unseen_pass BOOLEAN;
|
|
has_transfer_collision BOOLEAN;
|
|
BEGIN
|
|
IF NEW.assessment_payload->>'progress' IS DISTINCT FROM NEW.progress
|
|
OR COALESCE((NEW.assessment_payload->>'mastery_allowed')::BOOLEAN, FALSE)
|
|
IS DISTINCT FROM NEW.mastery_allowed THEN
|
|
RAISE EXCEPTION 'practice episode columns must match assessment payload'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
attempts := COALESCE(NEW.assessment_payload->'attempts', '[]'::JSONB);
|
|
IF jsonb_typeof(attempts) <> 'array' OR jsonb_array_length(attempts) = 0 THEN
|
|
RAISE EXCEPTION 'practice episode requires attempt evidence'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM jsonb_array_elements(attempts) item
|
|
WHERE item->>'outcome' = 'passed'
|
|
AND item->>'scenario_novelty' = 'familiar'
|
|
) INTO has_familiar_pass;
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM jsonb_array_elements(attempts) item
|
|
WHERE item->>'outcome' = 'passed'
|
|
AND item->>'scenario_novelty' = 'unseen_transfer'
|
|
AND length(btrim(COALESCE(item->>'utterance_template_id', ''))) > 0
|
|
) INTO has_unseen_pass;
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM jsonb_array_elements(attempts) unseen,
|
|
jsonb_array_elements(attempts) familiar
|
|
WHERE unseen->>'outcome' = 'passed'
|
|
AND unseen->>'scenario_novelty' = 'unseen_transfer'
|
|
AND familiar->>'outcome' = 'passed'
|
|
AND familiar->>'scenario_novelty' = 'familiar'
|
|
AND (
|
|
unseen->>'scenario_variant_id' = familiar->>'scenario_variant_id'
|
|
OR unseen->>'utterance_template_id' = familiar->>'utterance_template_id'
|
|
)
|
|
) INTO has_transfer_collision;
|
|
|
|
IF NEW.progress = 'mastered'
|
|
AND (NOT has_familiar_pass OR NOT has_unseen_pass OR has_transfer_collision) THEN
|
|
RAISE EXCEPTION 'practice mastery requires familiar success and novel unseen transfer evidence'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION audit.enforce_practice_attempt_sequence_and_transfer()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
latest_sequence INT;
|
|
familiar_pass_exists BOOLEAN;
|
|
repeated_template_exists BOOLEAN;
|
|
repeated_variant_exists BOOLEAN;
|
|
BEGIN
|
|
PERFORM pg_advisory_xact_lock(hashtextextended(NEW.episode_submission_id::TEXT, 0));
|
|
SELECT max(a.sequence_no) INTO latest_sequence
|
|
FROM app.practice_attempt_evidence a
|
|
WHERE a.episode_submission_id = NEW.episode_submission_id;
|
|
IF NEW.sequence_no <> COALESCE(latest_sequence, 0) + 1 THEN
|
|
RAISE EXCEPTION 'practice attempt sequence must be contiguous'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
|
|
IF NEW.outcome = 'passed' AND NEW.scenario_novelty = 'unseen_transfer' THEN
|
|
IF length(btrim(COALESCE(NEW.utterance_template_id, ''))) = 0 THEN
|
|
RAISE EXCEPTION 'unseen transfer pass requires a nonblank utterance template id'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM app.practice_attempt_evidence a
|
|
WHERE a.episode_submission_id = NEW.episode_submission_id
|
|
AND a.sequence_no < NEW.sequence_no
|
|
AND a.scenario_novelty = 'familiar'
|
|
AND a.outcome = 'passed'
|
|
) INTO familiar_pass_exists;
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM app.practice_attempt_evidence a
|
|
WHERE a.episode_submission_id = NEW.episode_submission_id
|
|
AND a.sequence_no < NEW.sequence_no
|
|
AND a.scenario_novelty = 'familiar'
|
|
AND a.utterance_template_id = NEW.utterance_template_id
|
|
) INTO repeated_template_exists;
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM app.practice_attempt_evidence a
|
|
WHERE a.episode_submission_id = NEW.episode_submission_id
|
|
AND a.sequence_no < NEW.sequence_no
|
|
AND a.scenario_novelty = 'familiar'
|
|
AND a.scenario_variant_id = NEW.scenario_variant_id
|
|
) INTO repeated_variant_exists;
|
|
IF NOT familiar_pass_exists OR repeated_template_exists OR repeated_variant_exists THEN
|
|
RAISE EXCEPTION 'unseen transfer pass cannot reuse familiar variant or memorized phrase'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION audit.enforce_competency_snapshot_chain()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
latest_snapshot_id UUID;
|
|
latest_snapshot_no INT;
|
|
latest_graph JSONB;
|
|
source_progress TEXT;
|
|
source_competency TEXT;
|
|
source_learner UUID;
|
|
source_session UUID;
|
|
promoted_ids TEXT[];
|
|
BEGIN
|
|
PERFORM pg_advisory_xact_lock(hashtextextended(NEW.learner_id::TEXT, 1));
|
|
SELECT s.snapshot_id, s.snapshot_no, s.graph_payload
|
|
INTO latest_snapshot_id, latest_snapshot_no, latest_graph
|
|
FROM app.competency_graph_snapshot s
|
|
WHERE s.learner_id = NEW.learner_id
|
|
ORDER BY s.snapshot_no DESC
|
|
LIMIT 1;
|
|
|
|
IF latest_snapshot_id IS NULL THEN
|
|
IF NEW.snapshot_no <> 1 OR NEW.supersedes_snapshot_id IS NOT NULL THEN
|
|
RAISE EXCEPTION 'first competency graph snapshot must be revision 1 without supersedes'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
ELSIF NEW.snapshot_no <> latest_snapshot_no + 1
|
|
OR NEW.supersedes_snapshot_id IS DISTINCT FROM latest_snapshot_id THEN
|
|
RAISE EXCEPTION 'competency graph snapshot must supersede the latest revision in order'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
|
|
IF NEW.source_prescription_submission_id IS NOT NULL THEN
|
|
SELECT p.learner_id, p.session_id INTO source_learner, source_session
|
|
FROM app.practice_prescription_submission p
|
|
WHERE p.submission_id = NEW.source_prescription_submission_id;
|
|
ELSE
|
|
SELECT e.learner_id, e.session_id INTO source_learner, source_session
|
|
FROM app.practice_episode_submission e
|
|
WHERE e.episode_submission_id = NEW.source_episode_submission_id;
|
|
END IF;
|
|
IF source_learner IS NULL
|
|
OR source_learner IS DISTINCT FROM NEW.learner_id
|
|
OR source_session IS DISTINCT FROM NEW.session_id THEN
|
|
RAISE EXCEPTION 'competency graph snapshot source must match learner and session'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
|
|
SELECT array_agg(new_state->>'competency_id')
|
|
INTO promoted_ids
|
|
FROM jsonb_array_elements(COALESCE(NEW.graph_payload->'states', '[]'::JSONB)) new_state
|
|
WHERE new_state->>'band' = 'transfer_verified'
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM jsonb_array_elements(COALESCE(latest_graph->'states', '[]'::JSONB)) old_state
|
|
WHERE old_state->>'competency_id' = new_state->>'competency_id'
|
|
AND old_state->>'band' = 'transfer_verified'
|
|
);
|
|
|
|
IF cardinality(promoted_ids) > 0 THEN
|
|
IF NEW.source_episode_submission_id IS NULL THEN
|
|
RAISE EXCEPTION 'new transfer_verified competency requires a mastered practice episode'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
SELECT e.progress, e.assessment_payload->>'competency_id'
|
|
INTO source_progress, source_competency
|
|
FROM app.practice_episode_submission e
|
|
WHERE e.episode_submission_id = NEW.source_episode_submission_id;
|
|
IF source_progress IS DISTINCT FROM 'mastered'
|
|
OR cardinality(promoted_ids) <> 1
|
|
OR promoted_ids[1] IS DISTINCT FROM source_competency THEN
|
|
RAISE EXCEPTION 'competency transfer promotion must match its mastered episode'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION audit.enforce_practice_curriculum_decision()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
selected_competency TEXT;
|
|
selected_novelty TEXT;
|
|
selected_difficulty INT;
|
|
snapshot_state JSONB;
|
|
BEGIN
|
|
SELECT p.competency_id, p.scenario_novelty, p.difficulty_level
|
|
INTO selected_competency, selected_novelty, selected_difficulty
|
|
FROM app.practice_prescription p
|
|
WHERE p.prescription_record_id = NEW.selected_prescription_record_id
|
|
AND p.learner_id = NEW.learner_id;
|
|
SELECT state INTO snapshot_state
|
|
FROM app.competency_graph_snapshot s,
|
|
jsonb_array_elements(COALESCE(s.graph_payload->'states', '[]'::JSONB)) state
|
|
WHERE s.snapshot_id = NEW.source_snapshot_id
|
|
AND state->>'competency_id' = selected_competency;
|
|
IF selected_competency IS NULL OR snapshot_state IS NULL THEN
|
|
RAISE EXCEPTION 'curriculum decision must target a competency in its graph snapshot'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
IF NEW.decision_payload->>'selected_prescription_id' IS DISTINCT FROM (
|
|
SELECT p.prescription_key FROM app.practice_prescription p
|
|
WHERE p.prescription_record_id = NEW.selected_prescription_record_id
|
|
) THEN
|
|
RAISE EXCEPTION 'curriculum decision payload must match selected prescription'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
IF selected_novelty = 'familiar'
|
|
AND COALESCE((snapshot_state->>'familiar_demonstrations')::INT, 0) >= 2
|
|
AND selected_difficulty <= COALESCE(
|
|
(snapshot_state->>'highest_familiar_difficulty')::INT, 0
|
|
) THEN
|
|
RAISE EXCEPTION 'curriculum decision cannot reward repeated easy familiar practice'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION audit.enforce_practice_teacher_correction()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
target app.practice_attempt_evidence%ROWTYPE;
|
|
latest_correction_id UUID;
|
|
latest_correction_no INT;
|
|
familiar_pass_exists BOOLEAN;
|
|
repeated_template_exists BOOLEAN;
|
|
repeated_variant_exists BOOLEAN;
|
|
BEGIN
|
|
PERFORM pg_advisory_xact_lock(hashtextextended(NEW.attempt_record_id::TEXT, 2));
|
|
SELECT * INTO target
|
|
FROM app.practice_attempt_evidence a
|
|
WHERE a.attempt_record_id = NEW.attempt_record_id;
|
|
IF target.attempt_record_id IS NULL
|
|
OR target.episode_submission_id IS DISTINCT FROM NEW.episode_submission_id
|
|
OR target.session_id IS DISTINCT FROM NEW.session_id
|
|
OR target.learner_id IS DISTINCT FROM NEW.learner_id THEN
|
|
RAISE EXCEPTION 'teacher correction must target one visible practice attempt'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
SELECT c.correction_id, c.correction_no
|
|
INTO latest_correction_id, latest_correction_no
|
|
FROM app.practice_teacher_correction c
|
|
WHERE c.attempt_record_id = NEW.attempt_record_id
|
|
ORDER BY c.correction_no DESC
|
|
LIMIT 1;
|
|
IF latest_correction_id IS NULL THEN
|
|
IF NEW.correction_no <> 1 OR NEW.supersedes_correction_id IS NOT NULL THEN
|
|
RAISE EXCEPTION 'first teacher correction must be revision 1 without supersedes'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
ELSIF NEW.correction_no <> latest_correction_no + 1
|
|
OR NEW.supersedes_correction_id IS DISTINCT FROM latest_correction_id THEN
|
|
RAISE EXCEPTION 'teacher correction must supersede the latest revision in order'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
|
|
IF NEW.corrected_outcome = 'passed' THEN
|
|
IF target.criterion_status <> 'observed'
|
|
OR target.client_response NOT IN ('engaged','explicit_alignment')
|
|
OR target.uncertainty > 0.5 THEN
|
|
RAISE EXCEPTION 'teacher pass correction requires observed behavior and engaged client evidence'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
IF target.scenario_novelty = 'unseen_transfer' THEN
|
|
IF length(btrim(COALESCE(target.utterance_template_id, ''))) = 0 THEN
|
|
RAISE EXCEPTION 'teacher transfer correction requires a nonblank utterance template id'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM app.practice_attempt_evidence prior
|
|
WHERE prior.episode_submission_id = target.episode_submission_id
|
|
AND prior.sequence_no < target.sequence_no
|
|
AND prior.scenario_novelty = 'familiar'
|
|
AND COALESCE(
|
|
(
|
|
SELECT correction.corrected_outcome
|
|
FROM app.practice_teacher_correction correction
|
|
WHERE correction.attempt_record_id = prior.attempt_record_id
|
|
ORDER BY correction.correction_no DESC
|
|
LIMIT 1
|
|
),
|
|
prior.outcome
|
|
) = 'passed'
|
|
) INTO familiar_pass_exists;
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM app.practice_attempt_evidence prior
|
|
WHERE prior.episode_submission_id = target.episode_submission_id
|
|
AND prior.sequence_no < target.sequence_no
|
|
AND prior.scenario_novelty = 'familiar'
|
|
AND prior.utterance_template_id = target.utterance_template_id
|
|
) INTO repeated_template_exists;
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM app.practice_attempt_evidence prior
|
|
WHERE prior.episode_submission_id = target.episode_submission_id
|
|
AND prior.sequence_no < target.sequence_no
|
|
AND prior.scenario_novelty = 'familiar'
|
|
AND prior.scenario_variant_id = target.scenario_variant_id
|
|
) INTO repeated_variant_exists;
|
|
IF NOT familiar_pass_exists OR repeated_template_exists OR repeated_variant_exists THEN
|
|
RAISE EXCEPTION 'teacher correction cannot bypass unseen transfer or memorized phrase gates'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
END IF;
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
DO $$
|
|
DECLARE
|
|
table_name TEXT;
|
|
BEGIN
|
|
FOREACH table_name IN ARRAY ARRAY[
|
|
'practice_prescription_submission',
|
|
'practice_coaching_card',
|
|
'practice_prescription',
|
|
'practice_episode_submission',
|
|
'practice_attempt_evidence',
|
|
'competency_graph_snapshot',
|
|
'practice_curriculum_decision_event',
|
|
'practice_teacher_correction'
|
|
] LOOP
|
|
EXECUTE format('DROP TRIGGER IF EXISTS trg_%s_session_learner ON app.%I', table_name, table_name);
|
|
EXECUTE format(
|
|
'CREATE TRIGGER trg_%s_session_learner BEFORE INSERT ON app.%I '
|
|
'FOR EACH ROW EXECUTE FUNCTION audit.enforce_practice_session_learner()',
|
|
table_name, table_name
|
|
);
|
|
EXECUTE format('DROP TRIGGER IF EXISTS trg_%s_append_only ON app.%I', table_name, table_name);
|
|
EXECUTE format(
|
|
'CREATE TRIGGER trg_%s_append_only BEFORE UPDATE OR DELETE ON app.%I '
|
|
'FOR EACH ROW EXECUTE FUNCTION audit.reject_measurement_mutation()',
|
|
table_name, table_name
|
|
);
|
|
END LOOP;
|
|
END;
|
|
$$;
|
|
|
|
DO $$
|
|
DECLARE
|
|
table_name TEXT;
|
|
BEGIN
|
|
FOREACH table_name IN ARRAY ARRAY[
|
|
'practice_coaching_card',
|
|
'practice_prescription',
|
|
'practice_episode_submission',
|
|
'practice_attempt_evidence',
|
|
'competency_graph_snapshot',
|
|
'practice_teacher_correction'
|
|
] LOOP
|
|
EXECUTE format('DROP TRIGGER IF EXISTS trg_%s_evidence_ownership ON app.%I', table_name, table_name);
|
|
EXECUTE format(
|
|
'CREATE TRIGGER trg_%s_evidence_ownership BEFORE INSERT ON app.%I '
|
|
'FOR EACH ROW EXECUTE FUNCTION audit.enforce_practice_evidence_turn_ownership()',
|
|
table_name, table_name
|
|
);
|
|
END LOOP;
|
|
END;
|
|
$$;
|
|
|
|
DROP TRIGGER IF EXISTS trg_practice_episode_transfer_gate ON app.practice_episode_submission;
|
|
CREATE TRIGGER trg_practice_episode_transfer_gate
|
|
BEFORE INSERT ON app.practice_episode_submission
|
|
FOR EACH ROW EXECUTE FUNCTION audit.enforce_practice_episode_transfer_gate();
|
|
DROP TRIGGER IF EXISTS trg_practice_attempt_contract ON app.practice_attempt_evidence;
|
|
CREATE TRIGGER trg_practice_attempt_contract
|
|
BEFORE INSERT ON app.practice_attempt_evidence
|
|
FOR EACH ROW EXECUTE FUNCTION audit.enforce_practice_attempt_sequence_and_transfer();
|
|
DROP TRIGGER IF EXISTS trg_competency_snapshot_chain ON app.competency_graph_snapshot;
|
|
CREATE TRIGGER trg_competency_snapshot_chain
|
|
BEFORE INSERT ON app.competency_graph_snapshot
|
|
FOR EACH ROW EXECUTE FUNCTION audit.enforce_competency_snapshot_chain();
|
|
DROP TRIGGER IF EXISTS trg_practice_curriculum_decision_contract
|
|
ON app.practice_curriculum_decision_event;
|
|
CREATE TRIGGER trg_practice_curriculum_decision_contract
|
|
BEFORE INSERT ON app.practice_curriculum_decision_event
|
|
FOR EACH ROW EXECUTE FUNCTION audit.enforce_practice_curriculum_decision();
|
|
DROP TRIGGER IF EXISTS trg_practice_teacher_correction_contract
|
|
ON app.practice_teacher_correction;
|
|
CREATE TRIGGER trg_practice_teacher_correction_contract
|
|
BEFORE INSERT ON app.practice_teacher_correction
|
|
FOR EACH ROW EXECUTE FUNCTION audit.enforce_practice_teacher_correction();
|
|
|
|
DO $$
|
|
DECLARE
|
|
table_name TEXT;
|
|
BEGIN
|
|
FOREACH table_name IN ARRAY ARRAY[
|
|
'practice_prescription_submission',
|
|
'practice_coaching_card',
|
|
'practice_prescription',
|
|
'practice_episode_submission',
|
|
'practice_attempt_evidence',
|
|
'competency_graph_snapshot',
|
|
'practice_curriculum_decision_event',
|
|
'practice_teacher_correction'
|
|
] LOOP
|
|
EXECUTE format('ALTER TABLE app.%I ENABLE ROW LEVEL SECURITY', table_name);
|
|
END LOOP;
|
|
END;
|
|
$$;
|
|
|
|
-- Prescription authoring is evaluator-only. Human reads are learner-self or cohort-scoped.
|
|
DROP POLICY IF EXISTS p_practice_prescription_submission_select
|
|
ON app.practice_prescription_submission;
|
|
DROP POLICY IF EXISTS p_practice_prescription_submission_insert
|
|
ON app.practice_prescription_submission;
|
|
CREATE POLICY p_practice_prescription_submission_select
|
|
ON app.practice_prescription_submission 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 (
|
|
app.current_role_name() = 'admin'
|
|
OR (app.current_role_name() = 'learner' AND learner_id = app.current_uid())
|
|
OR (app.current_role_name() = 'instructor' AND EXISTS (
|
|
SELECT 1 FROM app.app_user u
|
|
WHERE u.user_id = learner_id
|
|
AND u.cohort = current_setting('app.current_cohort', true)
|
|
))
|
|
))
|
|
);
|
|
CREATE POLICY p_practice_prescription_submission_insert
|
|
ON app.practice_prescription_submission FOR INSERT WITH CHECK (
|
|
app.is_ai_context()
|
|
AND current_setting('app.current_ai_view', true) = 'evaluator'
|
|
AND created_by_role = 'agent'
|
|
);
|
|
|
|
DO $$
|
|
DECLARE
|
|
table_name TEXT;
|
|
BEGIN
|
|
FOREACH table_name IN ARRAY ARRAY[
|
|
'practice_coaching_card',
|
|
'practice_prescription'
|
|
] LOOP
|
|
EXECUTE format('DROP POLICY IF EXISTS p_%s_select ON app.%I', table_name, table_name);
|
|
EXECUTE format('DROP POLICY IF EXISTS p_%s_insert ON app.%I', table_name, table_name);
|
|
EXECUTE format(
|
|
'CREATE POLICY p_%s_select ON app.%I 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 (app.current_role_name() = ''admin'' '
|
|
'OR (app.current_role_name() = ''learner'' AND learner_id = app.current_uid()) '
|
|
'OR (app.current_role_name() = ''instructor'' AND EXISTS ('
|
|
'SELECT 1 FROM app.app_user u WHERE u.user_id = learner_id '
|
|
'AND u.cohort = current_setting(''app.current_cohort'', true))))))',
|
|
table_name, table_name
|
|
);
|
|
EXECUTE format(
|
|
'CREATE POLICY p_%s_insert ON app.%I FOR INSERT WITH CHECK ('
|
|
'app.is_ai_context() AND current_setting(''app.current_ai_view'', true) = ''evaluator'')',
|
|
table_name, table_name
|
|
);
|
|
END LOOP;
|
|
END;
|
|
$$;
|
|
|
|
-- Learners append their own attempt transaction; evaluator/migration may append internally.
|
|
DO $$
|
|
DECLARE
|
|
table_name TEXT;
|
|
learner_created_guard TEXT;
|
|
ai_select_guard TEXT;
|
|
BEGIN
|
|
FOREACH table_name IN ARRAY ARRAY[
|
|
'practice_episode_submission',
|
|
'practice_attempt_evidence',
|
|
'competency_graph_snapshot',
|
|
'practice_curriculum_decision_event'
|
|
] LOOP
|
|
learner_created_guard := CASE
|
|
WHEN table_name = 'practice_attempt_evidence' THEN ''
|
|
ELSE ' AND created_by_role = ''learner'''
|
|
END;
|
|
ai_select_guard := CASE
|
|
WHEN table_name = 'practice_episode_submission' THEN
|
|
'current_setting(''app.current_ai_view'', true) = ANY(visible_to)'
|
|
WHEN table_name = 'practice_attempt_evidence' THEN
|
|
'EXISTS (SELECT 1 FROM app.practice_episode_submission parent '
|
|
'WHERE parent.episode_submission_id = app.practice_attempt_evidence.episode_submission_id '
|
|
'AND current_setting(''app.current_ai_view'', true) = ANY(parent.visible_to))'
|
|
ELSE
|
|
'current_setting(''app.current_ai_view'', true) '
|
|
'IN (''counselor'',''evaluator'',''supervisor'',''research'')'
|
|
END;
|
|
EXECUTE format('DROP POLICY IF EXISTS p_%s_select ON app.%I', table_name, table_name);
|
|
EXECUTE format('DROP POLICY IF EXISTS p_%s_insert ON app.%I', table_name, table_name);
|
|
EXECUTE format(
|
|
'CREATE POLICY p_%s_select ON app.%I FOR SELECT USING ('
|
|
'(app.is_ai_context() AND %s) OR (NOT app.is_ai_context() AND ('
|
|
'app.current_role_name() = ''admin'' '
|
|
'OR (app.current_role_name() = ''learner'' AND learner_id = app.current_uid()) '
|
|
'OR (app.current_role_name() = ''instructor'' AND EXISTS ('
|
|
'SELECT 1 FROM app.app_user u WHERE u.user_id = learner_id '
|
|
'AND u.cohort = current_setting(''app.current_cohort'', true))))))',
|
|
table_name, table_name, ai_select_guard
|
|
);
|
|
EXECUTE format(
|
|
'CREATE POLICY p_%s_insert ON app.%I FOR INSERT WITH CHECK ('
|
|
'(app.is_ai_context() AND current_setting(''app.current_ai_view'', true) = ''evaluator'') OR '
|
|
'(NOT app.is_ai_context() AND app.current_role_name() = ''learner'' '
|
|
'AND learner_id = app.current_uid()%s))',
|
|
table_name, table_name, learner_created_guard
|
|
);
|
|
END LOOP;
|
|
END;
|
|
$$;
|
|
|
|
DROP POLICY IF EXISTS p_practice_teacher_correction_select
|
|
ON app.practice_teacher_correction;
|
|
DROP POLICY IF EXISTS p_practice_teacher_correction_insert
|
|
ON app.practice_teacher_correction;
|
|
CREATE POLICY p_practice_teacher_correction_select
|
|
ON app.practice_teacher_correction FOR SELECT USING (
|
|
(app.is_ai_context() AND EXISTS (
|
|
SELECT 1
|
|
FROM app.practice_attempt_evidence attempt
|
|
JOIN app.practice_episode_submission episode
|
|
ON episode.episode_submission_id = attempt.episode_submission_id
|
|
WHERE attempt.attempt_record_id = app.practice_teacher_correction.attempt_record_id
|
|
AND current_setting('app.current_ai_view', true) = ANY(episode.visible_to)
|
|
))
|
|
OR (NOT app.is_ai_context() AND (
|
|
app.current_role_name() = 'admin'
|
|
OR (app.current_role_name() = 'learner' AND learner_id = app.current_uid())
|
|
OR (app.current_role_name() = 'instructor' AND EXISTS (
|
|
SELECT 1 FROM app.app_user u
|
|
WHERE u.user_id = learner_id
|
|
AND u.cohort = current_setting('app.current_cohort', true)
|
|
))
|
|
))
|
|
);
|
|
CREATE POLICY p_practice_teacher_correction_insert
|
|
ON app.practice_teacher_correction FOR INSERT WITH CHECK (
|
|
NOT app.is_ai_context()
|
|
AND (
|
|
app.current_role_name() = 'admin'
|
|
OR (
|
|
app.current_role_name() = 'instructor'
|
|
AND created_by_uid = app.current_uid()
|
|
AND created_by_role = 'instructor'
|
|
AND EXISTS (
|
|
SELECT 1 FROM app.app_user u
|
|
WHERE u.user_id = learner_id
|
|
AND u.cohort = current_setting('app.current_cohort', true)
|
|
)
|
|
)
|
|
)
|
|
);
|