ALTER TABLE article_versions ADD COLUMN IF NOT EXISTS plaintext_snapshot TEXT, ADD COLUMN IF NOT EXISTS manual_review_status VARCHAR(16) NOT NULL DEFAULT 'none', ADD COLUMN IF NOT EXISTS manual_review_id BIGINT, ADD COLUMN IF NOT EXISTS manual_review_updated_at TIMESTAMPTZ; ALTER TABLE article_versions DROP CONSTRAINT IF EXISTS ck_article_versions_manual_review_status; ALTER TABLE article_versions ADD CONSTRAINT ck_article_versions_manual_review_status CHECK (manual_review_status IN ('none', 'pending', 'approved', 'rejected')); COMMENT ON COLUMN article_versions.plaintext_snapshot IS 'Plaintext extracted at save time, used by compliance gate to skip diff reconstruction.'; COMMENT ON COLUMN article_versions.manual_review_status IS 'Hot-path manual review stamp: none|pending|approved|rejected.'; CREATE TABLE IF NOT EXISTS compliance_check_records ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL REFERENCES tenants(id), article_id BIGINT REFERENCES articles(id) ON DELETE SET NULL, article_version_id BIGINT REFERENCES article_versions(id) ON DELETE SET NULL, target_platforms TEXT[] NOT NULL DEFAULT '{}', content_hash CHAR(64) NOT NULL, policy_fingerprint VARCHAR(64) NOT NULL, trigger_source VARCHAR(32) NOT NULL, enforcement_mode VARCHAR(24) NOT NULL, highest_level VARCHAR(16), passed BOOLEAN NOT NULL, hit_count INT NOT NULL DEFAULT 0, stored_hit_count INT NOT NULL DEFAULT 0, truncated BOOLEAN NOT NULL DEFAULT FALSE, dict_versions JSONB NOT NULL DEFAULT '{}'::jsonb, llm_used BOOLEAN NOT NULL DEFAULT FALSE, duration_ms INT NOT NULL DEFAULT 0, checked_by BIGINT, checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), manual_review_status VARCHAR(16) NOT NULL DEFAULT 'none', manual_review_id BIGINT, CONSTRAINT ck_compliance_check_trigger_source CHECK ( trigger_source IN ( 'editor_manual', 'publish_gate', 'auto_draft', 'api', 'scheduler_recheck', 'manual_review_bypass' ) ), CONSTRAINT ck_compliance_check_mode CHECK (enforcement_mode IN ('mandatory', 'advisory', 'disabled')), CONSTRAINT ck_compliance_check_highest_level CHECK (highest_level IS NULL OR highest_level IN ('block', 'high', 'medium', 'info')), CONSTRAINT ck_compliance_check_manual_status CHECK (manual_review_status IN ('none', 'pending', 'approved', 'rejected')) ); CREATE INDEX IF NOT EXISTS idx_compliance_check_records_article_checked ON compliance_check_records (tenant_id, article_id, checked_at DESC); CREATE INDEX IF NOT EXISTS idx_compliance_check_records_version_checked ON compliance_check_records (article_version_id, checked_at DESC) WHERE article_version_id IS NOT NULL; CREATE TABLE IF NOT EXISTS compliance_violations ( id BIGSERIAL PRIMARY KEY, record_id BIGINT NOT NULL REFERENCES compliance_check_records(id) ON DELETE CASCADE, tenant_id BIGINT NOT NULL REFERENCES tenants(id), platform_code TEXT, source VARCHAR(24) NOT NULL, dictionary_code VARCHAR(64), dictionary_name VARCHAR(128), term_pattern TEXT, matched_text TEXT NOT NULL, start_offset INT NOT NULL DEFAULT 0, end_offset INT NOT NULL DEFAULT 0, dom_path TEXT, level VARCHAR(16) NOT NULL, hint TEXT, reference_law TEXT, acknowledged_in_ack_id BIGINT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT ck_compliance_violation_source CHECK (source IN ('dict', 'regex', 'llm')), CONSTRAINT ck_compliance_violation_level CHECK (level IN ('block', 'high', 'medium', 'info')), CONSTRAINT ck_compliance_violation_offsets CHECK (start_offset >= 0 AND end_offset >= start_offset), CONSTRAINT ck_violations_matched_text_len CHECK (char_length(matched_text) <= 80) ); CREATE INDEX IF NOT EXISTS idx_compliance_violations_record ON compliance_violations (record_id, id); CREATE INDEX IF NOT EXISTS idx_compliance_violations_tenant_level ON compliance_violations (tenant_id, level, id DESC); CREATE TABLE IF NOT EXISTS compliance_ack_records ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL REFERENCES tenants(id), article_id BIGINT NOT NULL REFERENCES articles(id), article_version_id BIGINT NOT NULL REFERENCES article_versions(id), check_record_id BIGINT NOT NULL REFERENCES compliance_check_records(id) ON DELETE CASCADE, content_hash CHAR(64) NOT NULL, policy_fingerprint VARCHAR(64) NOT NULL, acknowledged_by BIGINT NOT NULL REFERENCES users(id), ack_violation_ids BIGINT[] NOT NULL, expires_at TIMESTAMPTZ NOT NULL, consumed_at TIMESTAMPTZ, consumed_by_job_id UUID, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_ack_lookup ON compliance_ack_records (tenant_id, article_id, article_version_id, check_record_id) WHERE consumed_at IS NULL; ALTER TABLE compliance_violations DROP CONSTRAINT IF EXISTS fk_compliance_violations_ack; ALTER TABLE compliance_violations ADD CONSTRAINT fk_compliance_violations_ack FOREIGN KEY (acknowledged_in_ack_id) REFERENCES compliance_ack_records(id) ON DELETE SET NULL; CREATE TABLE IF NOT EXISTS compliance_review_jobs ( id BIGSERIAL PRIMARY KEY, message_id UUID NOT NULL, tenant_id BIGINT NOT NULL REFERENCES tenants(id), article_id BIGINT NOT NULL REFERENCES articles(id), article_version_id BIGINT NOT NULL REFERENCES article_versions(id), check_record_id BIGINT NOT NULL REFERENCES compliance_check_records(id) ON DELETE CASCADE, status VARCHAR(16) NOT NULL DEFAULT 'pending', attempts INT NOT NULL DEFAULT 0, available_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), locked_at TIMESTAMPTZ, locked_by TEXT, last_error TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uk_compliance_review_jobs_record UNIQUE (check_record_id), CONSTRAINT uk_compliance_review_jobs_message UNIQUE (message_id), CONSTRAINT ck_compliance_review_job_status CHECK (status IN ('pending', 'running', 'done', 'failed')) ); CREATE INDEX IF NOT EXISTS idx_compliance_review_jobs_pending ON compliance_review_jobs (available_at, id) WHERE status = 'pending'; CREATE TABLE IF NOT EXISTS compliance_manual_reviews ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL REFERENCES tenants(id), article_id BIGINT NOT NULL REFERENCES articles(id), article_version_id BIGINT NOT NULL REFERENCES article_versions(id), original_check_record_id BIGINT REFERENCES compliance_check_records(id) ON DELETE SET NULL, original_content_hash CHAR(64) NOT NULL, original_policy_fingerprint VARCHAR(64) NOT NULL, original_target_platforms TEXT[] NOT NULL DEFAULT '{}', original_violation_summary JSONB NOT NULL DEFAULT '[]'::jsonb, requested_by BIGINT NOT NULL REFERENCES users(id), requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), request_note TEXT, status VARCHAR(16) NOT NULL DEFAULT 'pending', cancelled_by BIGINT REFERENCES users(id), cancelled_at TIMESTAMPTZ, cancel_reason TEXT, reviewed_by BIGINT, reviewed_at TIMESTAMPTZ, decision_reason TEXT, CONSTRAINT ck_manual_review_request_note_len CHECK (request_note IS NULL OR char_length(request_note) <= 500), CONSTRAINT ck_manual_review_cancel_reason_len CHECK (cancel_reason IS NULL OR char_length(cancel_reason) <= 500), CONSTRAINT ck_manual_review_decision_reason_len CHECK (decision_reason IS NULL OR char_length(decision_reason) <= 1000), CONSTRAINT ck_manual_review_status CHECK (status IN ('pending', 'approved', 'rejected', 'cancelled')), CONSTRAINT ck_manual_review_decision CHECK ( (status = 'pending' AND reviewed_by IS NULL AND reviewed_at IS NULL AND decision_reason IS NULL AND cancelled_by IS NULL AND cancelled_at IS NULL AND cancel_reason IS NULL) OR (status IN ('approved','rejected') AND reviewed_by IS NOT NULL AND reviewed_at IS NOT NULL AND decision_reason IS NOT NULL AND cancelled_by IS NULL AND cancelled_at IS NULL AND cancel_reason IS NULL) OR (status = 'cancelled' AND reviewed_by IS NULL AND reviewed_at IS NULL AND decision_reason IS NULL AND cancelled_by IS NOT NULL AND cancelled_at IS NOT NULL) ) ); CREATE UNIQUE INDEX IF NOT EXISTS uk_manual_review_active_per_version ON compliance_manual_reviews (article_version_id) WHERE status IN ('pending', 'approved', 'rejected'); CREATE INDEX IF NOT EXISTS idx_manual_review_pending_queue ON compliance_manual_reviews (requested_at) WHERE status = 'pending'; CREATE INDEX IF NOT EXISTS idx_manual_review_lookup_by_version ON compliance_manual_reviews (article_version_id, status); CREATE INDEX IF NOT EXISTS idx_manual_review_tenant_status ON compliance_manual_reviews (tenant_id, status, requested_at DESC); ALTER TABLE article_versions DROP CONSTRAINT IF EXISTS fk_article_versions_manual_review; ALTER TABLE article_versions ADD CONSTRAINT fk_article_versions_manual_review FOREIGN KEY (manual_review_id) REFERENCES compliance_manual_reviews(id) ON DELETE SET NULL; ALTER TABLE desktop_publish_jobs DROP CONSTRAINT IF EXISTS desktop_publish_jobs_compliance_blocked_record_id_fkey; ALTER TABLE desktop_publish_jobs ADD CONSTRAINT desktop_publish_jobs_compliance_blocked_record_id_fkey FOREIGN KEY (compliance_blocked_record_id) REFERENCES compliance_check_records(id) ON DELETE SET NULL; CREATE INDEX IF NOT EXISTS idx_desktop_publish_jobs_status_scheduled ON desktop_publish_jobs (status, scheduled_at, created_at) WHERE status IN ('queued', 'blocked_by_compliance');