diff --git a/AGENTS.md b/AGENTS.md
index 3c9d578bb9..5932bcd9e1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -600,7 +600,7 @@ description. See [PR #803](https://github.com/block/buzz/pull/803).
4. **Worktrees: `cd` in the same command** — shell CWD doesn't persist between tool calls. Use `cd /path && cargo build` as one command.
5. **Desktop crate excluded from root workspace** — `cargo test` at repo root does NOT run desktop tests. Use `cargo test --manifest-path desktop/src-tauri/Cargo.toml` explicitly.
6. **React render perf: `React.memo` is all-or-nothing** — it only skips a re-render when *every* prop is reference-stable; one unstable prop (inline arrow/JSX, or a hook returning a fresh `{}`/`[]`/`Map` each render) defeats it. Two repeat offenders: (a) React Query results (`useMutation`/`useQuery`) are a **new object each render** — depend on the stable method (`mutation.mutateAsync`), not the object; (b) derived `Map`/array state that recomputes on a version bump — wrap in a content-equality ref cache (`shared/hooks/useStableReference.ts`). When chasing interaction lag, **measure with DevTools closed and no perf probes** (an open Web Inspector + per-keystroke `console.log` inflate the numbers), and isolate by removing one suspect at a time rather than guessing.
-7. **`pgschema` omits seed DML and some storage parameters** — Fresh desired-state bootstraps use `./bin/pgschema apply`, which does not execute `INSERT` statements or preserve every table storage parameter from `schema/schema.sql`. Put each unsupported invariant in `scripts/reconcile-schema-after-pgschema.sql` as an idempotent convergence statement plus a live catalog or data assertion. Every `pgschema apply` caller must run that script. A string assertion against `schema.sql` alone does not prove the pgschema-created database has the intended state.
+7. **`pgschema` omits seed DML and some storage parameters** — `schema/schema.sql` is an ordered `\i` manifest: `schema/tables/public/
.sql` holds only that table's `CREATE TABLE` and `CREATE INDEX` statements (one table per file, the shape SchemaBot reads), and types, functions, partitions, triggers, and seed rows live in the sibling `schema/` directories. Fresh desired-state bootstraps use `./bin/pgschema apply`, which does not execute `INSERT` statements or preserve every table storage parameter from `schema/schema.sql`. Put each unsupported invariant in `scripts/reconcile-schema-after-pgschema.sql` as an idempotent convergence statement plus a live catalog or data assertion. Every `pgschema apply` caller must run that script. A string assertion against `schema.sql` alone does not prove the pgschema-created database has the intended state.
---
diff --git a/crates/buzz-db/TESTING.md b/crates/buzz-db/TESTING.md
index 2cf6c7828b..bebe09dd98 100644
--- a/crates/buzz-db/TESTING.md
+++ b/crates/buzz-db/TESTING.md
@@ -23,7 +23,9 @@ shared tables, so tests may run concurrently without coordinating cleanup.
## Choose the schema intentionally
-Most tests use the committed desired-state schema from `schema/schema.sql`.
+Most tests use the committed desired-state schema from `schema/schema.sql`,
+a manifest that includes one `schema/tables/public/.sql` per table plus
+its types, functions, partitions, triggers, and seed rows.
That is the default and is appropriate for data-access behavior.
Tests in `migration::postgres_tests` receive an empty database and own the
diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs
index 26ceba80ca..d62086bf85 100644
--- a/crates/buzz-db/src/runtime/migration.rs
+++ b/crates/buzz-db/src/runtime/migration.rs
@@ -792,7 +792,7 @@ mod postgres_tests {
assert!(!migrations[0].sql.as_str().contains("idx_events_tags_gin"));
// schema.sql (CI / isolated relay bootstrap) must carry the same index,
// or e-tag reads there run on plans prod never sees.
- assert!(include_str!("../../../../schema/schema.sql").contains(
+ assert!(crate::test_support::desired_state_schema_sql().contains(
"CREATE INDEX idx_events_tags_gin ON events USING GIN (tags jsonb_path_ops)"
));
@@ -951,7 +951,7 @@ mod postgres_tests {
assert!(migrations[32].sql.as_str().contains("kind = 30179"));
assert!(migrations[32].sql.as_str().contains("search_tsv"));
assert!(!migrations[0].sql.as_str().contains("30179"));
- assert!(include_str!("../../../../schema/schema.sql")
+ assert!(crate::test_support::desired_state_schema_sql()
.contains("kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200)"));
// Public push-gateway authority is intentionally deployment-global and
@@ -1094,7 +1094,7 @@ mod postgres_tests {
.contains("CREATE INDEX relay_invites_expires_at_idx ON relay_invites (expires_at)"));
assert!(!relay_invites.contains("_operator_global_tables"));
- let desired_schema = include_str!("../../../../schema/schema.sql");
+ let desired_schema = crate::test_support::desired_state_schema_sql();
assert!(
desired_schema.contains("CREATE TABLE join_policy_acceptances"),
"desired-state schema must include join-policy evidence used by invite claims",
@@ -1185,17 +1185,28 @@ mod postgres_tests {
// Fresh desired-state bootstrap must install the identical executable
// fence as migration 0032. CI and isolated relay startup use schema.sql
// without running migrations, so drift reopens rolling-deploy races.
- fn extract_roster_fence(sql: &str) -> &str {
- let fence_start = "CREATE OR REPLACE FUNCTION guard_channel_roster_snapshot()";
- let fence_end = " FOR EACH ROW EXECUTE FUNCTION guard_channel_roster_snapshot();";
- let start = sql.find(fence_start).expect("roster fence function");
- let relative_end = sql[start..].find(fence_end).expect("roster fence trigger");
- &sql[start..start + relative_end + fence_end.len()]
+ // The desired state keeps the function and its trigger in separate
+ // files, so compare each statement rather than one contiguous span.
+ fn extract_span<'a>(sql: &'a str, start: &str, end: &str) -> &'a str {
+ let begin = sql.find(start).expect("span start");
+ let relative_end = sql[begin..].find(end).expect("span end");
+ &sql[begin..begin + relative_end + end.len()]
+ }
+ for (start, end) in [
+ (
+ "CREATE OR REPLACE FUNCTION guard_channel_roster_snapshot()",
+ "$$ LANGUAGE plpgsql;",
+ ),
+ (
+ "CREATE TRIGGER trg_events_guard_channel_roster_snapshot",
+ " FOR EACH ROW EXECUTE FUNCTION guard_channel_roster_snapshot();",
+ ),
+ ] {
+ assert_eq!(
+ extract_span(roster_fence, start, end),
+ extract_span(&desired_schema, start, end)
+ );
}
- assert_eq!(
- extract_roster_fence(roster_fence),
- extract_roster_fence(desired_schema)
- );
// The single-row heartbeat table is updated continuously. Prevent
// autovacuum from truncating its heap so standby queries are not
@@ -1469,7 +1480,162 @@ mod postgres_tests {
.sql
.as_str()
.contains("error_code"));
- assert!(include_str!("../../../../schema/schema.sql").contains("error_code TEXT"));
+ assert!(
+ crate::test_support::desired_state_schema_sql().contains("error_code TEXT")
+ );
+ }
+
+ /// `schema/schema.sql` includes every desired-state file exactly once, and
+ /// `schema/tables/public/` (the SchemaBot schema directory's `public`
+ /// namespace) holds only one table per file: its CREATE TABLE plus the
+ /// CREATE INDEX statements on it. Types, functions, partitions, triggers
+ /// and seed rows belong in the sibling directories SchemaBot never reads.
+ #[test]
+ fn desired_state_schema_layout_is_complete_and_schemabot_shaped() {
+ use std::collections::BTreeSet;
+
+ fn sql_files(dir: &std::path::Path, root: &std::path::Path, out: &mut Vec) {
+ for entry in std::fs::read_dir(dir).expect("read schema directory") {
+ let path = entry.expect("schema directory entry").path();
+ if path.is_dir() {
+ sql_files(&path, root, out);
+ } else if path.extension().is_some_and(|ext| ext == "sql") {
+ let relative = path.strip_prefix(root).expect("schema-relative path");
+ out.push(relative.to_str().expect("utf-8 path").replace('\\', "/"));
+ }
+ }
+ }
+
+ let schema_dir = crate::test_support::desired_state_schema_dir();
+ let manifest =
+ std::fs::read_to_string(schema_dir.join("schema.sql")).expect("read schema/schema.sql");
+ let mut included = Vec::new();
+ for line in manifest.lines() {
+ if let Some(include) = line.strip_prefix("\\i ") {
+ included.push(include.trim().to_owned());
+ }
+ }
+ let included_set: BTreeSet<_> = included.iter().cloned().collect();
+ assert_eq!(
+ included.len(),
+ included_set.len(),
+ "schema.sql includes a file twice"
+ );
+
+ let mut on_disk = Vec::new();
+ sql_files(&schema_dir, &schema_dir, &mut on_disk);
+ let on_disk: BTreeSet<_> = on_disk.into_iter().filter(|f| f != "schema.sql").collect();
+ assert_eq!(
+ on_disk, included_set,
+ "every schema/ file must be included by schema/schema.sql exactly once"
+ );
+
+ fn strip_comments(text: &str) -> String {
+ text.lines()
+ .map(|line| line.split_once("--").map_or(line, |(code, _)| code))
+ .collect::>()
+ .join("\n")
+ }
+
+ // Table and index DDL must live in its table file, where SchemaBot
+ // reads it. A merge conflict resolved by pasting DDL into the manifest,
+ // or DDL in a sibling directory, would still build and pass CI.
+ for line in strip_comments(&manifest).lines().map(str::trim) {
+ assert!(
+ line.is_empty()
+ || line.starts_with("\\i ")
+ || line.to_ascii_uppercase().starts_with("CREATE EXTENSION "),
+ "schema/schema.sql may only hold \\i includes and CREATE EXTENSION; \
+ put table DDL in tables/public/.sql: {line:.80}"
+ );
+ }
+ let non_table_files = std::iter::once("schema.sql").chain(
+ included
+ .iter()
+ .map(String::as_str)
+ .filter(|f| !f.starts_with("tables/public/")),
+ );
+ for file in non_table_files {
+ let text = std::fs::read_to_string(schema_dir.join(file)).expect("read schema file");
+ for statement in strip_comments(&text).to_ascii_uppercase().split(';') {
+ let tokens: Vec<_> = statement.split_whitespace().collect();
+ assert!(
+ !tokens.windows(2).any(|pair| matches!(
+ pair,
+ ["CREATE" | "UNIQUE", "INDEX"] | ["DROP" | "ALTER", "TABLE" | "INDEX"]
+ )),
+ "{file} may not create, alter or drop tables or indexes; \
+ change tables/public/.sql instead"
+ );
+ let creates_table = tokens.iter().enumerate().any(|(i, token)| {
+ *token == "CREATE"
+ && tokens[i + 1..].iter().find(|t| {
+ !matches!(**t, "UNLOGGED" | "TEMP" | "TEMPORARY" | "GLOBAL" | "LOCAL")
+ }) == Some(&"TABLE")
+ });
+ assert!(
+ !creates_table
+ || (file.starts_with("partitions/")
+ && tokens.windows(2).any(|pair| pair == ["PARTITION", "OF"])),
+ "{file} may not CREATE TABLE; only partitions/ may, and only PARTITION OF. \
+ Declare tables in tables/public/.sql"
+ );
+ }
+ }
+
+ let tables_dir = schema_dir.join("tables");
+ for entry in std::fs::read_dir(&tables_dir).expect("read schema/tables") {
+ let path = entry.expect("schema/tables entry").path();
+ assert!(
+ path.is_dir() && path.file_name().is_some_and(|name| name == "public"),
+ "schema/tables may contain only the `public` namespace: {}",
+ path.display()
+ );
+ }
+ let mut tables = 0;
+ for file in included.iter().filter(|f| f.starts_with("tables/public/")) {
+ let table = file
+ .strip_prefix("tables/public/")
+ .and_then(|name| name.strip_suffix(".sql"))
+ .expect("table file name");
+ let text = std::fs::read_to_string(schema_dir.join(file)).expect("read table file");
+ let code = strip_comments(&text);
+ let mut creates = 0;
+ for statement in code
+ .split(';')
+ .map(|s| s.split_whitespace().collect::>().join(" "))
+ {
+ if statement.is_empty() {
+ continue;
+ }
+ if let Some(rest) = statement.strip_prefix("CREATE TABLE ") {
+ assert!(
+ rest.starts_with(&format!("{table} (")) && !rest.contains(" PARTITION OF "),
+ "{file} may only create table {table}: {statement:.80}"
+ );
+ creates += 1;
+ } else {
+ let index = statement
+ .strip_prefix("CREATE INDEX ")
+ .or_else(|| statement.strip_prefix("CREATE UNIQUE INDEX "))
+ .unwrap_or_else(|| {
+ panic!("{file} may only hold CREATE TABLE/INDEX: {statement:.80}")
+ });
+ assert!(
+ !index.starts_with("IF NOT EXISTS ")
+ && !index.starts_with("CONCURRENTLY "),
+ "{file} must use a plain CREATE INDEX (SchemaBot refuses IF NOT EXISTS and CONCURRENTLY): {statement:.80}"
+ );
+ assert!(
+ index.contains(&format!(" ON {table} ")),
+ "{file} may only index {table}: {statement:.80}"
+ );
+ }
+ }
+ assert_eq!(creates, 1, "{file} must create exactly one table");
+ tables += 1;
+ }
+ assert!(tables > 0, "schema/tables/public declares no tables");
}
#[test]
@@ -1483,7 +1649,7 @@ mod postgres_tests {
assert!(sql.contains("NEW.kind IN (9, 40002, 45001, 45003)"));
assert!(!sql.contains("NEW.kind IN (7, 9, 1059, 40007, 46010)"));
- let desired_schema = include_str!("../../../../schema/schema.sql");
+ let desired_schema = crate::test_support::desired_state_schema_sql();
assert!(desired_schema.contains("NEW.kind IN (9, 40002, 45001, 45003)"));
assert!(!desired_schema.contains("NEW.kind IN (7, 9, 1059, 40007, 46010)"));
}
@@ -1754,13 +1920,7 @@ mod postgres_tests {
.sql
.as_ref()
.to_ascii_lowercase();
- let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
- .parent()
- .and_then(std::path::Path::parent)
- .expect("workspace root");
- let schema = std::fs::read_to_string(workspace_root.join("schema/schema.sql"))
- .expect("read schema/schema.sql")
- .to_ascii_lowercase();
+ let schema = crate::test_support::desired_state_schema_sql().to_ascii_lowercase();
for sql in [&migration, &schema] {
assert!(sql.contains("approval_origin text not null default 'operator'"));
@@ -1782,13 +1942,7 @@ mod postgres_tests {
.sql
.as_ref()
.to_ascii_lowercase();
- let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
- .parent()
- .and_then(std::path::Path::parent)
- .expect("workspace root");
- let schema = std::fs::read_to_string(workspace_root.join("schema/schema.sql"))
- .expect("read schema/schema.sql")
- .to_ascii_lowercase();
+ let schema = crate::test_support::desired_state_schema_sql().to_ascii_lowercase();
for sql in [&migration, &schema] {
assert!(sql.contains("community_deletion_requests_owner_quota_reservations"));
@@ -1918,12 +2072,7 @@ mod postgres_tests {
.expect("embedded migration 0051")
.sql
.as_ref();
- let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
- .parent()
- .and_then(std::path::Path::parent)
- .expect("workspace root");
- let schema_sql = std::fs::read_to_string(workspace_root.join("schema/schema.sql"))
- .expect("read schema/schema.sql");
+ let schema_sql = crate::test_support::desired_state_schema_sql();
let migration = surface(migration_0029);
let owner_admission_migration = surface(migration_0051);
diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs
index c5672ef0b9..a75f2e0483 100644
--- a/crates/buzz-db/src/store/channel_members.rs
+++ b/crates/buzz-db/src/store/channel_members.rs
@@ -3614,10 +3614,12 @@ mod postgres_tests {
.connect(&scratch_url)
.await
.expect("connect desired-schema scratch db");
- sqlx::raw_sql(include_str!("../../../../schema/schema.sql"))
- .execute(&pool)
- .await
- .expect("apply desired-state schema");
+ sqlx::raw_sql(sqlx::AssertSqlSafe(
+ crate::test_support::desired_state_schema_sql(),
+ ))
+ .execute(&pool)
+ .await
+ .expect("apply desired-state schema");
let db = Db::from_pool(pool.clone());
let community_uuid = Uuid::new_v4();
diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs
index b211d07218..eef0ac8b0b 100644
--- a/crates/buzz-db/src/store/deletion.rs
+++ b/crates/buzz-db/src/store/deletion.rs
@@ -8069,10 +8069,7 @@ mod postgres_tests {
let (base_prefix, _) = base_url.rsplit_once('/').expect("database url has a path");
let probe_url = format!("{base_prefix}/{probe_db}");
- let schema_sql = std::fs::read_to_string(
- std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../schema/schema.sql"),
- )
- .expect("read schema/schema.sql");
+ let schema_sql = crate::test_support::desired_state_schema_sql();
let bootstrap = PgPool::connect(&probe_url)
.await
.expect("connect probe database");
diff --git a/crates/buzz-db/src/test_support.rs b/crates/buzz-db/src/test_support.rs
index 7a518a044c..73fa14c366 100644
--- a/crates/buzz-db/src/test_support.rs
+++ b/crates/buzz-db/src/test_support.rs
@@ -51,3 +51,34 @@ pub(crate) async fn quiesce_community_for_tests(
pub(crate) fn is_admission_rejection(error: &crate::DbError) -> bool {
matches!(error, crate::DbError::AccessDenied(message) if message.contains("write-fenced (quiescing)"))
}
+
+/// The desired-state schema as one SQL script.
+///
+/// `schema/schema.sql` is a manifest of `\i` includes resolved relative to the
+/// including file (as `pgschema` does). This expands every include in place,
+/// so text assertions and `sqlx::raw_sql` bootstraps see the same statements,
+/// in the same order, that `pgschema apply` loads.
+pub(crate) fn desired_state_schema_sql() -> String {
+ fn expand(path: &std::path::Path, out: &mut String) {
+ let text = std::fs::read_to_string(path)
+ .unwrap_or_else(|error| panic!("read {}: {error}", path.display()));
+ let dir = path.parent().expect("schema file has a parent directory");
+ for line in text.split_inclusive('\n') {
+ match line.trim_end().strip_prefix("\\i ") {
+ Some(include) => expand(&dir.join(include.trim()), out),
+ None => out.push_str(line),
+ }
+ }
+ if !out.ends_with('\n') {
+ out.push('\n');
+ }
+ }
+ let mut out = String::new();
+ expand(&desired_state_schema_dir().join("schema.sql"), &mut out);
+ out
+}
+
+/// `schema/` at the workspace root.
+pub(crate) fn desired_state_schema_dir() -> std::path::PathBuf {
+ std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../schema")
+}
diff --git a/schema/community_write_fence.sql b/schema/community_write_fence.sql
new file mode 100644
index 0000000000..2ebc9fd1f9
--- /dev/null
+++ b/schema/community_write_fence.sql
@@ -0,0 +1,31 @@
+-- Attach the universal fence to every existing table carrying community_id,
+-- including deployment-private sidecars whose community_id is provenance.
+--
+-- Desired-state schema application does not replay migration history, so the
+-- explicit `SELECT attach_community_write_fence('')` calls in
+-- `triggers/.sql` stay as first-class catalog declarations. They also
+-- make the fence contract visible to migration linting instead of hiding it
+-- only in this dynamic bootstrap loop. This loop runs last, so it fences every
+-- desired-state table regardless: a fenced desired state does not prove the
+-- migration path attaches the fence. Keep the explicit calls.
+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
+$$;
diff --git a/schema/functions/assert_community_write_allowed.sql b/schema/functions/assert_community_write_allowed.sql
new file mode 100644
index 0000000000..f68b45091c
--- /dev/null
+++ b/schema/functions/assert_community_write_allowed.sql
@@ -0,0 +1,78 @@
+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
+$$;
diff --git a/schema/functions/attach_community_write_fence.sql b/schema/functions/attach_community_write_fence.sql
new file mode 100644
index 0000000000..2b004ff684
--- /dev/null
+++ b/schema/functions/attach_community_write_fence.sql
@@ -0,0 +1,45 @@
+-- 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
+$$;
diff --git a/schema/functions/channels_community_id_immutable.sql b/schema/functions/channels_community_id_immutable.sql
new file mode 100644
index 0000000000..a22a5f9110
--- /dev/null
+++ b/schema/functions/channels_community_id_immutable.sql
@@ -0,0 +1,12 @@
+-- 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;
diff --git a/schema/functions/community_deletion_lock_key.sql b/schema/functions/community_deletion_lock_key.sql
new file mode 100644
index 0000000000..8864ede390
--- /dev/null
+++ b/schema/functions/community_deletion_lock_key.sql
@@ -0,0 +1,4 @@
+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)
+$$;
diff --git a/schema/functions/community_write_allowed.sql b/schema/functions/community_write_allowed.sql
new file mode 100644
index 0000000000..2de857de92
--- /dev/null
+++ b/schema/functions/community_write_allowed.sql
@@ -0,0 +1,24 @@
+-- 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
+$$;
diff --git a/schema/functions/community_write_fence_excluded_table.sql b/schema/functions/community_write_fence_excluded_table.sql
new file mode 100644
index 0000000000..34735522f8
--- /dev/null
+++ b/schema/functions/community_write_fence_excluded_table.sql
@@ -0,0 +1,12 @@
+-- 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', 'operator_listener_outbox'
+ ]::TEXT[])
+$$;
diff --git a/schema/functions/enforce_community_tombstone.sql b/schema/functions/enforce_community_tombstone.sql
new file mode 100644
index 0000000000..b36b51333e
--- /dev/null
+++ b/schema/functions/enforce_community_tombstone.sql
@@ -0,0 +1,28 @@
+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
+$$;
diff --git a/schema/functions/enforce_community_write_fence.sql b/schema/functions/enforce_community_write_fence.sql
new file mode 100644
index 0000000000..b7b5e8d641
--- /dev/null
+++ b/schema/functions/enforce_community_write_fence.sql
@@ -0,0 +1,24 @@
+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
+$$;
diff --git a/schema/functions/enqueue_push_match_job.sql b/schema/functions/enqueue_push_match_job.sql
new file mode 100644
index 0000000000..0061fb2e52
--- /dev/null
+++ b/schema/functions/enqueue_push_match_job.sql
@@ -0,0 +1,28 @@
+-- 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 (9, 40002, 45001, 45003) 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
+$$;
diff --git a/schema/functions/events_created_at_floor_guard.sql b/schema/functions/events_created_at_floor_guard.sql
new file mode 100644
index 0000000000..263886f83f
--- /dev/null
+++ b/schema/functions/events_created_at_floor_guard.sql
@@ -0,0 +1,29 @@
+-- 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
+$$;
diff --git a/schema/functions/guard_channel_roster_snapshot.sql b/schema/functions/guard_channel_roster_snapshot.sql
new file mode 100644
index 0000000000..bcd5c9e534
--- /dev/null
+++ b/schema/functions/guard_channel_roster_snapshot.sql
@@ -0,0 +1,72 @@
+-- Channel roster snapshot fence (keep in sync with migrations/0032).
+-- Prevent mixed-version relay pods from publishing a stale NIP-29 member
+-- snapshot after a newer canonical roster has been committed.
+--
+-- Old binaries already serialize kind 39002 replacement on the replacement
+-- advisory key. This trigger adds the channel-membership key at INSERT time,
+-- after that canonical key, and validates every p tag against the current
+-- active membership set and roles. New binaries take both keys in the same
+-- order before capture and replacement. Thus old and new writers remain
+-- compatible during a rolling deploy.
+CREATE OR REPLACE FUNCTION guard_channel_roster_snapshot()
+RETURNS TRIGGER AS $$
+DECLARE
+ canonical_members TEXT[];
+ snapshot_members TEXT[];
+BEGIN
+ IF NEW.kind <> 39002 OR NEW.channel_id IS NULL THEN
+ RETURN NEW;
+ END IF;
+
+ PERFORM pg_advisory_xact_lock(hashtextextended(
+ 'buzz_channel_membership:' || NEW.community_id::text || ':' || NEW.channel_id::text,
+ 0
+ ));
+
+ SELECT COALESCE(
+ array_agg(encode(cm.pubkey, 'hex') || ':' || cm.role::text ORDER BY cm.pubkey),
+ ARRAY[]::TEXT[]
+ )
+ INTO canonical_members
+ FROM channel_members cm
+ WHERE cm.community_id = NEW.community_id
+ AND cm.channel_id = NEW.channel_id
+ AND cm.removed_at IS NULL;
+
+ -- A roster is canonical only when every p tag uses the emitted four-field
+ -- shape, contains a 32-byte hex pubkey and valid authoritative role, has no
+ -- duplicate members, and exactly matches the active membership rows.
+ IF EXISTS (
+ SELECT 1
+ FROM jsonb_array_elements(NEW.tags) AS roster_tag(tag_json)
+ WHERE roster_tag.tag_json->>0 = 'p'
+ AND (
+ jsonb_array_length(roster_tag.tag_json) <> 4
+ OR COALESCE(roster_tag.tag_json->>1, '') !~ '^[0-9a-fA-F]{64}$'
+ OR roster_tag.tag_json->>2 <> ''
+ OR COALESCE(roster_tag.tag_json->>3, '') NOT IN ('owner', 'admin', 'bot', 'member', 'guest')
+ )
+ ) THEN
+ RAISE EXCEPTION 'kind 39002 roster contains an invalid p tag'
+ USING ERRCODE = '23514';
+ END IF;
+
+ SELECT COALESCE(
+ array_agg(
+ lower((roster_tag.tag_json->>1)) || ':' || (roster_tag.tag_json->>3)
+ ORDER BY decode((roster_tag.tag_json->>1), 'hex')
+ ),
+ ARRAY[]::TEXT[]
+ )
+ INTO snapshot_members
+ FROM jsonb_array_elements(NEW.tags) AS roster_tag(tag_json)
+ WHERE roster_tag.tag_json->>0 = 'p';
+
+ IF snapshot_members IS DISTINCT FROM canonical_members THEN
+ RAISE EXCEPTION 'kind 39002 roster does not match canonical channel membership'
+ USING ERRCODE = '23514';
+ END IF;
+
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
diff --git a/schema/functions/prevent_community_deletion_approval_removal.sql b/schema/functions/prevent_community_deletion_approval_removal.sql
new file mode 100644
index 0000000000..2e1d95db88
--- /dev/null
+++ b/schema/functions/prevent_community_deletion_approval_removal.sql
@@ -0,0 +1,9 @@
+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;
+$$;
diff --git a/schema/functions/prevent_community_deletion_request_retargeting.sql b/schema/functions/prevent_community_deletion_request_retargeting.sql
new file mode 100644
index 0000000000..23cff698bd
--- /dev/null
+++ b/schema/functions/prevent_community_deletion_request_retargeting.sql
@@ -0,0 +1,39 @@
+CREATE OR REPLACE 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 NEW.request_origin IS DISTINCT FROM OLD.request_origin
+ OR NEW.owner_pubkey IS DISTINCT FROM OLD.owner_pubkey
+ OR NEW.mediating_operator_pubkey IS DISTINCT FROM OLD.mediating_operator_pubkey
+ OR NEW.acknowledgement_version IS DISTINCT FROM OLD.acknowledgement_version
+ THEN
+ RAISE EXCEPTION 'community deletion request provenance 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;
+$$;
diff --git a/schema/functions/protect_community_deletion_manifest_keys.sql b/schema/functions/protect_community_deletion_manifest_keys.sql
new file mode 100644
index 0000000000..4e90abaead
--- /dev/null
+++ b/schema/functions/protect_community_deletion_manifest_keys.sql
@@ -0,0 +1,48 @@
+-- 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;
+$$;
diff --git a/schema/functions/refresh_channel_ttl_after_event_insert.sql b/schema/functions/refresh_channel_ttl_after_event_insert.sql
new file mode 100644
index 0000000000..398c4c0db6
--- /dev/null
+++ b/schema/functions/refresh_channel_ttl_after_event_insert.sql
@@ -0,0 +1,41 @@
+-- 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
+$$;
diff --git a/schema/partitions/delivery_log.sql b/schema/partitions/delivery_log.sql
new file mode 100644
index 0000000000..9314b0b4b8
--- /dev/null
+++ b/schema/partitions/delivery_log.sql
@@ -0,0 +1,12 @@
+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);
diff --git a/schema/partitions/events.sql b/schema/partitions/events.sql
new file mode 100644
index 0000000000..ffb257390c
--- /dev/null
+++ b/schema/partitions/events.sql
@@ -0,0 +1,16 @@
+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);
diff --git a/schema/schema.sql b/schema/schema.sql
index d111488505..5ec8bd5829 100644
--- a/schema/schema.sql
+++ b/schema/schema.sql
@@ -1,3 +1,30 @@
+-- Desired-state database manifest.
+--
+-- `tables/public/.sql` declares one table per file: its CREATE TABLE
+-- and the CREATE INDEX statements on it, the one-table-per-file shape SchemaBot
+-- reads. `tables/` is the SchemaBot schema directory and `public` is its
+-- namespace, so nothing else may live under it.
+--
+-- Everything SchemaBot cannot apply lives in this directory's other
+-- subdirectories: `types/`, `functions/`, `partitions/`, `triggers/` (including
+-- write-fence attachment), `seeds/`, and the `community_write_fence.sql`
+-- bootstrap. This file composes all of them into one fresh database, in load
+-- order: types → functions → tables → partitions → triggers → seeds → fence.
+--
+-- `./bin/pgschema apply --file schema/schema.sql` resolves the `\i` includes
+-- relative to this file (it refuses `..`). `pgschema` skips INSERTs and some
+-- storage parameters, so every apply caller must then run
+-- `scripts/reconcile-schema-after-pgschema.sql`. Raw `psql` resolves `\i`
+-- against the working directory instead, so run it from here:
+-- `cd schema && psql -f schema.sql`.
+--
+-- Adding a table: add `tables/public/.sql` and include it below after
+-- the tables it references; put its partitions, triggers, and seed rows in the
+-- matching subdirectories and include those too. This file holds only `\i`
+-- lines and `CREATE EXTENSION`. Outside `tables/public/`, no file may create,
+-- alter or drop a table or index, except `CREATE TABLE ... PARTITION OF` in
+-- `partitions/`. The layout test enforces both.
+
-- Buzz initial Postgres schema — multi-tenant.
--
-- Source of truth for fresh database setup. This is a clean, from-scratch
@@ -18,2071 +45,167 @@
-- 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).
+-- 3. `channels.community_id` is immutable (triggers/channels.sql; 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;
-
--- Private accessory read progress. Never included in Nostr event queries.
--- A frontier is the relay arrival time (events.received_at) of the message a
--- context was read through; the unread cutoff alone is signed event time. An
--- empty root_id denotes a channel frontier.
-CREATE TABLE personal_read_accounts (
- community_id UUID NOT NULL REFERENCES communities(id),
- actor BYTEA NOT NULL CHECK (octet_length(actor) = 32),
- PRIMARY KEY (community_id, actor)
-);
-
-CREATE TABLE personal_read_frontiers (
- community_id UUID NOT NULL,
- actor BYTEA NOT NULL,
- channel_id UUID NOT NULL,
- root_id BYTEA NOT NULL DEFAULT ''::bytea CHECK (octet_length(root_id) IN (0, 32)),
- through_timestamp TIMESTAMPTZ NOT NULL,
- -- Whole-channel cut covering every thread; channel rows only.
- threads_through_timestamp TIMESTAMPTZ
- CHECK (threads_through_timestamp IS NULL OR root_id = ''::bytea),
- PRIMARY KEY (community_id, actor, channel_id, root_id),
- FOREIGN KEY (community_id, actor)
- REFERENCES personal_read_accounts (community_id, actor) ON DELETE CASCADE,
- FOREIGN KEY (community_id, channel_id)
- REFERENCES channels (community_id, id) ON DELETE CASCADE
-);
-
--- ── 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 + 0014 + 0033).
- search_tsv TSVECTOR GENERATED ALWAYS AS (
- CASE WHEN kind IN (1059, 30179, 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);
-
--- e-tag containment (`tags @> '[["e",""]]'`) for the aux closure and #e
--- reads. Mirrors migrations/0004; jsonb_path_ops supports exactly @>.
-CREATE INDEX idx_events_tags_gin ON events USING GIN (tags jsonb_path_ops);
-
--- ── 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,
- error_code 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_window
- ON thread_metadata (community_id, root_event_id, event_created_at DESC, event_id ASC);
-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_version SMALLINT NOT NULL DEFAULT 1 CHECK (hash_version IN (1, 2)),
- 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', 'processing', 'resolved', 'dismissed', 'escalated')),
- -- Non-null when status='processing': the relay_admin_actions row that claimed this report.
- active_action_id UUID,
- 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 ").
--- 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')),
- -- Deployment authority type for HTTP-initiated actions.
- actor_authority TEXT NOT NULL DEFAULT 'community'
- CHECK (actor_authority IN ('community', 'relay_operator', 'relay_moderator')),
- 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(),
- -- Operator-managed lifecycle status.
- status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'reviewed', 'archived')),
- 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 (9, 40002, 45001, 45003) 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();
-
--- Channel roster snapshot fence (keep in sync with migrations/0032).
--- Prevent mixed-version relay pods from publishing a stale NIP-29 member
--- snapshot after a newer canonical roster has been committed.
---
--- Old binaries already serialize kind 39002 replacement on the replacement
--- advisory key. This trigger adds the channel-membership key at INSERT time,
--- after that canonical key, and validates every p tag against the current
--- active membership set and roles. New binaries take both keys in the same
--- order before capture and replacement. Thus old and new writers remain
--- compatible during a rolling deploy.
-CREATE OR REPLACE FUNCTION guard_channel_roster_snapshot()
-RETURNS TRIGGER AS $$
-DECLARE
- canonical_members TEXT[];
- snapshot_members TEXT[];
-BEGIN
- IF NEW.kind <> 39002 OR NEW.channel_id IS NULL THEN
- RETURN NEW;
- END IF;
-
- PERFORM pg_advisory_xact_lock(hashtextextended(
- 'buzz_channel_membership:' || NEW.community_id::text || ':' || NEW.channel_id::text,
- 0
- ));
-
- SELECT COALESCE(
- array_agg(encode(cm.pubkey, 'hex') || ':' || cm.role::text ORDER BY cm.pubkey),
- ARRAY[]::TEXT[]
- )
- INTO canonical_members
- FROM channel_members cm
- WHERE cm.community_id = NEW.community_id
- AND cm.channel_id = NEW.channel_id
- AND cm.removed_at IS NULL;
-
- -- A roster is canonical only when every p tag uses the emitted four-field
- -- shape, contains a 32-byte hex pubkey and valid authoritative role, has no
- -- duplicate members, and exactly matches the active membership rows.
- IF EXISTS (
- SELECT 1
- FROM jsonb_array_elements(NEW.tags) AS roster_tag(tag_json)
- WHERE roster_tag.tag_json->>0 = 'p'
- AND (
- jsonb_array_length(roster_tag.tag_json) <> 4
- OR COALESCE(roster_tag.tag_json->>1, '') !~ '^[0-9a-fA-F]{64}$'
- OR roster_tag.tag_json->>2 <> ''
- OR COALESCE(roster_tag.tag_json->>3, '') NOT IN ('owner', 'admin', 'bot', 'member', 'guest')
- )
- ) THEN
- RAISE EXCEPTION 'kind 39002 roster contains an invalid p tag'
- USING ERRCODE = '23514';
- END IF;
-
- SELECT COALESCE(
- array_agg(
- lower((roster_tag.tag_json->>1)) || ':' || (roster_tag.tag_json->>3)
- ORDER BY decode((roster_tag.tag_json->>1), 'hex')
- ),
- ARRAY[]::TEXT[]
- )
- INTO snapshot_members
- FROM jsonb_array_elements(NEW.tags) AS roster_tag(tag_json)
- WHERE roster_tag.tag_json->>0 = 'p';
-
- IF snapshot_members IS DISTINCT FROM canonical_members THEN
- RAISE EXCEPTION 'kind 39002 roster does not match canonical channel membership'
- USING ERRCODE = '23514';
- END IF;
-
- RETURN NEW;
-END;
-$$ LANGUAGE plpgsql;
-
-DROP TRIGGER IF EXISTS trg_events_guard_channel_roster_snapshot ON events;
-CREATE TRIGGER trg_events_guard_channel_roster_snapshot
- BEFORE INSERT ON events
- FOR EACH ROW EXECUTE FUNCTION guard_channel_roster_snapshot();
-
-
--- 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 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 = 'buzz-ios-dogfood'),
- 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()
-);
-CREATE UNIQUE INDEX push_gateway_installations_active_app_attest_key
- ON push_gateway_installations (app_attest_key_id) WHERE revoked_at IS NULL;
-CREATE UNIQUE INDEX push_gateway_installations_active_profile_token
- ON push_gateway_installations (app_profile, token_fingerprint) WHERE revoked_at IS NULL;
-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
-) WITH (
- vacuum_truncate = false
-);
-
-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,
- request_origin TEXT NOT NULL DEFAULT 'operator'
- CHECK (request_origin IN ('operator', 'owner')),
- owner_pubkey TEXT,
- mediating_operator_pubkey TEXT,
- acknowledgement_version INTEGER,
- 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 (
- 'submitted', '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)),
- CONSTRAINT community_deletion_owner_provenance CHECK (
- (request_origin = 'operator'
- AND owner_pubkey IS NULL
- AND mediating_operator_pubkey IS NULL
- AND acknowledgement_version IS NULL)
- OR
- (request_origin = 'owner'
- AND NOT (owner_pubkey IS NULL)
- AND NOT (mediating_operator_pubkey IS NULL)
- AND NOT (acknowledgement_version IS NULL)
- AND owner_pubkey ~ '^[0-9a-f]{64}$'
- AND mediating_operator_pubkey ~ '^[0-9a-f]{64}$'
- AND acknowledgement_version BETWEEN 1 AND 32767
- AND requested_by = owner_pubkey)
- ),
- 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 INDEX community_deletion_requests_owner_preparable
- ON community_deletion_requests (next_attempt_at, created_at)
- WHERE request_origin = 'owner'
- AND stage = 'submitted'
- AND blocked_at IS NULL;
-CREATE INDEX community_deletion_requests_owner_quota_reservations
- ON community_deletion_requests (owner_pubkey)
- INCLUDE (community_id, completed_at)
- WHERE request_origin = 'owner'
- AND stage <> 'aborted';
-
-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,
- approval_origin TEXT NOT NULL DEFAULT 'operator'
- CHECK (approval_origin IN ('operator', 'owner_automatic')),
- 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 OR REPLACE 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 NEW.request_origin IS DISTINCT FROM OLD.request_origin
- OR NEW.owner_pubkey IS DISTINCT FROM OLD.owner_pubkey
- OR NEW.mediating_operator_pubkey IS DISTINCT FROM OLD.mediating_operator_pubkey
- OR NEW.acknowledgement_version IS DISTINCT FROM OLD.acknowledgement_version
- THEN
- RAISE EXCEPTION 'community deletion request provenance 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', 'operator_listener_outbox'
- ]::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('personal_read_accounts');
-SELECT attach_community_write_fence('personal_read_frontiers');
-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');
-
--- ── Relay operator/moderator roster ──────────────────────────────────────────
--- Deployment-level principals staffed via the admin API. Config-backed operators
--- (RELAY_OPERATOR_PUBKEYS, RELAY_OWNER_PUBKEY owner-fallback) are NOT seeded here;
--- they are authoritative in config and outrank any DB row.
-
-CREATE TABLE relay_operators (
- pubkey BYTEA NOT NULL PRIMARY KEY CHECK (length(pubkey) = 32),
- role TEXT NOT NULL CHECK (role IN ('operator', 'moderator')),
- added_by BYTEA NOT NULL CHECK (length(added_by) = 32),
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
-);
-
-INSERT INTO _operator_global_tables (table_name, reason) VALUES
- ('relay_operators', 'deployment-global operator/moderator roster; no community_id intentionally');
-
--- ── Relay admin actions (HTTP enforcement state machine) ──────────────────────
--- One row per HTTP report-resolution enforcement action. Tracks the durable
--- state machine from claim → enforcing → succeeded|failed|cancelled.
-
-CREATE TABLE relay_admin_actions (
- id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
- -- NULL for a report-less direct action (migration 0055).
- report_id UUID,
- report_community_id UUID NOT NULL,
- -- Client-generated idempotency key (signed in NIP-98 request body).
- request_id UUID NOT NULL,
- -- Principal who claimed the report.
- actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32),
- actor_role TEXT NOT NULL CHECK (actor_role IN ('operator', 'moderator')),
- -- The enforcement action requested.
- action TEXT NOT NULL,
- reason TEXT,
- -- Timeout expiration for timeout actions; NULL otherwise.
- timeout_until TIMESTAMPTZ,
- -- Durable state machine: pending → enforcing → succeeded|failed|cancelled.
- state TEXT NOT NULL DEFAULT 'pending'
- CHECK (state IN ('pending', 'enforcing', 'succeeded', 'failed', 'cancelled')),
- -- Step marker: the last durably committed mutation step (NULL = none yet).
- -- Values: 'mutation_committed' (core DB mutation done), 'artifacts_done' (tombstone/notice done).
- step_marker TEXT CHECK (step_marker IN ('mutation_committed', 'artifacts_done')),
- -- Principal who cancelled a pre-mutation failed action; NULL until cancelled.
- -- Attributes the cancel transition on the action row itself, mirroring
- -- moderation_reports.resolved_by for report resolution.
- cancelled_by BYTEA CHECK (cancelled_by IS NULL OR length(cancelled_by) = 32),
- -- Error from the last failure, if any.
- error_message TEXT,
- -- Per-action exclusive lease (migration 0037): fences concurrent same-request
- -- retries and lets the recovery worker claim/re-drive stranded actions.
- action_lease_token UUID,
- action_lease_expires_at TIMESTAMPTZ,
- -- Authoritative enforcement target (migration 0047): persisted at claim time
- -- so crash-recovery can fire live side effects without re-deriving from mutable
- -- sources. enforcement_target_pubkey is the resolved target pubkey bytes for
- -- kick/ban/timeout actions; NULL for event/blob targets. enforcement_channel_id
- -- is the channel targeted by kick actions; NULL for community-wide actions.
- enforcement_target_pubkey BYTEA
- CHECK (enforcement_target_pubkey IS NULL OR length(enforcement_target_pubkey) = 32),
- enforcement_channel_id UUID,
- -- Direct actions (migration 0055): deleted event, and requested timeout
- -- duration compared on idempotent retry.
- enforcement_target_event_id BYTEA
- CHECK (enforcement_target_event_id IS NULL OR length(enforcement_target_event_id) = 32),
- timeout_secs BIGINT CHECK (timeout_secs IS NULL OR timeout_secs > 0),
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- -- Report-scoped idempotency: one action per (report, request_id).
- UNIQUE (report_community_id, report_id, request_id),
- CONSTRAINT relay_admin_actions_direct_shape CHECK (
- report_id IS NOT NULL
- OR (action = 'ban' AND enforcement_target_pubkey IS NOT NULL
- AND timeout_secs IS NULL AND timeout_until IS NULL)
- OR (action = 'timeout' AND enforcement_target_pubkey IS NOT NULL
- AND timeout_secs IS NOT NULL AND timeout_until IS NOT NULL)
- OR (action = 'delete' AND enforcement_target_event_id IS NOT NULL
- AND enforcement_target_pubkey IS NOT NULL
- AND timeout_secs IS NULL AND timeout_until IS NULL)
- ),
- FOREIGN KEY (report_community_id, report_id)
- REFERENCES moderation_reports (community_id, id)
-);
-
-CREATE INDEX idx_relay_admin_actions_report
- ON relay_admin_actions (report_community_id, report_id);
--- Direct-action idempotency (migration 0055): one per (community, request_id).
-CREATE UNIQUE INDEX idx_relay_admin_actions_direct_request
- ON relay_admin_actions (report_community_id, request_id)
- WHERE report_id IS NULL;
-CREATE INDEX idx_relay_admin_actions_state
- ON relay_admin_actions (state)
- WHERE state IN ('pending', 'enforcing');
--- Recovery worker (migration 0037): find stranded actions by lease expiry.
-CREATE INDEX idx_relay_admin_actions_lease
- ON relay_admin_actions (action_lease_expires_at)
- WHERE state IN ('pending', 'enforcing');
-
-INSERT INTO _operator_global_tables (table_name, reason) VALUES
- ('relay_admin_actions', 'deployment-global enforcement state machine; community deletion purges rows by report_community_id');
-
--- ── Relay admin outbox (durable enforcement delivery) ────────────────────────
--- Transactional outbox for durable artifact/notice delivery.
-
-CREATE TABLE relay_admin_outbox (
- id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
- action_id UUID NOT NULL REFERENCES relay_admin_actions(id),
- -- Delivery task type: 'tombstone' | 'system_message' | 'reporter_notice'.
- task_type TEXT NOT NULL,
- -- Task payload (JSON).
- payload JSONB NOT NULL DEFAULT '{}'::jsonb,
- -- Lease-based delivery: held_by identifies the worker pod.
- held_by TEXT,
- lease_expires_at TIMESTAMPTZ,
- -- Delivery state: pending → delivered | failed.
- state TEXT NOT NULL DEFAULT 'pending'
- CHECK (state IN ('pending', 'delivered', 'failed')),
- -- Deduplication key: prevents re-creating an artifact after delivery.
- dedup_key TEXT UNIQUE,
- error_message TEXT,
- -- Retryable delivery with backoff (migration 0037): failures reschedule via
- -- retry_after rather than terminating immediately.
- attempt_count INT NOT NULL DEFAULT 0,
- retry_after TIMESTAMPTZ,
- -- Per-claim ownership fence (migration 0038): completion/failure updates
- -- require the token written at claim time, so a stale worker cannot overwrite
- -- a newer worker's terminal update.
- outbox_claim_token UUID,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
-);
-
-CREATE INDEX idx_relay_admin_outbox_action
- ON relay_admin_outbox (action_id);
-CREATE INDEX idx_relay_admin_outbox_pending
- ON relay_admin_outbox (retry_after, created_at)
- WHERE state = 'pending';
-
-INSERT INTO _operator_global_tables (table_name, reason) VALUES
- ('relay_admin_outbox', 'deployment-global enforcement artifact delivery queue; community deletion purges rows with their action');
-
--- ── Operator-listener mention delivery ──────────────────────────────────────
--- Listener registrations are deployment-global. The outbox records community
--- provenance for the event, but is intentionally not tenant-owned.
-
-CREATE TABLE operator_listener_pubkeys (
- listener_pubkey BYTEA NOT NULL CHECK (length(listener_pubkey) = 32),
- target_pubkey BYTEA NOT NULL CHECK (length(target_pubkey) = 32),
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- PRIMARY KEY (listener_pubkey, target_pubkey)
-);
-CREATE INDEX operator_listener_pubkeys_target
- ON operator_listener_pubkeys (target_pubkey, listener_pubkey);
-CREATE INDEX operator_listener_pubkeys_created_at
- ON operator_listener_pubkeys (created_at);
-
-CREATE TABLE operator_listener_outbox (
- id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
- listener_pubkey BYTEA NOT NULL CHECK (length(listener_pubkey) = 32),
- target_pubkey BYTEA NOT NULL CHECK (length(target_pubkey) = 32),
- community_id UUID NOT NULL,
- event_id BYTEA NOT NULL CHECK (length(event_id) = 32),
- event_kind INTEGER NOT NULL,
- event_created_at TIMESTAMPTZ NOT NULL,
- state TEXT NOT NULL DEFAULT 'pending'
- CHECK (state IN ('pending', 'sending')),
- 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(),
- UNIQUE (listener_pubkey, target_pubkey, community_id, event_id)
-);
-CREATE INDEX operator_listener_outbox_due
- ON operator_listener_outbox (next_attempt_at, created_at, id)
- WHERE state = 'pending';
-CREATE INDEX operator_listener_outbox_recovery
- ON operator_listener_outbox (lease_until, created_at, id)
- WHERE state = 'sending';
-CREATE INDEX operator_listener_outbox_created_at
- ON operator_listener_outbox (created_at);
-
-INSERT INTO _operator_global_tables (table_name, reason) VALUES
- ('operator_listener_pubkeys', 'deployment-global target registrations for operator listeners'),
- ('operator_listener_outbox', 'deployment-global mention delivery queue; community_id is event provenance');
-
--- ── Relay operator audit (append-only roster mutation trail) ─────────────────
--- One row per PUT/DELETE /operators/{pubkey} mutation. The roster is the
--- deployment-wide root of trust and its mutations overwrite/remove in place;
--- this append-only trail records who granted, elevated, or revoked whom, and
--- when, so privilege changes are as auditable as the enforcement actions those
--- principals perform. Written only inside the upsert/delete transactions; no
--- UPDATE/DELETE path.
-
-CREATE TABLE relay_operator_audit (
- id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
- actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32),
- target_pubkey BYTEA NOT NULL CHECK (length(target_pubkey) = 32),
- op TEXT NOT NULL CHECK (op IN ('grant', 'revoke')),
- prev_role TEXT CHECK (prev_role IN ('operator', 'moderator')),
- new_role TEXT CHECK (new_role IN ('operator', 'moderator')),
- -- created_at is wall-clock occurrence time (clock_timestamp()), informational
- -- only — not monotonic, so it never establishes order. `seq` is the sole
- -- chronology key: mutations write their audit row under the serializing lock,
- -- so identity order equals the true privilege chain. Reads use ORDER BY seq.
- created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
- seq BIGINT GENERATED ALWAYS AS IDENTITY
-);
-
-CREATE INDEX idx_relay_operator_audit_target
- ON relay_operator_audit (target_pubkey, seq);
-
-INSERT INTO _operator_global_tables (table_name, reason) VALUES
- ('relay_operator_audit', 'deployment-global append-only roster mutation audit trail; no community_id intentionally');
-
--- ── Storage accounting snapshot ─────────────────────────────────────────────
--- Deployment-global singleton produced by the isolated S3 accounting worker.
-
-CREATE TABLE storage_accounting_snapshots (
- singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton),
- snapshot JSONB NOT NULL CHECK (jsonb_typeof(snapshot) = 'object'),
- completed_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(),
- duration_ms BIGINT NOT NULL CHECK (duration_ms >= 0),
- max_objects BIGINT NOT NULL CHECK (max_objects > 0),
- code_sha TEXT NOT NULL CHECK (octet_length(code_sha) BETWEEN 1 AND 128)
-);
-
-INSERT INTO _operator_global_tables (table_name, reason) VALUES
- ('storage_accounting_snapshots', 'deployment-global completed media accounting handoff');
-
--- NIP-AR current heads and acceptance ledger are independent of event retention.
-CREATE TABLE artifact_heads (
- community_id UUID NOT NULL REFERENCES communities(id),
- artifact_id UUID NOT NULL,
- event_id BYTEA NOT NULL CHECK (length(event_id) = 32),
- channel_id UUID NOT NULL,
- artifact_type TEXT NOT NULL,
- root BYTEA,
- deleted BOOLEAN NOT NULL DEFAULT false,
- PRIMARY KEY (community_id, artifact_id)
-);
-CREATE INDEX artifact_heads_event ON artifact_heads (community_id, event_id);
--- Every accepted revision ID, so replays stay idempotent after redaction or
--- retention.
-CREATE TABLE artifact_revisions (
- community_id UUID NOT NULL REFERENCES communities(id),
- event_id BYTEA NOT NULL CHECK (length(event_id) = 32),
- artifact_id UUID NOT NULL,
- PRIMARY KEY (community_id, event_id)
-);
-
-SELECT attach_community_write_fence('artifact_heads');
-SELECT attach_community_write_fence('artifact_revisions');
-
--- The relay does not expire events. Any future row retention or partition
--- retirement must skip payloads referenced by `artifact_heads.event_id`
--- (NIP-AR: expiring earlier revisions MUST NOT remove the current revision).
+-- types
+\i types/channel_type.sql
+\i types/channel_visibility.sql
+\i types/member_role.sql
+\i types/workflow_status.sql
+\i types/run_status.sql
+\i types/approval_status.sql
+\i types/delivery_method.sql
+\i types/subscription_status.sql
+\i types/pause_reason.sql
+\i types/channel_add_policy.sql
+
+-- functions
+\i functions/channels_community_id_immutable.sql
+\i functions/enqueue_push_match_job.sql
+\i functions/refresh_channel_ttl_after_event_insert.sql
+\i functions/guard_channel_roster_snapshot.sql
+\i functions/events_created_at_floor_guard.sql
+\i functions/prevent_community_deletion_request_retargeting.sql
+\i functions/prevent_community_deletion_approval_removal.sql
+\i functions/protect_community_deletion_manifest_keys.sql
+\i functions/community_deletion_lock_key.sql
+\i functions/community_write_fence_excluded_table.sql
+\i functions/community_write_allowed.sql
+\i functions/assert_community_write_allowed.sql
+\i functions/enforce_community_write_fence.sql
+\i functions/enforce_community_tombstone.sql
+\i functions/attach_community_write_fence.sql
+
+-- tables
+\i tables/public/communities.sql
+\i tables/public/channels.sql
+\i tables/public/channel_members.sql
+\i tables/public/users.sql
+\i tables/public/personal_read_accounts.sql
+\i tables/public/personal_read_frontiers.sql
+\i tables/public/events.sql
+\i tables/public/event_mentions.sql
+\i tables/public/subscriptions.sql
+\i tables/public/delivery_log.sql
+\i tables/public/workflows.sql
+\i tables/public/workflow_runs.sql
+\i tables/public/workflow_approvals.sql
+\i tables/public/scheduled_workflow_fires.sql
+\i tables/public/api_tokens.sql
+\i tables/public/rate_limit_violations.sql
+\i tables/public/thread_metadata.sql
+\i tables/public/reactions.sql
+\i tables/public/pubkey_allowlist.sql
+\i tables/public/relay_members.sql
+\i tables/public/join_policy_acceptances.sql
+\i tables/public/relay_invites.sql
+\i tables/public/archived_identities.sql
+\i tables/public/audit_log.sql
+\i tables/public/moderation_actions.sql
+\i tables/public/moderation_reports.sql
+\i tables/public/community_bans.sql
+\i tables/public/_operator_global_tables.sql
+\i tables/public/git_repo_names.sql
+\i tables/public/parameterized_event_watermarks.sql
+\i tables/public/product_feedback.sql
+\i tables/public/push_leases.sql
+\i tables/public/push_wake_outbox.sql
+\i tables/public/push_match_queue.sql
+\i tables/public/push_gateway_challenges.sql
+\i tables/public/push_gateway_installations.sql
+\i tables/public/push_gateway_delegations.sql
+\i tables/public/push_gateway_endpoint_quotas.sql
+\i tables/public/push_gateway_delivery_auth_replays.sql
+\i tables/public/push_gateway_delivery_request_replays.sql
+\i tables/public/replica_heartbeat.sql
+\i tables/public/community_deletion_requests.sql
+\i tables/public/community_deletion_approvals.sql
+\i tables/public/community_deletion_checkpoints.sql
+\i tables/public/community_deletion_manifest_keys.sql
+\i tables/public/storage_taxonomy_sweeps.sql
+\i tables/public/community_serving_write_leases.sql
+\i tables/public/community_deletion_executor_heartbeats.sql
+\i tables/public/relay_operators.sql
+\i tables/public/relay_admin_actions.sql
+\i tables/public/relay_admin_outbox.sql
+\i tables/public/operator_listener_pubkeys.sql
+\i tables/public/operator_listener_outbox.sql
+\i tables/public/relay_operator_audit.sql
+\i tables/public/storage_accounting_snapshots.sql
+\i tables/public/artifact_heads.sql
+\i tables/public/artifact_revisions.sql
+
+-- partitions
+\i partitions/events.sql
+\i partitions/delivery_log.sql
+
+-- triggers
+\i triggers/channels.sql
+\i triggers/events.sql
+\i triggers/community_deletion_requests.sql
+\i triggers/community_deletion_approvals.sql
+\i triggers/community_deletion_manifest_keys.sql
+\i triggers/communities.sql
+\i triggers/api_tokens.sql
+\i triggers/archived_identities.sql
+\i triggers/audit_log.sql
+\i triggers/channel_members.sql
+\i triggers/community_bans.sql
+\i triggers/delivery_log.sql
+\i triggers/event_mentions.sql
+\i triggers/git_repo_names.sql
+\i triggers/join_policy_acceptances.sql
+\i triggers/moderation_actions.sql
+\i triggers/moderation_reports.sql
+\i triggers/parameterized_event_watermarks.sql
+\i triggers/personal_read_accounts.sql
+\i triggers/personal_read_frontiers.sql
+\i triggers/pubkey_allowlist.sql
+\i triggers/push_leases.sql
+\i triggers/push_match_queue.sql
+\i triggers/push_wake_outbox.sql
+\i triggers/reactions.sql
+\i triggers/relay_invites.sql
+\i triggers/relay_members.sql
+\i triggers/scheduled_workflow_fires.sql
+\i triggers/subscriptions.sql
+\i triggers/thread_metadata.sql
+\i triggers/users.sql
+\i triggers/workflow_approvals.sql
+\i triggers/workflow_runs.sql
+\i triggers/workflows.sql
+\i triggers/artifact_heads.sql
+\i triggers/artifact_revisions.sql
+
+-- seeds
+\i seeds/communities.sql
+\i seeds/rate_limit_violations.sql
+\i seeds/_operator_global_tables.sql
+\i seeds/product_feedback.sql
+\i seeds/push_gateway_challenges.sql
+\i seeds/push_gateway_installations.sql
+\i seeds/push_gateway_delegations.sql
+\i seeds/push_gateway_endpoint_quotas.sql
+\i seeds/push_gateway_delivery_auth_replays.sql
+\i seeds/push_gateway_delivery_request_replays.sql
+\i seeds/replica_heartbeat.sql
+\i seeds/community_deletion_requests.sql
+\i seeds/community_deletion_approvals.sql
+\i seeds/community_deletion_checkpoints.sql
+\i seeds/community_deletion_manifest_keys.sql
+\i seeds/storage_taxonomy_sweeps.sql
+\i seeds/community_serving_write_leases.sql
+\i seeds/community_deletion_executor_heartbeats.sql
+\i seeds/relay_operators.sql
+\i seeds/relay_admin_actions.sql
+\i seeds/relay_admin_outbox.sql
+\i seeds/operator_listener_pubkeys.sql
+\i seeds/operator_listener_outbox.sql
+\i seeds/relay_operator_audit.sql
+\i seeds/storage_accounting_snapshots.sql
+
+-- Fence any community-scoped table that lacks an explicit attach.
+\i community_write_fence.sql
diff --git a/schema/seeds/_operator_global_tables.sql b/schema/seeds/_operator_global_tables.sql
new file mode 100644
index 0000000000..13f126998c
--- /dev/null
+++ b/schema/seeds/_operator_global_tables.sql
@@ -0,0 +1,2 @@
+INSERT INTO _operator_global_tables (table_name, reason) VALUES
+ ('_operator_global_tables', 'the registry table itself');
diff --git a/schema/seeds/communities.sql b/schema/seeds/communities.sql
new file mode 100644
index 0000000000..1c9a71d770
--- /dev/null
+++ b/schema/seeds/communities.sql
@@ -0,0 +1,2 @@
+INSERT INTO _operator_global_tables (table_name, reason) VALUES
+ ('communities', 'the tenant registry itself; id IS the community key');
diff --git a/schema/seeds/community_deletion_approvals.sql b/schema/seeds/community_deletion_approvals.sql
new file mode 100644
index 0000000000..41443042de
--- /dev/null
+++ b/schema/seeds/community_deletion_approvals.sql
@@ -0,0 +1,2 @@
+INSERT INTO _operator_global_tables (table_name, reason) VALUES
+ ('community_deletion_approvals', 'deployment operator destructive approvals');
diff --git a/schema/seeds/community_deletion_checkpoints.sql b/schema/seeds/community_deletion_checkpoints.sql
new file mode 100644
index 0000000000..c915d1946c
--- /dev/null
+++ b/schema/seeds/community_deletion_checkpoints.sql
@@ -0,0 +1,2 @@
+INSERT INTO _operator_global_tables (table_name, reason) VALUES
+ ('community_deletion_checkpoints', 'deployment deletion executor checkpoints and failures');
diff --git a/schema/seeds/community_deletion_executor_heartbeats.sql b/schema/seeds/community_deletion_executor_heartbeats.sql
new file mode 100644
index 0000000000..6ec6b5130e
--- /dev/null
+++ b/schema/seeds/community_deletion_executor_heartbeats.sql
@@ -0,0 +1,2 @@
+INSERT INTO _operator_global_tables (table_name, reason) VALUES
+ ('community_deletion_executor_heartbeats', 'deployment deletion worker liveness');
diff --git a/schema/seeds/community_deletion_manifest_keys.sql b/schema/seeds/community_deletion_manifest_keys.sql
new file mode 100644
index 0000000000..3c75f516a2
--- /dev/null
+++ b/schema/seeds/community_deletion_manifest_keys.sql
@@ -0,0 +1,2 @@
+INSERT INTO _operator_global_tables (table_name, reason) VALUES
+ ('community_deletion_manifest_keys', 'deployment deletion frozen destructive key chunks');
diff --git a/schema/seeds/community_deletion_requests.sql b/schema/seeds/community_deletion_requests.sql
new file mode 100644
index 0000000000..55a46a04e1
--- /dev/null
+++ b/schema/seeds/community_deletion_requests.sql
@@ -0,0 +1,2 @@
+INSERT INTO _operator_global_tables (table_name, reason) VALUES
+ ('community_deletion_requests', 'deployment deletion lifecycle and frozen inventory');
diff --git a/schema/seeds/community_serving_write_leases.sql b/schema/seeds/community_serving_write_leases.sql
new file mode 100644
index 0000000000..3afc4ca3ac
--- /dev/null
+++ b/schema/seeds/community_serving_write_leases.sql
@@ -0,0 +1,2 @@
+INSERT INTO _operator_global_tables (table_name, reason) VALUES
+ ('community_serving_write_leases', 'deployment serving side-effect leases drained by deletion');
diff --git a/schema/seeds/operator_listener_outbox.sql b/schema/seeds/operator_listener_outbox.sql
new file mode 100644
index 0000000000..c860859379
--- /dev/null
+++ b/schema/seeds/operator_listener_outbox.sql
@@ -0,0 +1,2 @@
+INSERT INTO _operator_global_tables (table_name, reason) VALUES
+ ('operator_listener_outbox', 'deployment-global mention delivery queue; community_id is event provenance');
diff --git a/schema/seeds/operator_listener_pubkeys.sql b/schema/seeds/operator_listener_pubkeys.sql
new file mode 100644
index 0000000000..5c9b9ea1fa
--- /dev/null
+++ b/schema/seeds/operator_listener_pubkeys.sql
@@ -0,0 +1,2 @@
+INSERT INTO _operator_global_tables (table_name, reason) VALUES
+ ('operator_listener_pubkeys', 'deployment-global target registrations for operator listeners');
diff --git a/schema/seeds/product_feedback.sql b/schema/seeds/product_feedback.sql
new file mode 100644
index 0000000000..de52155ba8
--- /dev/null
+++ b/schema/seeds/product_feedback.sql
@@ -0,0 +1,2 @@
+INSERT INTO _operator_global_tables (table_name, reason) VALUES
+ ('product_feedback', 'deployment product inbox; community_id is provenance only');
diff --git a/schema/seeds/push_gateway_challenges.sql b/schema/seeds/push_gateway_challenges.sql
new file mode 100644
index 0000000000..7ac406342a
--- /dev/null
+++ b/schema/seeds/push_gateway_challenges.sql
@@ -0,0 +1,2 @@
+INSERT INTO _operator_global_tables (table_name, reason) VALUES
+ ('push_gateway_challenges', 'public gateway one-time challenges span relay communities');
diff --git a/schema/seeds/push_gateway_delegations.sql b/schema/seeds/push_gateway_delegations.sql
new file mode 100644
index 0000000000..9d60ef8b28
--- /dev/null
+++ b/schema/seeds/push_gateway_delegations.sql
@@ -0,0 +1,2 @@
+INSERT INTO _operator_global_tables (table_name, reason) VALUES
+ ('push_gateway_delegations', 'public gateway relay delegations span relay communities');
diff --git a/schema/seeds/push_gateway_delivery_auth_replays.sql b/schema/seeds/push_gateway_delivery_auth_replays.sql
new file mode 100644
index 0000000000..af15a22d60
--- /dev/null
+++ b/schema/seeds/push_gateway_delivery_auth_replays.sql
@@ -0,0 +1,2 @@
+INSERT INTO _operator_global_tables (table_name, reason) VALUES
+ ('push_gateway_delivery_auth_replays', 'public gateway signed-event replay admission spans relay communities');
diff --git a/schema/seeds/push_gateway_delivery_request_replays.sql b/schema/seeds/push_gateway_delivery_request_replays.sql
new file mode 100644
index 0000000000..5c6557fecc
--- /dev/null
+++ b/schema/seeds/push_gateway_delivery_request_replays.sql
@@ -0,0 +1,2 @@
+INSERT INTO _operator_global_tables (table_name, reason) VALUES
+ ('push_gateway_delivery_request_replays', 'public gateway stable request-id admission spans relay communities');
diff --git a/schema/seeds/push_gateway_endpoint_quotas.sql b/schema/seeds/push_gateway_endpoint_quotas.sql
new file mode 100644
index 0000000000..5f753fe80d
--- /dev/null
+++ b/schema/seeds/push_gateway_endpoint_quotas.sql
@@ -0,0 +1,2 @@
+INSERT INTO _operator_global_tables (table_name, reason) VALUES
+ ('push_gateway_endpoint_quotas', 'public gateway endpoint abuse ceilings span relay communities');
diff --git a/schema/seeds/push_gateway_installations.sql b/schema/seeds/push_gateway_installations.sql
new file mode 100644
index 0000000000..878e870016
--- /dev/null
+++ b/schema/seeds/push_gateway_installations.sql
@@ -0,0 +1,2 @@
+INSERT INTO _operator_global_tables (table_name, reason) VALUES
+ ('push_gateway_installations', 'public gateway installation authority spans relay communities');
diff --git a/schema/seeds/rate_limit_violations.sql b/schema/seeds/rate_limit_violations.sql
new file mode 100644
index 0000000000..128e564457
--- /dev/null
+++ b/schema/seeds/rate_limit_violations.sql
@@ -0,0 +1,2 @@
+INSERT INTO _operator_global_tables (table_name, reason) VALUES
+ ('rate_limit_violations', 'deployment abuse/health; never tenant-observable; community_id is an attribution label only');
diff --git a/schema/seeds/relay_admin_actions.sql b/schema/seeds/relay_admin_actions.sql
new file mode 100644
index 0000000000..80eb2a10c0
--- /dev/null
+++ b/schema/seeds/relay_admin_actions.sql
@@ -0,0 +1,2 @@
+INSERT INTO _operator_global_tables (table_name, reason) VALUES
+ ('relay_admin_actions', 'deployment-global enforcement state machine; community deletion purges rows by report_community_id');
diff --git a/schema/seeds/relay_admin_outbox.sql b/schema/seeds/relay_admin_outbox.sql
new file mode 100644
index 0000000000..4680db86c1
--- /dev/null
+++ b/schema/seeds/relay_admin_outbox.sql
@@ -0,0 +1,2 @@
+INSERT INTO _operator_global_tables (table_name, reason) VALUES
+ ('relay_admin_outbox', 'deployment-global enforcement artifact delivery queue; community deletion purges rows with their action');
diff --git a/schema/seeds/relay_operator_audit.sql b/schema/seeds/relay_operator_audit.sql
new file mode 100644
index 0000000000..69d803f8ad
--- /dev/null
+++ b/schema/seeds/relay_operator_audit.sql
@@ -0,0 +1,2 @@
+INSERT INTO _operator_global_tables (table_name, reason) VALUES
+ ('relay_operator_audit', 'deployment-global append-only roster mutation audit trail; no community_id intentionally');
diff --git a/schema/seeds/relay_operators.sql b/schema/seeds/relay_operators.sql
new file mode 100644
index 0000000000..c9f350a96f
--- /dev/null
+++ b/schema/seeds/relay_operators.sql
@@ -0,0 +1,2 @@
+INSERT INTO _operator_global_tables (table_name, reason) VALUES
+ ('relay_operators', 'deployment-global operator/moderator roster; no community_id intentionally');
diff --git a/schema/seeds/replica_heartbeat.sql b/schema/seeds/replica_heartbeat.sql
new file mode 100644
index 0000000000..ff9a2de4ca
--- /dev/null
+++ b/schema/seeds/replica_heartbeat.sql
@@ -0,0 +1,3 @@
+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');
diff --git a/schema/seeds/storage_accounting_snapshots.sql b/schema/seeds/storage_accounting_snapshots.sql
new file mode 100644
index 0000000000..b31a6f0105
--- /dev/null
+++ b/schema/seeds/storage_accounting_snapshots.sql
@@ -0,0 +1,2 @@
+INSERT INTO _operator_global_tables (table_name, reason) VALUES
+ ('storage_accounting_snapshots', 'deployment-global completed media accounting handoff');
diff --git a/schema/seeds/storage_taxonomy_sweeps.sql b/schema/seeds/storage_taxonomy_sweeps.sql
new file mode 100644
index 0000000000..e8e7ce0935
--- /dev/null
+++ b/schema/seeds/storage_taxonomy_sweeps.sql
@@ -0,0 +1,2 @@
+INSERT INTO _operator_global_tables (table_name, reason) VALUES
+ ('storage_taxonomy_sweeps', 'deployment object-store taxonomy sweep evidence');
diff --git a/schema/tables/public/_operator_global_tables.sql b/schema/tables/public/_operator_global_tables.sql
new file mode 100644
index 0000000000..8bddca95ba
--- /dev/null
+++ b/schema/tables/public/_operator_global_tables.sql
@@ -0,0 +1,11 @@
+-- ── 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
+);
diff --git a/schema/tables/public/api_tokens.sql b/schema/tables/public/api_tokens.sql
new file mode 100644
index 0000000000..35666c52cb
--- /dev/null
+++ b/schema/tables/public/api_tokens.sql
@@ -0,0 +1,24 @@
+-- ── 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);
diff --git a/schema/tables/public/archived_identities.sql b/schema/tables/public/archived_identities.sql
new file mode 100644
index 0000000000..d8475df045
--- /dev/null
+++ b/schema/tables/public/archived_identities.sql
@@ -0,0 +1,14 @@
+-- ── 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)
+);
diff --git a/schema/tables/public/artifact_heads.sql b/schema/tables/public/artifact_heads.sql
new file mode 100644
index 0000000000..168ec09d41
--- /dev/null
+++ b/schema/tables/public/artifact_heads.sql
@@ -0,0 +1,16 @@
+-- NIP-AR current heads and acceptance ledger are independent of event retention.
+CREATE TABLE artifact_heads (
+ community_id UUID NOT NULL REFERENCES communities(id),
+ artifact_id UUID NOT NULL,
+ event_id BYTEA NOT NULL CHECK (length(event_id) = 32),
+ channel_id UUID NOT NULL,
+ artifact_type TEXT NOT NULL,
+ root BYTEA,
+ deleted BOOLEAN NOT NULL DEFAULT false,
+ PRIMARY KEY (community_id, artifact_id)
+);
+CREATE INDEX artifact_heads_event ON artifact_heads (community_id, event_id);
+
+-- The relay does not expire events. Any future row retention or partition
+-- retirement must skip payloads referenced by `artifact_heads.event_id`
+-- (NIP-AR: expiring earlier revisions MUST NOT remove the current revision).
diff --git a/schema/tables/public/artifact_revisions.sql b/schema/tables/public/artifact_revisions.sql
new file mode 100644
index 0000000000..5708601814
--- /dev/null
+++ b/schema/tables/public/artifact_revisions.sql
@@ -0,0 +1,8 @@
+-- Every accepted revision ID, so replays stay idempotent after redaction or
+-- retention.
+CREATE TABLE artifact_revisions (
+ community_id UUID NOT NULL REFERENCES communities(id),
+ event_id BYTEA NOT NULL CHECK (length(event_id) = 32),
+ artifact_id UUID NOT NULL,
+ PRIMARY KEY (community_id, event_id)
+);
diff --git a/schema/tables/public/audit_log.sql b/schema/tables/public/audit_log.sql
new file mode 100644
index 0000000000..dcef51e617
--- /dev/null
+++ b/schema/tables/public/audit_log.sql
@@ -0,0 +1,20 @@
+-- ── 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_version SMALLINT NOT NULL DEFAULT 1 CHECK (hash_version IN (1, 2)),
+ 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);
diff --git a/schema/tables/public/channel_members.sql b/schema/tables/public/channel_members.sql
new file mode 100644
index 0000000000..7510e455de
--- /dev/null
+++ b/schema/tables/public/channel_members.sql
@@ -0,0 +1,20 @@
+-- ── 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;
diff --git a/schema/tables/public/channels.sql b/schema/tables/public/channels.sql
new file mode 100644
index 0000000000..5fe5659cfb
--- /dev/null
+++ b/schema/tables/public/channels.sql
@@ -0,0 +1,54 @@
+-- ── 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;
diff --git a/schema/tables/public/communities.sql b/schema/tables/public/communities.sql
new file mode 100644
index 0000000000..06f74c3f58
--- /dev/null
+++ b/schema/tables/public/communities.sql
@@ -0,0 +1,30 @@
+-- ── 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));
diff --git a/schema/tables/public/community_bans.sql b/schema/tables/public/community_bans.sql
new file mode 100644
index 0000000000..b6dadb2d29
--- /dev/null
+++ b/schema/tables/public/community_bans.sql
@@ -0,0 +1,22 @@
+-- ── 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 ").
+-- 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)
+);
diff --git a/schema/tables/public/community_deletion_approvals.sql b/schema/tables/public/community_deletion_approvals.sql
new file mode 100644
index 0000000000..633f1f79ba
--- /dev/null
+++ b/schema/tables/public/community_deletion_approvals.sql
@@ -0,0 +1,13 @@
+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,
+ approval_origin TEXT NOT NULL DEFAULT 'operator'
+ CHECK (approval_origin IN ('operator', 'owner_automatic')),
+ 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
+);
diff --git a/schema/tables/public/community_deletion_checkpoints.sql b/schema/tables/public/community_deletion_checkpoints.sql
new file mode 100644
index 0000000000..b2d8e17213
--- /dev/null
+++ b/schema/tables/public/community_deletion_checkpoints.sql
@@ -0,0 +1,17 @@
+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))
+);
diff --git a/schema/tables/public/community_deletion_executor_heartbeats.sql b/schema/tables/public/community_deletion_executor_heartbeats.sql
new file mode 100644
index 0000000000..1d7a90f2c3
--- /dev/null
+++ b/schema/tables/public/community_deletion_executor_heartbeats.sql
@@ -0,0 +1,9 @@
+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
+);
diff --git a/schema/tables/public/community_deletion_manifest_keys.sql b/schema/tables/public/community_deletion_manifest_keys.sql
new file mode 100644
index 0000000000..49bd90bb3c
--- /dev/null
+++ b/schema/tables/public/community_deletion_manifest_keys.sql
@@ -0,0 +1,14 @@
+-- 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)
+);
diff --git a/schema/tables/public/community_deletion_requests.sql b/schema/tables/public/community_deletion_requests.sql
new file mode 100644
index 0000000000..80f4b3cba5
--- /dev/null
+++ b/schema/tables/public/community_deletion_requests.sql
@@ -0,0 +1,89 @@
+-- ── 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,
+ request_origin TEXT NOT NULL DEFAULT 'operator'
+ CHECK (request_origin IN ('operator', 'owner')),
+ owner_pubkey TEXT,
+ mediating_operator_pubkey TEXT,
+ acknowledgement_version INTEGER,
+ 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 (
+ 'submitted', '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)),
+ CONSTRAINT community_deletion_owner_provenance CHECK (
+ (request_origin = 'operator'
+ AND owner_pubkey IS NULL
+ AND mediating_operator_pubkey IS NULL
+ AND acknowledgement_version IS NULL)
+ OR
+ (request_origin = 'owner'
+ AND NOT (owner_pubkey IS NULL)
+ AND NOT (mediating_operator_pubkey IS NULL)
+ AND NOT (acknowledgement_version IS NULL)
+ AND owner_pubkey ~ '^[0-9a-f]{64}$'
+ AND mediating_operator_pubkey ~ '^[0-9a-f]{64}$'
+ AND acknowledgement_version BETWEEN 1 AND 32767
+ AND requested_by = owner_pubkey)
+ ),
+ 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 INDEX community_deletion_requests_owner_preparable
+ ON community_deletion_requests (next_attempt_at, created_at)
+ WHERE request_origin = 'owner'
+ AND stage = 'submitted'
+ AND blocked_at IS NULL;
+CREATE INDEX community_deletion_requests_owner_quota_reservations
+ ON community_deletion_requests (owner_pubkey)
+ INCLUDE (community_id, completed_at)
+ WHERE request_origin = 'owner'
+ AND stage <> 'aborted';
diff --git a/schema/tables/public/community_serving_write_leases.sql b/schema/tables/public/community_serving_write_leases.sql
new file mode 100644
index 0000000000..6a905fe6b4
--- /dev/null
+++ b/schema/tables/public/community_serving_write_leases.sql
@@ -0,0 +1,14 @@
+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);
diff --git a/schema/tables/public/delivery_log.sql b/schema/tables/public/delivery_log.sql
new file mode 100644
index 0000000000..51e42c41db
--- /dev/null
+++ b/schema/tables/public/delivery_log.sql
@@ -0,0 +1,19 @@
+-- ── 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 INDEX idx_delivery_log_community_sub ON delivery_log (community_id, subscription_id);
diff --git a/schema/tables/public/event_mentions.sql b/schema/tables/public/event_mentions.sql
new file mode 100644
index 0000000000..b0eb9dcb4a
--- /dev/null
+++ b/schema/tables/public/event_mentions.sql
@@ -0,0 +1,22 @@
+-- ── 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);
+CREATE INDEX idx_event_mentions_community_event
+ ON event_mentions (community_id, event_id);
diff --git a/schema/tables/public/events.sql b/schema/tables/public/events.sql
new file mode 100644
index 0000000000..10c1adc19e
--- /dev/null
+++ b/schema/tables/public/events.sql
@@ -0,0 +1,71 @@
+-- ── 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 + 0014 + 0033).
+ search_tsv TSVECTOR GENERATED ALWAYS AS (
+ CASE WHEN kind IN (1059, 30179, 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);
+
+-- 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);
+
+-- e-tag containment (`tags @> '[["e",""]]'`) for the aux closure and #e
+-- reads. Mirrors migrations/0004; jsonb_path_ops supports exactly @>.
+CREATE INDEX idx_events_tags_gin ON events USING GIN (tags jsonb_path_ops);
diff --git a/schema/tables/public/git_repo_names.sql b/schema/tables/public/git_repo_names.sql
new file mode 100644
index 0000000000..e366d92fcc
--- /dev/null
+++ b/schema/tables/public/git_repo_names.sql
@@ -0,0 +1,10 @@
+-- ── 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);
diff --git a/schema/tables/public/join_policy_acceptances.sql b/schema/tables/public/join_policy_acceptances.sql
new file mode 100644
index 0000000000..95db631584
--- /dev/null
+++ b/schema/tables/public/join_policy_acceptances.sql
@@ -0,0 +1,14 @@
+-- ── 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
+);
diff --git a/schema/tables/public/moderation_actions.sql b/schema/tables/public/moderation_actions.sql
new file mode 100644
index 0000000000..309c5a54d1
--- /dev/null
+++ b/schema/tables/public/moderation_actions.sql
@@ -0,0 +1,39 @@
+-- ── 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')),
+ -- Deployment authority type for HTTP-initiated actions.
+ actor_authority TEXT NOT NULL DEFAULT 'community'
+ CHECK (actor_authority IN ('community', 'relay_operator', 'relay_moderator')),
+ 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;
diff --git a/schema/tables/public/moderation_reports.sql b/schema/tables/public/moderation_reports.sql
new file mode 100644
index 0000000000..13243e365f
--- /dev/null
+++ b/schema/tables/public/moderation_reports.sql
@@ -0,0 +1,60 @@
+-- ── 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', 'processing', 'resolved', 'dismissed', 'escalated')),
+ -- Non-null when status='processing': the relay_admin_actions row that claimed this report.
+ active_action_id UUID,
+ 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),
+ -- Same-community resolution provenance: a report can only be resolved by an
+ -- action row in its own community.
+ FOREIGN KEY (community_id, action_id) REFERENCES moderation_actions (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);
diff --git a/schema/tables/public/operator_listener_outbox.sql b/schema/tables/public/operator_listener_outbox.sql
new file mode 100644
index 0000000000..b8c274aca7
--- /dev/null
+++ b/schema/tables/public/operator_listener_outbox.sql
@@ -0,0 +1,25 @@
+CREATE TABLE operator_listener_outbox (
+ id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
+ listener_pubkey BYTEA NOT NULL CHECK (length(listener_pubkey) = 32),
+ target_pubkey BYTEA NOT NULL CHECK (length(target_pubkey) = 32),
+ community_id UUID NOT NULL,
+ event_id BYTEA NOT NULL CHECK (length(event_id) = 32),
+ event_kind INTEGER NOT NULL,
+ event_created_at TIMESTAMPTZ NOT NULL,
+ state TEXT NOT NULL DEFAULT 'pending'
+ CHECK (state IN ('pending', 'sending')),
+ 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(),
+ UNIQUE (listener_pubkey, target_pubkey, community_id, event_id)
+);
+CREATE INDEX operator_listener_outbox_due
+ ON operator_listener_outbox (next_attempt_at, created_at, id)
+ WHERE state = 'pending';
+CREATE INDEX operator_listener_outbox_recovery
+ ON operator_listener_outbox (lease_until, created_at, id)
+ WHERE state = 'sending';
+CREATE INDEX operator_listener_outbox_created_at
+ ON operator_listener_outbox (created_at);
diff --git a/schema/tables/public/operator_listener_pubkeys.sql b/schema/tables/public/operator_listener_pubkeys.sql
new file mode 100644
index 0000000000..e24c301963
--- /dev/null
+++ b/schema/tables/public/operator_listener_pubkeys.sql
@@ -0,0 +1,14 @@
+-- ── Operator-listener mention delivery ──────────────────────────────────────
+-- Listener registrations are deployment-global. The outbox records community
+-- provenance for the event, but is intentionally not tenant-owned.
+
+CREATE TABLE operator_listener_pubkeys (
+ listener_pubkey BYTEA NOT NULL CHECK (length(listener_pubkey) = 32),
+ target_pubkey BYTEA NOT NULL CHECK (length(target_pubkey) = 32),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (listener_pubkey, target_pubkey)
+);
+CREATE INDEX operator_listener_pubkeys_target
+ ON operator_listener_pubkeys (target_pubkey, listener_pubkey);
+CREATE INDEX operator_listener_pubkeys_created_at
+ ON operator_listener_pubkeys (created_at);
diff --git a/schema/tables/public/parameterized_event_watermarks.sql b/schema/tables/public/parameterized_event_watermarks.sql
new file mode 100644
index 0000000000..107d982399
--- /dev/null
+++ b/schema/tables/public/parameterized_event_watermarks.sql
@@ -0,0 +1,9 @@
+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)
+);
diff --git a/schema/tables/public/personal_read_accounts.sql b/schema/tables/public/personal_read_accounts.sql
new file mode 100644
index 0000000000..2761fa55b8
--- /dev/null
+++ b/schema/tables/public/personal_read_accounts.sql
@@ -0,0 +1,9 @@
+-- Private accessory read progress. Never included in Nostr event queries.
+-- A frontier is the relay arrival time (events.received_at) of the message a
+-- context was read through; the unread cutoff alone is signed event time. An
+-- empty root_id denotes a channel frontier.
+CREATE TABLE personal_read_accounts (
+ community_id UUID NOT NULL REFERENCES communities(id),
+ actor BYTEA NOT NULL CHECK (octet_length(actor) = 32),
+ PRIMARY KEY (community_id, actor)
+);
diff --git a/schema/tables/public/personal_read_frontiers.sql b/schema/tables/public/personal_read_frontiers.sql
new file mode 100644
index 0000000000..b695a50f6f
--- /dev/null
+++ b/schema/tables/public/personal_read_frontiers.sql
@@ -0,0 +1,15 @@
+CREATE TABLE personal_read_frontiers (
+ community_id UUID NOT NULL,
+ actor BYTEA NOT NULL,
+ channel_id UUID NOT NULL,
+ root_id BYTEA NOT NULL DEFAULT ''::bytea CHECK (octet_length(root_id) IN (0, 32)),
+ through_timestamp TIMESTAMPTZ NOT NULL,
+ -- Whole-channel cut covering every thread; channel rows only.
+ threads_through_timestamp TIMESTAMPTZ
+ CHECK (threads_through_timestamp IS NULL OR root_id = ''::bytea),
+ PRIMARY KEY (community_id, actor, channel_id, root_id),
+ FOREIGN KEY (community_id, actor)
+ REFERENCES personal_read_accounts (community_id, actor) ON DELETE CASCADE,
+ FOREIGN KEY (community_id, channel_id)
+ REFERENCES channels (community_id, id) ON DELETE CASCADE
+);
diff --git a/schema/tables/public/product_feedback.sql b/schema/tables/public/product_feedback.sql
new file mode 100644
index 0000000000..fdbfc79dab
--- /dev/null
+++ b/schema/tables/public/product_feedback.sql
@@ -0,0 +1,18 @@
+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(),
+ -- Operator-managed lifecycle status.
+ status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'reviewed', 'archived')),
+ 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);
diff --git a/schema/tables/public/pubkey_allowlist.sql b/schema/tables/public/pubkey_allowlist.sql
new file mode 100644
index 0000000000..af623765f9
--- /dev/null
+++ b/schema/tables/public/pubkey_allowlist.sql
@@ -0,0 +1,12 @@
+-- ── 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)
+);
diff --git a/schema/tables/public/push_gateway_challenges.sql b/schema/tables/public/push_gateway_challenges.sql
new file mode 100644
index 0000000000..73f8c45527
--- /dev/null
+++ b/schema/tables/public/push_gateway_challenges.sql
@@ -0,0 +1,10 @@
+-- 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);
diff --git a/schema/tables/public/push_gateway_delegations.sql b/schema/tables/public/push_gateway_delegations.sql
new file mode 100644
index 0000000000..5022e65103
--- /dev/null
+++ b/schema/tables/public/push_gateway_delegations.sql
@@ -0,0 +1,14 @@
+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;
diff --git a/schema/tables/public/push_gateway_delivery_auth_replays.sql b/schema/tables/public/push_gateway_delivery_auth_replays.sql
new file mode 100644
index 0000000000..7940e24c16
--- /dev/null
+++ b/schema/tables/public/push_gateway_delivery_auth_replays.sql
@@ -0,0 +1,7 @@
+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);
diff --git a/schema/tables/public/push_gateway_delivery_request_replays.sql b/schema/tables/public/push_gateway_delivery_request_replays.sql
new file mode 100644
index 0000000000..9fe10b6ec0
--- /dev/null
+++ b/schema/tables/public/push_gateway_delivery_request_replays.sql
@@ -0,0 +1,7 @@
+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);
diff --git a/schema/tables/public/push_gateway_endpoint_quotas.sql b/schema/tables/public/push_gateway_endpoint_quotas.sql
new file mode 100644
index 0000000000..3df2bece25
--- /dev/null
+++ b/schema/tables/public/push_gateway_endpoint_quotas.sql
@@ -0,0 +1,7 @@
+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);
diff --git a/schema/tables/public/push_gateway_installations.sql b/schema/tables/public/push_gateway_installations.sql
new file mode 100644
index 0000000000..ad847f4712
--- /dev/null
+++ b/schema/tables/public/push_gateway_installations.sql
@@ -0,0 +1,19 @@
+CREATE TABLE push_gateway_installations (
+ id UUID PRIMARY KEY,
+ app_attest_key_id BYTEA NOT NULL 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 = 'buzz-ios-dogfood'),
+ 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()
+);
+CREATE UNIQUE INDEX push_gateway_installations_active_app_attest_key
+ ON push_gateway_installations (app_attest_key_id) WHERE revoked_at IS NULL;
+CREATE UNIQUE INDEX push_gateway_installations_active_profile_token
+ ON push_gateway_installations (app_profile, token_fingerprint) WHERE revoked_at IS NULL;
+CREATE INDEX push_gateway_installations_expiry ON push_gateway_installations (expires_at) WHERE revoked_at IS NULL;
diff --git a/schema/tables/public/push_leases.sql b/schema/tables/public/push_leases.sql
new file mode 100644
index 0000000000..fed2e0756a
--- /dev/null
+++ b/schema/tables/public/push_leases.sql
@@ -0,0 +1,27 @@
+-- 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;
diff --git a/schema/tables/public/push_match_queue.sql b/schema/tables/public/push_match_queue.sql
new file mode 100644
index 0000000000..809ad6b0c7
--- /dev/null
+++ b/schema/tables/public/push_match_queue.sql
@@ -0,0 +1,19 @@
+-- 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';
diff --git a/schema/tables/public/push_wake_outbox.sql b/schema/tables/public/push_wake_outbox.sql
new file mode 100644
index 0000000000..cee9366339
--- /dev/null
+++ b/schema/tables/public/push_wake_outbox.sql
@@ -0,0 +1,25 @@
+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';
diff --git a/schema/tables/public/rate_limit_violations.sql b/schema/tables/public/rate_limit_violations.sql
new file mode 100644
index 0000000000..9309f376c9
--- /dev/null
+++ b/schema/tables/public/rate_limit_violations.sql
@@ -0,0 +1,15 @@
+-- ── 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)
+);
diff --git a/schema/tables/public/reactions.sql b/schema/tables/public/reactions.sql
new file mode 100644
index 0000000000..b9ed4488b6
--- /dev/null
+++ b/schema/tables/public/reactions.sql
@@ -0,0 +1,20 @@
+-- ── 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;
diff --git a/schema/tables/public/relay_admin_actions.sql b/schema/tables/public/relay_admin_actions.sql
new file mode 100644
index 0000000000..0643e29325
--- /dev/null
+++ b/schema/tables/public/relay_admin_actions.sql
@@ -0,0 +1,79 @@
+-- ── Relay admin actions (HTTP enforcement state machine) ──────────────────────
+-- One row per HTTP report-resolution enforcement action. Tracks the durable
+-- state machine from claim → enforcing → succeeded|failed|cancelled.
+
+CREATE TABLE relay_admin_actions (
+ id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
+ -- NULL for a report-less direct action (migration 0055).
+ report_id UUID,
+ report_community_id UUID NOT NULL,
+ -- Client-generated idempotency key (signed in NIP-98 request body).
+ request_id UUID NOT NULL,
+ -- Principal who claimed the report.
+ actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32),
+ actor_role TEXT NOT NULL CHECK (actor_role IN ('operator', 'moderator')),
+ -- The enforcement action requested.
+ action TEXT NOT NULL,
+ reason TEXT,
+ -- Timeout expiration for timeout actions; NULL otherwise.
+ timeout_until TIMESTAMPTZ,
+ -- Durable state machine: pending → enforcing → succeeded|failed|cancelled.
+ state TEXT NOT NULL DEFAULT 'pending'
+ CHECK (state IN ('pending', 'enforcing', 'succeeded', 'failed', 'cancelled')),
+ -- Step marker: the last durably committed mutation step (NULL = none yet).
+ -- Values: 'mutation_committed' (core DB mutation done), 'artifacts_done' (tombstone/notice done).
+ step_marker TEXT CHECK (step_marker IN ('mutation_committed', 'artifacts_done')),
+ -- Principal who cancelled a pre-mutation failed action; NULL until cancelled.
+ -- Attributes the cancel transition on the action row itself, mirroring
+ -- moderation_reports.resolved_by for report resolution.
+ cancelled_by BYTEA CHECK (cancelled_by IS NULL OR length(cancelled_by) = 32),
+ -- Error from the last failure, if any.
+ error_message TEXT,
+ -- Per-action exclusive lease (migration 0037): fences concurrent same-request
+ -- retries and lets the recovery worker claim/re-drive stranded actions.
+ action_lease_token UUID,
+ action_lease_expires_at TIMESTAMPTZ,
+ -- Authoritative enforcement target (migration 0047): persisted at claim time
+ -- so crash-recovery can fire live side effects without re-deriving from mutable
+ -- sources. enforcement_target_pubkey is the resolved target pubkey bytes for
+ -- kick/ban/timeout actions; NULL for event/blob targets. enforcement_channel_id
+ -- is the channel targeted by kick actions; NULL for community-wide actions.
+ enforcement_target_pubkey BYTEA
+ CHECK (enforcement_target_pubkey IS NULL OR length(enforcement_target_pubkey) = 32),
+ enforcement_channel_id UUID,
+ -- Direct actions (migration 0055): deleted event, and requested timeout
+ -- duration compared on idempotent retry.
+ enforcement_target_event_id BYTEA
+ CHECK (enforcement_target_event_id IS NULL OR length(enforcement_target_event_id) = 32),
+ timeout_secs BIGINT CHECK (timeout_secs IS NULL OR timeout_secs > 0),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ -- Report-scoped idempotency: one action per (report, request_id).
+ UNIQUE (report_community_id, report_id, request_id),
+ CONSTRAINT relay_admin_actions_direct_shape CHECK (
+ report_id IS NOT NULL
+ OR (action = 'ban' AND enforcement_target_pubkey IS NOT NULL
+ AND timeout_secs IS NULL AND timeout_until IS NULL)
+ OR (action = 'timeout' AND enforcement_target_pubkey IS NOT NULL
+ AND timeout_secs IS NOT NULL AND timeout_until IS NOT NULL)
+ OR (action = 'delete' AND enforcement_target_event_id IS NOT NULL
+ AND enforcement_target_pubkey IS NOT NULL
+ AND timeout_secs IS NULL AND timeout_until IS NULL)
+ ),
+ FOREIGN KEY (report_community_id, report_id)
+ REFERENCES moderation_reports (community_id, id)
+);
+
+CREATE INDEX idx_relay_admin_actions_report
+ ON relay_admin_actions (report_community_id, report_id);
+-- Direct-action idempotency (migration 0055): one per (community, request_id).
+CREATE UNIQUE INDEX idx_relay_admin_actions_direct_request
+ ON relay_admin_actions (report_community_id, request_id)
+ WHERE report_id IS NULL;
+CREATE INDEX idx_relay_admin_actions_state
+ ON relay_admin_actions (state)
+ WHERE state IN ('pending', 'enforcing');
+-- Recovery worker (migration 0037): find stranded actions by lease expiry.
+CREATE INDEX idx_relay_admin_actions_lease
+ ON relay_admin_actions (action_lease_expires_at)
+ WHERE state IN ('pending', 'enforcing');
diff --git a/schema/tables/public/relay_admin_outbox.sql b/schema/tables/public/relay_admin_outbox.sql
new file mode 100644
index 0000000000..f81de55ca0
--- /dev/null
+++ b/schema/tables/public/relay_admin_outbox.sql
@@ -0,0 +1,36 @@
+-- ── Relay admin outbox (durable enforcement delivery) ────────────────────────
+-- Transactional outbox for durable artifact/notice delivery.
+
+CREATE TABLE relay_admin_outbox (
+ id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
+ action_id UUID NOT NULL REFERENCES relay_admin_actions(id),
+ -- Delivery task type: 'tombstone' | 'system_message' | 'reporter_notice'.
+ task_type TEXT NOT NULL,
+ -- Task payload (JSON).
+ payload JSONB NOT NULL DEFAULT '{}'::jsonb,
+ -- Lease-based delivery: held_by identifies the worker pod.
+ held_by TEXT,
+ lease_expires_at TIMESTAMPTZ,
+ -- Delivery state: pending → delivered | failed.
+ state TEXT NOT NULL DEFAULT 'pending'
+ CHECK (state IN ('pending', 'delivered', 'failed')),
+ -- Deduplication key: prevents re-creating an artifact after delivery.
+ dedup_key TEXT UNIQUE,
+ error_message TEXT,
+ -- Retryable delivery with backoff (migration 0037): failures reschedule via
+ -- retry_after rather than terminating immediately.
+ attempt_count INT NOT NULL DEFAULT 0,
+ retry_after TIMESTAMPTZ,
+ -- Per-claim ownership fence (migration 0038): completion/failure updates
+ -- require the token written at claim time, so a stale worker cannot overwrite
+ -- a newer worker's terminal update.
+ outbox_claim_token UUID,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX idx_relay_admin_outbox_action
+ ON relay_admin_outbox (action_id);
+CREATE INDEX idx_relay_admin_outbox_pending
+ ON relay_admin_outbox (retry_after, created_at)
+ WHERE state = 'pending';
diff --git a/schema/tables/public/relay_invites.sql b/schema/tables/public/relay_invites.sql
new file mode 100644
index 0000000000..4e0f2eab0a
--- /dev/null
+++ b/schema/tables/public/relay_invites.sql
@@ -0,0 +1,21 @@
+-- ── 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);
diff --git a/schema/tables/public/relay_members.sql b/schema/tables/public/relay_members.sql
new file mode 100644
index 0000000000..8084a4db71
--- /dev/null
+++ b/schema/tables/public/relay_members.sql
@@ -0,0 +1,15 @@
+-- ── 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);
diff --git a/schema/tables/public/relay_operator_audit.sql b/schema/tables/public/relay_operator_audit.sql
new file mode 100644
index 0000000000..5aab629bfa
--- /dev/null
+++ b/schema/tables/public/relay_operator_audit.sql
@@ -0,0 +1,25 @@
+-- ── Relay operator audit (append-only roster mutation trail) ─────────────────
+-- One row per PUT/DELETE /operators/{pubkey} mutation. The roster is the
+-- deployment-wide root of trust and its mutations overwrite/remove in place;
+-- this append-only trail records who granted, elevated, or revoked whom, and
+-- when, so privilege changes are as auditable as the enforcement actions those
+-- principals perform. Written only inside the upsert/delete transactions; no
+-- UPDATE/DELETE path.
+
+CREATE TABLE relay_operator_audit (
+ id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
+ actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32),
+ target_pubkey BYTEA NOT NULL CHECK (length(target_pubkey) = 32),
+ op TEXT NOT NULL CHECK (op IN ('grant', 'revoke')),
+ prev_role TEXT CHECK (prev_role IN ('operator', 'moderator')),
+ new_role TEXT CHECK (new_role IN ('operator', 'moderator')),
+ -- created_at is wall-clock occurrence time (clock_timestamp()), informational
+ -- only — not monotonic, so it never establishes order. `seq` is the sole
+ -- chronology key: mutations write their audit row under the serializing lock,
+ -- so identity order equals the true privilege chain. Reads use ORDER BY seq.
+ created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
+ seq BIGINT GENERATED ALWAYS AS IDENTITY
+);
+
+CREATE INDEX idx_relay_operator_audit_target
+ ON relay_operator_audit (target_pubkey, seq);
diff --git a/schema/tables/public/relay_operators.sql b/schema/tables/public/relay_operators.sql
new file mode 100644
index 0000000000..d8596ae166
--- /dev/null
+++ b/schema/tables/public/relay_operators.sql
@@ -0,0 +1,11 @@
+-- ── Relay operator/moderator roster ──────────────────────────────────────────
+-- Deployment-level principals staffed via the admin API. Config-backed operators
+-- (RELAY_OPERATOR_PUBKEYS, RELAY_OWNER_PUBKEY owner-fallback) are NOT seeded here;
+-- they are authoritative in config and outrank any DB row.
+
+CREATE TABLE relay_operators (
+ pubkey BYTEA NOT NULL PRIMARY KEY CHECK (length(pubkey) = 32),
+ role TEXT NOT NULL CHECK (role IN ('operator', 'moderator')),
+ added_by BYTEA NOT NULL CHECK (length(added_by) = 32),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
diff --git a/schema/tables/public/replica_heartbeat.sql b/schema/tables/public/replica_heartbeat.sql
new file mode 100644
index 0000000000..160e97f34f
--- /dev/null
+++ b/schema/tables/public/replica_heartbeat.sql
@@ -0,0 +1,16 @@
+-- ── 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
+) WITH (
+ vacuum_truncate = false
+);
diff --git a/schema/tables/public/scheduled_workflow_fires.sql b/schema/tables/public/scheduled_workflow_fires.sql
new file mode 100644
index 0000000000..507bde2d21
--- /dev/null
+++ b/schema/tables/public/scheduled_workflow_fires.sql
@@ -0,0 +1,30 @@
+-- ── 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);
diff --git a/schema/tables/public/storage_accounting_snapshots.sql b/schema/tables/public/storage_accounting_snapshots.sql
new file mode 100644
index 0000000000..87a7f62d9e
--- /dev/null
+++ b/schema/tables/public/storage_accounting_snapshots.sql
@@ -0,0 +1,11 @@
+-- ── Storage accounting snapshot ─────────────────────────────────────────────
+-- Deployment-global singleton produced by the isolated S3 accounting worker.
+
+CREATE TABLE storage_accounting_snapshots (
+ singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton),
+ snapshot JSONB NOT NULL CHECK (jsonb_typeof(snapshot) = 'object'),
+ completed_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(),
+ duration_ms BIGINT NOT NULL CHECK (duration_ms >= 0),
+ max_objects BIGINT NOT NULL CHECK (max_objects > 0),
+ code_sha TEXT NOT NULL CHECK (octet_length(code_sha) BETWEEN 1 AND 128)
+);
diff --git a/schema/tables/public/storage_taxonomy_sweeps.sql b/schema/tables/public/storage_taxonomy_sweeps.sql
new file mode 100644
index 0000000000..f887d54298
--- /dev/null
+++ b/schema/tables/public/storage_taxonomy_sweeps.sql
@@ -0,0 +1,15 @@
+-- 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);
diff --git a/schema/tables/public/subscriptions.sql b/schema/tables/public/subscriptions.sql
new file mode 100644
index 0000000000..0e74dfdde5
--- /dev/null
+++ b/schema/tables/public/subscriptions.sql
@@ -0,0 +1,23 @@
+-- ── 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)
+);
diff --git a/schema/tables/public/thread_metadata.sql b/schema/tables/public/thread_metadata.sql
new file mode 100644
index 0000000000..2c9ed9800a
--- /dev/null
+++ b/schema/tables/public/thread_metadata.sql
@@ -0,0 +1,28 @@
+-- ── 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_window
+ ON thread_metadata (community_id, root_event_id, event_created_at DESC, event_id ASC);
+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);
diff --git a/schema/tables/public/users.sql b/schema/tables/public/users.sql
new file mode 100644
index 0000000000..9fb1688588
--- /dev/null
+++ b/schema/tables/public/users.sql
@@ -0,0 +1,32 @@
+-- ── 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;
diff --git a/schema/tables/public/workflow_approvals.sql b/schema/tables/public/workflow_approvals.sql
new file mode 100644
index 0000000000..6f91297345
--- /dev/null
+++ b/schema/tables/public/workflow_approvals.sql
@@ -0,0 +1,29 @@
+-- ── 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);
diff --git a/schema/tables/public/workflow_runs.sql b/schema/tables/public/workflow_runs.sql
new file mode 100644
index 0000000000..12a51809a7
--- /dev/null
+++ b/schema/tables/public/workflow_runs.sql
@@ -0,0 +1,23 @@
+-- ── 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,
+ error_code 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);
diff --git a/schema/tables/public/workflows.sql b/schema/tables/public/workflows.sql
new file mode 100644
index 0000000000..dba16b42e5
--- /dev/null
+++ b/schema/tables/public/workflows.sql
@@ -0,0 +1,25 @@
+-- ── 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;
diff --git a/schema/triggers/api_tokens.sql b/schema/triggers/api_tokens.sql
new file mode 100644
index 0000000000..214339212a
--- /dev/null
+++ b/schema/triggers/api_tokens.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('api_tokens');
diff --git a/schema/triggers/archived_identities.sql b/schema/triggers/archived_identities.sql
new file mode 100644
index 0000000000..af7a0d4696
--- /dev/null
+++ b/schema/triggers/archived_identities.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('archived_identities');
diff --git a/schema/triggers/artifact_heads.sql b/schema/triggers/artifact_heads.sql
new file mode 100644
index 0000000000..b5114f4fde
--- /dev/null
+++ b/schema/triggers/artifact_heads.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('artifact_heads');
diff --git a/schema/triggers/artifact_revisions.sql b/schema/triggers/artifact_revisions.sql
new file mode 100644
index 0000000000..efdb38afa2
--- /dev/null
+++ b/schema/triggers/artifact_revisions.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('artifact_revisions');
diff --git a/schema/triggers/audit_log.sql b/schema/triggers/audit_log.sql
new file mode 100644
index 0000000000..39ea4644f0
--- /dev/null
+++ b/schema/triggers/audit_log.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('audit_log');
diff --git a/schema/triggers/channel_members.sql b/schema/triggers/channel_members.sql
new file mode 100644
index 0000000000..3dc104ace8
--- /dev/null
+++ b/schema/triggers/channel_members.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('channel_members');
diff --git a/schema/triggers/channels.sql b/schema/triggers/channels.sql
new file mode 100644
index 0000000000..0dd5f73ba4
--- /dev/null
+++ b/schema/triggers/channels.sql
@@ -0,0 +1,4 @@
+CREATE TRIGGER trg_channels_community_id_immutable
+ BEFORE UPDATE ON channels
+ FOR EACH ROW EXECUTE FUNCTION channels_community_id_immutable();
+SELECT attach_community_write_fence('channels');
diff --git a/schema/triggers/communities.sql b/schema/triggers/communities.sql
new file mode 100644
index 0000000000..3bb684e175
--- /dev/null
+++ b/schema/triggers/communities.sql
@@ -0,0 +1,2 @@
+CREATE TRIGGER communities_deletion_tombstone BEFORE UPDATE OR DELETE ON communities
+FOR EACH ROW EXECUTE FUNCTION enforce_community_tombstone();
diff --git a/schema/triggers/community_bans.sql b/schema/triggers/community_bans.sql
new file mode 100644
index 0000000000..063e3ab456
--- /dev/null
+++ b/schema/triggers/community_bans.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('community_bans');
diff --git a/schema/triggers/community_deletion_approvals.sql b/schema/triggers/community_deletion_approvals.sql
new file mode 100644
index 0000000000..d23673a95b
--- /dev/null
+++ b/schema/triggers/community_deletion_approvals.sql
@@ -0,0 +1,4 @@
+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();
diff --git a/schema/triggers/community_deletion_manifest_keys.sql b/schema/triggers/community_deletion_manifest_keys.sql
new file mode 100644
index 0000000000..477339bcab
--- /dev/null
+++ b/schema/triggers/community_deletion_manifest_keys.sql
@@ -0,0 +1,4 @@
+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();
diff --git a/schema/triggers/community_deletion_requests.sql b/schema/triggers/community_deletion_requests.sql
new file mode 100644
index 0000000000..952f03322c
--- /dev/null
+++ b/schema/triggers/community_deletion_requests.sql
@@ -0,0 +1,4 @@
+CREATE TRIGGER community_deletion_request_retargeting_guard
+BEFORE UPDATE ON community_deletion_requests
+FOR EACH ROW
+EXECUTE FUNCTION prevent_community_deletion_request_retargeting();
diff --git a/schema/triggers/delivery_log.sql b/schema/triggers/delivery_log.sql
new file mode 100644
index 0000000000..7d0044a9cf
--- /dev/null
+++ b/schema/triggers/delivery_log.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('delivery_log');
diff --git a/schema/triggers/event_mentions.sql b/schema/triggers/event_mentions.sql
new file mode 100644
index 0000000000..b5b5767d0f
--- /dev/null
+++ b/schema/triggers/event_mentions.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('event_mentions');
diff --git a/schema/triggers/events.sql b/schema/triggers/events.sql
new file mode 100644
index 0000000000..5491d1631d
--- /dev/null
+++ b/schema/triggers/events.sql
@@ -0,0 +1,24 @@
+CREATE TRIGGER events_enqueue_push_match
+AFTER INSERT ON events
+FOR EACH ROW EXECUTE FUNCTION enqueue_push_match_job();
+
+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();
+CREATE TRIGGER trg_events_guard_channel_roster_snapshot
+ BEFORE INSERT ON events
+ FOR EACH ROW EXECUTE FUNCTION guard_channel_roster_snapshot();
+
+-- 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();
+SELECT attach_community_write_fence('events');
diff --git a/schema/triggers/git_repo_names.sql b/schema/triggers/git_repo_names.sql
new file mode 100644
index 0000000000..3d3cdea862
--- /dev/null
+++ b/schema/triggers/git_repo_names.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('git_repo_names');
diff --git a/schema/triggers/join_policy_acceptances.sql b/schema/triggers/join_policy_acceptances.sql
new file mode 100644
index 0000000000..e3baa7b247
--- /dev/null
+++ b/schema/triggers/join_policy_acceptances.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('join_policy_acceptances');
diff --git a/schema/triggers/moderation_actions.sql b/schema/triggers/moderation_actions.sql
new file mode 100644
index 0000000000..a87bc0965b
--- /dev/null
+++ b/schema/triggers/moderation_actions.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('moderation_actions');
diff --git a/schema/triggers/moderation_reports.sql b/schema/triggers/moderation_reports.sql
new file mode 100644
index 0000000000..3755e4bdc8
--- /dev/null
+++ b/schema/triggers/moderation_reports.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('moderation_reports');
diff --git a/schema/triggers/parameterized_event_watermarks.sql b/schema/triggers/parameterized_event_watermarks.sql
new file mode 100644
index 0000000000..678759eff8
--- /dev/null
+++ b/schema/triggers/parameterized_event_watermarks.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('parameterized_event_watermarks');
diff --git a/schema/triggers/personal_read_accounts.sql b/schema/triggers/personal_read_accounts.sql
new file mode 100644
index 0000000000..f8eef31200
--- /dev/null
+++ b/schema/triggers/personal_read_accounts.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('personal_read_accounts');
diff --git a/schema/triggers/personal_read_frontiers.sql b/schema/triggers/personal_read_frontiers.sql
new file mode 100644
index 0000000000..4655135502
--- /dev/null
+++ b/schema/triggers/personal_read_frontiers.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('personal_read_frontiers');
diff --git a/schema/triggers/pubkey_allowlist.sql b/schema/triggers/pubkey_allowlist.sql
new file mode 100644
index 0000000000..358256108c
--- /dev/null
+++ b/schema/triggers/pubkey_allowlist.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('pubkey_allowlist');
diff --git a/schema/triggers/push_leases.sql b/schema/triggers/push_leases.sql
new file mode 100644
index 0000000000..307e25e622
--- /dev/null
+++ b/schema/triggers/push_leases.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('push_leases');
diff --git a/schema/triggers/push_match_queue.sql b/schema/triggers/push_match_queue.sql
new file mode 100644
index 0000000000..57e3085077
--- /dev/null
+++ b/schema/triggers/push_match_queue.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('push_match_queue');
diff --git a/schema/triggers/push_wake_outbox.sql b/schema/triggers/push_wake_outbox.sql
new file mode 100644
index 0000000000..b544ce5136
--- /dev/null
+++ b/schema/triggers/push_wake_outbox.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('push_wake_outbox');
diff --git a/schema/triggers/reactions.sql b/schema/triggers/reactions.sql
new file mode 100644
index 0000000000..8d51e860be
--- /dev/null
+++ b/schema/triggers/reactions.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('reactions');
diff --git a/schema/triggers/relay_invites.sql b/schema/triggers/relay_invites.sql
new file mode 100644
index 0000000000..d6f658a54c
--- /dev/null
+++ b/schema/triggers/relay_invites.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('relay_invites');
diff --git a/schema/triggers/relay_members.sql b/schema/triggers/relay_members.sql
new file mode 100644
index 0000000000..1a4018a69c
--- /dev/null
+++ b/schema/triggers/relay_members.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('relay_members');
diff --git a/schema/triggers/scheduled_workflow_fires.sql b/schema/triggers/scheduled_workflow_fires.sql
new file mode 100644
index 0000000000..24ba3a936e
--- /dev/null
+++ b/schema/triggers/scheduled_workflow_fires.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('scheduled_workflow_fires');
diff --git a/schema/triggers/subscriptions.sql b/schema/triggers/subscriptions.sql
new file mode 100644
index 0000000000..a9d80ddfdc
--- /dev/null
+++ b/schema/triggers/subscriptions.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('subscriptions');
diff --git a/schema/triggers/thread_metadata.sql b/schema/triggers/thread_metadata.sql
new file mode 100644
index 0000000000..68e53007dc
--- /dev/null
+++ b/schema/triggers/thread_metadata.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('thread_metadata');
diff --git a/schema/triggers/users.sql b/schema/triggers/users.sql
new file mode 100644
index 0000000000..5f65f3c763
--- /dev/null
+++ b/schema/triggers/users.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('users');
diff --git a/schema/triggers/workflow_approvals.sql b/schema/triggers/workflow_approvals.sql
new file mode 100644
index 0000000000..58b616f3cc
--- /dev/null
+++ b/schema/triggers/workflow_approvals.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('workflow_approvals');
diff --git a/schema/triggers/workflow_runs.sql b/schema/triggers/workflow_runs.sql
new file mode 100644
index 0000000000..5c9f54ed86
--- /dev/null
+++ b/schema/triggers/workflow_runs.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('workflow_runs');
diff --git a/schema/triggers/workflows.sql b/schema/triggers/workflows.sql
new file mode 100644
index 0000000000..9a136a071e
--- /dev/null
+++ b/schema/triggers/workflows.sql
@@ -0,0 +1 @@
+SELECT attach_community_write_fence('workflows');
diff --git a/schema/types/approval_status.sql b/schema/types/approval_status.sql
new file mode 100644
index 0000000000..a96dae859c
--- /dev/null
+++ b/schema/types/approval_status.sql
@@ -0,0 +1 @@
+CREATE TYPE approval_status AS ENUM ('pending', 'granted', 'denied', 'expired');
diff --git a/schema/types/channel_add_policy.sql b/schema/types/channel_add_policy.sql
new file mode 100644
index 0000000000..fe8295a9e4
--- /dev/null
+++ b/schema/types/channel_add_policy.sql
@@ -0,0 +1 @@
+CREATE TYPE channel_add_policy AS ENUM ('anyone', 'owner_only', 'nobody');
diff --git a/schema/types/channel_type.sql b/schema/types/channel_type.sql
new file mode 100644
index 0000000000..9c5e7c3cb6
--- /dev/null
+++ b/schema/types/channel_type.sql
@@ -0,0 +1,3 @@
+-- ── Custom types ──────────────────────────────────────────────────────────────
+
+CREATE TYPE channel_type AS ENUM ('stream', 'forum', 'dm', 'workflow');
diff --git a/schema/types/channel_visibility.sql b/schema/types/channel_visibility.sql
new file mode 100644
index 0000000000..e3a0dbe114
--- /dev/null
+++ b/schema/types/channel_visibility.sql
@@ -0,0 +1 @@
+CREATE TYPE channel_visibility AS ENUM ('open', 'private');
diff --git a/schema/types/delivery_method.sql b/schema/types/delivery_method.sql
new file mode 100644
index 0000000000..b35c62e71c
--- /dev/null
+++ b/schema/types/delivery_method.sql
@@ -0,0 +1 @@
+CREATE TYPE delivery_method AS ENUM ('webhook', 'websocket');
diff --git a/schema/types/member_role.sql b/schema/types/member_role.sql
new file mode 100644
index 0000000000..47d7849372
--- /dev/null
+++ b/schema/types/member_role.sql
@@ -0,0 +1 @@
+CREATE TYPE member_role AS ENUM ('owner', 'admin', 'member', 'guest', 'bot');
diff --git a/schema/types/pause_reason.sql b/schema/types/pause_reason.sql
new file mode 100644
index 0000000000..7517da078c
--- /dev/null
+++ b/schema/types/pause_reason.sql
@@ -0,0 +1 @@
+CREATE TYPE pause_reason AS ENUM ('user', 'system', 'rate_limit');
diff --git a/schema/types/run_status.sql b/schema/types/run_status.sql
new file mode 100644
index 0000000000..c14773bdb2
--- /dev/null
+++ b/schema/types/run_status.sql
@@ -0,0 +1 @@
+CREATE TYPE run_status AS ENUM ('pending', 'running', 'waiting_approval', 'completed', 'failed', 'cancelled');
diff --git a/schema/types/subscription_status.sql b/schema/types/subscription_status.sql
new file mode 100644
index 0000000000..c447f20d9b
--- /dev/null
+++ b/schema/types/subscription_status.sql
@@ -0,0 +1 @@
+CREATE TYPE subscription_status AS ENUM ('active', 'paused', 'deleted');
diff --git a/schema/types/workflow_status.sql b/schema/types/workflow_status.sql
new file mode 100644
index 0000000000..f23ca51e9e
--- /dev/null
+++ b/schema/types/workflow_status.sql
@@ -0,0 +1 @@
+CREATE TYPE workflow_status AS ENUM ('active', 'disabled', 'archived');
diff --git a/scripts/cutover/README.md b/scripts/cutover/README.md
index 51c6b836b8..92fd2e0ff8 100644
--- a/scripts/cutover/README.md
+++ b/scripts/cutover/README.md
@@ -17,7 +17,8 @@ derived from the deployment host.
Only when upgrading a Postgres that already holds **pre-1321 single-community
data** to 1321. A brand-new deployment does **not** run this — it provisions
-from `migrations/0001_initial_schema.sql` (or `schema/schema.sql`) directly.
+from `migrations/0001_initial_schema.sql` (or `schema/schema.sql`; with raw `psql`,
+run `cd schema && psql -f schema.sql` so its `\i` includes resolve) directly.
## Supported writer contract during transition