feat(db): add document revision ledger schema 00028

Introduce immutable blobs/snapshots, versions, events, candidates,
change reports, and ledger_* root pointers; rename freeform
current_version_id to legacy_version_id.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 06:15:20 +00:00
co-authored by Cursor
parent 7778559201
commit 7bbd6783fc
@@ -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;