mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(deletion): add durable whole-community deletion (#4425)
## Summary Adds a durable, operator-controlled V1 for deleting an entire Buzz community without deleting another tenant's data. The workflow is exposed through `buzz-admin deletions`: - `sweep` records independent fleet storage-taxonomy observations - `submit`, `list`, `inspect`, and `approve` manage a deletion request - `unblock` resumes a fail-closed request after an operator records remediation identity and reason - `run` and `drain` execute bounded work Requests advance through a PostgreSQL-backed state machine and stop at `retention_pending` after logical deletion has been independently verified across PostgreSQL, object storage, and Redis. This PR ships the engine and CLI, not a continuously running worker or Kubernetes packaging. For V1, a cluster/VM administrator invokes `/usr/local/bin/buzz-admin` from the existing relay image, for example with `kubectl exec` or an equivalent container/VM exec path. ## What whole-community V1 removes For the target community, V1 removes: - rows from the allowlisted community-scoped PostgreSQL catalog, including members, profiles, authored events and bodies, DMs, reactions, mentions, memberships, tokens, workflows, moderation, audit, feedback, and rate-limit state - media sidecars and upload-attribution records under `_meta/<community>/` and `_uploads/<community>/` - Git repository pointers under `repos/<community>/` - Redis keys under `buzz:<community>:*` The community row survives as a permanent tombstone, and deletion control-plane records remain as evidence of the request, approval, execution, and result. ## Safety model Deletion is not a broad `DELETE CASCADE` followed by optimistic cleanup. The destructive boundaries are durable and fail closed. ### 1. Inventory and approval - `submit` resolves the target and freezes the schema plus summary-only storage inventory. - Approval is bound to the exact request, community, and frozen inventory digest. - Unsupported manifest versions, malformed keys inside the target's owned prefixes, live scoped-table/write-fence coverage drift, frozen-inventory mismatch, and approval mismatch block execution rather than guessing. Migration and catalog revision numbers are not authorization gates; the executor validates the live safety shape instead. - Storage inventory is server-side prefix scoped to exactly: - `_meta/<community>/` - `_uploads/<community>/` - `repos/<community>/` - The deletion path never lists the whole shared bucket and has no arbitrary per-community object cap. Its listing work is proportional to the target community's bindings, not total fleet storage. - Fleet-wide taxonomy sweeps remain independent observability. They report unknown writer shapes but do not gate deletion submission, fencing, or destructive progress. Maintainers must add deletion taxonomy coverage whenever a new community-owned object-key class is introduced; writer-coverage tests bind the current media and Git writers to that contract. ### 2. Quiesce, fence, and destructive freeze - Writes continue through submission, inventory, and approval. They stop when execution moves the target into `quiescing` and then establishes the durable fence. - Already-admitted external effects finish under heartbeated serving-write leases; the exact admitted lease may renew while the community is quiescing, but new lease acquisition is rejected. The executor drains admitted leases before destructive work. - Invite minting after quiescing begins fails as typed `AccessDenied` (HTTP 503 at the relay boundary) before an invite can be persisted. - Database triggers enforce the community write fence across the complete catalog of community-scoped tables. Startup/readiness and destructive execution validate that catalog so a newly added but unfenced table cannot silently escape. - **Named isolation assumption — fresh write snapshot.** Every writer transaction that can reach a community-fenced relation must use PostgreSQL `READ COMMITTED`; each guarded write therefore observes a statement snapshot no older than acquisition of the community deletion lock. `REPEATABLE READ` and `SERIALIZABLE` can retain a pre-fence snapshot and are unsupported for writers. The writer pool refuses non-`READ COMMITTED` sessions at connection setup, and both SQL fence functions reject an explicit per-transaction isolation override with SQLSTATE `25000`. Configuration-delivered bad isolation can surface through SQLx as a pool-acquire timeout because every `after_connect` attempt is rejected; the precise `community writes require READ COMMITTED isolation` reason remains observable when the SQL guard is reached. Read-only replica transactions are outside this assumption. - Holding the shared advisory lock until the guarded write executes is a separate liveness condition: under `READ COMMITTED`, releasing it early does not permit resurrection because the trigger rechecks the fence, but it can turn a fleet sweep into a statement-wide SQLSTATE `55000` abort. - After the fence closes writers, storage is re-enumerated into chunked side-table rows. Per-prefix counts and digests bind those concrete keys to the destructive manifest. - Manifest chunk insertion, update, and deletion are protected after freeze. This closes the race where an unbound key could otherwise appear after the manifest was committed. ### 3. Checkpointed destruction - Target-owned object bindings are deleted from the frozen destructive manifest in bounded batches with durable progress. - The concrete key list lives in chunked side-table rows rather than one request-row JSON value. It supports large communities, resumable execution, and terminal cleanup. - Missing objects are accepted as idempotent crash-window outcomes; malformed ownership, changed evidence, and unexplained target-prefix drift fail closed. - PostgreSQL purging remains scoped by `community_id`, including the guarded NIP-RS hard-delete path discovered with real Desktop kind `30078` read-state data. - Redis cleanup explicitly scans and `UNLINK`s only `buzz:<community_id>:*`. Natural expiry is insufficient because some keys, including tunnel generation counters used as fencing state, are deliberately persistent. ### 4. Independent verification - PostgreSQL logical absence is checked after purge. - The three target-owned storage prefixes are freshly inventoried again and must be empty. - Redis requires two complete empty namespace scans. - Only after all three stores pass does the request advance through `logically_verified` to `retention_pending`. ## What V1 deliberately does not erase ### Shared content-addressed storage Per-community deletion removes bindings, metadata, attribution records, and Git pointers. It does **not** physically delete fleet-shared CAS bytes that another community may still reference: - media blobs and thumbnails - Git manifests, packs, and indexes (`manifests/`, `packs/`, and `idx/`) Safe reclamation requires a separate fleet-wide reachability and retention GC. Unknown keys elsewhere in the shared bucket do not block one community's deletion; malformed or unrecognized keys inside that community's three owned prefixes still fail closed. ### External retained copies The online logical-deletion proof does not erase object versions/replicas, database backups/WAL, CDN copies, provider retention copies, or observability exports. Those require their own retention and purge controls. ### Member-only erasure This PR erases a whole community. It does not implement the different operation "erase one npub while preserving the community." Removing membership or accepting NIP-09 is not member erasure. A member-only workflow would need to find and selectively remove or redact authored event content and pubkeys, profile data, DMs, reactions, mentions, memberships/roles, tokens, workflows/subscriptions, upload attribution, moderation/audit history, repository attribution, and identity embedded in tags or JSON. It would also need explicit rules for ownership transfer, surviving replies and thread metadata, audit-chain integrity, immutable Git history, and shared-CAS reachability. That requires a pubkey-level fence and selective graph rewrite; it is a separate deletion product, not a safe extension of this whole-tenant worker. ## In scope - migration `0029_community_deletion.sql`: requests, approvals, leases, manifest chunks, checkpoints, tombstones, and the universal write-fence catalog - durable executor leases, generations, heartbeats, retry/block state, and resumable stage transitions - operator-driven `sweep`, `submit`, `list`, `inspect`, `approve`, `unblock`, `run`, and `drain` commands - serving-path fences for database writes and external effects across event ingest, media, Git, workflow, push, invites, mesh/tunnel, and related paths - target-prefix-only storage inventory, summary manifests, post-fence destructive chunks, and bounded batch deletion - exact community Redis namespace purge and two-pass absence verification - cross-community isolation, crash/resume, manifest-integrity, writer-taxonomy, and schema/migration regressions - desired-state `schema/schema.sql` support without requiring a SQLx migration ledger ## Deferred / not covered - dedicated Helm/chart worker Deployment, service account, secrets, probes, resources, and network policy - autonomous `buzz-admin deletions worker` poll loop and worker-only health server - least-privilege separation among migration, relay-serving, and destructive execution roles - fleet-wide shared-CAS physical GC - backup/provider/CDN/observability retention completion - member-only erasure - provider-native conditional-delete improvements - a general force-continue escape hatch; permanent safety failures remain fail closed unless an operator remediates the cause and records an audited `unblock` The removed continuous-worker implementation remains deferred; no remote follow-up branch is claimed by this PR. ## Validation ### Current PR head and repository state Current pushed head: `359d8402ee15f049768f54156f67b953c7a7e2ed`, rebased onto `cc9a2f783375e51a6e8d1f2f9d01d5f7e22813d1` (`origin/main` at push time). The complete PR diff is now 47 files, 9,834 additions, and 517 deletions. The bespoke source-scanner stack was removed to keep this PR scoped to community deletion. Tyler/team requested the underlying fenced-write safety behavior, not `ast-grep`, `crates/buzz-db/tests/community_fenced_writes.rs`, its 27 fixtures, or the new `scripts/lints/community_*.yml` rules. Those scanner-specific files, dependencies, Hermit links, and runner wiring are absent from the current tree. The production database write fence, startup/destructive live-catalog validation, and deletion behavior remain. Source validation on this exact SHA passed: - `cargo fmt --all -- --check` - `bash -n scripts/run-tests.sh` - `cargo nextest run -p buzz-db --all-targets`: 102 passed, 173 skipped, 0 failed - `cargo nextest run -p buzz-deletion --all-targets`: 10 passed, 9 skipped, 0 failed - `cargo nextest run -p buzz-admin --all-targets`: 1 passed, 0 failed - affected-package/all-target Clippy with warnings denied - lockfile consistency - Helm 3.16.4 lint and all 44 chart unit tests - Helm region controls using that fixture: default `BUZZ_S3_REGION=us-east-1`, explicit `eu-west-2` override, and blank-region schema rejection The prior Kubernetes battery below was run against `928992237358a3294621ac0280830b77155abc04`. It remains useful evidence for the patch-equivalent production deletion implementation, but it is **not** claimed as exact-SHA evidence for current head `359d8402ee15f049768f54156f67b953c7a7e2ed`; the current cleanup removes only scanner/test/tooling infrastructure. CI restarted for the new head after the rebase and is pending. Human review remains `CHANGES_REQUESTED`. ### Prior-head live Kubernetes deletion and safety gates The full program used one immutable image, real PostgreSQL, Redis, MinIO, and a three-relay Kubernetes release: - source: `928992237358a3294621ac0280830b77155abc04` (**prior head**) - image: `buzz-e2e:sha-928992237358` - immutable image digest: `sha256:a1a204f4618ac22d9e210be5e5290645a15d79831ae30b0e44379357c8e4a895` - evidence root: `/tmp/buzz-e2e/20260807T033025Z-928992237358-full-gates/` - evidence-manifest digest: `82875c5bc9bea7370b796a7aef3457b3a1c8306c84c59e0f7388bbb5ad30e865` Passed gates at that prior head: - **Chart/operator region:** default `us-east-1`, explicit nondefault propagation, blank-region schema rejection, live in-pod environment, and an in-pod taxonomy sweep over 18 objects with zero unknown. - **Fenced writers and lifecycle:** open-write/fence ordering; 100-attempt anti-starvation; invite, push matcher, and exhausted-reaper bystander isolation; non-`READ-COMMITTED` rejection; manifest/tombstone contracts; eight-failure stage block and audited `unblock`. - **Destructive lifecycle:** submit → approve → run → `retention_pending`; PostgreSQL tombstone and Redis/S3 verification true; zero retries/errors; terminal reruns rejected with exit 5. - **Fresh 10,001-object crash boundary:** exactly two chunks (10,000 + 1). The executor deleted chunk 0 from MinIO while its PostgreSQL stamp was row-lock-blocked, was killed with `SIGKILL`, left one object and both stamps absent, then resumed the same request under generation 2 to zero objects and terminal state. - **Independent dead-owner recovery:** a dedicated executor claimed generation 1, blocked before effects, and was killed through containerd with `SIGKILL` (no TERM cleanup). The request remained owned and unreclaimable before lease expiry; a successor claimed generation 2 after 60 seconds and completed with two attempts and zero retries. - **Three-pod socket isolation:** ordinary NIP-42 and joined huddle-audio target witnesses on every replica received exact `1008 / community deleted`; healthy-tenant witnesses on those pods remained live; deleted-host reconnect returned HTTP 404. - **Health/provenance:** all replicas independently returned ready and retained the exact image digest before/after destructive runs and an audio-enabled rolling restart; PostgreSQL, Redis, and MinIO were healthy at close. Instrument corrections were retained as evidence rather than counted as product failures: a foreground PostgreSQL forward caused an initial `PoolTimedOut`; Kubernetes pod deletion exercised graceful TERM rather than dead-owner recovery; shell-background socket witnesses died with their parent; and the first image build hit the corporate TLS proxy. Detached forwarding/witnesses, containerd `SIGKILL`, and the configured internal CA/Artifactory mirror produced the discriminating runs without weakening product security. ### Prior-head cleanup For the prior-head Kubernetes run, the Helm release was removed, namespace absence was verified, run-owned Screen sessions were absent, and that source worktree remained clean. The evidence manifest was independently recomputed and every indexed artifact passed `shasum -a 256 -c`. The current `359d8402` source worktree is also clean after the scanner-only cleanup and push. --------- Signed-off-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz> Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: Kalvin Chau <kalvin@block.xyz> Signed-off-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: cid <d9f92a72922bf45c17379a47d64dae84b6020397c2d5a52b5317d512068cd9d3@buzz.block.builderlab.xyz> Co-authored-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz> Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Co-authored-by: cid <d9f92a72922bf45c17379a47d64dae84b6020397c2d5a52b5317d512068cd9d3@buzz.block.builderlab.xyz>
This commit is contained in:
co-authored by
npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc
npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7
cid
parent
63d14a0e95
commit
8a2c9af2db
@@ -59,6 +59,9 @@ CREATE TABLE communities (
|
||||
icon TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
archived_at TIMESTAMPTZ,
|
||||
deletion_state TEXT NOT NULL DEFAULT 'active' CHECK (deletion_state IN ('active', 'quiescing', 'fenced', 'tombstone')),
|
||||
deletion_fence_generation BIGINT NOT NULL DEFAULT 0 CHECK (deletion_fence_generation >= 0),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT chk_communities_id_not_nil CHECK (id <> '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
);
|
||||
|
||||
@@ -791,6 +794,48 @@ INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('communities', 'the tenant registry itself; id IS the community key'),
|
||||
('rate_limit_violations', 'deployment abuse/health; never tenant-observable; community_id is an attribution label only'),
|
||||
('_operator_global_tables', 'the registry table itself');
|
||||
|
||||
-- ── Additive tenant tables represented in migrations 0002/0007/0017 ──────────
|
||||
-- Keep desired-state schema parity with the embedded SQLx migration path.
|
||||
CREATE TABLE git_repo_names (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
repo_id TEXT NOT NULL,
|
||||
owner_pubkey TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (community_id, repo_id)
|
||||
);
|
||||
CREATE INDEX idx_git_repo_names_owner ON git_repo_names (community_id, owner_pubkey);
|
||||
|
||||
CREATE TABLE parameterized_event_watermarks (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
kind INT NOT NULL,
|
||||
pubkey BYTEA NOT NULL,
|
||||
d_tag TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
event_id BYTEA NOT NULL,
|
||||
PRIMARY KEY (community_id, kind, pubkey, d_tag)
|
||||
);
|
||||
CREATE INDEX idx_event_mentions_community_event
|
||||
ON event_mentions (community_id, event_id);
|
||||
|
||||
CREATE TABLE product_feedback (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
community_id UUID REFERENCES communities(id) ON DELETE SET NULL,
|
||||
event_id BYTEA NOT NULL CHECK (length(event_id) = 32),
|
||||
submitter_pubkey BYTEA NOT NULL CHECK (length(submitter_pubkey) = 32),
|
||||
category TEXT CHECK (category IN ('bug', 'praise', 'needs-work')),
|
||||
body TEXT NOT NULL CHECK (length(btrim(body)) > 0),
|
||||
tags JSONB NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(tags) = 'array'),
|
||||
event_created_at TIMESTAMPTZ NOT NULL,
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (event_id)
|
||||
);
|
||||
CREATE INDEX idx_product_feedback_received
|
||||
ON product_feedback (received_at DESC, id);
|
||||
CREATE INDEX idx_product_feedback_community_received
|
||||
ON product_feedback (community_id, received_at DESC, id);
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('product_feedback', 'deployment product inbox; community_id is provenance only');
|
||||
-- NIP-PL effective lease state and durable wake outbox. Every key is led by
|
||||
-- community_id: client-provided origin is confirmation only, never routing.
|
||||
CREATE TABLE push_leases (
|
||||
@@ -1080,3 +1125,545 @@ INSERT INTO replica_heartbeat (id) VALUES (1);
|
||||
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('replica_heartbeat', 'single-row replication freshness token; describes deployment topology, never tenant data');
|
||||
|
||||
-- ── Whole-community deletion control plane (migration 0029) ─────────────────
|
||||
CREATE TABLE community_deletion_requests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
community_host TEXT NOT NULL,
|
||||
stage TEXT NOT NULL DEFAULT 'submitted' CHECK (stage IN (
|
||||
'submitted', 'inventoried', 'approved', 'fenced', 'drained',
|
||||
'bindings_removed', 'postgres_purged', 'cache_purged',
|
||||
'logically_verified', 'retention_pending', 'aborted'
|
||||
)),
|
||||
requested_by TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
schema_manifest JSONB,
|
||||
storage_manifest JSONB,
|
||||
destructive_storage_manifest JSONB,
|
||||
destructive_storage_frozen_at TIMESTAMPTZ,
|
||||
inventory_manifest JSONB,
|
||||
inventory_digest BYTEA CHECK (inventory_digest IS NULL OR length(inventory_digest) = 32),
|
||||
inventory_frozen_at TIMESTAMPTZ,
|
||||
fence_generation BIGINT CHECK (fence_generation IS NULL OR fence_generation > 0),
|
||||
lease_owner TEXT,
|
||||
lease_generation BIGINT NOT NULL DEFAULT 0 CHECK (lease_generation >= 0),
|
||||
lease_until TIMESTAMPTZ,
|
||||
attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
|
||||
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0),
|
||||
retry_stage TEXT CHECK (retry_stage IS NULL OR retry_stage IN (
|
||||
'approved', 'fenced', 'drained', 'bindings_removed',
|
||||
'postgres_purged', 'cache_purged', 'logically_verified'
|
||||
)),
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_error TEXT,
|
||||
last_error_at TIMESTAMPTZ,
|
||||
blocked_at TIMESTAMPTZ,
|
||||
blocked_reason TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
pre_quiesce_archived_at TIMESTAMPTZ,
|
||||
quiescing_started_at TIMESTAMPTZ,
|
||||
aborted_by TEXT,
|
||||
abort_reason TEXT,
|
||||
aborted_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
CHECK ((blocked_at IS NULL) = (blocked_reason IS NULL)),
|
||||
CHECK ((stage = 'aborted') = (aborted_at IS NOT NULL)),
|
||||
CHECK ((aborted_at IS NULL) = (aborted_by IS NULL)),
|
||||
CHECK ((aborted_at IS NULL) = (abort_reason IS NULL)),
|
||||
CHECK ((inventory_frozen_at IS NULL) = (inventory_digest IS NULL)),
|
||||
UNIQUE (id, community_id, inventory_digest)
|
||||
);
|
||||
CREATE UNIQUE INDEX community_deletion_requests_active_community
|
||||
ON community_deletion_requests (community_id)
|
||||
WHERE stage <> 'aborted';
|
||||
CREATE INDEX community_deletion_requests_runnable
|
||||
ON community_deletion_requests (next_attempt_at, created_at)
|
||||
WHERE blocked_at IS NULL
|
||||
AND stage IN ('approved', 'fenced', 'drained', 'bindings_removed',
|
||||
'postgres_purged', 'cache_purged', 'logically_verified');
|
||||
CREATE INDEX community_deletion_requests_lease
|
||||
ON community_deletion_requests (lease_until) WHERE lease_owner IS NOT NULL;
|
||||
|
||||
CREATE TABLE community_deletion_approvals (
|
||||
request_id UUID PRIMARY KEY,
|
||||
community_id UUID NOT NULL,
|
||||
inventory_digest BYTEA NOT NULL CHECK (length(inventory_digest) = 32),
|
||||
approved_by TEXT NOT NULL,
|
||||
note TEXT,
|
||||
approved_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (request_id, community_id, inventory_digest)
|
||||
REFERENCES community_deletion_requests(id, community_id, inventory_digest)
|
||||
ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE FUNCTION prevent_community_deletion_request_retargeting()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.community_id IS DISTINCT FROM OLD.community_id
|
||||
OR NEW.community_host IS DISTINCT FROM OLD.community_host
|
||||
THEN
|
||||
RAISE EXCEPTION 'community deletion target identity is immutable'
|
||||
USING ERRCODE = 'integrity_constraint_violation';
|
||||
END IF;
|
||||
IF OLD.inventory_frozen_at IS NOT NULL AND (
|
||||
NEW.schema_manifest IS DISTINCT FROM OLD.schema_manifest
|
||||
OR NEW.storage_manifest IS DISTINCT FROM OLD.storage_manifest
|
||||
OR NEW.inventory_manifest IS DISTINCT FROM OLD.inventory_manifest
|
||||
OR NEW.inventory_digest IS DISTINCT FROM OLD.inventory_digest
|
||||
OR NEW.inventory_frozen_at IS DISTINCT FROM OLD.inventory_frozen_at
|
||||
) THEN
|
||||
RAISE EXCEPTION 'frozen community deletion inventory is immutable'
|
||||
USING ERRCODE = 'integrity_constraint_violation';
|
||||
END IF;
|
||||
IF OLD.destructive_storage_frozen_at IS NOT NULL AND (
|
||||
NEW.destructive_storage_manifest IS DISTINCT FROM OLD.destructive_storage_manifest
|
||||
OR NEW.destructive_storage_frozen_at IS DISTINCT FROM OLD.destructive_storage_frozen_at
|
||||
) THEN
|
||||
RAISE EXCEPTION 'frozen destructive storage manifest is immutable'
|
||||
USING ERRCODE = 'integrity_constraint_violation';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER community_deletion_request_retargeting_guard
|
||||
BEFORE UPDATE ON community_deletion_requests
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION prevent_community_deletion_request_retargeting();
|
||||
|
||||
CREATE FUNCTION prevent_community_deletion_approval_removal()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'community deletion approval evidence is immutable'
|
||||
USING ERRCODE = 'integrity_constraint_violation';
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER community_deletion_approval_removal_guard
|
||||
BEFORE UPDATE OR DELETE ON community_deletion_approvals
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION prevent_community_deletion_approval_removal();
|
||||
|
||||
CREATE TABLE community_deletion_checkpoints (
|
||||
request_id UUID NOT NULL REFERENCES community_deletion_requests(id) ON DELETE RESTRICT,
|
||||
sequence BIGINT GENERATED ALWAYS AS IDENTITY,
|
||||
stage TEXT NOT NULL,
|
||||
unit_key TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('started', 'completed', 'failed')),
|
||||
lease_generation BIGINT NOT NULL CHECK (lease_generation > 0),
|
||||
attempts INTEGER NOT NULL DEFAULT 1 CHECK (attempts > 0),
|
||||
detail JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
error TEXT,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
PRIMARY KEY (request_id, sequence),
|
||||
UNIQUE (request_id, stage, unit_key),
|
||||
CHECK ((status = 'completed') = (completed_at IS NOT NULL)),
|
||||
CHECK ((status = 'failed') = (error IS NOT NULL))
|
||||
);
|
||||
|
||||
-- Frozen destructive key list, chunked out of the request row so a large
|
||||
-- tenant (100k-1M objects) never materializes as one multi-hundred-MB JSONB
|
||||
-- value. Rows are written once in the fenced stage, stamped `deleted_at` as
|
||||
-- the executor confirms each chunk removed, and dropped at logical
|
||||
-- verification. The request row keeps only per-prefix count/bytes/digest
|
||||
-- summaries; the chunk stream must hash to those frozen digests.
|
||||
CREATE TABLE community_deletion_manifest_keys (
|
||||
request_id UUID NOT NULL REFERENCES community_deletion_requests(id) ON DELETE CASCADE,
|
||||
chunk_no BIGINT NOT NULL CHECK (chunk_no >= 0),
|
||||
prefix TEXT NOT NULL,
|
||||
keys JSONB NOT NULL,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
PRIMARY KEY (request_id, chunk_no)
|
||||
);
|
||||
|
||||
-- Chunk content is immutable once written; the only permitted update is the
|
||||
-- one-way deleted_at stamp. New chunks are permitted only while the request is
|
||||
-- fenced and its destructive manifest remains unfrozen. Removal is permitted
|
||||
-- only while the destructive manifest has not yet frozen (a retried partial
|
||||
-- freeze rewrites its chunks) or once the request has passed logical
|
||||
-- verification (terminal cleanup).
|
||||
CREATE FUNCTION protect_community_deletion_manifest_keys()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
frozen_at TIMESTAMPTZ;
|
||||
request_stage TEXT;
|
||||
BEGIN
|
||||
IF TG_OP = 'UPDATE' THEN
|
||||
IF NEW.request_id IS DISTINCT FROM OLD.request_id
|
||||
OR NEW.chunk_no IS DISTINCT FROM OLD.chunk_no
|
||||
OR NEW.prefix IS DISTINCT FROM OLD.prefix
|
||||
OR NEW.keys IS DISTINCT FROM OLD.keys
|
||||
OR OLD.deleted_at IS NOT NULL
|
||||
THEN
|
||||
RAISE EXCEPTION 'community deletion manifest key chunks are immutable'
|
||||
USING ERRCODE = 'integrity_constraint_violation';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
SELECT destructive_storage_frozen_at, stage
|
||||
INTO frozen_at, request_stage
|
||||
FROM community_deletion_requests
|
||||
WHERE id = CASE WHEN TG_OP = 'INSERT' THEN NEW.request_id ELSE OLD.request_id END
|
||||
FOR UPDATE;
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
IF FOUND AND frozen_at IS NULL AND request_stage = 'fenced' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
RAISE EXCEPTION 'community deletion manifest key chunks require an unfrozen fenced request'
|
||||
USING ERRCODE = 'integrity_constraint_violation';
|
||||
END IF;
|
||||
IF NOT FOUND
|
||||
OR frozen_at IS NULL
|
||||
OR request_stage IN ('logically_verified', 'retention_pending')
|
||||
THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RAISE EXCEPTION 'community deletion manifest key chunks cannot be removed mid-execution'
|
||||
USING ERRCODE = 'integrity_constraint_violation';
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER community_deletion_manifest_keys_guard
|
||||
BEFORE INSERT OR UPDATE OR DELETE ON community_deletion_manifest_keys
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION protect_community_deletion_manifest_keys();
|
||||
|
||||
-- Fleet-wide object-store taxonomy sweep evidence. This is an independent
|
||||
-- observability record: community deletion inventories only the target's owned
|
||||
-- prefixes and does not gate submission or execution on sweep state.
|
||||
CREATE TABLE storage_taxonomy_sweeps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
completed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
listed_objects BIGINT NOT NULL CHECK (listed_objects >= 0),
|
||||
unknown_object_count BIGINT NOT NULL CHECK (unknown_object_count >= 0),
|
||||
unknown_key_sample JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
object_cap BIGINT NOT NULL CHECK (object_cap > 0),
|
||||
CHECK (completed_at >= started_at)
|
||||
);
|
||||
CREATE INDEX storage_taxonomy_sweeps_latest
|
||||
ON storage_taxonomy_sweeps (completed_at DESC);
|
||||
|
||||
CREATE TABLE community_serving_write_leases (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
operation TEXT NOT NULL,
|
||||
owner TEXT NOT NULL,
|
||||
generation BIGINT NOT NULL DEFAULT 1 CHECK (generation > 0),
|
||||
-- Community fence generation observed when this lease was acquired.
|
||||
fence_generation BIGINT NOT NULL CHECK (fence_generation >= 0),
|
||||
lease_until TIMESTAMPTZ NOT NULL,
|
||||
heartbeat_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX community_serving_write_leases_active
|
||||
ON community_serving_write_leases (community_id, lease_until);
|
||||
|
||||
CREATE TABLE community_deletion_executor_heartbeats (
|
||||
executor_id TEXT PRIMARY KEY,
|
||||
mode TEXT NOT NULL CHECK (mode IN ('run', 'drain', 'worker')),
|
||||
request_id UUID REFERENCES community_deletion_requests(id) ON DELETE SET NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
heartbeat_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
draining BOOLEAN NOT NULL DEFAULT false,
|
||||
stopped_at TIMESTAMPTZ
|
||||
);
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('community_deletion_requests', 'deployment deletion lifecycle and frozen inventory'),
|
||||
('community_deletion_approvals', 'deployment operator destructive approvals'),
|
||||
('community_deletion_checkpoints', 'deployment deletion executor checkpoints and failures'),
|
||||
('community_deletion_manifest_keys', 'deployment deletion frozen destructive key chunks'),
|
||||
('storage_taxonomy_sweeps', 'deployment object-store taxonomy sweep evidence'),
|
||||
('community_serving_write_leases', 'deployment serving side-effect leases drained by deletion'),
|
||||
('community_deletion_executor_heartbeats', 'deployment deletion worker liveness');
|
||||
|
||||
CREATE FUNCTION community_deletion_lock_key(target UUID) RETURNS BIGINT
|
||||
LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE AS $$
|
||||
SELECT hashtextextended('buzz-community-deletion:' || target::text, 0)
|
||||
$$;
|
||||
-- Keep the deletion control plane writable while its target tenant is fenced.
|
||||
-- This predicate is the single SQL source of truth used by attachment and live
|
||||
-- catalog validation.
|
||||
CREATE FUNCTION community_write_fence_excluded_table(target NAME) RETURNS BOOLEAN
|
||||
LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE AS $$
|
||||
SELECT target::TEXT = ANY (ARRAY[
|
||||
'community_deletion_requests',
|
||||
'community_deletion_approvals',
|
||||
'community_deletion_checkpoints',
|
||||
'community_serving_write_leases',
|
||||
'community_deletion_executor_heartbeats',
|
||||
'product_feedback',
|
||||
'rate_limit_violations'
|
||||
]::TEXT[])
|
||||
$$;
|
||||
|
||||
-- Fleet-wide writers filter candidates through this VOLATILE predicate in
|
||||
-- the mutating statement so fenced tenants are skipped before row triggers run.
|
||||
CREATE FUNCTION community_write_allowed(target UUID) RETURNS BOOLEAN
|
||||
LANGUAGE plpgsql VOLATILE AS $$
|
||||
DECLARE
|
||||
lifecycle TEXT;
|
||||
BEGIN
|
||||
IF current_setting('transaction_isolation') <> 'read committed' THEN
|
||||
RAISE EXCEPTION 'community writes require READ COMMITTED isolation'
|
||||
USING ERRCODE = 'invalid_transaction_state';
|
||||
END IF;
|
||||
|
||||
IF target IS NULL THEN
|
||||
RETURN true;
|
||||
END IF;
|
||||
|
||||
PERFORM pg_advisory_xact_lock_shared(community_deletion_lock_key(target));
|
||||
SELECT deletion_state
|
||||
INTO lifecycle
|
||||
FROM communities
|
||||
WHERE id = target;
|
||||
RETURN FOUND AND lifecycle = 'active';
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION assert_community_write_allowed(target UUID) RETURNS VOID
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
lifecycle TEXT;
|
||||
generation BIGINT;
|
||||
executor_community TEXT;
|
||||
executor_generation TEXT;
|
||||
serving_community TEXT;
|
||||
serving_lease_id TEXT;
|
||||
serving_owner TEXT;
|
||||
serving_generation TEXT;
|
||||
serving_fence_generation TEXT;
|
||||
serving_lease_valid BOOLEAN := false;
|
||||
BEGIN
|
||||
-- The fence proof requires a fresh statement snapshot after lock grant;
|
||||
-- pinned RR/Serializable snapshots can retain pre-fence authorization.
|
||||
IF current_setting('transaction_isolation') <> 'read committed' THEN
|
||||
RAISE EXCEPTION 'community writes require READ COMMITTED isolation'
|
||||
USING ERRCODE = 'invalid_transaction_state';
|
||||
END IF;
|
||||
|
||||
-- Nullable operator-attribution rows without a tenant are unrelated.
|
||||
IF target IS NULL THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
PERFORM pg_advisory_xact_lock_shared(community_deletion_lock_key(target));
|
||||
SELECT deletion_state, deletion_fence_generation
|
||||
INTO lifecycle, generation
|
||||
FROM communities
|
||||
WHERE id = target;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'community write rejected: community % is missing', target
|
||||
USING ERRCODE = 'object_not_in_prerequisite_state';
|
||||
END IF;
|
||||
|
||||
-- Authorization is evaluated independently for every community checked.
|
||||
executor_community := current_setting('buzz.deletion_executor_community', true);
|
||||
executor_generation := current_setting('buzz.deletion_fence_generation', true);
|
||||
IF executor_community = target::TEXT
|
||||
AND executor_generation ~ '^[0-9]+$'
|
||||
AND executor_generation::BIGINT = generation THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- A serving mutation admitted before quiescing may finish only while its
|
||||
-- exact durable lease remains current and bound to this fence generation.
|
||||
serving_community := current_setting('buzz.serving_write_community', true);
|
||||
serving_lease_id := current_setting('buzz.serving_write_lease_id', true);
|
||||
serving_owner := current_setting('buzz.serving_write_owner', true);
|
||||
serving_generation := current_setting('buzz.serving_write_generation', true);
|
||||
serving_fence_generation := current_setting('buzz.serving_write_fence_generation', true);
|
||||
IF lifecycle IN ('active', 'quiescing')
|
||||
AND serving_community = target::TEXT
|
||||
AND serving_lease_id ~ '^[0-9a-fA-F-]{36}$'
|
||||
AND serving_generation ~ '^[0-9]+$'
|
||||
AND serving_fence_generation ~ '^[0-9]+$'
|
||||
AND serving_fence_generation::BIGINT = generation THEN
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM community_serving_write_leases lease
|
||||
WHERE lease.id = serving_lease_id::UUID
|
||||
AND lease.community_id = target
|
||||
AND lease.owner = serving_owner
|
||||
AND lease.generation = serving_generation::BIGINT
|
||||
AND lease.fence_generation = serving_fence_generation::BIGINT
|
||||
AND lease.lease_until >= now()
|
||||
) INTO serving_lease_valid;
|
||||
IF serving_lease_valid THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
IF lifecycle <> 'active' THEN
|
||||
RAISE EXCEPTION 'community write fenced: community % generation %', target, generation
|
||||
USING ERRCODE = 'object_not_in_prerequisite_state';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION enforce_community_write_fence() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
PERFORM assert_community_write_allowed(NEW.community_id);
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
PERFORM assert_community_write_allowed(OLD.community_id);
|
||||
ELSIF OLD.community_id IS NOT DISTINCT FROM NEW.community_id THEN
|
||||
PERFORM assert_community_write_allowed(OLD.community_id);
|
||||
ELSIF OLD.community_id IS NULL THEN
|
||||
PERFORM assert_community_write_allowed(NEW.community_id);
|
||||
ELSIF NEW.community_id IS NULL THEN
|
||||
PERFORM assert_community_write_allowed(OLD.community_id);
|
||||
ELSIF OLD.community_id < NEW.community_id THEN
|
||||
PERFORM assert_community_write_allowed(OLD.community_id);
|
||||
PERFORM assert_community_write_allowed(NEW.community_id);
|
||||
ELSE
|
||||
PERFORM assert_community_write_allowed(NEW.community_id);
|
||||
PERFORM assert_community_write_allowed(OLD.community_id);
|
||||
END IF;
|
||||
|
||||
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION enforce_community_tombstone() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
executor_community TEXT := current_setting('buzz.deletion_executor_community', true);
|
||||
executor_generation TEXT := current_setting('buzz.deletion_fence_generation', true);
|
||||
expected_generation BIGINT;
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
IF OLD.deletion_state <> 'active' OR OLD.deleted_at IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'community tombstones are permanent'
|
||||
USING ERRCODE = 'object_not_in_prerequisite_state';
|
||||
END IF;
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
expected_generation := CASE WHEN NEW.deletion_fence_generation > OLD.deletion_fence_generation
|
||||
THEN NEW.deletion_fence_generation ELSE OLD.deletion_fence_generation END;
|
||||
IF executor_community = OLD.id::text AND executor_generation ~ '^[0-9]+$'
|
||||
AND executor_generation::BIGINT = expected_generation THEN RETURN NEW; END IF;
|
||||
IF OLD.deletion_state <> 'active' OR NEW.deletion_state <> OLD.deletion_state
|
||||
OR NEW.deletion_fence_generation <> OLD.deletion_fence_generation
|
||||
OR NEW.deleted_at IS DISTINCT FROM OLD.deleted_at THEN
|
||||
RAISE EXCEPTION 'community tombstone mutation rejected: community % generation %',
|
||||
OLD.id, OLD.deletion_fence_generation
|
||||
USING ERRCODE = 'object_not_in_prerequisite_state';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END
|
||||
$$;
|
||||
CREATE TRIGGER communities_deletion_tombstone BEFORE UPDATE OR DELETE ON communities
|
||||
FOR EACH ROW EXECUTE FUNCTION enforce_community_tombstone();
|
||||
-- Attach the universal fence to one community-scoped relation. Future
|
||||
-- migrations must invoke this helper explicitly after CREATE/ALTER introduces
|
||||
-- community_id; the migration lint enforces that contract.
|
||||
CREATE FUNCTION attach_community_write_fence(target REGCLASS) RETURNS VOID
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
relation_name NAME;
|
||||
BEGIN
|
||||
SELECT c.relname
|
||||
INTO relation_name
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.oid = target
|
||||
AND n.nspname = current_schema()
|
||||
AND c.relkind IN ('r', 'p')
|
||||
AND NOT c.relispartition;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'community write fence target % is not a table in the current schema', target
|
||||
USING ERRCODE = 'wrong_object_type';
|
||||
END IF;
|
||||
IF community_write_fence_excluded_table(relation_name) THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_attribute
|
||||
WHERE attrelid = target AND attname = 'community_id' AND NOT attisdropped
|
||||
) THEN
|
||||
RAISE EXCEPTION 'community write fence target % has no community_id', target
|
||||
USING ERRCODE = 'undefined_column';
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_trigger
|
||||
WHERE tgrelid = target
|
||||
AND tgname = 'community_write_fence_' || relation_name
|
||||
AND NOT tgisinternal
|
||||
) THEN
|
||||
EXECUTE format(
|
||||
'CREATE TRIGGER %I BEFORE INSERT OR UPDATE OR DELETE ON %s '
|
||||
'FOR EACH ROW EXECUTE FUNCTION enforce_community_write_fence()',
|
||||
'community_write_fence_' || relation_name,
|
||||
target
|
||||
);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Attach the universal fence to every existing table carrying community_id,
|
||||
-- including deployment-private sidecars whose community_id is provenance.
|
||||
DO $$
|
||||
DECLARE
|
||||
target REGCLASS;
|
||||
BEGIN
|
||||
FOR target IN
|
||||
SELECT c.oid::REGCLASS
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
JOIN pg_attribute a ON a.attrelid = c.oid
|
||||
WHERE n.nspname = current_schema()
|
||||
AND c.relkind IN ('r', 'p')
|
||||
AND NOT c.relispartition
|
||||
AND a.attname = 'community_id'
|
||||
AND NOT a.attisdropped
|
||||
AND NOT community_write_fence_excluded_table(c.relname)
|
||||
ORDER BY c.oid::REGCLASS::TEXT
|
||||
LOOP
|
||||
PERFORM attach_community_write_fence(target);
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Desired-state schema application does not replay migration history, so keep
|
||||
-- these explicit calls as first-class catalog declarations. They also make the
|
||||
-- fence contract visible to migration linting instead of hiding it only in the
|
||||
-- dynamic bootstrap loop above.
|
||||
SELECT attach_community_write_fence('api_tokens');
|
||||
SELECT attach_community_write_fence('archived_identities');
|
||||
SELECT attach_community_write_fence('audit_log');
|
||||
SELECT attach_community_write_fence('channel_members');
|
||||
SELECT attach_community_write_fence('channels');
|
||||
SELECT attach_community_write_fence('community_bans');
|
||||
SELECT attach_community_write_fence('delivery_log');
|
||||
SELECT attach_community_write_fence('event_mentions');
|
||||
SELECT attach_community_write_fence('events');
|
||||
SELECT attach_community_write_fence('git_repo_names');
|
||||
SELECT attach_community_write_fence('join_policy_acceptances');
|
||||
SELECT attach_community_write_fence('moderation_actions');
|
||||
SELECT attach_community_write_fence('moderation_reports');
|
||||
SELECT attach_community_write_fence('parameterized_event_watermarks');
|
||||
SELECT attach_community_write_fence('pubkey_allowlist');
|
||||
SELECT attach_community_write_fence('push_leases');
|
||||
SELECT attach_community_write_fence('push_match_queue');
|
||||
SELECT attach_community_write_fence('push_wake_outbox');
|
||||
SELECT attach_community_write_fence('reactions');
|
||||
SELECT attach_community_write_fence('relay_invites');
|
||||
SELECT attach_community_write_fence('relay_members');
|
||||
SELECT attach_community_write_fence('scheduled_workflow_fires');
|
||||
SELECT attach_community_write_fence('subscriptions');
|
||||
SELECT attach_community_write_fence('thread_metadata');
|
||||
SELECT attach_community_write_fence('users');
|
||||
SELECT attach_community_write_fence('workflow_approvals');
|
||||
SELECT attach_community_write_fence('workflow_runs');
|
||||
SELECT attach_community_write_fence('workflows');
|
||||
|
||||
Reference in New Issue
Block a user