Files
dify/knowledge-fs/packages/database/migrations/0017_durable_deletion.postgres.sql
Jyong 4ee43b8afc chore: migrate knowledge-fs source tree
Import the committed KnowledgeFS snapshot dc4072ee302317145612087ce7440851dc329fd0 under knowledge-fs/ without its Git history, local IDE settings, or build artifacts.
2026-07-20 04:54:20 -04:00

656 lines
26 KiB
SQL

-- Knowledge Platform schema migration
-- Migration id: 0017_durable_deletion
-- Dialect: postgres
-- Resource rows remain present while deletion is in progress. Defaults preserve rolling-upgrade
-- compatibility for old writers; the application only enables the deletion endpoint after all
-- writers understand these fences.
ALTER TABLE "knowledge_spaces"
ADD COLUMN IF NOT EXISTS "revision" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS "lifecycle_state" VARCHAR(16) NOT NULL DEFAULT 'active',
ADD COLUMN IF NOT EXISTS "deletion_job_id" UUID,
ADD COLUMN IF NOT EXISTS "deleting_at" TIMESTAMPTZ;
ALTER TABLE "sources"
ADD COLUMN IF NOT EXISTS "deletion_job_id" UUID,
ADD COLUMN IF NOT EXISTS "deleting_at" TIMESTAMPTZ;
ALTER TABLE "document_assets"
ADD COLUMN IF NOT EXISTS "lifecycle_state" VARCHAR(16) NOT NULL DEFAULT 'active',
ADD COLUMN IF NOT EXISTS "deletion_job_id" UUID,
ADD COLUMN IF NOT EXISTS "deleting_at" TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS "row_version" INTEGER NOT NULL DEFAULT 1;
ALTER TABLE "knowledge_space_mutation_leases"
ADD COLUMN IF NOT EXISTS "lease_token" UUID,
ADD COLUMN IF NOT EXISTS "heartbeat_at" TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS "expires_at" TIMESTAMPTZ;
-- Rows created by the pre-0017 ownerless lease protocol are made immediately reclaimable. A live
-- writer remains fenced by the resource tombstone even if it returns after this compatibility cut.
UPDATE "knowledge_space_mutation_leases"
SET "lease_token" = "id",
"heartbeat_at" = "acquired_at",
"expires_at" = "acquired_at"
WHERE "lease_token" IS NULL OR "heartbeat_at" IS NULL OR "expires_at" IS NULL;
-- The catalog models manifests as tenant-owned rows. The original schema constrained only the
-- UUID, so fail closed on historical cross-tenant rows before installing the composite invariant.
-- Add the stronger FK before removing the legacy one so marker-loss replay never leaves a gap.
DO $kfs_0017_knowledge_space_manifests_space_fk$
BEGIN
IF EXISTS (
SELECT 1
FROM "knowledge_space_manifests" AS manifest
LEFT JOIN "knowledge_spaces" AS space
ON space."tenant_id" = manifest."tenant_id"
AND space."id" = manifest."knowledge_space_id"
WHERE space."id" IS NULL
) THEN
RAISE EXCEPTION
'knowledge_space_manifests contains a tenant/space ownership mismatch';
END IF;
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint"
WHERE "conrelid" = 'knowledge_space_manifests'::regclass
AND "conname" = 'knowledge_space_manifests_space_fk'
) THEN
ALTER TABLE "knowledge_space_manifests"
ADD CONSTRAINT "knowledge_space_manifests_space_fk"
FOREIGN KEY ("tenant_id", "knowledge_space_id")
REFERENCES "knowledge_spaces" ("tenant_id", "id") ON DELETE CASCADE;
END IF;
ALTER TABLE "knowledge_space_manifests"
DROP CONSTRAINT IF EXISTS "knowledge_space_manifests_knowledge_space_id_fkey";
END
$kfs_0017_knowledge_space_manifests_space_fk$;
-- Evidence bundles were originally ownerless. Keep the columns nullable for a rolling writer
-- upgrade, backfill only when every durable reference agrees on one tenant/space, and quarantine
-- ambiguous rows by leaving them NULL. Application reads fail closed and rollout readiness
-- requires the NULL population to be purged before durable deletion is enabled.
ALTER TABLE "evidence_bundles"
ADD COLUMN IF NOT EXISTS "tenant_id" VARCHAR(255),
ADD COLUMN IF NOT EXISTS "knowledge_space_id" UUID;
WITH evidence_scope_candidates AS (
SELECT evidence."id" AS bundle_id,
space."tenant_id" AS tenant_id,
trace."knowledge_space_id" AS knowledge_space_id
FROM "evidence_bundles" AS evidence
INNER JOIN "answer_traces" AS trace
ON trace."evidence_bundle_id" = evidence."id" OR trace."id" = evidence."trace_id"
INNER JOIN "knowledge_spaces" AS space
ON space."id" = trace."knowledge_space_id"
UNION ALL
SELECT evidence."id" AS bundle_id,
partial."tenant_id" AS tenant_id,
partial."knowledge_space_id" AS knowledge_space_id
FROM "evidence_bundles" AS evidence
INNER JOIN "research_task_partial_results" AS partial
ON CASE
WHEN jsonb_typeof(partial."evidence_bundle") = 'object'
THEN partial."evidence_bundle" ->> 'id'
ELSE NULL
END = CAST(evidence."id" AS TEXT)
INNER JOIN "knowledge_spaces" AS space
ON space."tenant_id" = partial."tenant_id"
AND space."id" = partial."knowledge_space_id"
), distinct_candidate_scopes AS (
SELECT DISTINCT bundle_id, tenant_id, knowledge_space_id
FROM evidence_scope_candidates
), unambiguous_evidence_scopes AS (
SELECT bundle_id,
MIN(tenant_id) AS tenant_id,
MIN(CAST(knowledge_space_id AS TEXT))::UUID AS knowledge_space_id
FROM distinct_candidate_scopes
GROUP BY bundle_id
HAVING COUNT(*) = 1
)
UPDATE "evidence_bundles" AS evidence
SET "tenant_id" = resolved."tenant_id",
"knowledge_space_id" = resolved."knowledge_space_id"
FROM unambiguous_evidence_scopes AS resolved
WHERE evidence."id" = resolved.bundle_id
AND evidence."tenant_id" IS NULL
AND evidence."knowledge_space_id" IS NULL;
DO $kfs_0017_evidence_bundles_scope_pair_ck$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint"
WHERE "conrelid" = 'evidence_bundles'::regclass
AND "conname" = 'evidence_bundles_scope_pair_ck'
) THEN
ALTER TABLE "evidence_bundles"
ADD CONSTRAINT "evidence_bundles_scope_pair_ck" CHECK (
("tenant_id" IS NULL AND "knowledge_space_id" IS NULL)
OR ("tenant_id" IS NOT NULL AND "knowledge_space_id" IS NOT NULL)
);
END IF;
END
$kfs_0017_evidence_bundles_scope_pair_ck$;
DO $kfs_0017_evidence_bundles_scope_fk$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint"
WHERE "conrelid" = 'evidence_bundles'::regclass
AND "conname" = 'evidence_bundles_scope_fk'
) THEN
ALTER TABLE "evidence_bundles"
ADD CONSTRAINT "evidence_bundles_scope_fk"
FOREIGN KEY ("tenant_id", "knowledge_space_id")
REFERENCES "knowledge_spaces" ("tenant_id", "id") ON DELETE CASCADE;
END IF;
END
$kfs_0017_evidence_bundles_scope_fk$;
CREATE INDEX IF NOT EXISTS "evidence_bundles_scope_created_idx"
ON "evidence_bundles" ("tenant_id", "knowledge_space_id", "created_at", "id");
DO $kfs_0017_knowledge_spaces_deletion_lifecycle_ck$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint"
WHERE "conrelid" = 'knowledge_spaces'::regclass
AND "conname" = 'knowledge_spaces_deletion_lifecycle_ck'
) THEN
ALTER TABLE "knowledge_spaces"
ADD CONSTRAINT "knowledge_spaces_deletion_lifecycle_ck" CHECK (
"revision" >= 1
AND (
("lifecycle_state" = 'active' AND "deletion_job_id" IS NULL AND "deleting_at" IS NULL)
OR
("lifecycle_state" = 'deleting' AND "deletion_job_id" IS NOT NULL AND "deleting_at" IS NOT NULL)
)
);
END IF;
END
$kfs_0017_knowledge_spaces_deletion_lifecycle_ck$;
DO $kfs_0017_sources_deletion_lifecycle_ck$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint"
WHERE "conrelid" = 'sources'::regclass
AND "conname" = 'sources_deletion_lifecycle_ck'
) THEN
ALTER TABLE "sources"
ADD CONSTRAINT "sources_deletion_lifecycle_ck" CHECK (
(
"status" = 'deleting'
AND "deletion_job_id" IS NOT NULL
AND "deleting_at" IS NOT NULL
)
OR (
"status" <> 'deleting'
AND "deletion_job_id" IS NULL
AND "deleting_at" IS NULL
)
);
END IF;
END
$kfs_0017_sources_deletion_lifecycle_ck$;
DO $kfs_0017_document_assets_deletion_lifecycle_ck$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint"
WHERE "conrelid" = 'document_assets'::regclass
AND "conname" = 'document_assets_deletion_lifecycle_ck'
) THEN
ALTER TABLE "document_assets"
ADD CONSTRAINT "document_assets_deletion_lifecycle_ck" CHECK (
"row_version" >= 1
AND (
("lifecycle_state" = 'active' AND "deletion_job_id" IS NULL AND "deleting_at" IS NULL)
OR
("lifecycle_state" = 'deleting' AND "deletion_job_id" IS NOT NULL AND "deleting_at" IS NOT NULL)
)
);
END IF;
END
$kfs_0017_document_assets_deletion_lifecycle_ck$;
-- Retrieval execution leases are durable, token-fenced evidence that a request may still be
-- reading the space. Deletion drains active leases before destructive cleanup and can reclaim
-- only rows whose heartbeat has expired.
CREATE TABLE IF NOT EXISTS "retrieval_execution_leases" (
"id" UUID PRIMARY KEY NOT NULL,
"tenant_id" VARCHAR(255) NOT NULL,
"knowledge_space_id" UUID NOT NULL,
"subject_id" TEXT NOT NULL,
"trace_id" UUID NOT NULL,
"lease_token" VARCHAR(128) NOT NULL,
"status" VARCHAR(16) NOT NULL,
"row_version" INTEGER NOT NULL,
"acquired_at" TIMESTAMPTZ NOT NULL,
"heartbeat_at" TIMESTAMPTZ NOT NULL,
"expires_at" TIMESTAMPTZ NOT NULL,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "retrieval_execution_leases_state_ck" CHECK (
"status" IN ('active', 'released', 'expired')
AND "row_version" >= 0
AND "heartbeat_at" >= "acquired_at"
AND "expires_at" > "heartbeat_at"
AND "updated_at" >= "acquired_at"
),
CONSTRAINT "retrieval_execution_leases_space_fk"
FOREIGN KEY ("tenant_id", "knowledge_space_id")
REFERENCES "knowledge_spaces" ("tenant_id", "id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "retrieval_execution_leases_space_expiry_idx"
ON "retrieval_execution_leases" (
"tenant_id", "knowledge_space_id", "status", "expires_at", "id"
);
-- Jobs intentionally have no FK to any resource or permission snapshot that they delete. The
-- copied tenant/scope/requester fields are a durable authorization and audit record.
CREATE TABLE IF NOT EXISTS "deletion_jobs" (
"id" UUID PRIMARY KEY NOT NULL,
"tenant_id" VARCHAR(255) NOT NULL,
"knowledge_space_id" UUID NOT NULL,
"target_type" VARCHAR(32) NOT NULL,
"target_id" UUID NOT NULL,
"target_revision" INTEGER NOT NULL,
"delete_mode" VARCHAR(16) NOT NULL,
"requested_by_subject_id" VARCHAR(255) NOT NULL,
"permission_snapshot_id" UUID NOT NULL,
"permission_snapshot_revision" INTEGER NOT NULL,
"access_channel" VARCHAR(16) NOT NULL,
"api_key_id" UUID,
"api_key_revision" INTEGER,
"api_key_expires_at" TIMESTAMPTZ,
"idempotency_key" VARCHAR(512) NOT NULL,
"request_fingerprint" CHAR(64) NOT NULL,
"name_challenge_digest" CHAR(64),
"checkpoint" VARCHAR(32) NOT NULL,
"scan_phase" VARCHAR(64),
"scan_cursor" VARCHAR(1024),
"inventory_complete" BOOLEAN NOT NULL,
"run_state" VARCHAR(16) NOT NULL,
"active_slot" INTEGER,
"execution_attempts" INTEGER NOT NULL,
"max_execution_attempts" INTEGER NOT NULL,
"retry_at" TIMESTAMPTZ,
"worker_id" VARCHAR(255),
"lease_token" UUID,
"lease_expires_at" TIMESTAMPTZ,
"heartbeat_at" TIMESTAMPTZ,
"queue_job_id" VARCHAR(255),
"last_error_code" VARCHAR(64),
"last_error_message" TEXT,
"row_version" INTEGER NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL,
"updated_at" TIMESTAMPTZ NOT NULL,
"started_at" TIMESTAMPTZ,
"completed_at" TIMESTAMPTZ,
CONSTRAINT "deletion_jobs_target_ck" CHECK (
"target_type" IN ('knowledge_space', 'source', 'document_asset')
AND (
("target_type" = 'source' AND "delete_mode" IN ('keep', 'cascade') AND "name_challenge_digest" IS NULL)
OR
("target_type" = 'knowledge_space' AND "delete_mode" = 'cascade' AND "name_challenge_digest" IS NOT NULL)
OR
("target_type" = 'document_asset' AND "delete_mode" = 'cascade' AND "name_challenge_digest" IS NULL)
)
),
CONSTRAINT "deletion_jobs_checkpoint_ck" CHECK (
"checkpoint" IN (
'requested', 'quiescing', 'deleting_objects', 'deleting_derived_data',
'deleting_primary_data', 'completed'
)
),
CONSTRAINT "deletion_jobs_run_state_ck" CHECK (
"run_state" IN ('dispatch_pending', 'queued', 'running', 'retry_wait', 'succeeded', 'failed', 'canceled')
),
CONSTRAINT "deletion_jobs_access_channel_ck" CHECK (
"access_channel" IN ('interactive', 'service_api', 'mcp', 'agent')
),
CONSTRAINT "deletion_jobs_api_key_binding_ck" CHECK (
(
"api_key_id" IS NULL
AND "api_key_revision" IS NULL
AND "api_key_expires_at" IS NULL
)
OR (
"api_key_id" IS NOT NULL
AND "api_key_revision" >= 1
AND "access_channel" = 'service_api'
)
),
CONSTRAINT "deletion_jobs_positive_ck" CHECK (
"target_revision" >= 1
AND "permission_snapshot_revision" >= 1
AND "row_version" >= 1
AND "execution_attempts" >= 0
AND "max_execution_attempts" >= 1
AND "execution_attempts" <= "max_execution_attempts"
AND ("active_slot" IS NULL OR "active_slot" = 1)
),
CONSTRAINT "deletion_jobs_lifecycle_ck" CHECK (
(
"run_state" IN ('dispatch_pending', 'queued', 'running', 'retry_wait', 'failed')
AND "active_slot" = 1
AND "completed_at" IS NULL
)
OR (
"run_state" IN ('succeeded', 'canceled')
AND "active_slot" IS NULL
AND "completed_at" IS NOT NULL
)
),
CONSTRAINT "deletion_jobs_completion_ck" CHECK (
("run_state" = 'succeeded' AND "checkpoint" = 'completed')
OR ("run_state" <> 'succeeded' AND "checkpoint" <> 'completed')
),
CONSTRAINT "deletion_jobs_retry_ck" CHECK (
("run_state" = 'retry_wait' AND "retry_at" IS NOT NULL)
OR ("run_state" <> 'retry_wait' AND "retry_at" IS NULL)
),
CONSTRAINT "deletion_jobs_lease_ck" CHECK (
(
"run_state" = 'running'
AND "worker_id" IS NOT NULL
AND "lease_token" IS NOT NULL
AND "lease_expires_at" IS NOT NULL
AND "heartbeat_at" IS NOT NULL
)
OR (
"run_state" <> 'running'
AND "worker_id" IS NULL
AND "lease_token" IS NULL
AND "lease_expires_at" IS NULL
AND "heartbeat_at" IS NULL
)
)
);
CREATE UNIQUE INDEX IF NOT EXISTS "deletion_jobs_idempotency_uq"
ON "deletion_jobs" ("tenant_id", "idempotency_key");
CREATE UNIQUE INDEX IF NOT EXISTS "deletion_jobs_target_active_uq"
ON "deletion_jobs" (
"tenant_id", "knowledge_space_id", "target_type", "target_id", "active_slot"
);
CREATE INDEX IF NOT EXISTS "deletion_jobs_claim_idx"
ON "deletion_jobs" ("run_state", "retry_at", "lease_expires_at", "created_at", "id");
CREATE INDEX IF NOT EXISTS "deletion_jobs_scope_history_idx"
ON "deletion_jobs" ("tenant_id", "knowledge_space_id", "created_at", "id");
CREATE INDEX IF NOT EXISTS "deletion_jobs_requester_provenance_idx"
ON "deletion_jobs" (
"tenant_id", "knowledge_space_id", "requested_by_subject_id",
"api_key_id", "api_key_revision", "created_at", "id"
);
-- Tombstones have no FK, including no FK back to the job, so the permanent no-republish fence
-- survives resource deletion and independent job retention.
CREATE TABLE IF NOT EXISTS "deletion_tombstones" (
"id" UUID PRIMARY KEY NOT NULL,
"deletion_job_id" UUID NOT NULL,
"tenant_id" VARCHAR(255) NOT NULL,
"knowledge_space_id" UUID NOT NULL,
"target_type" VARCHAR(32) NOT NULL,
"target_id" UUID NOT NULL,
"target_revision" INTEGER NOT NULL,
"state" VARCHAR(16) NOT NULL,
"row_version" INTEGER NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL,
"completed_at" TIMESTAMPTZ,
CONSTRAINT "deletion_tombstones_target_ck" CHECK (
"target_type" IN ('knowledge_space', 'source', 'document_asset')
),
CONSTRAINT "deletion_tombstones_state_ck" CHECK (
("state" = 'active' AND "completed_at" IS NULL)
OR ("state" = 'completed' AND "completed_at" IS NOT NULL)
),
CONSTRAINT "deletion_tombstones_positive_ck" CHECK (
"target_revision" >= 1 AND "row_version" >= 1
)
);
CREATE UNIQUE INDEX IF NOT EXISTS "deletion_tombstones_target_uq"
ON "deletion_tombstones" ("tenant_id", "target_type", "target_id");
CREATE INDEX IF NOT EXISTS "deletion_tombstones_space_target_idx"
ON "deletion_tombstones" ("tenant_id", "knowledge_space_id", "target_type", "target_id");
CREATE INDEX IF NOT EXISTS "deletion_tombstones_job_idx"
ON "deletion_tombstones" ("deletion_job_id", "id");
CREATE TABLE IF NOT EXISTS "deletion_job_items" (
"id" UUID PRIMARY KEY NOT NULL,
"deletion_job_id" UUID NOT NULL,
"ordinal" BIGINT NOT NULL,
"kind" VARCHAR(32) NOT NULL,
"resource_id" UUID,
"object_key" TEXT,
"credential_ref" TEXT,
"cache_key" TEXT,
"payload_digest" CHAR(64) NOT NULL,
"idempotency_key" VARCHAR(512) NOT NULL,
"status" VARCHAR(16) NOT NULL,
"attempts" INTEGER NOT NULL,
"max_attempts" INTEGER NOT NULL,
"next_attempt_at" TIMESTAMPTZ,
"last_error_code" VARCHAR(64),
"last_error_message" TEXT,
"row_version" INTEGER NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL,
"updated_at" TIMESTAMPTZ NOT NULL,
"completed_at" TIMESTAMPTZ,
"redacted_at" TIMESTAMPTZ,
CONSTRAINT "deletion_job_items_kind_ck" CHECK (
"kind" IN ('object', 'secret_ref', 'cache_key', 'document_cascade', 'document_detach')
),
CONSTRAINT "deletion_job_items_status_ck" CHECK (
"status" IN ('pending', 'retry_wait', 'completed', 'dead')
),
CONSTRAINT "deletion_job_items_positive_ck" CHECK (
"ordinal" >= 0 AND "attempts" >= 0 AND "max_attempts" >= 1
AND "attempts" <= "max_attempts" AND "row_version" >= 1
),
CONSTRAINT "deletion_job_items_retry_ck" CHECK (
("status" = 'retry_wait' AND "next_attempt_at" IS NOT NULL)
OR ("status" <> 'retry_wait' AND "next_attempt_at" IS NULL)
),
CONSTRAINT "deletion_job_items_terminal_ck" CHECK (
("status" IN ('completed', 'dead') AND "completed_at" IS NOT NULL)
OR ("status" IN ('pending', 'retry_wait') AND "completed_at" IS NULL)
),
CONSTRAINT "deletion_job_items_payload_ck" CHECK (
(
"kind" = 'object' AND "credential_ref" IS NULL AND "cache_key" IS NULL
AND (
("status" = 'completed' AND "object_key" IS NULL AND "redacted_at" IS NOT NULL)
OR ("status" <> 'completed' AND "object_key" IS NOT NULL AND "redacted_at" IS NULL)
)
)
OR (
"kind" = 'secret_ref' AND "object_key" IS NULL AND "cache_key" IS NULL
AND (
("status" = 'completed' AND "credential_ref" IS NULL AND "redacted_at" IS NOT NULL)
OR ("status" <> 'completed' AND "credential_ref" IS NOT NULL AND "redacted_at" IS NULL)
)
)
OR (
"kind" = 'cache_key' AND "object_key" IS NULL AND "credential_ref" IS NULL
AND (
("status" = 'completed' AND "cache_key" IS NULL AND "redacted_at" IS NOT NULL)
OR ("status" <> 'completed' AND "cache_key" IS NOT NULL AND "redacted_at" IS NULL)
)
)
OR (
"kind" IN ('document_cascade', 'document_detach')
AND "resource_id" IS NOT NULL AND "object_key" IS NULL
AND "credential_ref" IS NULL AND "cache_key" IS NULL AND "redacted_at" IS NULL
)
),
FOREIGN KEY ("deletion_job_id") REFERENCES "deletion_jobs" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "deletion_job_items_idempotency_uq"
ON "deletion_job_items" ("deletion_job_id", "idempotency_key");
CREATE UNIQUE INDEX IF NOT EXISTS "deletion_job_items_ordinal_uq"
ON "deletion_job_items" ("deletion_job_id", "ordinal");
CREATE INDEX IF NOT EXISTS "deletion_job_items_work_idx"
ON "deletion_job_items" (
"deletion_job_id", "status", "next_attempt_at", "ordinal", "id"
);
CREATE INDEX IF NOT EXISTS "deletion_job_items_resource_idx"
ON "deletion_job_items" ("deletion_job_id", "kind", "resource_id", "id");
CREATE TABLE IF NOT EXISTS "deletion_outbox" (
"id" UUID PRIMARY KEY NOT NULL,
"deletion_job_id" UUID NOT NULL,
"delivery_revision" INTEGER NOT NULL,
"event_type" VARCHAR(32) NOT NULL,
"schema_version" INTEGER NOT NULL,
"idempotency_key" VARCHAR(512) NOT NULL,
"request_idempotency_key" VARCHAR(512) NOT NULL,
"request_fingerprint" CHAR(64) NOT NULL,
"payload" JSONB NOT NULL,
"status" VARCHAR(16) NOT NULL,
"available_at" TIMESTAMPTZ NOT NULL,
"dispatch_attempts" INTEGER NOT NULL,
"locked_by" VARCHAR(255),
"locked_until" TIMESTAMPTZ,
"lock_token" UUID,
"queue_job_id" VARCHAR(255),
"last_error" TEXT,
"delivered_at" TIMESTAMPTZ,
"created_at" TIMESTAMPTZ NOT NULL,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "deletion_outbox_event_ck" CHECK ("event_type" = 'deletion.job'),
CONSTRAINT "deletion_outbox_schema_ck" CHECK ("schema_version" = 1),
CONSTRAINT "deletion_outbox_status_ck" CHECK (
"status" IN ('pending', 'dispatching', 'dispatched', 'leased', 'completed', 'canceled', 'dead')
),
CONSTRAINT "deletion_outbox_positive_ck" CHECK (
"delivery_revision" >= 1 AND "dispatch_attempts" >= 0
),
CONSTRAINT "deletion_outbox_lock_ck" CHECK (
("lock_token" IS NULL AND "locked_by" IS NULL AND "locked_until" IS NULL)
OR ("lock_token" IS NOT NULL AND "locked_by" IS NOT NULL AND "locked_until" IS NOT NULL)
),
FOREIGN KEY ("deletion_job_id") REFERENCES "deletion_jobs" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "deletion_outbox_idempotency_uq"
ON "deletion_outbox" ("idempotency_key");
CREATE UNIQUE INDEX IF NOT EXISTS "deletion_outbox_job_delivery_uq"
ON "deletion_outbox" ("deletion_job_id", "delivery_revision");
CREATE UNIQUE INDEX IF NOT EXISTS "deletion_outbox_job_request_uq"
ON "deletion_outbox" ("deletion_job_id", "request_idempotency_key");
CREATE INDEX IF NOT EXISTS "deletion_outbox_claim_idx"
ON "deletion_outbox" ("status", "available_at", "locked_until", "id");
-- Every manual retry has immutable actor provenance. This is intentionally separate from
-- deletion_jobs: an owner rescue must never overwrite the original deletion requester, and there
-- is deliberately no FK to the permission snapshot or target space because both may be deleted.
CREATE TABLE IF NOT EXISTS "deletion_retry_audits" (
"id" UUID PRIMARY KEY NOT NULL,
"deletion_job_id" UUID NOT NULL,
"outbox_id" UUID NOT NULL,
"tenant_id" VARCHAR(255) NOT NULL,
"knowledge_space_id" UUID NOT NULL,
"retry_authority" VARCHAR(32) NOT NULL,
"actor_subject_id" VARCHAR(255) NOT NULL,
"permission_snapshot_id" UUID NOT NULL,
"permission_snapshot_revision" INTEGER NOT NULL,
"access_channel" VARCHAR(16) NOT NULL,
"api_key_id" UUID,
"api_key_revision" INTEGER,
"api_key_expires_at" TIMESTAMPTZ,
"request_idempotency_key" VARCHAR(512) NOT NULL,
"request_fingerprint" CHAR(64) NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "deletion_retry_audits_authority_ck" CHECK (
"retry_authority" IN ('original_requester', 'interactive_owner_rescue')
),
CONSTRAINT "deletion_retry_audits_access_channel_ck" CHECK (
"access_channel" IN ('interactive', 'service_api', 'mcp', 'agent')
),
CONSTRAINT "deletion_retry_audits_positive_ck" CHECK (
"permission_snapshot_revision" >= 1
),
CONSTRAINT "deletion_retry_audits_api_key_binding_ck" CHECK (
(
"api_key_id" IS NULL
AND "api_key_revision" IS NULL
AND "api_key_expires_at" IS NULL
)
OR (
"api_key_id" IS NOT NULL
AND "api_key_revision" >= 1
AND "access_channel" = 'service_api'
)
),
CONSTRAINT "deletion_retry_audits_owner_rescue_ck" CHECK (
"retry_authority" <> 'interactive_owner_rescue'
OR (
"access_channel" = 'interactive'
AND "api_key_id" IS NULL
AND "api_key_revision" IS NULL
AND "api_key_expires_at" IS NULL
)
),
FOREIGN KEY ("deletion_job_id") REFERENCES "deletion_jobs" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "deletion_retry_audits_job_request_uq"
ON "deletion_retry_audits" ("deletion_job_id", "request_idempotency_key");
CREATE UNIQUE INDEX IF NOT EXISTS "deletion_retry_audits_outbox_uq"
ON "deletion_retry_audits" ("outbox_id");
CREATE INDEX IF NOT EXISTS "deletion_retry_audits_actor_idx"
ON "deletion_retry_audits" (
"tenant_id", "knowledge_space_id", "actor_subject_id", "created_at", "id"
);
CREATE INDEX IF NOT EXISTS "knowledge_spaces_lifecycle_idx"
ON "knowledge_spaces" ("tenant_id", "lifecycle_state", "updated_at", "id");
CREATE INDEX IF NOT EXISTS "sources_deletion_job_idx"
ON "sources" ("knowledge_space_id", "deletion_job_id", "id");
CREATE INDEX IF NOT EXISTS "document_assets_lifecycle_idx"
ON "document_assets" ("knowledge_space_id", "lifecycle_state", "source_id", "version", "id");
CREATE INDEX IF NOT EXISTS "page_index_manifests_document_idx"
ON "page_index_manifests" ("document_asset_id", "publication_generation_id", "id");
-- Agent workspace snapshots contain command logs and opaque metadata that cannot be attributed to
-- one document after the fact. Persist exact creator authorization and support whole-space
-- invalidation so every API replica observes deletion immediately.
CREATE TABLE IF NOT EXISTS "agent_workspace_snapshots" (
"id" UUID PRIMARY KEY NOT NULL,
"tenant_id" VARCHAR(255) NOT NULL,
"knowledge_space_id" UUID NOT NULL,
"subject_id" VARCHAR(255) NOT NULL,
"access_channel" VARCHAR(16) NOT NULL,
"permission_snapshot_id" UUID NOT NULL,
"permission_snapshot_revision" INTEGER NOT NULL,
"permission_scopes" JSONB NOT NULL,
"fingerprint" VARCHAR(80) NOT NULL,
"payload" JSONB NOT NULL,
"invalidated_at" TIMESTAMPTZ,
"invalidation_reason" VARCHAR(64),
"created_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "agent_workspace_snapshots_channel_ck" CHECK (
"access_channel" IN ('interactive', 'service_api', 'mcp', 'agent')
),
CONSTRAINT "agent_workspace_snapshots_revision_ck" CHECK (
"permission_snapshot_revision" >= 1
),
CONSTRAINT "agent_workspace_snapshots_invalidation_ck" CHECK (
("invalidated_at" IS NULL AND "invalidation_reason" IS NULL)
OR ("invalidated_at" IS NOT NULL AND "invalidation_reason" IS NOT NULL)
),
FOREIGN KEY ("tenant_id", "knowledge_space_id")
REFERENCES "knowledge_spaces" ("tenant_id", "id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "agent_workspace_snapshots_tenant_lookup_idx"
ON "agent_workspace_snapshots" ("tenant_id", "id", "invalidated_at");
CREATE INDEX IF NOT EXISTS "agent_workspace_snapshots_space_cleanup_idx"
ON "agent_workspace_snapshots" (
"tenant_id", "knowledge_space_id", "invalidated_at", "id"
);