feat(compliance): add content compliance detection across tenant, ops, and clients
Implements the Revision 8/9 compliance design: tenant + ops backends, ops-web content-safety pages, admin-web editor/publish gate integration, desktop publish block surfacing, and supporting migrations / shared types / config plumbing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
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
|
||||
ADD COLUMN IF NOT EXISTS article_id BIGINT REFERENCES articles(id),
|
||||
ADD COLUMN IF NOT EXISTS article_version_id BIGINT REFERENCES article_versions(id),
|
||||
ADD COLUMN IF NOT EXISTS status VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||
ADD COLUMN IF NOT EXISTS compliance_blocked_record_id BIGINT REFERENCES compliance_check_records(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS compliance_blocked_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS compliance_blocked_reason TEXT;
|
||||
|
||||
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');
|
||||
Reference in New Issue
Block a user