829 lines
38 KiB
PL/PgSQL
829 lines
38 KiB
PL/PgSQL
-- Outcome & Alliance OS G5: Calibration Mirror & unseen-transfer ledgers.
|
|
-- Prerequisites: 02_schema.sql, 04_audit_eval_rls.sql, 07_measurement_foundation.sql.
|
|
|
|
INSERT INTO app.measurement_instrument (
|
|
instrument_id, instrument_version, name_ko, instrument_kind, construct,
|
|
validation_basis, scoring_schema, metadata
|
|
) VALUES
|
|
(
|
|
'calibration-mirror-g5', '1.0.0', '외부평가 전 자기보정',
|
|
'training_metric', 'self_calibration',
|
|
'외부평가 공개 전 자기예측과 독립 수행 관측의 차이를 역량별로 추적한다.',
|
|
'{"prediction_probability":{"min":0,"max":1},"calibration_error":{"min":0,"max":1}}'::jsonb,
|
|
'{"clinical_claim_allowed":false,"aggregate_total_forbidden":true}'::jsonb
|
|
),
|
|
(
|
|
'unseen-transfer-g5', '1.0.0', '미지 사례 역량 전이',
|
|
'training_metric', 'transfer',
|
|
'훈련 문구와 분리된 합성 미지 사례에서 역량 유지와 subgroup drift를 추적한다.',
|
|
'{"transfer_success_rate":{"min":0,"max":1}}'::jsonb,
|
|
'{"clinical_claim_allowed":false,"data_classification":"synthetic_educational","aggregate_total_forbidden":true}'::jsonb
|
|
),
|
|
(
|
|
'vignette.calibration-self-prediction', '1.0.0', '외부평가 전 자기보정 (호환용)',
|
|
'training_metric', 'self_calibration',
|
|
'외부평가 공개 전 자기예측과 독립 수행 관측의 차이를 역량별로 추적한다.',
|
|
'{"prediction_probability":{"min":0,"max":1},"calibration_error":{"min":0,"max":1}}'::jsonb,
|
|
'{"clinical_claim_allowed":false,"aggregate_total_forbidden":true}'::jsonb
|
|
)
|
|
ON CONFLICT (instrument_id, instrument_version) DO NOTHING;
|
|
|
|
CREATE TABLE IF NOT EXISTS app.calibration_prediction_history (
|
|
history_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,
|
|
competency_id TEXT NOT NULL CHECK (competency_id ~ '^competency\.[a-z0-9_.-]+$'),
|
|
practice_block_id TEXT NOT NULL CHECK (practice_block_id ~ '^oas-g5-block-[a-z0-9-]+$'),
|
|
scenario_variant_id TEXT NOT NULL CHECK (length(btrim(scenario_variant_id)) > 0),
|
|
phrase_family_id TEXT NOT NULL CHECK (length(btrim(phrase_family_id)) > 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_role TEXT NOT NULL DEFAULT 'learner'
|
|
CHECK (created_by_role IN ('learner','migration')),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
UNIQUE (learner_id, practice_block_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_calibration_prediction_history_learner
|
|
ON app.calibration_prediction_history(learner_id, created_at, history_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.calibration_prediction_revision (
|
|
prediction_revision_id UUID PRIMARY KEY,
|
|
submission_id UUID NOT NULL UNIQUE,
|
|
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
|
history_id UUID NOT NULL REFERENCES app.calibration_prediction_history(history_id) ON DELETE RESTRICT,
|
|
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,
|
|
revision_no INT NOT NULL CHECK (revision_no >= 1),
|
|
supersedes_prediction_revision_id UUID
|
|
REFERENCES app.calibration_prediction_revision(prediction_revision_id) ON DELETE RESTRICT,
|
|
predicted_success_probability DOUBLE PRECISION NOT NULL
|
|
CHECK (predicted_success_probability BETWEEN 0 AND 1),
|
|
confidence DOUBLE PRECISION NOT NULL CHECK (confidence BETWEEN 0 AND 1),
|
|
recorded_sequence INT NOT NULL CHECK (recorded_sequence >= 1),
|
|
revision_reason TEXT NOT NULL CHECK (length(btrim(revision_reason)) > 0),
|
|
source_kind TEXT NOT NULL DEFAULT 'learner_reported'
|
|
CHECK (source_kind = 'learner_reported'),
|
|
perspective TEXT NOT NULL DEFAULT 'learner_self_report'
|
|
CHECK (perspective = 'learner_self_report'),
|
|
model_run_id UUID CHECK (model_run_id IS NULL),
|
|
instrument_id TEXT NOT NULL,
|
|
instrument_version TEXT NOT NULL,
|
|
evidence_turn_ids UUID[] NOT NULL DEFAULT '{}',
|
|
created_by_role TEXT NOT NULL DEFAULT 'learner' CHECK (created_by_role = 'learner'),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
UNIQUE (history_id, revision_no),
|
|
UNIQUE (history_id, recorded_sequence),
|
|
UNIQUE (supersedes_prediction_revision_id),
|
|
UNIQUE (prediction_revision_id, history_id, session_id, learner_id),
|
|
FOREIGN KEY (instrument_id, instrument_version)
|
|
REFERENCES app.measurement_instrument(instrument_id, instrument_version) ON DELETE RESTRICT,
|
|
CHECK (prediction_revision_id IS DISTINCT FROM supersedes_prediction_revision_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_calibration_prediction_revision_latest
|
|
ON app.calibration_prediction_revision(history_id, revision_no DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.calibration_prediction_lock (
|
|
lock_id UUID PRIMARY KEY,
|
|
submission_id UUID NOT NULL UNIQUE,
|
|
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
|
history_id UUID NOT NULL UNIQUE
|
|
REFERENCES app.calibration_prediction_history(history_id) ON DELETE RESTRICT,
|
|
prediction_revision_id UUID NOT NULL UNIQUE,
|
|
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,
|
|
locked_sequence INT NOT NULL CHECK (locked_sequence >= 1),
|
|
created_by_role TEXT NOT NULL DEFAULT 'learner' CHECK (created_by_role = 'learner'),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
FOREIGN KEY (prediction_revision_id, history_id, session_id, learner_id)
|
|
REFERENCES app.calibration_prediction_revision(
|
|
prediction_revision_id, history_id, session_id, learner_id
|
|
) ON DELETE RESTRICT,
|
|
UNIQUE (lock_id, history_id, session_id, learner_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.calibration_performance_observation (
|
|
observation_id UUID PRIMARY KEY,
|
|
submission_id UUID NOT NULL UNIQUE,
|
|
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
|
history_id UUID NOT NULL UNIQUE
|
|
REFERENCES app.calibration_prediction_history(history_id) ON DELETE RESTRICT,
|
|
prediction_lock_id UUID NOT NULL UNIQUE,
|
|
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_.-]+$'),
|
|
practice_block_id TEXT NOT NULL CHECK (practice_block_id ~ '^oas-g5-block-[a-z0-9-]+$'),
|
|
scenario_variant_id TEXT NOT NULL CHECK (length(btrim(scenario_variant_id)) > 0),
|
|
phrase_family_id TEXT NOT NULL CHECK (length(btrim(phrase_family_id)) > 0),
|
|
status TEXT NOT NULL CHECK (status IN ('passed','failed','insufficient_evidence')),
|
|
source_kind TEXT NOT NULL CHECK (source_kind IN ('model_inferred','observed_runtime')),
|
|
perspective TEXT NOT NULL CHECK (
|
|
perspective IN ('independent_observer','runtime_observation')
|
|
),
|
|
model_run_id UUID REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT,
|
|
instrument_id TEXT NOT NULL,
|
|
instrument_version TEXT NOT NULL,
|
|
uncertainty DOUBLE PRECISION NOT NULL CHECK (uncertainty BETWEEN 0 AND 1),
|
|
evidence_turn_ids UUID[] NOT NULL DEFAULT '{}',
|
|
counterevidence TEXT[] NOT NULL DEFAULT '{}',
|
|
revealed_sequence INT NOT NULL CHECK (revealed_sequence >= 1),
|
|
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(),
|
|
FOREIGN KEY (prediction_lock_id, history_id, session_id, learner_id)
|
|
REFERENCES app.calibration_prediction_lock(lock_id, history_id, session_id, learner_id)
|
|
ON DELETE RESTRICT,
|
|
FOREIGN KEY (instrument_id, instrument_version)
|
|
REFERENCES app.measurement_instrument(instrument_id, instrument_version) ON DELETE RESTRICT,
|
|
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')
|
|
),
|
|
CHECK (
|
|
(status = 'insufficient_evidence' AND uncertainty = 1 AND cardinality(evidence_turn_ids) = 0)
|
|
OR (status IN ('passed','failed') AND cardinality(evidence_turn_ids) > 0)
|
|
),
|
|
CHECK (status <> 'failed' OR cardinality(counterevidence) > 0),
|
|
UNIQUE (observation_id, session_id, learner_id, competency_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_calibration_observation_learner_competency
|
|
ON app.calibration_performance_observation(learner_id, competency_id, created_at);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.calibration_assessment_snapshot (
|
|
assessment_snapshot_id UUID PRIMARY KEY,
|
|
submission_id UUID NOT NULL UNIQUE,
|
|
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
|
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_.-]+$'),
|
|
snapshot_no INT NOT NULL CHECK (snapshot_no >= 1),
|
|
supersedes_assessment_snapshot_id UUID
|
|
REFERENCES app.calibration_assessment_snapshot(assessment_snapshot_id) ON DELETE RESTRICT,
|
|
source_observation_ids UUID[] NOT NULL CHECK (cardinality(source_observation_ids) > 0),
|
|
assessment_payload JSONB NOT NULL CHECK (jsonb_typeof(assessment_payload) = 'object'),
|
|
model_run_id UUID NOT NULL REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT,
|
|
instrument_id TEXT NOT NULL,
|
|
instrument_version TEXT NOT NULL,
|
|
evidence_turn_ids UUID[] 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(),
|
|
FOREIGN KEY (instrument_id, instrument_version)
|
|
REFERENCES app.measurement_instrument(instrument_id, instrument_version) ON DELETE RESTRICT,
|
|
UNIQUE (learner_id, competency_id, snapshot_no),
|
|
UNIQUE (supersedes_assessment_snapshot_id),
|
|
UNIQUE (assessment_snapshot_id, session_id, learner_id, competency_id),
|
|
CHECK (assessment_snapshot_id IS DISTINCT FROM supersedes_assessment_snapshot_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_calibration_assessment_latest
|
|
ON app.calibration_assessment_snapshot(learner_id, competency_id, snapshot_no DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.calibration_metacognitive_prescription (
|
|
prescription_id UUID PRIMARY KEY,
|
|
assessment_snapshot_id UUID NOT NULL UNIQUE,
|
|
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_.-]+$'),
|
|
prescription_payload JSONB NOT NULL CHECK (jsonb_typeof(prescription_payload) = 'object'),
|
|
model_run_id UUID NOT NULL REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT,
|
|
instrument_id TEXT NOT NULL,
|
|
instrument_version TEXT NOT NULL,
|
|
evidence_turn_ids UUID[] 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(),
|
|
FOREIGN KEY (assessment_snapshot_id, session_id, learner_id, competency_id)
|
|
REFERENCES app.calibration_assessment_snapshot(
|
|
assessment_snapshot_id, session_id, learner_id, competency_id
|
|
) ON DELETE RESTRICT,
|
|
FOREIGN KEY (instrument_id, instrument_version)
|
|
REFERENCES app.measurement_instrument(instrument_id, instrument_version) ON DELETE RESTRICT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.calibration_transfer_suite (
|
|
transfer_suite_record_id UUID PRIMARY KEY,
|
|
submission_id UUID NOT NULL UNIQUE,
|
|
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
|
suite_key TEXT NOT NULL CHECK (suite_key ~ '^oas-g5-suite-[a-z0-9-]+$'),
|
|
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,
|
|
training_phrase_family_ids TEXT[] NOT NULL CHECK (cardinality(training_phrase_family_ids) > 0),
|
|
model_run_id UUID NOT NULL REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT,
|
|
instrument_id TEXT NOT NULL,
|
|
instrument_version TEXT NOT NULL,
|
|
data_classification TEXT NOT NULL DEFAULT 'synthetic_educational'
|
|
CHECK (data_classification = 'synthetic_educational'),
|
|
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE),
|
|
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(),
|
|
FOREIGN KEY (instrument_id, instrument_version)
|
|
REFERENCES app.measurement_instrument(instrument_id, instrument_version) ON DELETE RESTRICT,
|
|
UNIQUE (learner_id, suite_key),
|
|
UNIQUE (transfer_suite_record_id, session_id, learner_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.calibration_transfer_trial (
|
|
transfer_trial_record_id UUID PRIMARY KEY,
|
|
transfer_suite_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,
|
|
trial_key TEXT NOT NULL CHECK (trial_key ~ '^oas-g5-transfer-[a-z0-9-]+$'),
|
|
competency_id TEXT NOT NULL CHECK (competency_id ~ '^competency\.[a-z0-9_.-]+$'),
|
|
scenario_variant_id TEXT NOT NULL CHECK (length(btrim(scenario_variant_id)) > 0),
|
|
scenario_novelty TEXT NOT NULL DEFAULT 'unseen_transfer'
|
|
CHECK (scenario_novelty = 'unseen_transfer'),
|
|
context_variant TEXT NOT NULL CHECK (length(btrim(context_variant)) > 0),
|
|
relationship_style TEXT NOT NULL CHECK (
|
|
relationship_style IN ('collaborative','withdrawn','confrontational','ambivalent')
|
|
),
|
|
difficulty_level INT NOT NULL CHECK (difficulty_level BETWEEN 1 AND 5),
|
|
expression_variant TEXT NOT NULL CHECK (length(btrim(expression_variant)) > 0),
|
|
synthetic_subgroup TEXT NOT NULL CHECK (synthetic_subgroup ~ '^synthetic-[a-z0-9-]+$'),
|
|
scenario_family_id TEXT NOT NULL CHECK (length(btrim(scenario_family_id)) > 0),
|
|
phrase_family_id TEXT NOT NULL CHECK (length(btrim(phrase_family_id)) > 0),
|
|
status TEXT NOT NULL CHECK (status IN ('passed','failed','insufficient_evidence')),
|
|
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,
|
|
instrument_id TEXT NOT NULL,
|
|
instrument_version TEXT NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
FOREIGN KEY (transfer_suite_record_id, session_id, learner_id)
|
|
REFERENCES app.calibration_transfer_suite(transfer_suite_record_id, session_id, learner_id)
|
|
ON DELETE RESTRICT,
|
|
FOREIGN KEY (instrument_id, instrument_version)
|
|
REFERENCES app.measurement_instrument(instrument_id, instrument_version) ON DELETE RESTRICT,
|
|
UNIQUE (transfer_suite_record_id, trial_key),
|
|
UNIQUE (transfer_trial_record_id, transfer_suite_record_id, session_id, learner_id, competency_id),
|
|
CHECK (
|
|
(status = 'insufficient_evidence' AND uncertainty = 1 AND cardinality(evidence_turn_ids) = 0)
|
|
OR (status IN ('passed','failed') AND cardinality(evidence_turn_ids) > 0)
|
|
),
|
|
CHECK (status <> 'failed' OR cardinality(counterevidence) > 0)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_calibration_transfer_trial_suite_competency
|
|
ON app.calibration_transfer_trial(transfer_suite_record_id, competency_id, created_at);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.calibration_transfer_assessment (
|
|
transfer_assessment_id UUID PRIMARY KEY,
|
|
transfer_suite_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_.-]+$'),
|
|
source_trial_ids UUID[] NOT NULL CHECK (cardinality(source_trial_ids) > 0),
|
|
assessment_payload JSONB NOT NULL CHECK (jsonb_typeof(assessment_payload) = 'object'),
|
|
evidence_turn_ids UUID[] NOT NULL DEFAULT '{}',
|
|
model_run_id UUID NOT NULL REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT,
|
|
instrument_id TEXT NOT NULL,
|
|
instrument_version TEXT NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
FOREIGN KEY (transfer_suite_record_id, session_id, learner_id)
|
|
REFERENCES app.calibration_transfer_suite(transfer_suite_record_id, session_id, learner_id)
|
|
ON DELETE RESTRICT,
|
|
FOREIGN KEY (instrument_id, instrument_version)
|
|
REFERENCES app.measurement_instrument(instrument_id, instrument_version) ON DELETE RESTRICT,
|
|
UNIQUE (transfer_suite_record_id, competency_id),
|
|
UNIQUE (transfer_assessment_id, session_id, learner_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.calibration_subgroup_drift_report (
|
|
drift_report_id UUID PRIMARY KEY,
|
|
transfer_suite_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_.-]+$'),
|
|
source_trial_ids UUID[] NOT NULL CHECK (cardinality(source_trial_ids) > 0),
|
|
report_payload JSONB NOT NULL CHECK (jsonb_typeof(report_payload) = 'object'),
|
|
model_run_id UUID NOT NULL REFERENCES audit.model_run(model_run_id) ON DELETE RESTRICT,
|
|
instrument_id TEXT NOT NULL,
|
|
instrument_version TEXT NOT NULL,
|
|
data_classification TEXT NOT NULL DEFAULT 'synthetic_educational'
|
|
CHECK (data_classification = 'synthetic_educational'),
|
|
clinical_claim_allowed BOOLEAN NOT NULL DEFAULT FALSE CHECK (clinical_claim_allowed = FALSE),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
FOREIGN KEY (transfer_suite_record_id, session_id, learner_id)
|
|
REFERENCES app.calibration_transfer_suite(transfer_suite_record_id, session_id, learner_id)
|
|
ON DELETE RESTRICT,
|
|
FOREIGN KEY (instrument_id, instrument_version)
|
|
REFERENCES app.measurement_instrument(instrument_id, instrument_version) ON DELETE RESTRICT,
|
|
UNIQUE (transfer_suite_record_id, competency_id),
|
|
UNIQUE (drift_report_id, session_id, learner_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS app.calibration_teacher_review_event (
|
|
review_id UUID PRIMARY KEY,
|
|
submission_id UUID NOT NULL UNIQUE,
|
|
content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
|
|
target_kind TEXT NOT NULL CHECK (
|
|
target_kind IN ('calibration_assessment','transfer_assessment','drift_report')
|
|
),
|
|
target_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,
|
|
review_no INT NOT NULL CHECK (review_no >= 1),
|
|
supersedes_review_id UUID
|
|
REFERENCES app.calibration_teacher_review_event(review_id) ON DELETE RESTRICT,
|
|
disposition TEXT NOT NULL CHECK (
|
|
disposition IN ('confirmed','corrected','needs_more_evidence')
|
|
),
|
|
correction_payload JSONB NOT NULL DEFAULT '{}'::JSONB
|
|
CHECK (jsonb_typeof(correction_payload) = 'object'),
|
|
review_reason TEXT NOT NULL CHECK (length(btrim(review_reason)) > 0),
|
|
evidence_turn_ids UUID[] NOT NULL DEFAULT '{}',
|
|
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 (target_kind, target_id, review_no),
|
|
UNIQUE (supersedes_review_id),
|
|
CHECK (review_id IS DISTINCT FROM supersedes_review_id),
|
|
CHECK (disposition = 'corrected' OR correction_payload = '{}'::JSONB)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_calibration_teacher_review_target
|
|
ON app.calibration_teacher_review_event(target_kind, target_id, review_no DESC);
|
|
|
|
CREATE OR REPLACE FUNCTION audit.enforce_calibration_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 'calibration row must match its session learner'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION audit.enforce_calibration_evidence_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 'calibration evidence turns must belong to its session'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION audit.enforce_calibration_payload_boundary()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
payload JSONB;
|
|
BEGIN
|
|
payload := COALESCE(
|
|
to_jsonb(NEW)->'assessment_payload',
|
|
to_jsonb(NEW)->'prescription_payload',
|
|
to_jsonb(NEW)->'report_payload',
|
|
to_jsonb(NEW)->'correction_payload',
|
|
'{}'::JSONB
|
|
);
|
|
IF payload::TEXT ~* '"(raw_transcript|transcript|text_masked|utterance_text|quote|total_score|overall_score)"[[:space:]]*:' THEN
|
|
RAISE EXCEPTION 'calibration payload cannot store transcript text or aggregate score'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION audit.enforce_calibration_prediction_revision()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
history_row app.calibration_prediction_history%ROWTYPE;
|
|
latest_id UUID;
|
|
latest_no INT;
|
|
latest_sequence INT;
|
|
BEGIN
|
|
PERFORM pg_advisory_xact_lock(hashtextextended(NEW.history_id::TEXT, 5));
|
|
SELECT * INTO history_row FROM app.calibration_prediction_history WHERE history_id = NEW.history_id;
|
|
IF history_row.history_id IS NULL
|
|
OR history_row.session_id IS DISTINCT FROM NEW.session_id
|
|
OR history_row.learner_id IS DISTINCT FROM NEW.learner_id THEN
|
|
RAISE EXCEPTION 'prediction revision must match its history anchor'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
IF EXISTS (SELECT 1 FROM app.calibration_prediction_lock l WHERE l.history_id = NEW.history_id)
|
|
OR EXISTS (SELECT 1 FROM app.calibration_performance_observation o WHERE o.history_id = NEW.history_id) THEN
|
|
RAISE EXCEPTION 'self-prediction cannot be revised after lock or external reveal'
|
|
USING ERRCODE = '55000';
|
|
END IF;
|
|
SELECT prediction_revision_id, revision_no, recorded_sequence
|
|
INTO latest_id, latest_no, latest_sequence
|
|
FROM app.calibration_prediction_revision
|
|
WHERE history_id = NEW.history_id
|
|
ORDER BY revision_no DESC
|
|
LIMIT 1;
|
|
IF latest_id IS NULL THEN
|
|
IF NEW.revision_no <> 1 OR NEW.supersedes_prediction_revision_id IS NOT NULL THEN
|
|
RAISE EXCEPTION 'first self-prediction revision must start at one without supersedes'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
ELSIF NEW.revision_no <> latest_no + 1
|
|
OR NEW.supersedes_prediction_revision_id IS DISTINCT FROM latest_id
|
|
OR NEW.recorded_sequence <= latest_sequence THEN
|
|
RAISE EXCEPTION 'self-prediction revision must be contiguous and supersede latest'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION audit.enforce_calibration_prediction_lock()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
latest_id UUID;
|
|
latest_sequence INT;
|
|
BEGIN
|
|
PERFORM pg_advisory_xact_lock(hashtextextended(NEW.history_id::TEXT, 5));
|
|
SELECT prediction_revision_id, recorded_sequence INTO latest_id, latest_sequence
|
|
FROM app.calibration_prediction_revision
|
|
WHERE history_id = NEW.history_id
|
|
ORDER BY revision_no DESC
|
|
LIMIT 1;
|
|
IF latest_id IS NULL OR NEW.prediction_revision_id IS DISTINCT FROM latest_id
|
|
OR NEW.locked_sequence < latest_sequence THEN
|
|
RAISE EXCEPTION 'prediction lock must target the latest revision at or after its sequence'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION audit.enforce_calibration_observation_reveal()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
history_row app.calibration_prediction_history%ROWTYPE;
|
|
lock_sequence INT;
|
|
BEGIN
|
|
SELECT * INTO history_row FROM app.calibration_prediction_history WHERE history_id = NEW.history_id;
|
|
SELECT locked_sequence INTO lock_sequence
|
|
FROM app.calibration_prediction_lock
|
|
WHERE lock_id = NEW.prediction_lock_id AND history_id = NEW.history_id;
|
|
IF history_row.history_id IS NULL OR lock_sequence IS NULL
|
|
OR NEW.revealed_sequence <= lock_sequence
|
|
OR NEW.session_id IS DISTINCT FROM history_row.session_id
|
|
OR NEW.learner_id IS DISTINCT FROM history_row.learner_id
|
|
OR NEW.competency_id IS DISTINCT FROM history_row.competency_id
|
|
OR NEW.practice_block_id IS DISTINCT FROM history_row.practice_block_id
|
|
OR NEW.scenario_variant_id IS DISTINCT FROM history_row.scenario_variant_id
|
|
OR NEW.phrase_family_id IS DISTINCT FROM history_row.phrase_family_id THEN
|
|
RAISE EXCEPTION 'external observation requires a matching locked prediction and later reveal sequence'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION audit.enforce_calibration_assessment_chain()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
latest_id UUID;
|
|
latest_no INT;
|
|
matched_count INT;
|
|
BEGIN
|
|
PERFORM pg_advisory_xact_lock(hashtextextended(NEW.learner_id::TEXT || ':' || NEW.competency_id, 6));
|
|
SELECT assessment_snapshot_id, snapshot_no INTO latest_id, latest_no
|
|
FROM app.calibration_assessment_snapshot
|
|
WHERE learner_id = NEW.learner_id AND competency_id = NEW.competency_id
|
|
ORDER BY snapshot_no DESC LIMIT 1;
|
|
IF latest_id IS NULL THEN
|
|
IF NEW.snapshot_no <> 1 OR NEW.supersedes_assessment_snapshot_id IS NOT NULL THEN
|
|
RAISE EXCEPTION 'first calibration assessment snapshot must start at one'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
ELSIF NEW.snapshot_no <> latest_no + 1
|
|
OR NEW.supersedes_assessment_snapshot_id IS DISTINCT FROM latest_id THEN
|
|
RAISE EXCEPTION 'calibration assessment must supersede latest snapshot'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
SELECT count(DISTINCT o.observation_id) INTO matched_count
|
|
FROM app.calibration_performance_observation o
|
|
WHERE o.observation_id = ANY(NEW.source_observation_ids)
|
|
AND o.learner_id = NEW.learner_id
|
|
AND o.competency_id = NEW.competency_id;
|
|
IF matched_count <> cardinality(NEW.source_observation_ids)
|
|
OR NEW.assessment_payload->>'competency_id' IS DISTINCT FROM NEW.competency_id THEN
|
|
RAISE EXCEPTION 'calibration assessment sources or competency do not match'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION audit.enforce_calibration_transfer_children()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
matched_count INT;
|
|
BEGIN
|
|
IF TG_TABLE_NAME = 'calibration_transfer_assessment' THEN
|
|
SELECT count(DISTINCT t.transfer_trial_record_id) INTO matched_count
|
|
FROM app.calibration_transfer_trial t
|
|
WHERE t.transfer_trial_record_id = ANY(NEW.source_trial_ids)
|
|
AND t.transfer_suite_record_id = NEW.transfer_suite_record_id
|
|
AND t.competency_id = NEW.competency_id;
|
|
IF matched_count <> cardinality(NEW.source_trial_ids)
|
|
OR NEW.assessment_payload->>'competency_id' IS DISTINCT FROM NEW.competency_id THEN
|
|
RAISE EXCEPTION 'transfer assessment sources or competency do not match'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
ELSIF TG_TABLE_NAME = 'calibration_subgroup_drift_report' THEN
|
|
SELECT count(DISTINCT t.transfer_trial_record_id) INTO matched_count
|
|
FROM app.calibration_transfer_trial t
|
|
WHERE t.transfer_trial_record_id = ANY(NEW.source_trial_ids)
|
|
AND t.transfer_suite_record_id = NEW.transfer_suite_record_id
|
|
AND t.competency_id = NEW.competency_id;
|
|
IF matched_count <> cardinality(NEW.source_trial_ids)
|
|
OR NEW.report_payload->>'competency_id' IS DISTINCT FROM NEW.competency_id THEN
|
|
RAISE EXCEPTION 'subgroup drift sources or competency do not match'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION audit.enforce_calibration_teacher_review()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
target_session UUID;
|
|
target_learner UUID;
|
|
latest_id UUID;
|
|
latest_no INT;
|
|
BEGIN
|
|
IF NEW.target_kind = 'calibration_assessment' THEN
|
|
SELECT session_id, learner_id INTO target_session, target_learner
|
|
FROM app.calibration_assessment_snapshot WHERE assessment_snapshot_id = NEW.target_id;
|
|
ELSIF NEW.target_kind = 'transfer_assessment' THEN
|
|
SELECT session_id, learner_id INTO target_session, target_learner
|
|
FROM app.calibration_transfer_assessment WHERE transfer_assessment_id = NEW.target_id;
|
|
ELSE
|
|
SELECT session_id, learner_id INTO target_session, target_learner
|
|
FROM app.calibration_subgroup_drift_report WHERE drift_report_id = NEW.target_id;
|
|
END IF;
|
|
IF target_session IS NULL OR NEW.session_id IS DISTINCT FROM target_session
|
|
OR NEW.learner_id IS DISTINCT FROM target_learner THEN
|
|
RAISE EXCEPTION 'teacher review target is missing or mismatched'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
PERFORM pg_advisory_xact_lock(hashtextextended(NEW.target_kind || ':' || NEW.target_id::TEXT, 7));
|
|
SELECT review_id, review_no INTO latest_id, latest_no
|
|
FROM app.calibration_teacher_review_event
|
|
WHERE target_kind = NEW.target_kind AND target_id = NEW.target_id
|
|
ORDER BY review_no DESC LIMIT 1;
|
|
IF latest_id IS NULL THEN
|
|
IF NEW.review_no <> 1 OR NEW.supersedes_review_id IS NOT NULL THEN
|
|
RAISE EXCEPTION 'first teacher review must start at one'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
ELSIF NEW.review_no <> latest_no + 1 OR NEW.supersedes_review_id IS DISTINCT FROM latest_id THEN
|
|
RAISE EXCEPTION 'teacher review must supersede latest review'
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
DO $$
|
|
DECLARE
|
|
table_name TEXT;
|
|
BEGIN
|
|
FOREACH table_name IN ARRAY ARRAY[
|
|
'calibration_prediction_history','calibration_prediction_revision',
|
|
'calibration_prediction_lock','calibration_performance_observation',
|
|
'calibration_assessment_snapshot','calibration_metacognitive_prescription',
|
|
'calibration_transfer_suite','calibration_transfer_trial',
|
|
'calibration_transfer_assessment','calibration_subgroup_drift_report',
|
|
'calibration_teacher_review_event'
|
|
] LOOP
|
|
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
|
|
);
|
|
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_calibration_session_learner()',
|
|
table_name, table_name
|
|
);
|
|
EXECUTE format('ALTER TABLE app.%I ENABLE ROW LEVEL SECURITY', table_name);
|
|
END LOOP;
|
|
END;
|
|
$$;
|
|
|
|
DO $$
|
|
DECLARE
|
|
table_name TEXT;
|
|
BEGIN
|
|
FOREACH table_name IN ARRAY ARRAY[
|
|
'calibration_prediction_revision','calibration_performance_observation',
|
|
'calibration_assessment_snapshot','calibration_metacognitive_prescription',
|
|
'calibration_transfer_trial','calibration_transfer_assessment',
|
|
'calibration_teacher_review_event'
|
|
] 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_calibration_evidence_ownership()',
|
|
table_name, table_name
|
|
);
|
|
END LOOP;
|
|
END;
|
|
$$;
|
|
|
|
DROP TRIGGER IF EXISTS trg_calibration_prediction_revision_contract
|
|
ON app.calibration_prediction_revision;
|
|
CREATE TRIGGER trg_calibration_prediction_revision_contract
|
|
BEFORE INSERT ON app.calibration_prediction_revision
|
|
FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_prediction_revision();
|
|
DROP TRIGGER IF EXISTS trg_calibration_prediction_lock_contract
|
|
ON app.calibration_prediction_lock;
|
|
CREATE TRIGGER trg_calibration_prediction_lock_contract
|
|
BEFORE INSERT ON app.calibration_prediction_lock
|
|
FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_prediction_lock();
|
|
DROP TRIGGER IF EXISTS trg_calibration_observation_reveal_contract
|
|
ON app.calibration_performance_observation;
|
|
CREATE TRIGGER trg_calibration_observation_reveal_contract
|
|
BEFORE INSERT ON app.calibration_performance_observation
|
|
FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_observation_reveal();
|
|
DROP TRIGGER IF EXISTS trg_calibration_assessment_chain
|
|
ON app.calibration_assessment_snapshot;
|
|
CREATE TRIGGER trg_calibration_assessment_chain
|
|
BEFORE INSERT ON app.calibration_assessment_snapshot
|
|
FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_assessment_chain();
|
|
DROP TRIGGER IF EXISTS trg_calibration_transfer_assessment_contract
|
|
ON app.calibration_transfer_assessment;
|
|
CREATE TRIGGER trg_calibration_transfer_assessment_contract
|
|
BEFORE INSERT ON app.calibration_transfer_assessment
|
|
FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_transfer_children();
|
|
DROP TRIGGER IF EXISTS trg_calibration_subgroup_drift_contract
|
|
ON app.calibration_subgroup_drift_report;
|
|
CREATE TRIGGER trg_calibration_subgroup_drift_contract
|
|
BEFORE INSERT ON app.calibration_subgroup_drift_report
|
|
FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_transfer_children();
|
|
DROP TRIGGER IF EXISTS trg_calibration_teacher_review_contract
|
|
ON app.calibration_teacher_review_event;
|
|
CREATE TRIGGER trg_calibration_teacher_review_contract
|
|
BEFORE INSERT ON app.calibration_teacher_review_event
|
|
FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_teacher_review();
|
|
|
|
DO $$
|
|
DECLARE
|
|
table_name TEXT;
|
|
BEGIN
|
|
FOREACH table_name IN ARRAY ARRAY[
|
|
'calibration_assessment_snapshot','calibration_metacognitive_prescription',
|
|
'calibration_transfer_assessment','calibration_subgroup_drift_report',
|
|
'calibration_teacher_review_event'
|
|
] LOOP
|
|
EXECUTE format('DROP TRIGGER IF EXISTS trg_%s_payload_boundary ON app.%I', table_name, table_name);
|
|
EXECUTE format(
|
|
'CREATE TRIGGER trg_%s_payload_boundary BEFORE INSERT ON app.%I '
|
|
'FOR EACH ROW EXECUTE FUNCTION audit.enforce_calibration_payload_boundary()',
|
|
table_name, table_name
|
|
);
|
|
END LOOP;
|
|
END;
|
|
$$;
|
|
|
|
-- Human SELECT: learner-self, teacher cohort, admin. AI SELECT/INSERT: evaluator only.
|
|
DO $$
|
|
DECLARE
|
|
table_name TEXT;
|
|
BEGIN
|
|
FOREACH table_name IN ARRAY ARRAY[
|
|
'calibration_prediction_history','calibration_prediction_revision',
|
|
'calibration_prediction_lock','calibration_performance_observation',
|
|
'calibration_assessment_snapshot','calibration_metacognitive_prescription',
|
|
'calibration_transfer_suite','calibration_transfer_trial',
|
|
'calibration_transfer_assessment','calibration_subgroup_drift_report'
|
|
] LOOP
|
|
EXECUTE format('DROP POLICY IF EXISTS p_%s_select 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) = ''evaluator'') 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
|
|
);
|
|
END LOOP;
|
|
END;
|
|
$$;
|
|
|
|
DO $$
|
|
DECLARE
|
|
table_name TEXT;
|
|
BEGIN
|
|
FOREACH table_name IN ARRAY ARRAY[
|
|
'calibration_prediction_history','calibration_prediction_revision','calibration_prediction_lock'
|
|
] LOOP
|
|
EXECUTE format('DROP POLICY IF EXISTS p_%s_insert ON app.%I', table_name, table_name);
|
|
EXECUTE format(
|
|
'CREATE POLICY p_%s_insert ON app.%I FOR INSERT WITH CHECK ('
|
|
'NOT app.is_ai_context() AND app.current_role_name() = ''learner'' '
|
|
'AND learner_id = app.current_uid())',
|
|
table_name, table_name
|
|
);
|
|
END LOOP;
|
|
END;
|
|
$$;
|
|
|
|
DO $$
|
|
DECLARE
|
|
table_name TEXT;
|
|
BEGIN
|
|
FOREACH table_name IN ARRAY ARRAY[
|
|
'calibration_performance_observation','calibration_assessment_snapshot',
|
|
'calibration_metacognitive_prescription','calibration_transfer_suite',
|
|
'calibration_transfer_trial','calibration_transfer_assessment',
|
|
'calibration_subgroup_drift_report'
|
|
] LOOP
|
|
EXECUTE format('DROP POLICY IF EXISTS p_%s_insert ON app.%I', 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;
|
|
$$;
|
|
|
|
DROP POLICY IF EXISTS p_calibration_teacher_review_event_select
|
|
ON app.calibration_teacher_review_event;
|
|
DROP POLICY IF EXISTS p_calibration_teacher_review_event_insert
|
|
ON app.calibration_teacher_review_event;
|
|
CREATE POLICY p_calibration_teacher_review_event_select
|
|
ON app.calibration_teacher_review_event FOR SELECT USING (
|
|
(app.is_ai_context() AND current_setting('app.current_ai_view', true) = 'evaluator')
|
|
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_calibration_teacher_review_event_insert
|
|
ON app.calibration_teacher_review_event 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)
|
|
)
|
|
)
|
|
)
|
|
);
|