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