diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 98bf240a229..7b033c803bb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -489,23 +489,17 @@ All database access. Uses `sqlx::query()` (runtime, not compile-time macros) — - Approval tokens: `create_approval` receives the raw token and hashes it internally with SHA-256. - DDL injection protection in partition manager: allowlist of table names + strict suffix/date validators. -### Supported Writer Contract (transition state) - -The repository is moving toward a **supported-writer contract** where relay-owned -write APIs provide the only supported mutation path for tenant data. The current -shape is an explicit transition, not a flag day: - -- **Trajectory:** converge from trigger/function/FK-enforced fencing toward - application-owned transaction protocols, while keeping existing DB backstops - in place until coverage and fleet gates are proven. -- **Community fence (transition foundation):** runtime admission APIs now offer - one shared community deletion lock per transaction, while deletion lifecycle - transitions take the matching exclusive lock. A supported serving transaction - never spans communities. Serving leases use the same application admission. - Startup and deletion catalog validation tolerate the universal fence objects - being absent; inventories still record live fences for older executors. - Desired-state bootstraps install only explicitly declared legacy fences. - New tenant-owned tables rely on application admission, without a fence trigger. +### Supported Writer Contract + +Relay-owned write APIs provide the supported mutation path for tenant data: + +- **Community fence:** supported serving writers admit each tenant transaction + through the runtime API, which takes one shared community deletion lock and + checks active, undeleted state. Deletion lifecycle transitions take the + matching exclusive lock. A serving transaction never spans communities. + The universal per-table write-fence triggers and their functions have been + retired after writer coverage and fleet gates; the community tombstone guard + remains. New tenant-owned tables rely on this admission alone. - **Replica floor:** supported channel-event insertions check `created_at` against `clock_timestamp()` on their admitted transaction using the shared 960-second floor. The ordered heartbeat handshake and activity scan retain @@ -517,11 +511,6 @@ shape is an explicit transition, not a flag day: Drain old connections/in-flight transactions, stop out-of-band writers, and hold the replica breaker closed through the drop until replay catches up. Already-running old pods do not reverify the trigger on each heartbeat. -- **Dual enforcement (community fence transition):** application-owned - lock+precheck paths run in front of the existing community trigger/function - enforcement. Removing those backstops is a separate gated migration, after - every pod tolerates their absence and no rollback below that version is - expected. - **Role separation:** relay/runtime code owns admission and lock protocols; operator maintenance/backfill workflows must either use those protocols or run under explicit reviewed procedures that keep routing fences closed. diff --git a/crates/buzz-db/src/runtime/admitted_tx.rs b/crates/buzz-db/src/runtime/admitted_tx.rs index d70512c233c..c6d7f7bdaa5 100644 --- a/crates/buzz-db/src/runtime/admitted_tx.rs +++ b/crates/buzz-db/src/runtime/admitted_tx.rs @@ -24,10 +24,10 @@ use crate::Result; /// statements on it, and [`AdmittedTx::commit`] and [`AdmittedTx::rollback`] /// are the only ways it can end the transaction. Inside the crate, the /// `conn()` accessor hands statements the connection; the type can't stop -/// crate code from issuing a raw `COMMIT` through it, so the database -/// community write fences remain the backstop while deployed. Internal writers -/// must preserve this boundary and use `commit()` for staged follow-up work. -/// Dropping the value without committing rolls back, as with [`sqlx::Transaction`]. +/// crate code from issuing a raw `COMMIT` through it. Internal writers must +/// preserve this transaction boundary and use `commit()` for staged follow-up +/// work. Dropping the value without committing rolls back, as with +/// [`sqlx::Transaction`]. /// /// These are live regressions for the guarantee. Event-write helpers accept an /// admitted transaction: diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 743dda04b25..02a25f55ad6 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -14,10 +14,6 @@ use crate::Result; static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("../../migrations"); -#[cfg(test)] -#[path = "tests/schema_fence_bootstrap_postgres_tests.rs"] -mod schema_fence_bootstrap_postgres_tests; - /// Run all pending Buzz database migrations. /// /// The entire run holds the exclusive [`SCHEMA_DESTRUCTION_LOCK_KEY`] session @@ -894,7 +890,12 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 87); + assert_eq!(migrations.len(), 88); + assert_eq!(migrations[87].version, 88); + assert!(migrations[87] + .sql + .as_str() + .contains("DROP FUNCTION enforce_community_write_fence();")); assert!(migrations[83] .sql .as_str() @@ -1442,6 +1443,41 @@ mod postgres_tests { assert!(deletion.contains("CREATE FUNCTION attach_community_write_fence")); assert!(deletion.contains("community_write_fence_excluded_table")); assert!(deletion.contains("CREATE FUNCTION enforce_community_tombstone")); + let retirement = migrations + .iter() + .find(|migration| migration.version == 88) + .expect("retirement migration 0088") + .sql + .as_str(); + assert!(retirement.starts_with("SET LOCAL lock_timeout = '5s';")); + // Tables added after docs/database.md Rule 1 never had a fence. + let never_fenced = [ + "nip_fi_profile_heads", + "media_uploaders", + "media_publications", + ]; + for table in crate::deletion::EXPECTED_SCOPED_TABLES + .iter() + .filter(|table| !never_fenced.contains(table)) + { + assert!( + retirement.contains(&format!( + "DROP TRIGGER IF EXISTS community_write_fence_{table} ON {table};" + )), + "0088 must retire the universal fence on {table}" + ); + } + for signature in [ + "attach_community_write_fence(REGCLASS)", + "enforce_community_write_fence()", + "assert_community_write_allowed(UUID)", + "community_write_allowed(UUID)", + "community_write_fence_excluded_table(NAME)", + ] { + assert!(retirement.contains(&format!("DROP FUNCTION {signature};"))); + } + assert!(!retirement.contains("DROP FUNCTION community_deletion_lock_key")); + assert!(!retirement.contains("DROP FUNCTION enforce_community_tombstone")); assert!(deletion.contains("community tombstones are permanent")); assert!(deletion.contains("SET LOCAL lock_timeout = '5s'")); assert!(deletion.contains("'active', 'quiescing', 'fenced', 'tombstone'")); @@ -1684,13 +1720,6 @@ mod postgres_tests { "schema.sql must declare {index_shape}" ); } - // schema.sql keeps the restored (pre-0041) deletion exclusions and also - // leaves the deployment-global listener outbox outside tenant fencing. - assert!( - desired_schema - .contains("'rate_limit_violations', 'operator_listener_outbox'\n ]::TEXT[])"), - "schema.sql must exclude the deployment-global listener outbox from tenant fencing" - ); assert_eq!(migrations[51].version, 52); assert!(migrations[51] .sql @@ -2313,8 +2342,7 @@ mod postgres_tests { /// Compares parsed statements, not substrings: every deletion control- /// plane table, function, trigger, and index 0028 creates must exist in /// schema.sql with an identical normalized definition; every operator- - /// global registry row 0028 inserts must be inserted by schema.sql; the - /// write-fence attachment target sets must be equal; and every column + /// global registry row 0028 inserts must be inserted by schema.sql; every column /// 0028 adds to `communities` must exist in the desired-state /// `communities` table. A desired-state bootstrap that passes this test /// cannot silently omit part of the deletion surface the way the @@ -2333,7 +2361,6 @@ mod postgres_tests { triggers: BTreeMap, indexes: BTreeSet, registry_rows: BTreeSet<(String, String)>, - fence_attachments: BTreeSet, communities_added_columns: BTreeSet, } @@ -2402,14 +2429,6 @@ mod postgres_tests { surface.communities_added_columns.insert(column); } } - if let Some(position) = normalized.find("attach_community_write_fence('") { - let target = normalized[position + "attach_community_write_fence('".len()..] - .split('\'') - .next() - .expect("fence attachment target") - .to_owned(); - surface.fence_attachments.insert(target); - } } surface } @@ -2438,7 +2457,6 @@ mod postgres_tests { "0029 deletion control plane must define exactly the known tables: {:?}", migration.tables.keys().collect::>() ); - assert!(!migration.fence_attachments.is_empty()); assert!(!migration.registry_rows.is_empty()); for (table, definition) in &migration.tables { @@ -2491,13 +2509,23 @@ mod postgres_tests { ); } for (function, definition) in &migration.functions { + if [ + "community_write_fence_excluded_table", + "community_write_allowed", + "assert_community_write_allowed", + "enforce_community_write_fence", + "attach_community_write_fence", + ] + .contains(&function.as_str()) + { + assert!(!schema.functions.contains_key(function)); + continue; + } let in_schema = schema .functions .get(function) .unwrap_or_else(|| panic!("schema.sql is missing deletion function {function}")); - if function != "community_write_fence_excluded_table" - && function != "prevent_community_deletion_request_retargeting" - { + if function != "prevent_community_deletion_request_retargeting" { assert_eq!( in_schema, definition, "schema.sql definition of {function}() drifted from migration 0029" @@ -2553,9 +2581,6 @@ mod postgres_tests { "schema.sql is missing operator-global registry row {row:?}" ); } - let mut expected_fences = migration.fence_attachments.clone(); - expected_fences.remove("product_feedback"); - expected_fences.remove("rate_limit_violations"); let personal = surface( MIGRATOR .iter() @@ -2609,21 +2634,6 @@ mod postgres_tests { "personal read table {table} differs" ); } - expected_fences.extend(personal.fence_attachments); - expected_fences.extend(["artifact_heads", "artifact_revisions"].map(str::to_owned)); - // 0066 fences the NIP-FI issuer bindings table. - assert!(MIGRATOR - .iter() - .find(|m| m.version == 66) - .expect("NIP-FI binding migration") - .sql - .as_str() - .contains("attach_community_write_fence('community_nip_fi_issuers')")); - expected_fences.insert("community_nip_fi_issuers".to_owned()); - assert_eq!( - expected_fences, schema.fence_attachments, - "write-fence attachment targets differ after recovery policy" - ); // 0029's ALTER TABLE additions are expressed inline by the // desired-state `communities` definition; require the columns to @@ -3744,164 +3754,70 @@ mod postgres_tests { search_expression.contains("ELSE NULL::tsvector"), "fresh installs must default non-allowlisted kinds to NULL: {search_expression}" ); + } - let active_a = uuid::Uuid::new_v4(); - let active_b = uuid::Uuid::new_v4(); - let to_fence = uuid::Uuid::new_v4(); - for (community, label) in [ - (active_a, "active-a"), - (active_b, "active-b"), - (to_fence, "to-fence"), - ] { - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(community) - .bind(format!("late-fence-{label}-{}.example", community.simple())) - .execute(&pool) - .await - .expect("insert late-table test community"); - } - sqlx::query( - "CREATE TABLE late_created_scoped (\ - community_id UUID NOT NULL, id BIGINT PRIMARY KEY, value TEXT NOT NULL\ - )", - ) - .execute(&pool) - .await - .expect("create late scoped table"); - sqlx::query("SELECT attach_community_write_fence('late_created_scoped'::regclass)") - .execute(&pool) - .await - .expect("attach late create fence"); - sqlx::query("CREATE TABLE late_altered_scoped (id BIGINT PRIMARY KEY)") - .execute(&pool) + #[tokio::test] + #[ignore = "requires Postgres"] + async fn migrated_catalog_retires_universal_fences_but_keeps_deletion_guards() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + let retirement = MIGRATOR + .iter() + .find(|m| m.description == "retire community write fence") + .expect("fence retirement migration is embedded") + .version; + MIGRATOR + .run_to(retirement - 1, &pool) .await - .expect("create table before late alter"); - sqlx::query("ALTER TABLE late_altered_scoped ADD COLUMN community_id UUID NOT NULL") - .execute(&pool) + .expect("apply pre-retirement migrations"); + let store = crate::deletion::DeletionStore::new(pool.clone()); + store + .validate_serving_catalog() .await - .expect("add late community id"); - sqlx::query("SELECT attach_community_write_fence('late_altered_scoped'::regclass)") - .execute(&pool) + .expect("new runtime must accept pre-retirement catalog"); + store + .validate_catalog() .await - .expect("attach late alter fence"); - let attached: Vec = sqlx::query_scalar( - "SELECT c.relname FROM pg_trigger trigger \ - JOIN pg_class c ON c.oid = trigger.tgrelid \ - JOIN pg_proc procedure ON procedure.oid = trigger.tgfoid \ - WHERE c.relname IN ('late_created_scoped', 'late_altered_scoped') \ - AND procedure.proname = 'enforce_community_write_fence' \ - AND NOT trigger.tgisinternal ORDER BY c.relname", - ) - .fetch_all(&pool) - .await - .expect("read late trigger catalog"); - assert_eq!(attached, vec!["late_altered_scoped", "late_created_scoped"]); - let malformed_fence_triggers: i64 = sqlx::query_scalar( - "SELECT count(*)::BIGINT FROM pg_trigger trigger \ - JOIN pg_class c ON c.oid = trigger.tgrelid \ - JOIN pg_proc procedure ON procedure.oid = trigger.tgfoid \ - WHERE c.relname IN ('late_created_scoped', 'late_altered_scoped') \ - AND procedure.proname = 'enforce_community_write_fence' \ - AND NOT trigger.tgisinternal \ - AND (trigger.tgenabled <> 'O' OR (trigger.tgtype & 31) <> 31)", + .expect("deletion must accept pre-retirement catalog"); + run_migrations(&pool).await.expect("apply all migrations"); + let fence_triggers: i64 = sqlx::query_scalar( + "SELECT count(*) FROM pg_trigger WHERE NOT tgisinternal AND tgname LIKE 'community_write_fence_%'", ) .fetch_one(&pool) .await - .expect("validate late trigger mode and operations"); - assert_eq!(malformed_fence_triggers, 0); - - sqlx::query( - "INSERT INTO late_created_scoped (community_id, id, value) \ - VALUES ($1, 1, 'same'), ($2, 2, 'source-fenced'), \ - ($1, 3, 'destination-fenced'), ($1, 4, 'opposite-a'), \ - ($3, 5, 'opposite-b')", - ) - .bind(active_a) - .bind(to_fence) - .bind(active_b) - .execute(&pool) - .await - .expect("seed late table while communities active"); - sqlx::query("UPDATE late_created_scoped SET value = 'same-ok' WHERE id = 1") - .execute(&pool) - .await - .expect("same-tenant active update"); - sqlx::query("UPDATE late_created_scoped SET community_id = $1 WHERE id = 1") - .bind(active_b) - .execute(&pool) - .await - .expect("active-to-active update"); - - let mut fence_connection = pool.acquire().await.expect("fence connection"); - sqlx::query("BEGIN") - .execute(&mut *fence_connection) - .await - .expect("begin direct fence"); - sqlx::query( - "SELECT set_config('buzz.deletion_executor_community', $1, true), \ - set_config('buzz.deletion_fence_generation', '1', true)", - ) - .bind(to_fence.to_string()) - .execute(&mut *fence_connection) - .await - .expect("authorize direct fence"); - sqlx::query( - "UPDATE communities SET deletion_state = 'fenced', \ - deletion_fence_generation = 1, archived_at = now() WHERE id = $1", - ) - .bind(to_fence) - .execute(&mut *fence_connection) - .await - .expect("fence test destination"); - sqlx::query("COMMIT") - .execute(&mut *fence_connection) - .await - .expect("commit direct fence"); - - let active_to_fenced = - sqlx::query("UPDATE late_created_scoped SET community_id = $1 WHERE id = 3") - .bind(to_fence) - .execute(&pool) - .await - .expect_err("active to fenced destination must fail"); - assert!(active_to_fenced - .to_string() - .contains("community write fenced")); - let fenced_to_active = - sqlx::query("UPDATE late_created_scoped SET community_id = $1 WHERE id = 2") - .bind(active_a) - .execute(&pool) + .expect("count retired triggers"); + assert_eq!(fence_triggers, 0); + let fence_functions: i64 = + sqlx::query_scalar("SELECT count(*) FROM pg_proc WHERE proname = ANY($1)") + .bind( + &[ + "attach_community_write_fence", + "enforce_community_write_fence", + "assert_community_write_allowed", + "community_write_allowed", + "community_write_fence_excluded_table", + ][..], + ) + .fetch_one(&pool) .await - .expect_err("fenced source to active destination must fail"); - assert!(fenced_to_active - .to_string() - .contains("community write fenced")); - let row_locations: Vec<(i64, uuid::Uuid)> = sqlx::query_as( - "SELECT id, community_id FROM late_created_scoped WHERE id IN (2, 3) ORDER BY id", + .expect("count retired functions"); + assert_eq!(fence_functions, 0); + let retained: (bool, bool) = sqlx::query_as( + "SELECT to_regprocedure('community_deletion_lock_key(uuid)') IS NOT NULL, \ + EXISTS(SELECT 1 FROM pg_trigger WHERE tgname = 'communities_deletion_tombstone')", ) - .fetch_all(&pool) - .await - .expect("failed moves preserve row location"); - assert_eq!(row_locations, vec![(2, to_fence), (3, active_a)]); - - let move_a = sqlx::query("UPDATE late_created_scoped SET community_id = $1 WHERE id = 4") - .bind(active_b) - .execute(&pool); - let move_b = sqlx::query("UPDATE late_created_scoped SET community_id = $1 WHERE id = 5") - .bind(active_a) - .execute(&pool); - tokio::time::timeout(std::time::Duration::from_secs(2), async { - let (a, b) = tokio::join!(move_a, move_b); - a.expect("opposite active move A"); - b.expect("opposite active move B"); - }) + .fetch_one(&pool) .await - .expect("opposite cross-tenant updates must not deadlock"); - - sqlx::query("DROP TABLE late_created_scoped, late_altered_scoped") - .execute(&pool) + .expect("read retained guards"); + assert_eq!(retained, (true, true)); + store + .validate_serving_catalog() + .await + .expect("new code must boot against migrated catalog"); + store + .validate_catalog() .await - .expect("drop late-table fixtures"); + .expect("deletion catalog must accept post-retirement catalog"); } /// Verify migration 0044 applies cleanly against a DB that has rows in diff --git a/crates/buzz-db/src/runtime/tests/schema_fence_bootstrap_postgres_tests.rs b/crates/buzz-db/src/runtime/tests/schema_fence_bootstrap_postgres_tests.rs deleted file mode 100644 index 67eb9a9a990..00000000000 --- a/crates/buzz-db/src/runtime/tests/schema_fence_bootstrap_postgres_tests.rs +++ /dev/null @@ -1,39 +0,0 @@ -use sqlx::PgPool; - -/// A fresh bootstrap keeps the declared backstops but never discovers new -/// community tables and silently attaches a trigger to them. -#[tokio::test] -#[ignore = "requires Postgres"] -async fn migration_schema_bootstrap_keeps_only_explicit_legacy_fences() { - let pool = PgPool::connect(&crate::test_support::database_url()) - .await - .unwrap(); - sqlx::raw_sql("CREATE TABLE new_tenant_probe (community_id UUID NOT NULL)") - .execute(&pool) - .await - .unwrap(); - // Only checked-in schema files contribute SQL to this test bootstrap. - sqlx::raw_sql(sqlx::AssertSqlSafe( - crate::test_support::desired_state_schema_sql(), - )) - .execute(&pool) - .await - .unwrap(); - - for (table, expected) in [ - ("new_tenant_probe", false), - ("events", true), - ("users", true), - ] { - let fenced: bool = sqlx::query_scalar( - "SELECT EXISTS (SELECT 1 FROM pg_trigger \ - WHERE tgrelid = $1::regclass \ - AND tgfoid = 'enforce_community_write_fence()'::regprocedure)", - ) - .bind(table) - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(fenced, expected, "unexpected fence on {table}"); - } -} diff --git a/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs b/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs index 93e61bde7a6..ac31f6043f0 100644 --- a/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs +++ b/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs @@ -237,7 +237,7 @@ async fn migration_schema_thread_window_prebuild_does_not_queue_behind_writer() .unwrap(); // Model an ingestion transaction already holding the table's writer lock. - // It stays held through every control: releasing it would hide the convoy. + // It stays held through the 0049 index controls that this test exercises. let mut writer = pool.begin().await.unwrap(); sqlx::query("LOCK TABLE thread_metadata IN ROW EXCLUSIVE MODE") .execute(&mut *writer) @@ -283,8 +283,10 @@ async fn migration_schema_thread_window_prebuild_does_not_queue_behind_writer() .await .unwrap(); - // Also bind the public migrator: the advisory schema/destruction lock, - // ledger write and post-migration catalog checks must work with ingestion. + // Later migrations include table DDL such as the universal-fence trigger + // retirement, which correctly waits for writers. Release this writer after + // proving 0049 does not queue, then validate the full public migrator. + writer.rollback().await.unwrap(); let production_result = migration::run_migrations(&pool).await; sqlx::query(insert) .bind(community) @@ -309,7 +311,6 @@ async fn migration_schema_thread_window_prebuild_does_not_queue_behind_writer() .await .unwrap(); drop(witness); - writer.rollback().await.unwrap(); assert!( result.is_ok(), diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs index 3a3d4e6e4fd..c38d86a2ddc 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -122,9 +122,7 @@ pub const EXPECTED_SCOPED_TABLES: &[&str] = &[ const EXCLUDED_SCOPED_TABLES: &[&str] = &[ "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", @@ -395,7 +393,7 @@ pub struct SchemaManifest { /// Per-table row counts for the target. pub row_counts: BTreeMap, /// Legacy fence snapshot retained for existing frozen inventory digests. - /// Populated from the live catalog while mixed-version executors need it. + /// New inventories leave it empty after trigger retirement. #[serde(default)] pub fenced_tables: Vec, } @@ -1280,7 +1278,6 @@ impl DeletionStore { pub async fn inventory_schema(&self, community: CommunityId) -> Result { self.validate_catalog().await?; let live_tables = self.live_scoped_tables().await?; - let fenced_tables = self.live_fenced_tables().await?; let mut row_counts = BTreeMap::new(); for table in &live_tables { let sql = format!("SELECT count(*)::BIGINT FROM {table} WHERE community_id = $1"); @@ -1296,7 +1293,7 @@ impl DeletionStore { Ok(SchemaManifest { scoped_tables: live_tables.into_iter().collect(), row_counts, - fenced_tables: fenced_tables.into_iter().collect(), + fenced_tables: Vec::new(), }) } @@ -3357,11 +3354,6 @@ impl DeletionStore { let mut conn = self.pool.acquire().await?; live_scoped_tables_on(&mut conn).await } - - async fn live_fenced_tables(&self) -> Result> { - let mut conn = self.pool.acquire().await?; - live_fenced_tables_on(&mut conn).await - } } async fn lock_community_deletion( @@ -3511,32 +3503,6 @@ async fn live_scoped_tables_on(conn: &mut PgConnection) -> Result Result> { - let rows: Vec = sqlx::query_scalar( - r#" - SELECT c.relname - FROM pg_trigger trigger - JOIN pg_class c ON c.oid = trigger.tgrelid - JOIN pg_namespace n ON n.oid = c.relnamespace - JOIN pg_proc procedure ON procedure.oid = trigger.tgfoid - WHERE n.nspname = 'public' - AND NOT trigger.tgisinternal - AND NOT c.relispartition - AND procedure.proname = 'enforce_community_write_fence' - AND trigger.tgenabled = 'O' - AND (trigger.tgtype & 1) = 1 - AND (trigger.tgtype & 2) = 2 - AND (trigger.tgtype & 4) = 4 - AND (trigger.tgtype & 8) = 8 - AND (trigger.tgtype & 16) = 16 - ORDER BY c.relname - "#, - ) - .fetch_all(conn) - .await?; - Ok(rows.into_iter().collect()) -} - /// Fail closed when a community-prefix inventory has an unsafe shape. pub fn validate_storage_manifest(manifest: &StorageManifest) -> Result<()> { if !matches!(manifest.version, 4 | 5) { @@ -3982,24 +3948,6 @@ fn bound_text(input: &str, max: usize) -> String { mod tests { use super::*; - #[test] - fn scoped_table_exclusions_match_sql_during_transition() { - let sql = - include_str!("../../../../schema/functions/community_write_fence_excluded_table.sql"); - let sql_names = sql - .split("ARRAY[") - .nth(1) - .expect("SQL exclusion array") - .split(']') - .next() - .expect("SQL exclusion names") - .split(',') - .map(|name| name.trim().trim_matches('\'')) - .collect::>(); - assert_eq!(sql_names.len(), 8); - assert_eq!(sql_names, EXCLUDED_SCOPED_TABLES.iter().copied().collect()); - } - fn empty_prefix(prefix: &str) -> PrefixManifest { PrefixManifest { prefix: prefix.to_string(), @@ -4580,6 +4528,94 @@ mod postgres_tests { (db, store) } + /// Replaces the SQL-parity check retired with the fence functions. + /// + /// A name in `EXCLUDED_SCOPED_TABLES` hides that table from deletion + /// catalog drift detection, so the list is pinned against control-plane + /// tables written out here, not derived from either tenant manifest, and + /// each must still exist with community provenance. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn excluded_scoped_tables_are_live_control_plane_tables() { + const COMMUNITY_SCOPED_CONTROL_PLANE_TABLES: [&str; 6] = [ + "community_deletion_approvals", + "community_deletion_requests", + "community_serving_write_leases", + "operator_listener_outbox", + "product_feedback", + "rate_limit_violations", + ]; + let control_plane = BTreeSet::from(COMMUNITY_SCOPED_CONTROL_PLANE_TABLES); + let excluded = EXCLUDED_SCOPED_TABLES + .iter() + .copied() + .collect::>(); + assert_eq!(excluded, control_plane); + for table in EXPECTED_SCOPED_TABLES.iter().chain(PURGE_SCOPED_TABLES) { + assert!( + !control_plane.contains(table), + "tenant table {table} must not be excluded" + ); + } + + let (db, _) = store().await; + let live: BTreeSet = sqlx::query_scalar( + "SELECT c.relname 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 = 'public' AND c.relkind IN ('r', 'p') \ + AND NOT c.relispartition AND a.attname = 'community_id' \ + AND NOT a.attisdropped", + ) + .fetch_all(&db.pool) + .await + .expect("community provenance tables") + .into_iter() + .collect(); + for table in COMMUNITY_SCOPED_CONTROL_PLANE_TABLES { + assert!(live.contains(table), "stale exclusion {table}"); + } + let accounted = EXPECTED_SCOPED_TABLES + .iter() + .chain(COMMUNITY_SCOPED_CONTROL_PLANE_TABLES.iter()) + .map(|table| (*table).to_owned()) + .collect::>(); + assert_eq!(live, accounted); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn desired_state_catalog_has_no_universal_fence_and_still_boots() { + let (db, store) = store().await; + store + .validate_serving_catalog() + .await + .expect("serving startup catalog"); + store.validate_catalog().await.expect("deletion catalog"); + let retired_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM pg_trigger WHERE NOT tgisinternal AND tgname LIKE 'community_write_fence_%'", + ) + .fetch_one(&db.pool) + .await + .expect("count retired triggers"); + assert_eq!(retired_count, 0); + let retired_function: bool = sqlx::query_scalar( + "SELECT to_regprocedure('community_write_allowed(uuid)') IS NOT NULL", + ) + .fetch_one(&db.pool) + .await + .expect("lookup retired function"); + assert!(!retired_function); + let retained: (bool, bool) = sqlx::query_as( + "SELECT to_regprocedure('community_deletion_lock_key(uuid)') IS NOT NULL, \ + EXISTS(SELECT 1 FROM pg_trigger WHERE tgname = 'communities_deletion_tombstone')", + ) + .fetch_one(&db.pool) + .await + .expect("lookup retained guards"); + assert_eq!(retained, (true, true)); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn migration_schema_fence_objects_are_optional_for_serving_and_deletion() { @@ -4591,16 +4627,12 @@ mod postgres_tests { }) .await .expect("connect isolated migration DB"); - crate::migration::run_migrations_through(&db.pool, 66) + // Stop just before the retirement migration so every current table + // exists and the universal fence objects are still present. + crate::migration::run_migrations_through(&db.pool, 86) .await .expect("migrate with fence objects present"); let store = db.deletion_store(); - let legacy_fenced_tables = store.live_fenced_tables().await.expect("legacy fences"); - // Include current tables without requiring new application-admitted - // tables to acquire legacy database fence triggers. - db.migrate() - .await - .expect("migrate current deletion catalog"); let community = db .ensure_configured_community("optional-fences.example") .await @@ -4668,14 +4700,7 @@ DROP FUNCTION community_write_fence_excluded_table(NAME); store.validate_catalog().await.expect("deletion catalog"); let inventory = store.inventory_schema(community).await.expect("inventory"); assert_eq!(inventory.scoped_tables.len(), EXPECTED_SCOPED_TABLES.len()); - assert_eq!( - inventory.fenced_tables.into_iter().collect::>(), - if objects_present { - legacy_fenced_tables.clone() - } else { - BTreeSet::new() - } - ); + assert!(inventory.fenced_tables.is_empty()); let lease = store .acquire_serving_write_lease( community, @@ -7424,56 +7449,6 @@ DROP FUNCTION community_write_fence_excluded_table(NAME); .expect("quiesce after writer release"); } - #[tokio::test] - #[ignore = "requires Postgres"] - async fn fence_waits_for_open_write_and_rejects_it_after_transition() { - let (db, store) = store().await; - let (request, _) = inventoried_request(&db, &store).await; - store - .approve(request.id, "approver", None) - .await - .expect("approve"); - let claim = store - .claim_specific(request.id, "executor", DEFAULT_LEASE_DURATION) - .await - .expect("claim") - .expect("won claim"); - - // A raw writer transaction on purpose: this pins that the fenced-table - // trigger itself takes the shared deletion lock, not the chokepoint. - let mut open_write = db.pool.begin().await.expect("open write transaction"); - sqlx::query("INSERT INTO pubkey_allowlist (community_id, pubkey) VALUES ($1, $2)") - .bind(request.community_id.as_uuid()) - .bind(vec![7_u8; 32]) - .execute(&mut *open_write) - .await - .expect("write acquires shared deletion lock"); - - let store_for_fence = store.clone(); - let lease = claim.lease.clone(); - let fencing = tokio::spawn(async move { - store_for_fence.begin_quiescing(&lease).await?; - store_for_fence.fence(&lease).await - }); - tokio::time::sleep(Duration::from_millis(50)).await; - assert!( - !fencing.is_finished(), - "exclusive fence must wait for open writer" - ); - open_write - .commit() - .await - .expect("pre-fence writer commits first"); - fencing.await.expect("fence task").expect("fence completes"); - - assert!( - db.add_to_allowlist(request.community_id, &[8_u8; 32], &[9_u8; 32], None) - .await - .is_err(), - "post-fence serving write must fail" - ); - } - #[tokio::test] #[ignore = "requires Postgres"] async fn deletion_lock_admission_rejects_pinned_snapshot_isolation() { @@ -8472,12 +8447,10 @@ DROP FUNCTION community_write_fence_excluded_table(NAME); let probe_table = format!("deletion_probe_{}", Uuid::new_v4().simple()); let create_probe = format!("CREATE TABLE {probe_table} (community_id UUID NOT NULL, payload TEXT)"); - let attach_probe = - format!("SELECT attach_community_write_fence('{probe_table}'::regclass)"); let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>(); let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>(); let migration_pool = db.pool.clone(); - let (create_probe_sql, attach_probe_sql) = (create_probe.clone(), attach_probe.clone()); + let create_probe_sql = create_probe.clone(); let migration_run = tokio::spawn(async move { crate::migration::with_exclusive_schema_destruction_lock( &migration_pool, @@ -8491,9 +8464,6 @@ DROP FUNCTION community_write_fence_excluded_table(NAME); sqlx::query(AssertSqlSafe(create_probe_sql)) .execute(&mut *ddl) .await?; - sqlx::query(AssertSqlSafe(attach_probe_sql)) - .execute(&mut *ddl) - .await?; ddl.commit().await?; Ok(()) } @@ -8511,7 +8481,7 @@ DROP FUNCTION community_write_fence_excluded_table(NAME); "purge must wait for the in-flight migration instead of validating a stale surface" ); - // The migration commits its new fenced scoped table, then finishes. + // The migration commits its new scoped table, then finishes. release_tx.send(()).expect("unpark migration"); migration_run .await @@ -8551,10 +8521,6 @@ DROP FUNCTION community_write_fence_excluded_table(NAME); .execute(&db.pool) .await .expect("recreate probe scoped table"); - sqlx::query(AssertSqlSafe(attach_probe)) - .execute(&db.pool) - .await - .expect("attach probe fence again"); let verify_denied = store.verify_postgres_logically_deleted(&token).await; let verify_denied_on_probe = matches!( &verify_denied, diff --git a/crates/buzz-db/src/store/push.rs b/crates/buzz-db/src/store/push.rs index 44f693c4116..78a1ab37407 100644 --- a/crates/buzz-db/src/store/push.rs +++ b/crates/buzz-db/src/store/push.rs @@ -2669,10 +2669,6 @@ mod postgres_tests { #[ignore = "requires Postgres"] async fn matcher_claim_skips_quiescing_tenant_while_active_bystanders_progress() { let pool = setup_pool().await; - sqlx::query("DELETE FROM push_match_queue") - .execute(&pool) - .await - .expect("drain active matcher queue"); let active_a = make_community(&pool).await; let target = make_community(&pool).await; let active_x = make_community(&pool).await; @@ -2699,10 +2695,6 @@ mod postgres_tests { #[ignore = "requires Postgres"] async fn exhausted_match_reaper_skips_quiescing_tenant_and_reaps_active_bystanders() { let pool = setup_pool().await; - sqlx::query("DELETE FROM push_match_queue") - .execute(&pool) - .await - .expect("drain active matcher queue"); let active_a = make_community(&pool).await; let target = make_community(&pool).await; let active_x = make_community(&pool).await; diff --git a/crates/buzz-db/src/store/relay_invite.rs b/crates/buzz-db/src/store/relay_invite.rs index 327beb32ab3..390ecb15c25 100644 --- a/crates/buzz-db/src/store/relay_invite.rs +++ b/crates/buzz-db/src/store/relay_invite.rs @@ -185,9 +185,9 @@ async fn mint( let expires_at = now + chrono::Duration::seconds(ttl_secs as i64); // Mint a v2 opaque invite inside the same lifecycle gate as every other - // community-scoped database write. The trigger remains the final backstop, - // but this typed guard keeps a quiescing community from surfacing as an - // opaque SQLSTATE/HTTP 500 at the API boundary. + // community-scoped database write. The app-side guard is the community + // fence: it holds the shared deletion lock and refuses quiescing writes + // before the insert. let mut tx = crate::begin_community_write_transaction( pool, community, diff --git a/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs b/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs index b9cb3bce3e5..aba20605793 100644 --- a/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs +++ b/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs @@ -418,10 +418,12 @@ async fn assert_admission_column(pool: &PgPool) { } async fn apply_pgschema_to_existing_database(pool: &PgPool) { - // The pinned migration history has retired triggers on the partitioned - // events parent and auto-named deletion-request checks that the desired - // schema replaces. pgschema orders child-trigger drops before their parent - // and schedules a replacement check before dropping its same-named copy. + // The pinned migration history has retired triggers on partitioned + // parents (events, delivery_log), including the floor trigger 0087 + // drops, and auto-named deletion-request checks that the desired schema + // replaces. pgschema orders child-trigger drops + // before their parent (Postgres refuses with 2BP01) and schedules a + // replacement check before dropping its same-named copy. // Remove only those stale objects in this disposable fixture; pgschema // still applies desired state; historical repair is a separate reviewed // operation after deployment. @@ -430,6 +432,9 @@ async fn apply_pgschema_to_existing_database(pool: &PgPool) { DROP TRIGGER IF EXISTS trg_events_nip_rs_watermark ON events; \ DROP TRIGGER IF EXISTS trg_events_purge_soft_deleted_nip_rs ON events; \ DROP TRIGGER IF EXISTS trg_events_purge_soft_deleted_buzz_mesh_status ON events; \ + DROP TRIGGER IF EXISTS events_created_at_floor ON events; \ + DROP TRIGGER IF EXISTS community_write_fence_events ON events; \ + DROP TRIGGER IF EXISTS community_write_fence_delivery_log ON delivery_log; \ ALTER TABLE community_deletion_requests \ DROP CONSTRAINT IF EXISTS community_deletion_requests_check1; \ ALTER TABLE community_deletion_requests \ diff --git a/crates/buzz-db/src/test_support.rs b/crates/buzz-db/src/test_support.rs index 6fa07632bcb..42afa1786a4 100644 --- a/crates/buzz-db/src/test_support.rs +++ b/crates/buzz-db/src/test_support.rs @@ -184,8 +184,9 @@ fn drop_cluster_object(admin_url: &str, object: &str, statement: String) { eprintln!("warning: failed to drop {object}: {error}"); } -/// One-session fixture that bypasses ordinary write-fence triggers while -/// retaining application admission. Communities are active, quiescing, fenced. +/// One-session fixture with ordinary triggers disabled, so application +/// admission is the only community write fence it exercises (as it is in +/// production after migration 0088). Communities are active, quiescing, fenced. pub(crate) async fn admission_fixture() -> (crate::Db, [buzz_core::CommunityId; 3]) { use buzz_core::CommunityId; use uuid::Uuid; @@ -222,13 +223,6 @@ pub(crate) async fn admission_fixture() -> (crate::Db, [buzz_core::CommunityId; .await .expect("read replication role"); assert_eq!(role, "replica"); - // A raw write to the fenced tenant proves the DB backstop is bypassed. - sqlx::query("INSERT INTO users (community_id, pubkey) VALUES ($1, $2)") - .bind(communities[2].as_uuid()) - .bind(vec![0xfe_u8; 32]) - .execute(pool) - .await - .expect("raw fenced insert succeeds with triggers disabled"); (db, communities) } @@ -446,7 +440,7 @@ pub(crate) async fn quiesce_community_for_tests( } /// Whether `error` is the community-admission rejection taken at transaction -/// entry, as opposed to a later commit-time trigger rejection. +/// entry, as opposed to a later database constraint rejection. pub(crate) fn is_admission_rejection(error: &crate::DbError) -> bool { matches!(error, crate::DbError::AccessDenied(message) if message.contains("write-fenced (quiescing)")) } diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 35ae3cab4ae..e95bd562642 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -2133,8 +2133,8 @@ async fn finalize_push_inner( } // An already-running receive-pack may cross the durable fence after - // request admission. Revalidate immediately before object-store CAS; DB - // trigger fencing alone cannot roll back an S3 pointer mutation. + // request admission. Revalidate immediately before object-store CAS; + // database admission alone cannot roll back an S3 pointer mutation. let serving_write = match buzz_deletion::acquire_serving_write( &state.db, ctx.tenant.community(), diff --git a/docs/buzz-v1-read-state.md b/docs/buzz-v1-read-state.md index f89e2e07740..07890364cd8 100644 --- a/docs/buzz-v1-read-state.md +++ b/docs/buzz-v1-read-state.md @@ -259,8 +259,9 @@ no fixed context-count ceiling. Leaving/rejoining does not erase progress; revoked access hides it. Soft-deleted channels are inaccessible, while hard channel deletion cascades their frontiers. Deleting an account row cascades that actor's frontiers in the same community; -community erasure inventories both tables under the existing write fence. There -is no new public account export/reset endpoint. Operator-assisted erasure/export +community erasure inventories both tables, with serving writes fenced by the +app-side admission guard and the community deletion-lock protocol. There is no +new public account export/reset endpoint. Operator-assisted erasure/export must use the established authenticated operational process and explicitly scope both community and actor; never equate the read-time horizon with data erasure. diff --git a/docs/database.md b/docs/database.md index b96c0c6876a..25095b46859 100644 --- a/docs/database.md +++ b/docs/database.md @@ -76,9 +76,9 @@ transaction that owns the write: Dropping or narrowing an existing trigger or function is welcome. Follow the retirement sequence below. -**No exception for the community write fence.** A new table MUST NOT get a -fence trigger, including by calling the existing helper -`attach_community_write_fence`. Writers of a new tenant-owned table MUST be +**No exception for the community write fence.** Migration 0088 dropped the +universal fence triggers and their helper functions; a new table MUST NOT bring +a fence trigger back. Writers of a new tenant-owned table MUST be fenced in the application instead, by community admission. New serving work, including fleet sweeps (one community at a time), is admitted by `DeletionStore::guard_transaction`, which takes the shared community deletion @@ -93,32 +93,22 @@ a write that finishes under an already-admitted serving lease complete while the community is `quiescing`. Keep it. Helpers that take `&mut AdmittedTx` reject a raw transaction at compile time, -but crate code can still bypass the type, so the fence triggers remain the -database backstop until they are dropped (ARCHITECTURE.md § *Supported Writer -Contract*). A table without a trigger has no database backstop. Every serving -writer of a fenced table already goes through admission. The remaining -fence-retirement work drops the fence triggers and functions, and MUST also -update ARCHITECTURE.md § *Dual enforcement*. +but crate code can still bypass the type, and no table has a database fence +backstop (ARCHITECTURE.md § *Supported Writer Contract*). Every serving writer +of a tenant table goes through admission. **New tenant-owned tables use application admission.** Startup and deletion validation no longer require a fence on any table; they pass with every universal fence object dropped (`migration_schema_fence_objects_are_optional_for_serving_and_deletion` -in `crates/buzz-db/src/store/deletion.rs`). Two things still matter: - -- Deletion's catalog check is an exact set. A `community_id` table missing from - `EXPECTED_SCOPED_TABLES`, fenced or not, fails it - (`community deletion catalog drift (missing=…, unknown=…)`), and deletion - fails closed for every community, not only the new table's. Listing the - table is enough to pass. -- Desired-state bootstraps install only the legacy fences explicitly listed - under `schema/triggers/`. They do not discover and fence new tables. - Keep those existing backstops until their own retirement is verified; - new tables receive no fence in either schema path. - -The blanket desired-state fence attachment has been removed. A feature may -add a tenant-owned `community_id` table when it follows the checklist below. -Do not add a trigger, or an entry in -`community_write_fence_excluded_table` for a table the community owns. +in `crates/buzz-db/src/store/deletion.rs`). Deletion's catalog check is an +exact set: a `community_id` table missing from `EXPECTED_SCOPED_TABLES` fails it +(`community deletion catalog drift (missing=…, unknown=…)`), and deletion fails +closed for every community, not only the new table's. Listing the table is +enough to pass. + +A feature may add a tenant-owned `community_id` table when it follows the +checklist below. Do not add a trigger, or an entry in `EXCLUDED_SCOPED_TABLES` +(`crates/buzz-db/src/store/deletion.rs`) for a table the community owns. #### Checklist: adding a tenant-owned `community_id` table @@ -127,8 +117,7 @@ All in the same PR: - [ ] Every writer of the table goes through community admission (`begin_community_write_transaction` or another constructor that returns `AdmittedTx`). Nothing writes the table outside it. -- [ ] No fence trigger: no `attach_community_write_fence` call in the - migration and no `schema/triggers/` file. +- [ ] No fence trigger in the migration or under `schema/triggers/`. - [ ] The table in `EXPECTED_SCOPED_TABLES` (`crates/buzz-db/src/store/deletion.rs`). - [ ] The table in `PURGE_SCOPED_TABLES` in the same file, ordered child before parent. A table missing here keeps its rows after the purge, and @@ -148,11 +137,10 @@ whose rows must stay writable while that community is being deleted, is not owned by that community and is not fenced. The existing ones (the deletion control plane, `product_feedback`, `rate_limit_violations`, and `operator_listener_outbox`) predate this rule and are listed in -`community_write_fence_excluded_table`. A new one MUST name its column -something other than `community_id`, as -`relay_admin_actions.report_community_id` does, so the catalog does not treat -the table as scoped. Adding a case to the exclusion function would widen an -existing function, which Rule 1 forbids. Renaming the +`EXCLUDED_SCOPED_TABLES`. A new one MUST name its column something other than +`community_id`, as `relay_admin_actions.report_community_id` does, so the +catalog does not treat the table as scoped. Do not grow the exclusion list for +a new table. Renaming the column also removes the table from every automatic deletion surface, so rows that name a deleted community survive deletion and no check fails, unless the PR follows this checklist. All in the same PR: diff --git a/migrations/0088_retire_community_write_fence.sql b/migrations/0088_retire_community_write_fence.sql new file mode 100644 index 00000000000..43a0adf0ee3 --- /dev/null +++ b/migrations/0088_retire_community_write_fence.sql @@ -0,0 +1,44 @@ +SET LOCAL lock_timeout = '5s'; + +-- The fleet has moved to app-side admission for all supported serving writers. +-- Drop only the universal community write-fence triggers. The community +-- tombstone and all other table guards remain in place. +DROP TRIGGER IF EXISTS community_write_fence_api_tokens ON api_tokens; +DROP TRIGGER IF EXISTS community_write_fence_archived_identities ON archived_identities; +DROP TRIGGER IF EXISTS community_write_fence_artifact_heads ON artifact_heads; +DROP TRIGGER IF EXISTS community_write_fence_artifact_revisions ON artifact_revisions; +DROP TRIGGER IF EXISTS community_write_fence_audit_log ON audit_log; +DROP TRIGGER IF EXISTS community_write_fence_channel_members ON channel_members; +DROP TRIGGER IF EXISTS community_write_fence_channels ON channels; +DROP TRIGGER IF EXISTS community_write_fence_community_bans ON community_bans; +DROP TRIGGER IF EXISTS community_write_fence_community_nip_fi_issuers ON community_nip_fi_issuers; +DROP TRIGGER IF EXISTS community_write_fence_delivery_log ON delivery_log; +DROP TRIGGER IF EXISTS community_write_fence_event_mentions ON event_mentions; +DROP TRIGGER IF EXISTS community_write_fence_events ON events; +DROP TRIGGER IF EXISTS community_write_fence_git_repo_names ON git_repo_names; +DROP TRIGGER IF EXISTS community_write_fence_join_policy_acceptances ON join_policy_acceptances; +DROP TRIGGER IF EXISTS community_write_fence_moderation_actions ON moderation_actions; +DROP TRIGGER IF EXISTS community_write_fence_moderation_reports ON moderation_reports; +DROP TRIGGER IF EXISTS community_write_fence_parameterized_event_watermarks ON parameterized_event_watermarks; +DROP TRIGGER IF EXISTS community_write_fence_personal_read_accounts ON personal_read_accounts; +DROP TRIGGER IF EXISTS community_write_fence_personal_read_frontiers ON personal_read_frontiers; +DROP TRIGGER IF EXISTS community_write_fence_pubkey_allowlist ON pubkey_allowlist; +DROP TRIGGER IF EXISTS community_write_fence_push_leases ON push_leases; +DROP TRIGGER IF EXISTS community_write_fence_push_match_queue ON push_match_queue; +DROP TRIGGER IF EXISTS community_write_fence_push_wake_outbox ON push_wake_outbox; +DROP TRIGGER IF EXISTS community_write_fence_reactions ON reactions; +DROP TRIGGER IF EXISTS community_write_fence_relay_invites ON relay_invites; +DROP TRIGGER IF EXISTS community_write_fence_relay_members ON relay_members; +DROP TRIGGER IF EXISTS community_write_fence_scheduled_workflow_fires ON scheduled_workflow_fires; +DROP TRIGGER IF EXISTS community_write_fence_subscriptions ON subscriptions; +DROP TRIGGER IF EXISTS community_write_fence_thread_metadata ON thread_metadata; +DROP TRIGGER IF EXISTS community_write_fence_users ON users; +DROP TRIGGER IF EXISTS community_write_fence_workflow_approvals ON workflow_approvals; +DROP TRIGGER IF EXISTS community_write_fence_workflow_runs ON workflow_runs; +DROP TRIGGER IF EXISTS community_write_fence_workflows ON workflows; + +DROP FUNCTION attach_community_write_fence(REGCLASS); +DROP FUNCTION enforce_community_write_fence(); +DROP FUNCTION assert_community_write_allowed(UUID); +DROP FUNCTION community_write_allowed(UUID); +DROP FUNCTION community_write_fence_excluded_table(NAME); diff --git a/schema/functions/assert_community_write_allowed.sql b/schema/functions/assert_community_write_allowed.sql deleted file mode 100644 index f68b45091cd..00000000000 --- a/schema/functions/assert_community_write_allowed.sql +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index 94d510f75c2..00000000000 --- a/schema/functions/attach_community_write_fence.sql +++ /dev/null @@ -1,45 +0,0 @@ --- Attach the legacy universal fence to one community-scoped relation. Only --- tables fenced before fence retirement use it; new tenant tables rely on --- application admission and must not call it (docs/database.md Rule 1). -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/community_write_allowed.sql b/schema/functions/community_write_allowed.sql deleted file mode 100644 index 2de857de92f..00000000000 --- a/schema/functions/community_write_allowed.sql +++ /dev/null @@ -1,24 +0,0 @@ --- 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 deleted file mode 100644 index 34735522f88..00000000000 --- a/schema/functions/community_write_fence_excluded_table.sql +++ /dev/null @@ -1,12 +0,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[]) -$$; diff --git a/schema/functions/enforce_community_write_fence.sql b/schema/functions/enforce_community_write_fence.sql deleted file mode 100644 index b7b5e8d6419..00000000000 --- a/schema/functions/enforce_community_write_fence.sql +++ /dev/null @@ -1,24 +0,0 @@ -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/schema.sql b/schema/schema.sql index 70ae2264304..2c8ddeb16dc 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -6,9 +6,8 @@ -- namespace, so nothing else may live under it. -- -- Everything SchemaBot cannot apply lives in this directory's other --- subdirectories: `types/`, `functions/`, `partitions/`, `triggers/` (including --- explicit legacy write-fence attachment), and `seeds/`. This file composes --- them into one fresh database, in load order: +-- subdirectories: `types/`, `functions/`, `partitions/`, `triggers/`, and +-- `seeds/`. This file composes them into one fresh database, in load order: -- types → functions → tables → partitions → triggers → seeds. -- -- `./bin/pgschema apply --file schema/schema.sql` resolves the `\i` includes @@ -69,12 +68,7 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto; \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 \i functions/profile_name_fields.sql -- tables @@ -152,37 +146,6 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto; \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/community_nip_fi_issuers.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 diff --git a/schema/triggers/api_tokens.sql b/schema/triggers/api_tokens.sql deleted file mode 100644 index 214339212a9..00000000000 --- a/schema/triggers/api_tokens.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('api_tokens'); diff --git a/schema/triggers/archived_identities.sql b/schema/triggers/archived_identities.sql deleted file mode 100644 index af7a0d46966..00000000000 --- a/schema/triggers/archived_identities.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('archived_identities'); diff --git a/schema/triggers/artifact_heads.sql b/schema/triggers/artifact_heads.sql deleted file mode 100644 index b5114f4fdec..00000000000 --- a/schema/triggers/artifact_heads.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('artifact_heads'); diff --git a/schema/triggers/artifact_revisions.sql b/schema/triggers/artifact_revisions.sql deleted file mode 100644 index efdb38afa25..00000000000 --- a/schema/triggers/artifact_revisions.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('artifact_revisions'); diff --git a/schema/triggers/audit_log.sql b/schema/triggers/audit_log.sql deleted file mode 100644 index 39ea4644f0c..00000000000 --- a/schema/triggers/audit_log.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('audit_log'); diff --git a/schema/triggers/channel_members.sql b/schema/triggers/channel_members.sql deleted file mode 100644 index 3dc104ace8c..00000000000 --- a/schema/triggers/channel_members.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('channel_members'); diff --git a/schema/triggers/channels.sql b/schema/triggers/channels.sql index 0dd5f73ba46..0a7130cdb33 100644 --- a/schema/triggers/channels.sql +++ b/schema/triggers/channels.sql @@ -1,4 +1,3 @@ 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/community_bans.sql b/schema/triggers/community_bans.sql deleted file mode 100644 index 063e3ab4565..00000000000 --- a/schema/triggers/community_bans.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('community_bans'); diff --git a/schema/triggers/community_nip_fi_issuers.sql b/schema/triggers/community_nip_fi_issuers.sql deleted file mode 100644 index 7634ca372ae..00000000000 --- a/schema/triggers/community_nip_fi_issuers.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('community_nip_fi_issuers'); diff --git a/schema/triggers/delivery_log.sql b/schema/triggers/delivery_log.sql deleted file mode 100644 index 7d0044a9cf8..00000000000 --- a/schema/triggers/delivery_log.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('delivery_log'); diff --git a/schema/triggers/event_mentions.sql b/schema/triggers/event_mentions.sql deleted file mode 100644 index b5b5767d0f1..00000000000 --- a/schema/triggers/event_mentions.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('event_mentions'); diff --git a/schema/triggers/events.sql b/schema/triggers/events.sql index 7de86addbd2..0e998c8b1e2 100644 --- a/schema/triggers/events.sql +++ b/schema/triggers/events.sql @@ -1,5 +1,3 @@ CREATE TRIGGER trg_events_guard_channel_roster_snapshot BEFORE INSERT ON events FOR EACH ROW EXECUTE FUNCTION guard_channel_roster_snapshot(); - -SELECT attach_community_write_fence('events'); diff --git a/schema/triggers/git_repo_names.sql b/schema/triggers/git_repo_names.sql deleted file mode 100644 index 3d3cdea862f..00000000000 --- a/schema/triggers/git_repo_names.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('git_repo_names'); diff --git a/schema/triggers/join_policy_acceptances.sql b/schema/triggers/join_policy_acceptances.sql deleted file mode 100644 index e3baa7b247f..00000000000 --- a/schema/triggers/join_policy_acceptances.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('join_policy_acceptances'); diff --git a/schema/triggers/moderation_actions.sql b/schema/triggers/moderation_actions.sql deleted file mode 100644 index a87bc0965ba..00000000000 --- a/schema/triggers/moderation_actions.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('moderation_actions'); diff --git a/schema/triggers/moderation_reports.sql b/schema/triggers/moderation_reports.sql deleted file mode 100644 index 3755e4bdc8a..00000000000 --- a/schema/triggers/moderation_reports.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('moderation_reports'); diff --git a/schema/triggers/parameterized_event_watermarks.sql b/schema/triggers/parameterized_event_watermarks.sql deleted file mode 100644 index 678759eff8b..00000000000 --- a/schema/triggers/parameterized_event_watermarks.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('parameterized_event_watermarks'); diff --git a/schema/triggers/personal_read_accounts.sql b/schema/triggers/personal_read_accounts.sql deleted file mode 100644 index f8eef31200e..00000000000 --- a/schema/triggers/personal_read_accounts.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('personal_read_accounts'); diff --git a/schema/triggers/personal_read_frontiers.sql b/schema/triggers/personal_read_frontiers.sql deleted file mode 100644 index 46551355026..00000000000 --- a/schema/triggers/personal_read_frontiers.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('personal_read_frontiers'); diff --git a/schema/triggers/pubkey_allowlist.sql b/schema/triggers/pubkey_allowlist.sql deleted file mode 100644 index 358256108c2..00000000000 --- a/schema/triggers/pubkey_allowlist.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('pubkey_allowlist'); diff --git a/schema/triggers/push_leases.sql b/schema/triggers/push_leases.sql deleted file mode 100644 index 307e25e6221..00000000000 --- a/schema/triggers/push_leases.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('push_leases'); diff --git a/schema/triggers/push_match_queue.sql b/schema/triggers/push_match_queue.sql deleted file mode 100644 index 57e3085077a..00000000000 --- a/schema/triggers/push_match_queue.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('push_match_queue'); diff --git a/schema/triggers/push_wake_outbox.sql b/schema/triggers/push_wake_outbox.sql deleted file mode 100644 index b544ce51361..00000000000 --- a/schema/triggers/push_wake_outbox.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('push_wake_outbox'); diff --git a/schema/triggers/reactions.sql b/schema/triggers/reactions.sql deleted file mode 100644 index 8d51e860bee..00000000000 --- a/schema/triggers/reactions.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('reactions'); diff --git a/schema/triggers/relay_invites.sql b/schema/triggers/relay_invites.sql deleted file mode 100644 index d6f658a54c7..00000000000 --- a/schema/triggers/relay_invites.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('relay_invites'); diff --git a/schema/triggers/relay_members.sql b/schema/triggers/relay_members.sql deleted file mode 100644 index 1a4018a69ca..00000000000 --- a/schema/triggers/relay_members.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('relay_members'); diff --git a/schema/triggers/scheduled_workflow_fires.sql b/schema/triggers/scheduled_workflow_fires.sql deleted file mode 100644 index 24ba3a936e3..00000000000 --- a/schema/triggers/scheduled_workflow_fires.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('scheduled_workflow_fires'); diff --git a/schema/triggers/subscriptions.sql b/schema/triggers/subscriptions.sql deleted file mode 100644 index a9d80ddfdc8..00000000000 --- a/schema/triggers/subscriptions.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('subscriptions'); diff --git a/schema/triggers/thread_metadata.sql b/schema/triggers/thread_metadata.sql deleted file mode 100644 index 68e53007dc6..00000000000 --- a/schema/triggers/thread_metadata.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('thread_metadata'); diff --git a/schema/triggers/users.sql b/schema/triggers/users.sql deleted file mode 100644 index 5f65f3c763c..00000000000 --- a/schema/triggers/users.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('users'); diff --git a/schema/triggers/workflow_approvals.sql b/schema/triggers/workflow_approvals.sql deleted file mode 100644 index 58b616f3cc6..00000000000 --- a/schema/triggers/workflow_approvals.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('workflow_approvals'); diff --git a/schema/triggers/workflow_runs.sql b/schema/triggers/workflow_runs.sql deleted file mode 100644 index 5c9f54ed869..00000000000 --- a/schema/triggers/workflow_runs.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('workflow_runs'); diff --git a/schema/triggers/workflows.sql b/schema/triggers/workflows.sql deleted file mode 100644 index 9a136a071e6..00000000000 --- a/schema/triggers/workflows.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT attach_community_write_fence('workflows'); diff --git a/scripts/cutover/README.md b/scripts/cutover/README.md index 92fd2e0ff8a..354870e1249 100644 --- a/scripts/cutover/README.md +++ b/scripts/cutover/README.md @@ -30,9 +30,9 @@ steady-state path unless explicitly reviewed. replica-floor shared/exclusive lock ordering). - If a workflow cannot use those protocols yet, execute it under an explicitly reviewed procedure that keeps replica routing fences closed for the run. -- Existing trigger/function backstops remain the commit-time authority during - this transition. Their removal requires separate coverage and fleet gates; - this cutover does not authorize it. +- Community writes use application admission and the deletion-lock protocol. + Retained trigger/function guards remain authoritative for their invariants; + this cutover does not authorize their removal. - Role separation is deliberate: schema reconciliation scripts carry schema convergence only; serving admission/fencing semantics remain in runtime/store code paths with startup verification and metrics. diff --git a/scripts/reconcile-schema-after-pgschema.sql b/scripts/reconcile-schema-after-pgschema.sql index ee6b0125460..04bd8e557a6 100644 --- a/scripts/reconcile-schema-after-pgschema.sql +++ b/scripts/reconcile-schema-after-pgschema.sql @@ -16,7 +16,6 @@ BEGIN -- pgschema may copy parent triggers onto standalone children. Drop -- those copies before ATTACH; PostgreSQL recreates inherited parent -- triggers while attaching and rejects same-named child triggers. - DROP TRIGGER IF EXISTS community_write_fence_events ON events_p_past; DROP TRIGGER IF EXISTS trg_events_guard_channel_roster_snapshot ON events_p_past; ALTER TABLE events ATTACH PARTITION events_p_past FOR VALUES FROM (MINVALUE) TO ('2026-01-01'); @@ -27,7 +26,6 @@ BEGIN WHERE inhparent = 'events'::regclass AND inhrelid = 'events_p2026_01'::regclass ) THEN - DROP TRIGGER IF EXISTS community_write_fence_events ON events_p2026_01; DROP TRIGGER IF EXISTS trg_events_guard_channel_roster_snapshot ON events_p2026_01; ALTER TABLE events ATTACH PARTITION events_p2026_01 FOR VALUES FROM ('2026-01-01') TO ('2026-02-01'); @@ -38,7 +36,6 @@ BEGIN WHERE inhparent = 'events'::regclass AND inhrelid = 'events_p2026_02'::regclass ) THEN - DROP TRIGGER IF EXISTS community_write_fence_events ON events_p2026_02; DROP TRIGGER IF EXISTS trg_events_guard_channel_roster_snapshot ON events_p2026_02; ALTER TABLE events ATTACH PARTITION events_p2026_02 FOR VALUES FROM ('2026-02-01') TO ('2026-03-01'); @@ -49,7 +46,6 @@ BEGIN WHERE inhparent = 'events'::regclass AND inhrelid = 'events_p2026_03'::regclass ) THEN - DROP TRIGGER IF EXISTS community_write_fence_events ON events_p2026_03; DROP TRIGGER IF EXISTS trg_events_guard_channel_roster_snapshot ON events_p2026_03; ALTER TABLE events ATTACH PARTITION events_p2026_03 FOR VALUES FROM ('2026-03-01') TO ('2026-04-01'); @@ -60,7 +56,6 @@ BEGIN WHERE inhparent = 'events'::regclass AND inhrelid = 'events_p2026_04'::regclass ) THEN - DROP TRIGGER IF EXISTS community_write_fence_events ON events_p2026_04; DROP TRIGGER IF EXISTS trg_events_guard_channel_roster_snapshot ON events_p2026_04; ALTER TABLE events ATTACH PARTITION events_p2026_04 FOR VALUES FROM ('2026-04-01') TO ('2026-05-01'); @@ -71,7 +66,6 @@ BEGIN WHERE inhparent = 'events'::regclass AND inhrelid = 'events_p2026_05'::regclass ) THEN - DROP TRIGGER IF EXISTS community_write_fence_events ON events_p2026_05; DROP TRIGGER IF EXISTS trg_events_guard_channel_roster_snapshot ON events_p2026_05; ALTER TABLE events ATTACH PARTITION events_p2026_05 FOR VALUES FROM ('2026-05-01') TO ('2026-06-01'); @@ -82,7 +76,6 @@ BEGIN WHERE inhparent = 'events'::regclass AND inhrelid = 'events_p2026_06'::regclass ) THEN - DROP TRIGGER IF EXISTS community_write_fence_events ON events_p2026_06; DROP TRIGGER IF EXISTS trg_events_guard_channel_roster_snapshot ON events_p2026_06; ALTER TABLE events ATTACH PARTITION events_p2026_06 FOR VALUES FROM ('2026-06-01') TO ('2026-07-01'); @@ -93,7 +86,6 @@ BEGIN WHERE inhparent = 'events'::regclass AND inhrelid = 'events_p_future'::regclass ) THEN - DROP TRIGGER IF EXISTS community_write_fence_events ON events_p_future; DROP TRIGGER IF EXISTS trg_events_guard_channel_roster_snapshot ON events_p_future; ALTER TABLE events ATTACH PARTITION events_p_future FOR VALUES FROM ('2026-07-01') TO (MAXVALUE); @@ -112,7 +104,6 @@ BEGIN AND inhrelid = 'delivery_log_p_past'::regclass ) THEN ALTER TABLE delivery_log_p_past ALTER COLUMN id DROP IDENTITY IF EXISTS; - DROP TRIGGER IF EXISTS community_write_fence_delivery_log ON delivery_log_p_past; ALTER TABLE delivery_log ATTACH PARTITION delivery_log_p_past FOR VALUES FROM (MINVALUE) TO ('2026-03-01'); END IF; @@ -123,7 +114,6 @@ BEGIN AND inhrelid = 'delivery_log_p2026_03'::regclass ) THEN ALTER TABLE delivery_log_p2026_03 ALTER COLUMN id DROP IDENTITY IF EXISTS; - DROP TRIGGER IF EXISTS community_write_fence_delivery_log ON delivery_log_p2026_03; ALTER TABLE delivery_log ATTACH PARTITION delivery_log_p2026_03 FOR VALUES FROM ('2026-03-01') TO ('2026-04-01'); END IF; @@ -134,7 +124,6 @@ BEGIN AND inhrelid = 'delivery_log_p2026_04'::regclass ) THEN ALTER TABLE delivery_log_p2026_04 ALTER COLUMN id DROP IDENTITY IF EXISTS; - DROP TRIGGER IF EXISTS community_write_fence_delivery_log ON delivery_log_p2026_04; ALTER TABLE delivery_log ATTACH PARTITION delivery_log_p2026_04 FOR VALUES FROM ('2026-04-01') TO ('2026-05-01'); END IF; @@ -145,7 +134,6 @@ BEGIN AND inhrelid = 'delivery_log_p2026_05'::regclass ) THEN ALTER TABLE delivery_log_p2026_05 ALTER COLUMN id DROP IDENTITY IF EXISTS; - DROP TRIGGER IF EXISTS community_write_fence_delivery_log ON delivery_log_p2026_05; ALTER TABLE delivery_log ATTACH PARTITION delivery_log_p2026_05 FOR VALUES FROM ('2026-05-01') TO ('2026-06-01'); END IF; @@ -156,7 +144,6 @@ BEGIN AND inhrelid = 'delivery_log_p2026_06'::regclass ) THEN ALTER TABLE delivery_log_p2026_06 ALTER COLUMN id DROP IDENTITY IF EXISTS; - DROP TRIGGER IF EXISTS community_write_fence_delivery_log ON delivery_log_p2026_06; ALTER TABLE delivery_log ATTACH PARTITION delivery_log_p2026_06 FOR VALUES FROM ('2026-06-01') TO ('2026-07-01'); END IF; @@ -167,7 +154,6 @@ BEGIN AND inhrelid = 'delivery_log_p_future'::regclass ) THEN ALTER TABLE delivery_log_p_future ALTER COLUMN id DROP IDENTITY IF EXISTS; - DROP TRIGGER IF EXISTS community_write_fence_delivery_log ON delivery_log_p_future; ALTER TABLE delivery_log ATTACH PARTITION delivery_log_p_future FOR VALUES FROM ('2026-07-01') TO (MAXVALUE); END IF;