diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d3beff0740e..74f1bf20014 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -489,21 +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. +### 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. - **Replica floor (transition foundation):** runtime now provides a shared replica-floor lock helper for compliant channel-event writers, and the writer probe handshake takes the exclusive counterpart before sampling `S`, scanning @@ -511,19 +507,14 @@ shape is an explicit transition, not a flag day: sole token/fence-wall publication path; there is no separately persisted active floor cutoff. Commit-time trigger+GUC enforcement remains authoritative for all paths. -- **Dual enforcement (current):** application-owned lock+precheck paths run in - front of the existing trigger/function enforcement; commit-time trigger checks - remain authoritative during this phase. 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. - **Unsupported shape:** direct owner SQL that bypasses supported transaction and lock protocols is not a supported write path. -- **Fleet gates and reconciliation:** startup/fence probes verify guard catalog + - behavior, pgschema bootstraps run reconciliation, and lock/pool metrics remain - the operational evidence path for rollout safety. +- **Fleet gates and reconciliation:** startup probes verify the remaining guard + catalog and behavior, pgschema bootstraps run reconciliation, and lock/pool + metrics remain the operational evidence path for rollout safety. **Does NOT:** cache queries, implement connection pooling logic (delegated to sqlx), or make network calls outside Postgres. diff --git a/crates/buzz-db/src/runtime/admitted_tx.rs b/crates/buzz-db/src/runtime/admitted_tx.rs index d1e266d3fe9..8862f4a1886 100644 --- a/crates/buzz-db/src/runtime/admitted_tx.rs +++ b/crates/buzz-db/src/runtime/admitted_tx.rs @@ -23,10 +23,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 74d6560e19b..2d4817c61ed 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -709,7 +709,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 64); + assert_eq!(migrations.len(), 65); assert_eq!(migrations[58].version, 59); assert!(migrations[58].sql.as_str().contains("buzz.push_enabled")); assert_eq!(migrations[59].version, 60); @@ -729,6 +729,7 @@ mod postgres_tests { .sql .as_str() .contains("CREATE FUNCTION profile_name_fields")); + assert_eq!(migrations[64].version, 66); assert_eq!(migrations[55].version, 56); assert!(migrations[55] .sql @@ -1181,6 +1182,32 @@ 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 == 66) + .expect("retirement migration 0066") + .sql + .as_str(); + assert!(retirement.starts_with("SET LOCAL lock_timeout = '5s';")); + for table in crate::deletion::EXPECTED_SCOPED_TABLES { + assert!( + retirement.contains(&format!( + "DROP TRIGGER IF EXISTS community_write_fence_{table} ON {table};" + )), + "0066 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'")); @@ -1423,13 +1450,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 @@ -1993,8 +2013,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 @@ -2013,7 +2032,6 @@ mod postgres_tests { triggers: BTreeMap, indexes: BTreeSet, registry_rows: BTreeSet<(String, String)>, - fence_attachments: BTreeSet, communities_added_columns: BTreeSet, } @@ -2082,14 +2100,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 } @@ -2118,7 +2128,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 { @@ -2171,13 +2180,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" @@ -2233,9 +2252,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() @@ -2289,12 +2305,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)); - 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 @@ -2990,164 +3000,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/thread_window_postgres_tests.rs b/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs index beda4b199fc..9353d21177a 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 @@ -224,7 +224,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) @@ -270,8 +270,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) @@ -296,7 +298,6 @@ async fn migration_schema_thread_window_prebuild_does_not_queue_behind_writer() .await .unwrap(); drop(witness); - writer.rollback().await.unwrap(); drop_scratch_db(&admin, pool, &name).await; assert!( diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs index 09235ca4a1b..422e7e38fde 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -116,9 +116,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", @@ -385,7 +383,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, } @@ -1228,7 +1226,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"); @@ -1249,7 +1246,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(), }) } @@ -3288,11 +3285,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( @@ -3442,32 +3434,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) { @@ -3895,24 +3861,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(), @@ -4382,6 +4330,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() { @@ -4463,14 +4499,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.len(), - if objects_present { - EXPECTED_SCOPED_TABLES.len() - } else { - 0 - } - ); + assert!(inventory.fenced_tables.is_empty()); let lease = store .acquire_serving_write_lease( community, @@ -7189,15 +7218,19 @@ DROP FUNCTION community_write_fence_excluded_table(NAME); .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"); + let mut open_write = crate::begin_community_write_transaction( + &db.pool, + request.community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await + .expect("admit 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) + .execute(open_write.conn()) .await - .expect("write acquires shared deletion lock"); + .expect("admitted write"); let store_for_fence = store.clone(); let lease = claim.lease.clone(); @@ -8020,12 +8053,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, @@ -8039,9 +8070,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(()) } @@ -8059,7 +8087,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 @@ -8099,10 +8127,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 88637ea4fe1..d4be50365d9 100644 --- a/crates/buzz-db/src/store/push.rs +++ b/crates/buzz-db/src/store/push.rs @@ -2660,10 +2660,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; @@ -2690,10 +2686,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 252f313f940..2d1816fe75b 100644 --- a/crates/buzz-db/src/store/relay_invite.rs +++ b/crates/buzz-db/src/store/relay_invite.rs @@ -114,9 +114,9 @@ pub async fn mint_relay_invite( 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/test_support.rs b/crates/buzz-db/src/test_support.rs index 4159e5d83f6..e0a46921eef 100644 --- a/crates/buzz-db/src/test_support.rs +++ b/crates/buzz-db/src/test_support.rs @@ -8,8 +8,9 @@ pub(crate) fn database_url() -> String { .unwrap_or_else(|_| DEFAULT_DATABASE_URL.to_owned()) } -/// 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 0066). Communities are active, quiescing, fenced. pub(crate) async fn admission_fixture() -> (crate::Db, [buzz_core::CommunityId; 3]) { use buzz_core::CommunityId; use uuid::Uuid; @@ -46,13 +47,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) } @@ -189,7 +183,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 c3b292d8b46..c84a88c1542 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -1971,8 +1971,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/migrations/0066_retire_community_write_fence.sql b/migrations/0066_retire_community_write_fence.sql new file mode 100644 index 00000000000..1d682042d74 --- /dev/null +++ b/migrations/0066_retire_community_write_fence.sql @@ -0,0 +1,43 @@ +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_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/community_write_fence.sql b/schema/community_write_fence.sql deleted file mode 100644 index 2ebc9fd1f98..00000000000 --- a/schema/community_write_fence.sql +++ /dev/null @@ -1,31 +0,0 @@ --- 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 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 2b004ff6842..00000000000 --- a/schema/functions/attach_community_write_fence.sql +++ /dev/null @@ -1,45 +0,0 @@ --- 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/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 b6d355cff97..42ebcfc9d38 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -6,10 +6,9 @@ -- 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. +-- 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 -- relative to this file (it refuses `..`). `pgschema` skips INSERTs and some @@ -71,12 +70,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 @@ -149,36 +143,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/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 @@ -206,6 +170,3 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto; \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/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/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 4091f4fdc60..5af93a5703b 100644 --- a/schema/triggers/events.sql +++ b/schema/triggers/events.sql @@ -17,4 +17,3 @@ CREATE CONSTRAINT TRIGGER events_created_at_floor 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 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 2f6d8f54022..cb5d4beb4b5 100644 --- a/scripts/reconcile-schema-after-pgschema.sql +++ b/scripts/reconcile-schema-after-pgschema.sql @@ -18,7 +18,6 @@ BEGIN -- triggers while attaching and rejects same-named child triggers. DROP TRIGGER IF EXISTS events_refresh_channel_ttl ON events_p_past; DROP TRIGGER IF EXISTS events_created_at_floor ON events_p_past; - 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'); @@ -31,7 +30,6 @@ BEGIN ) THEN DROP TRIGGER IF EXISTS events_refresh_channel_ttl ON events_p2026_01; DROP TRIGGER IF EXISTS events_created_at_floor ON events_p2026_01; - 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'); @@ -44,7 +42,6 @@ BEGIN ) THEN DROP TRIGGER IF EXISTS events_refresh_channel_ttl ON events_p2026_02; DROP TRIGGER IF EXISTS events_created_at_floor ON events_p2026_02; - 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'); @@ -57,7 +54,6 @@ BEGIN ) THEN DROP TRIGGER IF EXISTS events_refresh_channel_ttl ON events_p2026_03; DROP TRIGGER IF EXISTS events_created_at_floor ON events_p2026_03; - 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'); @@ -70,7 +66,6 @@ BEGIN ) THEN DROP TRIGGER IF EXISTS events_refresh_channel_ttl ON events_p2026_04; DROP TRIGGER IF EXISTS events_created_at_floor ON events_p2026_04; - 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'); @@ -83,7 +78,6 @@ BEGIN ) THEN DROP TRIGGER IF EXISTS events_refresh_channel_ttl ON events_p2026_05; DROP TRIGGER IF EXISTS events_created_at_floor ON events_p2026_05; - 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'); @@ -96,7 +90,6 @@ BEGIN ) THEN DROP TRIGGER IF EXISTS events_refresh_channel_ttl ON events_p2026_06; DROP TRIGGER IF EXISTS events_created_at_floor ON events_p2026_06; - 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'); @@ -109,7 +102,6 @@ BEGIN ) THEN DROP TRIGGER IF EXISTS events_refresh_channel_ttl ON events_p_future; DROP TRIGGER IF EXISTS events_created_at_floor ON events_p_future; - 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); @@ -128,7 +120,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; @@ -139,7 +130,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; @@ -150,7 +140,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; @@ -161,7 +150,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; @@ -172,7 +160,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; @@ -183,7 +170,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;