diff --git a/deploy/postgres/goose/00028_2026-09-13-document-revisions.sql b/deploy/postgres/goose/00028_2026-09-13-document-revisions.sql new file mode 100644 index 00000000..135af7d1 --- /dev/null +++ b/deploy/postgres/goose/00028_2026-09-13-document-revisions.sql @@ -0,0 +1,197 @@ +-- +goose Up +-- Document revision ledger: immutable snapshots, Version/Revision, agent candidates, change reports. +-- Pointer columns use ledger_*/head_* to avoid colliding with freeform_documents.current_version_id. + +CREATE TABLE IF NOT EXISTS document_content_blobs ( + content_sha256 CHAR(64) PRIMARY KEY, + content_markdown TEXT NOT NULL, + byte_size BIGINT NOT NULL CHECK (byte_size >= 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS document_snapshots ( + id BIGSERIAL PRIMARY KEY, + doc_id TEXT NOT NULL, + content_sha256 CHAR(64) NOT NULL REFERENCES document_content_blobs(content_sha256), + parent_snapshot_id BIGINT REFERENCES document_snapshots(id), + block_map JSONB NOT NULL DEFAULT '[]'::jsonb, + source_kind TEXT NOT NULL, + actor_user_id BIGINT, + actor_ref TEXT NOT NULL DEFAULT '', + actor_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb, + source_run_id TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT chk_snapshot_source_kind CHECK (source_kind IN ('template','human','agent','agent_squad','migration','system')) +); + +CREATE INDEX IF NOT EXISTS idx_document_snapshots_doc ON document_snapshots(doc_id, created_at); + +CREATE TABLE IF NOT EXISTS document_versions ( + id BIGSERIAL PRIMARY KEY, + doc_id TEXT NOT NULL, + version_no INT NOT NULL, + status TEXT NOT NULL, + origin_kind TEXT NOT NULL, + baseline_snapshot_id BIGINT NOT NULL REFERENCES document_snapshots(id), + head_snapshot_id BIGINT NOT NULL REFERENCES document_snapshots(id), + human_revision_count INT NOT NULL DEFAULT 0, + source_run_id TEXT NOT NULL DEFAULT '', + change_report_id BIGINT, + opened_at TIMESTAMPTZ NOT NULL DEFAULT now(), + sealed_at TIMESTAMPTZ, + CONSTRAINT uq_document_versions_doc_no UNIQUE (doc_id, version_no), + CONSTRAINT chk_document_versions_status CHECK (status IN ('open','sealed')), + CONSTRAINT chk_document_versions_origin CHECK (origin_kind IN ('template','agent','agent_squad','migration')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_document_versions_one_open + ON document_versions(doc_id) WHERE status = 'open'; + +CREATE TABLE IF NOT EXISTS document_change_events ( + id BIGSERIAL PRIMARY KEY, + doc_id TEXT NOT NULL, + version_id BIGINT NOT NULL REFERENCES document_versions(id), + event_no INT NOT NULL, + from_snapshot_id BIGINT REFERENCES document_snapshots(id), + to_snapshot_id BIGINT NOT NULL REFERENCES document_snapshots(id), + change_kind TEXT NOT NULL, + actor_kind TEXT NOT NULL, + actor_user_id BIGINT, + actor_ref TEXT NOT NULL DEFAULT '', + actor_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb, + change_set JSONB NOT NULL DEFAULT '[]'::jsonb, + edit_session_id TEXT NOT NULL DEFAULT '', + request_id TEXT NOT NULL DEFAULT '', + idempotency_key TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_document_change_events_doc_event UNIQUE (doc_id, event_no), + CONSTRAINT uq_document_change_events_idemp UNIQUE (doc_id, idempotency_key), + CONSTRAINT chk_change_kind CHECK (change_kind IN ('human_revision','agent_commit','agent_noop','import','restore_as_new')), + CONSTRAINT chk_actor_kind CHECK (actor_kind IN ('human','agent','agent_squad','template','migration','system')) +); + +CREATE TABLE IF NOT EXISTS document_change_reports ( + id BIGSERIAL PRIMARY KEY, + doc_id TEXT NOT NULL, + from_snapshot_id BIGINT NOT NULL REFERENCES document_snapshots(id), + to_snapshot_id BIGINT NOT NULL REFERENCES document_snapshots(id), + schema_version INT NOT NULL DEFAULT 1, + summary JSONB NOT NULL DEFAULT '[]'::jsonb, + risk_level TEXT NOT NULL, + risk_items JSONB NOT NULL DEFAULT '[]'::jsonb, + project_impacts JSONB NOT NULL DEFAULT '{}'::jsonb, + document_impacts JSONB NOT NULL DEFAULT '[]'::jsonb, + open_questions JSONB NOT NULL DEFAULT '[]'::jsonb, + analysis_status TEXT NOT NULL, + model_snapshot TEXT NOT NULL DEFAULT '', + prompt_version TEXT NOT NULL DEFAULT '', + deterministic_facts JSONB NOT NULL DEFAULT '{}'::jsonb, + corrects_report_id BIGINT REFERENCES document_change_reports(id), + raw_json TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT chk_report_risk CHECK (risk_level IN ('low','medium','high','critical')), + CONSTRAINT chk_report_analysis CHECK (analysis_status IN ('complete','fallback','unassessed','corrected')) +); + +ALTER TABLE document_versions + ADD CONSTRAINT fk_document_versions_change_report + FOREIGN KEY (change_report_id) REFERENCES document_change_reports(id); + +CREATE TABLE IF NOT EXISTS document_agent_candidates ( + id BIGSERIAL PRIMARY KEY, + doc_id TEXT NOT NULL, + input_snapshot_id BIGINT NOT NULL REFERENCES document_snapshots(id), + output_snapshot_id BIGINT REFERENCES document_snapshots(id), + run_id TEXT NOT NULL, + proposal_id TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL, + authorized_scope TEXT NOT NULL DEFAULT '', + quality_result TEXT NOT NULL DEFAULT '', + change_report_id BIGINT REFERENCES document_change_reports(id), + actor_kind TEXT NOT NULL, + actor_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + decided_at TIMESTAMPTZ, + CONSTRAINT uq_document_agent_candidates_run UNIQUE (doc_id, run_id), + CONSTRAINT chk_candidate_status CHECK (status IN ('ready','needs_confirmation','committed','rejected','stale','quality_failed')), + CONSTRAINT chk_candidate_actor CHECK (actor_kind IN ('agent','agent_squad')) +); + +ALTER TABLE document_records + ADD COLUMN IF NOT EXISTS ledger_version_id BIGINT REFERENCES document_versions(id), + ADD COLUMN IF NOT EXISTS head_snapshot_id BIGINT REFERENCES document_snapshots(id), + ADD COLUMN IF NOT EXISTS head_revision_no INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS review_snapshot_id BIGINT REFERENCES document_snapshots(id), + ADD COLUMN IF NOT EXISTS revision_schema_version INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS row_revision BIGINT NOT NULL DEFAULT 0; + +-- Rename freeform legacy pointer to avoid semantic collision with ledger version id. +ALTER TABLE freeform_documents + RENAME COLUMN current_version_id TO legacy_version_id; + +ALTER TABLE freeform_documents + ADD COLUMN IF NOT EXISTS ledger_version_id BIGINT REFERENCES document_versions(id), + ADD COLUMN IF NOT EXISTS head_snapshot_id BIGINT REFERENCES document_snapshots(id), + ADD COLUMN IF NOT EXISTS head_revision_no INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS revision_schema_version INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS row_revision BIGINT NOT NULL DEFAULT 0; + +-- Prevent UPDATE/DELETE on immutable snapshot bodies via trigger. +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION document_snapshots_immutable() RETURNS trigger AS $$ +BEGIN + RAISE EXCEPTION 'document_snapshots is immutable'; +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd + +DROP TRIGGER IF EXISTS trg_document_snapshots_immutable ON document_snapshots; +CREATE TRIGGER trg_document_snapshots_immutable + BEFORE UPDATE OR DELETE ON document_snapshots + FOR EACH ROW EXECUTE FUNCTION document_snapshots_immutable(); + +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION document_content_blobs_immutable() RETURNS trigger AS $$ +BEGIN + RAISE EXCEPTION 'document_content_blobs is immutable'; +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd + +DROP TRIGGER IF EXISTS trg_document_content_blobs_immutable ON document_content_blobs; +CREATE TRIGGER trg_document_content_blobs_immutable + BEFORE UPDATE OR DELETE ON document_content_blobs + FOR EACH ROW EXECUTE FUNCTION document_content_blobs_immutable(); + +-- +goose Down +DROP TRIGGER IF EXISTS trg_document_content_blobs_immutable ON document_content_blobs; +DROP FUNCTION IF EXISTS document_content_blobs_immutable(); +DROP TRIGGER IF EXISTS trg_document_snapshots_immutable ON document_snapshots; +DROP FUNCTION IF EXISTS document_snapshots_immutable(); + +ALTER TABLE freeform_documents + DROP COLUMN IF EXISTS row_revision, + DROP COLUMN IF EXISTS revision_schema_version, + DROP COLUMN IF EXISTS head_revision_no, + DROP COLUMN IF EXISTS head_snapshot_id, + DROP COLUMN IF EXISTS ledger_version_id; + +ALTER TABLE freeform_documents + RENAME COLUMN legacy_version_id TO current_version_id; + +ALTER TABLE document_records + DROP COLUMN IF EXISTS row_revision, + DROP COLUMN IF EXISTS revision_schema_version, + DROP COLUMN IF EXISTS review_snapshot_id, + DROP COLUMN IF EXISTS head_revision_no, + DROP COLUMN IF EXISTS head_snapshot_id, + DROP COLUMN IF EXISTS ledger_version_id; + +ALTER TABLE document_versions DROP CONSTRAINT IF EXISTS fk_document_versions_change_report; + +DROP TABLE IF EXISTS document_agent_candidates; +DROP TABLE IF EXISTS document_change_reports; +DROP TABLE IF EXISTS document_change_events; +DROP TABLE IF EXISTS document_versions; +DROP TABLE IF EXISTS document_snapshots; +DROP TABLE IF EXISTS document_content_blobs;