Files
buzz/schema/schema.sql
T
8a2c9af2db 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>
2026-08-12 09:25:58 -07:00

1670 lines
80 KiB
PL/PgSQL

-- Buzz initial Postgres schema — multi-tenant.
--
-- Source of truth for fresh database setup. This is a clean, from-scratch
-- schema in which `community_id` is a first-class, server-resolved key on
-- every tenant-scoped row. It is NOT additive over the single-community
-- schema; the rewrite replaces it. Existing single-community deployments
-- migrate via the documented backfill migration (0002), which assigns all
-- pre-existing rows to one default community.
--
-- The governing contract is docs/multi-tenant-conformance.md. Every table
-- below cites the conformance surface it implements. The invariant behind the
-- whole schema (conformance "row zero"): a request's community is resolved
-- from the connection host by the server, never supplied by the client, and
-- every scoped row carries that immutable `community_id`.
--
-- Migration-lint obligations enforced by the Lane 0 lint harness:
-- 1. Every tenant-scoped table has `community_id NOT NULL`.
-- 2. No UNIQUE / PRIMARY KEY / FK on a scoped table is observable across
-- communities: each leads with `community_id` (or, for child rows whose
-- parent already pins the community, joins carry the community tuple).
-- 3. `channels.community_id` is immutable (trigger below; no UPDATE path).
-- 4. Operator-global tables are named in the explicit allowlist, not implied.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ── Custom types ──────────────────────────────────────────────────────────────
CREATE TYPE channel_type AS ENUM ('stream', 'forum', 'dm', 'workflow');
CREATE TYPE channel_visibility AS ENUM ('open', 'private');
CREATE TYPE member_role AS ENUM ('owner', 'admin', 'member', 'guest', 'bot');
CREATE TYPE workflow_status AS ENUM ('active', 'disabled', 'archived');
CREATE TYPE run_status AS ENUM ('pending', 'running', 'waiting_approval', 'completed', 'failed', 'cancelled');
CREATE TYPE approval_status AS ENUM ('pending', 'granted', 'denied', 'expired');
CREATE TYPE delivery_method AS ENUM ('webhook', 'websocket');
CREATE TYPE subscription_status AS ENUM ('active', 'paused', 'deleted');
CREATE TYPE pause_reason AS ENUM ('user', 'system', 'rate_limit');
CREATE TYPE channel_add_policy AS ENUM ('anyone', 'owner_only', 'nobody');
-- ── Communities ───────────────────────────────────────────────────────────────
-- Conformance: row zero (host binding). The host map. `resolve_host(host)`
-- reads exactly one row here to mint the request's TenantContext. This table
-- is OPERATOR-GLOBAL: it is the registry of tenants, not itself tenant-scoped,
-- so it carries no `community_id` of its own (its `id` IS the community key).
-- Listed in the lint allowlist as operator-global.
--
-- Host normalization (Lane 0 contract): `host` is stored already-normalized —
-- ASCII-lowercased, trailing dot stripped, default port omitted. The UNIQUE is
-- on `lower(host)` belt-and-suspenders so `Relay.Example` and `relay.example`
-- can never become two tenants even if a writer forgets to normalize.
-- `resolve_host()` (buzz-core) applies the identical normalization before
-- lookup, so resolution and storage agree by construction.
CREATE TABLE communities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
host VARCHAR(255) NOT NULL,
signing_key BYTEA,
-- Per-community workspace icon (NIP-11 `icon`), set via kind:9033.
-- Added by migration 0003; kept here so desired-state applies match.
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)
);
CREATE UNIQUE INDEX idx_communities_host ON communities (lower(host));
-- ── Channels ──────────────────────────────────────────────────────────────────
-- Conformance: "Channels and channel membership". `community_id` immutable.
-- Channel UUIDs stay valid wire identifiers, but they are NOT globally unique:
-- the PK is `(community_id, id)`, so the same UUID may legitimately exist in two
-- communities (conformance lists "same channel UUID collision in two
-- communities" as a required isolation test). Handlers always carry `ctx`, so
-- `(ctx.community, h)` names exactly one channel; a client-supplied `h` can
-- never reach another community's channel.
CREATE TABLE channels (
id UUID NOT NULL DEFAULT gen_random_uuid(),
community_id UUID NOT NULL REFERENCES communities(id),
name VARCHAR(255) NOT NULL,
channel_type channel_type NOT NULL DEFAULT 'stream',
visibility channel_visibility NOT NULL DEFAULT 'open',
description TEXT,
canvas TEXT,
created_by BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
archived_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
nip29_group_id VARCHAR(255),
topic_required BOOLEAN NOT NULL DEFAULT FALSE,
max_members INT,
topic TEXT,
topic_set_by BYTEA,
topic_set_at TIMESTAMPTZ,
purpose TEXT,
purpose_set_by BYTEA,
purpose_set_at TIMESTAMPTZ,
participant_hash BYTEA,
ttl_seconds INT,
ttl_deadline TIMESTAMPTZ,
PRIMARY KEY (community_id, id),
CONSTRAINT chk_channels_id_not_nil CHECK (id <> '00000000-0000-0000-0000-000000000000'::uuid)
);
-- nip29 group id and DM participant hash are unique WITHIN a community, not globally.
CREATE UNIQUE INDEX idx_channels_nip29_group ON channels (community_id, nip29_group_id)
WHERE nip29_group_id IS NOT NULL;
CREATE UNIQUE INDEX idx_channels_dm_hash ON channels (community_id, participant_hash)
WHERE participant_hash IS NOT NULL;
CREATE INDEX idx_channels_community_type ON channels (community_id, channel_type);
CREATE INDEX idx_channels_community_visibility ON channels (community_id, visibility);
CREATE INDEX idx_channels_created_by ON channels (community_id, created_by);
CREATE INDEX idx_channels_ttl_expiry ON channels (ttl_deadline)
WHERE ttl_seconds IS NOT NULL AND archived_at IS NULL AND deleted_at IS NULL;
-- Tenant-independent channel-id → community lookups (Db::communities_of_channels,
-- Db::community_of_channel) carry no community_id predicate, so no
-- community_id-leading index can serve them. Covering + partial: index-only scan.
-- Not UNIQUE — the same channel id may exist under more than one community.
CREATE INDEX idx_channels_id_live ON channels (id) INCLUDE (community_id)
WHERE deleted_at IS NULL;
-- channels.community_id is immutable: a channel can never be re-tenanted.
-- (Conformance: "Migration lint forbids channel re-tenanting except through an
-- explicitly modeled admission path." We have no such path, so: hard block.)
CREATE FUNCTION channels_community_id_immutable() RETURNS TRIGGER AS $$
BEGIN
IF NEW.community_id IS DISTINCT FROM OLD.community_id THEN
RAISE EXCEPTION 'channels.community_id is immutable (channel % cannot be re-tenanted)', OLD.id
USING ERRCODE = 'check_violation';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_channels_community_id_immutable
BEFORE UPDATE ON channels
FOR EACH ROW EXECUTE FUNCTION channels_community_id_immutable();
-- ── Channel members ───────────────────────────────────────────────────────────
-- Conformance: "Channels and channel membership". PK leads with community_id.
CREATE TABLE channel_members (
community_id UUID NOT NULL REFERENCES communities(id),
channel_id UUID NOT NULL,
pubkey BYTEA NOT NULL,
role member_role NOT NULL DEFAULT 'member',
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
invited_by BYTEA,
removed_at TIMESTAMPTZ,
removed_by BYTEA,
hidden_at TIMESTAMPTZ,
PRIMARY KEY (community_id, channel_id, pubkey),
FOREIGN KEY (community_id, channel_id)
REFERENCES channels (community_id, id) ON DELETE CASCADE
);
CREATE INDEX idx_channel_members_pubkey ON channel_members (community_id, pubkey)
WHERE removed_at IS NULL;
-- ── Users ─────────────────────────────────────────────────────────────────────
-- Conformance: "Users, profiles, NIP-05, and user search". One profile per
-- (community, pubkey): the same key reposts kind:0 in each community it joins.
CREATE TABLE users (
community_id UUID NOT NULL REFERENCES communities(id),
pubkey BYTEA NOT NULL,
nip05_handle VARCHAR(255),
display_name VARCHAR(255),
avatar_url TEXT,
about TEXT,
agent_type VARCHAR(255),
capabilities JSONB,
okta_user_id VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deactivated_at TIMESTAMPTZ,
metadata_event_id BYTEA,
agent_owner_pubkey BYTEA,
channel_add_policy channel_add_policy NOT NULL DEFAULT 'anyone',
PRIMARY KEY (community_id, pubkey),
CONSTRAINT chk_users_pubkey_len CHECK (LENGTH(pubkey) = 32),
-- agent owner is a user in the SAME community.
FOREIGN KEY (community_id, agent_owner_pubkey)
REFERENCES users (community_id, pubkey) ON DELETE SET NULL
);
-- NIP-05 handle and Okta id unique within a community, not globally.
CREATE UNIQUE INDEX idx_users_nip05 ON users (community_id, lower(nip05_handle))
WHERE nip05_handle IS NOT NULL;
CREATE UNIQUE INDEX idx_users_okta ON users (community_id, okta_user_id)
WHERE okta_user_id IS NOT NULL;
-- ── Events (partitioned by month on created_at) ──────────────────────────────
-- Conformance: "Channel-less global events and DMs". `community_id` leads the
-- PK and every hot-path index. Partition stays BY RANGE (created_at) — the
-- monthly partition manager is unchanged (Max's call, plan §5/Lane0 contract).
-- Cross-community dedup: same signed event may exist in two communities;
-- (community_id, created_at, id) dedupes within one, allows across.
CREATE TABLE events (
community_id UUID NOT NULL REFERENCES communities(id),
id BYTEA NOT NULL,
pubkey BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
kind INT NOT NULL,
tags JSONB NOT NULL,
content TEXT NOT NULL,
-- Full-text search vector (Typesense → Postgres FTS). Generated/STORED so
-- it is a single source of truth — no sidecar indexer to keep coherent
-- (Quinn option A, Lane-0 call). 'simple' config = no stemming/stopwords,
-- matching the existing substring-ish search semantics; the search lane can
-- revisit the config behind evidence. Tenant scoping is by the
-- community-leading btree filters BitmapAnd-ed with the GIN probe, so the
-- GIN index itself stays the minimal `GIN (search_tsv)` (Max's caveat:
-- avoid btree_gin unless EXPLAIN proves it buys something).
-- Privacy: encrypted/private routing wrappers and p-gated membership notices
-- must never be discoverable through NIP-50 full-text search. NULL tsvector
-- never matches `@@`.
-- Keep in sync with migrations (final state: 0001 + 0005 + 0009).
search_tsv TSVECTOR GENERATED ALWAYS AS (
CASE WHEN kind IN (1059, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector
ELSE to_tsvector('simple', content)
END
) STORED,
sig BYTEA NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
channel_id UUID,
deleted_at TIMESTAMPTZ,
d_tag TEXT,
not_before BIGINT,
delivered_at BIGINT,
PRIMARY KEY (community_id, created_at, id)
) PARTITION BY RANGE (created_at);
CREATE TABLE events_p_past PARTITION OF events
FOR VALUES FROM (MINVALUE) TO ('2026-01-01');
CREATE TABLE events_p2026_01 PARTITION OF events
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE events_p2026_02 PARTITION OF events
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
CREATE TABLE events_p2026_03 PARTITION OF events
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
CREATE TABLE events_p2026_04 PARTITION OF events
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
CREATE TABLE events_p2026_05 PARTITION OF events
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE events_p2026_06 PARTITION OF events
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
CREATE TABLE events_p_future PARTITION OF events
FOR VALUES FROM ('2026-07-01') TO (MAXVALUE);
-- Direct id lookup: the PK can't serve `WHERE id=$1` because created_at sits
-- between community_id and id. This index makes the scoped form
-- `WHERE community_id=$ AND id=$` index-served, not a partition scan.
CREATE INDEX idx_events_community_id ON events (community_id, id, created_at DESC);
-- Hot-path indexes, all community-leading.
CREATE INDEX idx_events_community_channel_created
ON events (community_id, channel_id, created_at DESC, id);
CREATE INDEX idx_events_community_pubkey_kind_created
ON events (community_id, pubkey, kind, created_at DESC, id);
CREATE INDEX idx_events_community_kind_created
ON events (community_id, kind, created_at DESC, id);
CREATE INDEX idx_events_community_deleted ON events (community_id, deleted_at);
-- Addressable (replaceable) and NIP-33 parameterized lookups.
CREATE INDEX idx_events_addressable
ON events (community_id, kind, pubkey, channel_id, deleted_at);
CREATE INDEX idx_events_parameterized
ON events (community_id, kind, pubkey, d_tag, created_at DESC, id)
WHERE d_tag IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX idx_events_not_before ON events (community_id, not_before)
WHERE not_before IS NOT NULL AND deleted_at IS NULL AND delivered_at IS NULL;
-- Full-text search. Minimal GIN over the generated tsvector; community scoping
-- is supplied by the community-leading btree filters above (BitmapAnd), so this
-- stays a single-column GIN. The search lane confirms the final spelling with
-- EXPLAIN before its work lands (Quinn option A; Max's index-spelling caveat).
CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv);
-- ── Event mentions ────────────────────────────────────────────────────────────
-- Conformance: "Channel-less global events and DMs" (#p fan-out). The join to
-- events MUST carry the community tuple (e.community_id = m.community_id AND
-- e.id = m.event_id) — bare e.id = m.event_id would leak cross-community
-- mentions (Max, verified at event.rs:222).
CREATE TABLE event_mentions (
community_id UUID NOT NULL REFERENCES communities(id),
pubkey_hex VARCHAR(64) NOT NULL,
event_id BYTEA NOT NULL,
event_created_at TIMESTAMPTZ NOT NULL,
channel_id UUID,
event_kind INT,
PRIMARY KEY (community_id, pubkey_hex, event_id)
);
CREATE INDEX idx_event_mentions_pubkey_created
ON event_mentions (community_id, pubkey_hex, event_created_at DESC);
CREATE INDEX idx_event_mentions_pubkey_kind_created
ON event_mentions (community_id, pubkey_hex, event_kind, event_created_at DESC);
-- ── Subscriptions ─────────────────────────────────────────────────────────────
-- Conformance: "Mesh, agents, ACP/MCP, and CLI" (persisted subscriptions).
CREATE TABLE subscriptions (
community_id UUID NOT NULL REFERENCES communities(id),
id VARCHAR(255) NOT NULL,
owner_pubkey BYTEA NOT NULL,
filter_kinds JSONB,
filter_authors JSONB,
filter_channel_ids JSONB,
filter_since TIMESTAMPTZ,
filter_until TIMESTAMPTZ,
delivery_method delivery_method NOT NULL DEFAULT 'webhook',
delivery_url TEXT,
status subscription_status NOT NULL DEFAULT 'active',
pause_reason pause_reason,
delivered_count BIGINT NOT NULL DEFAULT 0,
error_count BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (community_id, id),
FOREIGN KEY (community_id, owner_pubkey) REFERENCES users (community_id, pubkey)
);
-- ── Delivery log (partitioned by month on delivered_at) ──────────────────────
-- Conformance: subscription delivery audit. community_id carried for tenant
-- attribution; child of subscriptions.
CREATE TABLE delivery_log (
community_id UUID NOT NULL REFERENCES communities(id),
id BIGINT GENERATED ALWAYS AS IDENTITY,
subscription_id VARCHAR(255),
event_id BYTEA,
method delivery_method,
delivered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
success BOOLEAN,
http_status INT,
error_message TEXT,
attempt_number INT DEFAULT 1,
PRIMARY KEY (delivered_at, id)
) PARTITION BY RANGE (delivered_at);
CREATE TABLE delivery_log_p_past PARTITION OF delivery_log
FOR VALUES FROM (MINVALUE) TO ('2026-03-01');
CREATE TABLE delivery_log_p2026_03 PARTITION OF delivery_log
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
CREATE TABLE delivery_log_p2026_04 PARTITION OF delivery_log
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
CREATE TABLE delivery_log_p2026_05 PARTITION OF delivery_log
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE delivery_log_p2026_06 PARTITION OF delivery_log
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
CREATE TABLE delivery_log_p_future PARTITION OF delivery_log
FOR VALUES FROM ('2026-07-01') TO (MAXVALUE);
CREATE INDEX idx_delivery_log_community_sub ON delivery_log (community_id, subscription_id);
-- ── Workflows ─────────────────────────────────────────────────────────────────
-- Conformance: "Workflows, runs, approvals, webhooks, schedules". Definition's
-- community fixed at create from req.community; runs/approvals inherit it.
CREATE TABLE workflows (
community_id UUID NOT NULL REFERENCES communities(id),
id UUID NOT NULL DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
owner_pubkey BYTEA NOT NULL,
channel_id UUID,
definition JSONB NOT NULL,
definition_hash BYTEA NOT NULL,
status workflow_status NOT NULL DEFAULT 'active',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (community_id, id),
FOREIGN KEY (community_id, owner_pubkey) REFERENCES users (community_id, pubkey),
FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id)
);
CREATE INDEX idx_workflows_channel_active ON workflows (community_id, channel_id, status, enabled);
-- Scheduler scans enabled schedule workflows; community_id returned per row so
-- side effects run under the owning tenant's context (Lane0 contract §4a.5).
CREATE INDEX idx_workflows_enabled ON workflows (enabled, status) WHERE enabled;
-- ── Workflow runs ─────────────────────────────────────────────────────────────
CREATE TABLE workflow_runs (
community_id UUID NOT NULL REFERENCES communities(id),
id UUID NOT NULL DEFAULT gen_random_uuid(),
workflow_id UUID NOT NULL,
status run_status NOT NULL DEFAULT 'pending',
trigger_event_id BYTEA,
current_step INT NOT NULL DEFAULT 0,
execution_trace JSONB NOT NULL DEFAULT '[]',
trigger_context JSONB,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (community_id, id),
FOREIGN KEY (community_id, workflow_id)
REFERENCES workflows (community_id, id) ON DELETE CASCADE
);
CREATE INDEX idx_workflow_runs_workflow ON workflow_runs (community_id, workflow_id);
CREATE INDEX idx_workflow_runs_status ON workflow_runs (community_id, status);
-- ── Workflow approvals ────────────────────────────────────────────────────────
-- token-hash lookup scoped: approval token grants cannot act on another
-- community's same hash (conformance).
CREATE TABLE workflow_approvals (
community_id UUID NOT NULL REFERENCES communities(id),
token BYTEA NOT NULL,
workflow_id UUID NOT NULL,
run_id UUID NOT NULL,
step_id VARCHAR(64) NOT NULL,
step_index INT NOT NULL,
approver_spec TEXT NOT NULL,
status approval_status NOT NULL DEFAULT 'pending',
approver_pubkey BYTEA,
note TEXT,
granted_at TIMESTAMPTZ,
denied_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (community_id, token),
FOREIGN KEY (community_id, workflow_id)
REFERENCES workflows (community_id, id) ON DELETE CASCADE,
FOREIGN KEY (community_id, run_id)
REFERENCES workflow_runs (community_id, id) ON DELETE CASCADE
);
CREATE INDEX idx_workflow_approvals_workflow ON workflow_approvals (community_id, workflow_id);
CREATE INDEX idx_workflow_approvals_run ON workflow_approvals (community_id, run_id);
CREATE INDEX idx_workflow_approvals_status ON workflow_approvals (community_id, status);
-- ── Scheduled workflow fires (cron claim) ─────────────────────────────────────
-- Plan §5: the at-most-once cron fire claim. UNIQUE (community_id, workflow_id,
-- scheduled_for) — only the pod that wins the claim insert creates the run.
-- Restart-safe (DB-durable). community is server provenance: the scheduler passes
-- workflow.community_id from list_all_enabled_workflows(), never a client input.
-- workflow_id is NOT globally unique under the (community_id, id) workflow key, so
-- the claim binds both community and id explicitly rather than resolving from id.
-- workflow_run_id links the won claim to the run it created (audit; NULL until the
-- post-insert attach, and stays NULL if run creation failed after a won claim).
-- The FK to workflow_runs uses NO ACTION (not SET NULL): community_id is shared
-- with the claim PK and is NOT NULL, so SET NULL is unimplementable here; a future
-- delete of a still-linked run is blocked rather than orphaning the at-most-once
-- claim row. workflow_runs are not pruned today, so this is a guardrail, not a path.
CREATE TABLE scheduled_workflow_fires (
community_id UUID NOT NULL REFERENCES communities(id),
workflow_id UUID NOT NULL,
scheduled_for TIMESTAMPTZ NOT NULL,
claimed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
workflow_run_id UUID,
PRIMARY KEY (community_id, workflow_id, scheduled_for),
FOREIGN KEY (community_id, workflow_id)
REFERENCES workflows (community_id, id) ON DELETE CASCADE,
FOREIGN KEY (community_id, workflow_run_id)
REFERENCES workflow_runs (community_id, id) ON DELETE NO ACTION
);
-- The interval anchor reads MAX(scheduled_for) per workflow; the janitor prunes
-- by claimed_at globally (operator concern). See plan §5 retention coupling.
CREATE INDEX idx_scheduled_fires_claimed_at ON scheduled_workflow_fires (claimed_at);
-- ── API tokens ────────────────────────────────────────────────────────────────
-- Conformance: "API tokens and NIP-98 replay". token_hash uniqueness scoped to
-- (community_id, token_hash); channel claims reference channels in same community.
CREATE TABLE api_tokens (
community_id UUID NOT NULL REFERENCES communities(id),
id UUID NOT NULL DEFAULT gen_random_uuid(),
token_hash BYTEA NOT NULL,
owner_pubkey BYTEA NOT NULL,
name VARCHAR(255) NOT NULL,
scopes JSONB NOT NULL,
channel_ids JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
revoked_by BYTEA,
created_by_self_mint BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (community_id, id),
FOREIGN KEY (community_id, owner_pubkey) REFERENCES users (community_id, pubkey),
CONSTRAINT chk_api_tokens_hash_len CHECK (LENGTH(token_hash) = 32)
);
CREATE UNIQUE INDEX idx_api_tokens_hash ON api_tokens (community_id, token_hash);
-- ── Rate limit violations ─────────────────────────────────────────────────────
-- OPERATOR-GLOBAL: a deployment-health / abuse table, never tenant-observable.
-- Listed in the lint allowlist. Carries community_id as an attribution label
-- only (nullable, no uniqueness over it).
CREATE TABLE rate_limit_violations (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
community_id UUID,
pubkey BYTEA,
violation_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
limit_type VARCHAR(64),
limit_value INT,
actual_value INT,
action_taken VARCHAR(64)
);
-- ── Thread metadata ───────────────────────────────────────────────────────────
-- Conformance: thread lookups filter by community before event matching.
CREATE TABLE thread_metadata (
community_id UUID NOT NULL REFERENCES communities(id),
event_created_at TIMESTAMPTZ NOT NULL,
event_id BYTEA NOT NULL,
channel_id UUID NOT NULL,
parent_event_id BYTEA,
parent_event_created_at TIMESTAMPTZ,
root_event_id BYTEA,
root_event_created_at TIMESTAMPTZ,
depth INT NOT NULL DEFAULT 0,
reply_count INT NOT NULL DEFAULT 0,
descendant_count INT NOT NULL DEFAULT 0,
last_reply_at TIMESTAMPTZ,
broadcast BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (community_id, event_created_at, event_id),
FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id)
);
CREATE INDEX idx_thread_metadata_parent ON thread_metadata (community_id, parent_event_id);
CREATE INDEX idx_thread_metadata_root ON thread_metadata (community_id, root_event_id);
CREATE INDEX idx_thread_metadata_channel_depth
ON thread_metadata (community_id, channel_id, depth, event_created_at);
CREATE INDEX idx_thread_metadata_event_id ON thread_metadata (community_id, event_id);
-- ── Reactions ─────────────────────────────────────────────────────────────────
-- Conformance: reactions filter by community before event/pubkey matching.
CREATE TABLE reactions (
community_id UUID NOT NULL REFERENCES communities(id),
event_created_at TIMESTAMPTZ NOT NULL,
event_id BYTEA NOT NULL,
pubkey BYTEA NOT NULL,
emoji VARCHAR(66) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
removed_at TIMESTAMPTZ,
reaction_event_id BYTEA,
PRIMARY KEY (community_id, event_created_at, event_id, pubkey, emoji)
);
CREATE INDEX idx_reactions_event ON reactions (community_id, event_id, event_created_at);
CREATE INDEX idx_reactions_pubkey ON reactions (community_id, pubkey);
-- A reaction's source event id is unique within a community.
CREATE UNIQUE INDEX idx_reactions_source_event ON reactions (community_id, reaction_event_id)
WHERE reaction_event_id IS NOT NULL;
-- ── Pubkey allowlist ──────────────────────────────────────────────────────────
-- Conformance: "Relay membership, pubkey allowlist, archived identities".
-- PK becomes (community_id, pubkey).
CREATE TABLE pubkey_allowlist (
community_id UUID NOT NULL REFERENCES communities(id),
pubkey BYTEA NOT NULL,
added_by BYTEA,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
note TEXT,
PRIMARY KEY (community_id, pubkey)
);
-- ── Relay members (NIP-43) ────────────────────────────────────────────────────
-- Conformance: membership gate, community-scoped. pubkey stored as hex TEXT
-- (unchanged wire form). PK (community_id, pubkey).
CREATE TABLE relay_members (
community_id UUID NOT NULL REFERENCES communities(id),
pubkey TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
added_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (community_id, pubkey)
);
CREATE INDEX idx_relay_members_role ON relay_members (community_id, role);
-- ── Join policy acceptances ──────────────────────────────────────────────────
-- Durable evidence of the policy version accepted when an invite claim grants
-- relay membership. The composite foreign key keeps evidence bound to a live
-- member in the same community and removes it with that membership.
CREATE TABLE join_policy_acceptances (
community_id UUID NOT NULL,
pubkey TEXT NOT NULL,
policy_version TEXT NOT NULL CHECK (length(policy_version) = 64),
accepted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (community_id, pubkey, policy_version),
FOREIGN KEY (community_id, pubkey)
REFERENCES relay_members (community_id, pubkey) ON DELETE CASCADE
);
-- ── Relay invites (use-limited invite links) ──────────────────────────────────
-- Conformance: durable invite records for atomic redemption, community-scoped.
-- Stores only SHA-256(code) as 32-byte BYTEA; never the reusable bearer code.
-- PK and UNIQUE both lead with community_id. max_uses NULL = unlimited.
CREATE TABLE relay_invites (
community_id UUID NOT NULL REFERENCES communities(id),
id UUID NOT NULL DEFAULT gen_random_uuid(),
token_hash BYTEA NOT NULL CHECK (length(token_hash) = 32),
role TEXT NOT NULL DEFAULT 'member' CHECK (role = 'member'),
max_uses INTEGER CHECK (max_uses BETWEEN 1 AND 10000),
use_count INTEGER NOT NULL DEFAULT 0 CHECK (use_count >= 0),
expires_at TIMESTAMPTZ NOT NULL,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (community_id, id),
UNIQUE (community_id, token_hash),
CHECK (max_uses IS NULL OR use_count <= max_uses)
);
CREATE INDEX relay_invites_expires_at_idx ON relay_invites (expires_at);
-- ── Archived identities (NIP-IA) ──────────────────────────────────────────────
-- Conformance: archive cannot hide a key in another community. PK scoped.
CREATE TABLE archived_identities (
community_id UUID NOT NULL REFERENCES communities(id),
pubkey TEXT NOT NULL,
consent_path TEXT NOT NULL CHECK (consent_path IN ('self', 'owner', 'admin')),
actor TEXT NOT NULL,
reason TEXT,
replaced_by TEXT,
request_event_id TEXT NOT NULL,
archived_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (community_id, pubkey)
);
-- ── Audit log ─────────────────────────────────────────────────────────────────
-- Conformance: "Audit log and observability". Per-community hash chain:
-- uniqueness (community_id, seq) and (community_id, hash). One chain per tenant.
-- (Lane Audit/Dawn builds the chain logic; Lane 0 fixes the scoped schema.)
CREATE TABLE audit_log (
community_id UUID NOT NULL REFERENCES communities(id),
seq BIGINT NOT NULL,
hash BYTEA NOT NULL,
prev_hash BYTEA,
action VARCHAR(64) NOT NULL,
actor_pubkey BYTEA,
object_id TEXT,
detail JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (community_id, seq)
);
CREATE UNIQUE INDEX idx_audit_log_hash ON audit_log (community_id, hash);
-- ── NIP-56 reports (kind:1984 ingest) ─────────────────────────────────────────
-- One row per accepted report event. Reports are signals, never triggers:
-- nothing auto-actions on them (NIP-56). Reporter identity is visible to
-- moderators in the queue but never revealed to the reported author.
CREATE TABLE moderation_reports (
community_id UUID NOT NULL REFERENCES communities(id),
id UUID NOT NULL DEFAULT gen_random_uuid(),
-- The signed kind:1984 event id (stored for audit/idempotency).
report_event_id BYTEA NOT NULL CHECK (length(report_event_id) = 32),
reporter_pubkey BYTEA NOT NULL CHECK (length(reporter_pubkey) = 32),
-- What was reported. Exactly one target class per row (CHECK-enforced below).
target_kind TEXT NOT NULL CHECK (target_kind IN ('event', 'pubkey', 'blob')),
target_event_id BYTEA CHECK (target_event_id IS NULL OR length(target_event_id) = 32),
target_pubkey BYTEA CHECK (target_pubkey IS NULL OR length(target_pubkey) = 32),
target_blob_sha256 BYTEA CHECK (target_blob_sha256 IS NULL OR length(target_blob_sha256) = 32),
-- Channel inferred from an in-tenant target event row, when resolvable.
channel_id UUID,
-- NIP-56 report type: illegal|nudity|malware|spam|impersonation|profanity|other.
report_type TEXT NOT NULL,
-- Reporter's optional free-text context (mod-queue-only; never public).
note TEXT,
status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'resolved', 'dismissed', 'escalated')),
resolved_by BYTEA,
resolved_at TIMESTAMPTZ,
-- moderation_actions row that resolved this report, if any.
action_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (community_id, id),
-- Exactly one target class per row: target_kind is authoritative and the
-- matching column (only) is populated. Queue/action code never guesses.
CHECK (
(target_kind = 'event' AND target_event_id IS NOT NULL AND target_pubkey IS NULL AND target_blob_sha256 IS NULL) OR
(target_kind = 'pubkey' AND target_event_id IS NULL AND target_pubkey IS NOT NULL AND target_blob_sha256 IS NULL) OR
(target_kind = 'blob' AND target_event_id IS NULL AND target_pubkey IS NULL AND target_blob_sha256 IS NOT NULL)
),
-- Same-community channel provenance (channels are soft-deleted, never
-- hard-deleted, so this FK cannot dangle).
FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id)
);
-- Queue reads: open reports, newest first, per community.
CREATE INDEX idx_moderation_reports_status
ON moderation_reports (community_id, status, created_at DESC);
-- Group-by-target for triage aggregation.
CREATE INDEX idx_moderation_reports_target_event
ON moderation_reports (community_id, target_event_id)
WHERE target_event_id IS NOT NULL;
CREATE INDEX idx_moderation_reports_target_pubkey
ON moderation_reports (community_id, target_pubkey)
WHERE target_pubkey IS NOT NULL;
-- Idempotency: one row per report event per community.
CREATE UNIQUE INDEX idx_moderation_reports_event
ON moderation_reports (community_id, report_event_id);
-- ── Bans + timeouts (one restriction row per member) ──────────────────────────
-- Ban = connection block, enforced at the NIP-42 auth seam
-- ("blocked: you are banned from this community") + join/ingest surfaces.
-- Timeout = write-block only ("restricted: you are timed out until <ts>").
-- A row may be ban-only, timeout-only, or both over its lifetime.
CREATE TABLE community_bans (
community_id UUID NOT NULL REFERENCES communities(id),
pubkey BYTEA NOT NULL CHECK (length(pubkey) = 32),
banned BOOLEAN NOT NULL DEFAULT false,
-- NULL + banned=true ⇒ permanent.
ban_expires_at TIMESTAMPTZ,
ban_reason TEXT,
-- Write-block until this timestamp; NULL or past ⇒ not timed out.
muted_until TIMESTAMPTZ,
mute_reason TEXT,
-- Moderator who last modified this row.
actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (community_id, pubkey)
);
-- ── Moderation audit ──────────────────────────────────────────────────────────
-- One row per accepted moderation action. Full detail (reporter identities,
-- private reasons, matched NIP-OA principal) stays mod/audit-only; the public
-- tombstone carries only action_id + reason_code + sanitized public_reason.
CREATE TABLE moderation_actions (
community_id UUID NOT NULL REFERENCES communities(id),
id UUID NOT NULL DEFAULT gen_random_uuid(),
actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32),
action TEXT NOT NULL CHECK (action IN (
'delete_message', 'kick', 'ban', 'unban',
'timeout', 'untimeout', 'dismiss_report', 'escalate',
'resolve:delete', 'resolve:kick', 'resolve:ban',
'resolve:timeout')),
target_pubkey BYTEA CHECK (target_pubkey IS NULL OR length(target_pubkey) = 32),
target_event_id BYTEA CHECK (target_event_id IS NULL OR length(target_event_id) = 32),
channel_id UUID,
-- Machine-readable rule/reason code (e.g. "spam", "community_rule_3").
reason_code TEXT,
-- Sanitized, safe for the public tombstone.
public_reason TEXT,
-- Mod-only context; never leaves the audit surface.
private_reason TEXT,
-- NIP-OA: which principal matched a ban ('self' | 'owner'); audit-only,
-- the client never learns which.
matched_principal TEXT CHECK (matched_principal IS NULL OR matched_principal IN ('self', 'owner')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (community_id, id),
FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id)
);
CREATE INDEX idx_moderation_actions_created
ON moderation_actions (community_id, created_at DESC);
CREATE INDEX idx_moderation_actions_target_pubkey
ON moderation_actions (community_id, target_pubkey)
WHERE target_pubkey IS NOT NULL;
-- Same-community resolution provenance: a report can only be resolved by an
-- action row in its own community. Added after moderation_actions exists.
ALTER TABLE moderation_reports
ADD FOREIGN KEY (community_id, action_id)
REFERENCES moderation_actions (community_id, id);
-- ── Lint allowlist registry ───────────────────────────────────────────────────
-- The explicit registry of tables that are deliberately operator-global (NOT
-- tenant-scoped). The migration-lint harness reads this: any table NOT listed
-- here MUST carry a NOT NULL community_id and lead its uniques with it. Making
-- the allowlist a DB table (not a hard-coded list in the linter) keeps the
-- registry next to the schema it governs and reviewable in one migration diff.
CREATE TABLE _operator_global_tables (
table_name TEXT PRIMARY KEY,
reason TEXT NOT NULL
);
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 (
community_id UUID NOT NULL REFERENCES communities(id),
author BYTEA NOT NULL CHECK (length(author) = 32),
installation_id TEXT NOT NULL CHECK (octet_length(installation_id) BETWEEN 1 AND 64),
source_event_id BYTEA NOT NULL CHECK (length(source_event_id) = 32),
source_created_at BIGINT NOT NULL,
generation BIGINT NOT NULL CHECK (generation > 0),
active BOOLEAN NOT NULL,
endpoint_enabled BOOLEAN NOT NULL DEFAULT true,
app_profile TEXT,
endpoint_hash BYTEA CHECK (endpoint_hash IS NULL OR length(endpoint_hash) = 32),
endpoint_grant TEXT,
max_class TEXT CHECK (max_class IS NULL OR max_class IN ('silent','default','time_sensitive','urgent')),
subscriptions JSONB,
expires_at BIGINT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (community_id, author, installation_id),
UNIQUE (community_id, source_event_id),
CHECK ((active AND app_profile IS NOT NULL AND endpoint_hash IS NOT NULL AND endpoint_grant IS NOT NULL AND max_class IS NOT NULL AND subscriptions IS NOT NULL)
OR (NOT active AND app_profile IS NULL AND endpoint_hash IS NULL AND endpoint_grant IS NULL AND max_class IS NULL AND subscriptions IS NULL))
);
CREATE UNIQUE INDEX push_leases_endpoint_unique
ON push_leases (community_id, author, app_profile, endpoint_hash)
WHERE active;
CREATE INDEX push_leases_expiry ON push_leases (community_id, expires_at) WHERE active;
CREATE TABLE push_wake_outbox (
community_id UUID NOT NULL REFERENCES communities(id),
id UUID NOT NULL DEFAULT gen_random_uuid(),
author BYTEA NOT NULL CHECK (length(author) = 32),
installation_id TEXT NOT NULL,
lease_generation BIGINT NOT NULL CHECK (lease_generation > 0),
endpoint_hash BYTEA NOT NULL CHECK (length(endpoint_hash) = 32),
event_id BYTEA NOT NULL CHECK (length(event_id) = 32),
class TEXT NOT NULL CHECK (class IN ('silent','default','time_sensitive','urgent')),
expires_at BIGINT NOT NULL,
state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending','sending','delivered','failed')),
attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
lease_until TIMESTAMPTZ,
claim_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (community_id, id),
FOREIGN KEY (community_id, author, installation_id)
REFERENCES push_leases (community_id, author, installation_id),
UNIQUE (community_id, endpoint_hash, event_id)
);
CREATE INDEX push_wake_outbox_due
ON push_wake_outbox (community_id, next_attempt_at) WHERE state = 'pending';
CREATE INDEX push_wake_outbox_recovery
ON push_wake_outbox (community_id, lease_until) WHERE state = 'sending';
-- Durable event-to-push matching follower. The trigger runs in the event insert
-- transaction, so every accepted persistent event has a crash-safe match job and
-- rejected/rolled-back events never do. Processing is idempotent through the
-- push_wake_outbox endpoint/event unique key.
CREATE TABLE push_match_queue (
community_id UUID NOT NULL REFERENCES communities(id),
event_id BYTEA NOT NULL CHECK (length(event_id) = 32),
state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending','matching')),
attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
lease_until TIMESTAMPTZ,
claim_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (community_id, event_id)
);
CREATE INDEX push_match_queue_due
ON push_match_queue (next_attempt_at, created_at) WHERE state = 'pending';
CREATE INDEX push_match_queue_recovery
ON push_match_queue (lease_until) WHERE state = 'matching';
-- T1b push gate (keep in sync with migrations/0023). Enqueue only when the
-- community has an active, endpoint-enabled, unexpired lease; the shared
-- advisory lock pairs with the exclusive lock taken by lease activations
-- (crates/buzz-db/src/push.rs) to close the lost-wake race.
CREATE FUNCTION enqueue_push_match_job() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
-- Keep this allowlist identical to the relay's validated NIP-PL descriptor.
-- Centralizing it on the events table covers every durable producer,
-- including internal paths that bypass live dispatch.
IF NEW.kind IN (7, 9, 1059, 40007, 46010) THEN
PERFORM pg_advisory_xact_lock_shared(
hashtextextended('buzz_push_gate:' || NEW.community_id::text, 0));
IF EXISTS (
SELECT 1 FROM push_leases
WHERE community_id = NEW.community_id
AND active
AND endpoint_enabled
AND expires_at > EXTRACT(EPOCH FROM now())::bigint
) THEN
INSERT INTO push_match_queue (community_id, event_id)
VALUES (NEW.community_id, NEW.id)
ON CONFLICT DO NOTHING;
END IF;
END IF;
RETURN NEW;
END
$$;
CREATE TRIGGER events_enqueue_push_match
AFTER INSERT ON events
FOR EACH ROW EXECUTE FUNCTION enqueue_push_match_job();
-- Channel TTL refresh (keep in sync with migrations/0024). Runs deferred, in
-- the transaction that makes a channel-scoped event durable, so a TTL
-- transition committed while ingest was in flight is never missed. The
-- per-channel advisory lock is SHARED here — permanent-channel commits admit
-- each other — and taken EXCLUSIVE by TTL transitions (update_channel in
-- crates/buzz-db/src/channel.rs), which forces the same total order the
-- 0022 row lock provided without serializing the hot path.
CREATE FUNCTION refresh_channel_ttl_after_event_insert() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
channel_ttl INTEGER;
BEGIN
-- Kind 9007 creates the channel and initializes its deadline itself.
IF NEW.channel_id IS NOT NULL AND NEW.kind <> 9007 THEN
BEGIN
PERFORM pg_advisory_xact_lock_shared(hashtextextended(
'buzz_channel_ttl:' || NEW.community_id::text || ':' || NEW.channel_id::text, 0));
SELECT ttl_seconds INTO channel_ttl
FROM channels
WHERE community_id = NEW.community_id AND id = NEW.channel_id;
IF channel_ttl IS NOT NULL THEN
UPDATE channels
SET ttl_deadline = clock_timestamp() + make_interval(secs => ttl_seconds)
WHERE community_id = NEW.community_id
AND id = NEW.channel_id
AND ttl_seconds IS NOT NULL
AND archived_at IS NULL
AND deleted_at IS NULL;
END IF;
EXCEPTION WHEN OTHERS THEN
-- Preserve the existing best-effort contract: a TTL refresh failure
-- must not reject an otherwise valid durable event.
RAISE WARNING 'channel TTL refresh failed for community %, channel %: %',
NEW.community_id, NEW.channel_id, SQLERRM;
END;
END IF;
RETURN NULL;
END
$$;
CREATE CONSTRAINT TRIGGER events_refresh_channel_ttl
AFTER INSERT ON events
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION refresh_channel_ttl_after_event_insert();
-- Replica-fence floor guard (keep in sync with migrations/0021). A deferred
-- constraint trigger re-checks, inside COMMIT processing, that channel-bearing
-- event rows are no older than `buzz.created_at_floor` seconds before commit
-- time (clock_timestamp(), NOT the transaction-frozen now()). This turns the
-- relay's ingest-time created_at envelope into a commit-time storage
-- invariant, which is what lets keyset-cursor pages below the replica fence
-- be served by a read replica without holes. Enforcement is armed per session
-- via the GUC (set by the relay's writer pool on connect); sessions without
-- the GUC (pg_restore, manual backfills) bypass it and must hold the replica
-- fence closed for their duration. The only structural exemption is
-- channel_id IS NULL: those rows never appear in keyset-paged windows.
CREATE FUNCTION events_created_at_floor_guard() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
floor_secs numeric := nullif(current_setting('buzz.created_at_floor', true), '')::numeric;
BEGIN
IF floor_secs IS NOT NULL
AND floor_secs > 0
AND NEW.channel_id IS NOT NULL
AND NEW.created_at < clock_timestamp() - make_interval(secs => floor_secs)
THEN
RAISE EXCEPTION
'events.created_at % is more than % s before commit time %; below the replica-fence floor',
NEW.created_at, floor_secs, clock_timestamp()
USING ERRCODE = 'check_violation';
END IF;
RETURN NULL;
END
$$;
-- INSERT OR UPDATE OF: an UPDATE can move a previously exempt row into the
-- guarded set (channel_id NULL -> NOT NULL) or move a channel row's
-- created_at below the fence, so both mutation paths re-run the guard on the
-- NEW row. A created_at rewrite that crosses partition bounds runs as
-- DELETE + INSERT and hits the cloned AFTER INSERT guard on the destination
-- partition; an in-partition rewrite fires the UPDATE OF arm.
CREATE CONSTRAINT TRIGGER events_created_at_floor
AFTER INSERT OR UPDATE OF created_at, channel_id ON events
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION events_created_at_floor_guard();
-- Durable, deployment-global authority for the public NIP-PL push gateway.
-- This state is intentionally outside relay community tenancy: installations
-- delegate to relay signing keys and may authorize multiple relay deployments.
CREATE TABLE push_gateway_challenges (
id UUID PRIMARY KEY,
challenge_hash BYTEA NOT NULL CHECK (length(challenge_hash) = 32),
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX push_gateway_challenges_expiry ON push_gateway_challenges (expires_at);
CREATE TABLE push_gateway_installations (
id UUID PRIMARY KEY,
app_attest_key_id BYTEA NOT NULL UNIQUE CHECK (octet_length(app_attest_key_id) BETWEEN 1 AND 128),
app_attest_public_key BYTEA NOT NULL CHECK (octet_length(app_attest_public_key) BETWEEN 33 AND 256),
assertion_counter BIGINT NOT NULL CHECK (assertion_counter BETWEEN 0 AND 4294967295),
app_profile TEXT NOT NULL CHECK (app_profile IN ('buzz-ios-production','buzz-ios-sandbox')),
token_ciphertext BYTEA NOT NULL CHECK (octet_length(token_ciphertext) BETWEEN 1 AND 2048),
token_fingerprint BYTEA NOT NULL CHECK (length(token_fingerprint) = 32),
endpoint_epoch BIGINT NOT NULL CHECK (endpoint_epoch > 0),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (app_profile, token_fingerprint)
);
CREATE INDEX push_gateway_installations_expiry ON push_gateway_installations (expires_at) WHERE revoked_at IS NULL;
CREATE TABLE push_gateway_delegations (
id UUID PRIMARY KEY,
installation_id UUID NOT NULL REFERENCES push_gateway_installations(id),
relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32),
endpoint_epoch BIGINT NOT NULL CHECK (endpoint_epoch > 0),
generation BIGINT NOT NULL CHECK (generation > 0),
not_before TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (installation_id, relay_pubkey),
CHECK (not_before < expires_at)
);
CREATE INDEX push_gateway_delegations_expiry ON push_gateway_delegations (expires_at) WHERE revoked_at IS NULL;
CREATE TABLE push_gateway_endpoint_quotas (
token_fingerprint BYTEA PRIMARY KEY CHECK (length(token_fingerprint) = 32),
window_started_at TIMESTAMPTZ NOT NULL,
admitted BIGINT NOT NULL CHECK (admitted >= 0),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX push_gateway_endpoint_quotas_updated ON push_gateway_endpoint_quotas (updated_at);
CREATE TABLE push_gateway_delivery_auth_replays (
relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32),
auth_event_id BYTEA NOT NULL CHECK (length(auth_event_id) = 32),
expires_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (relay_pubkey, auth_event_id)
);
CREATE INDEX push_gateway_delivery_auth_replays_expiry ON push_gateway_delivery_auth_replays (expires_at);
CREATE TABLE push_gateway_delivery_request_replays (
relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32),
request_id UUID NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (relay_pubkey, request_id)
);
CREATE INDEX push_gateway_delivery_request_replays_expiry ON push_gateway_delivery_request_replays (expires_at);
INSERT INTO _operator_global_tables (table_name, reason) VALUES
('push_gateway_challenges', 'public gateway one-time challenges span relay communities'),
('push_gateway_installations', 'public gateway installation authority spans relay communities'),
('push_gateway_delegations', 'public gateway relay delegations span relay communities'),
('push_gateway_endpoint_quotas', 'public gateway endpoint abuse ceilings span relay communities'),
('push_gateway_delivery_auth_replays', 'public gateway signed-event replay admission spans relay communities'),
('push_gateway_delivery_request_replays', 'public gateway stable request-id admission spans relay communities');
-- ── Replica heartbeat (read-replica freshness fence) ─────────────────────────
-- Portable read-side freshness observation for the replica fence (see
-- crates/buzz-db/src/replica_fence.rs and migrations/0026). Exactly one row;
-- the single-row token UPDATE is the serialization point that makes tokens
-- globally commit-ordered across relay pods. `epoch` detects token resets
-- (restore/re-seed) so a stale retained token can never masquerade as fresh
-- coverage. Deployment-global by design: describes replication topology,
-- never tenant data.
CREATE TABLE replica_heartbeat (
id smallint PRIMARY KEY CHECK (id = 1),
epoch uuid NOT NULL DEFAULT gen_random_uuid(),
token bigint NOT NULL DEFAULT 0
);
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');