refactor(schema): stop auto-fencing new tables in desired-state bootstrap (#8620)

### Why?

This is a cleanup that brings the schema bootstrap in line with how the
codebase works today, as asked for by
[`docs/database.md`](dc25198d53/docs/database.md (L103-L124))
(#8273). Writes are now blocked during community deletion by application
admission (`begin_community_write_transaction`), and that doc's Rule 1
says new tables must not add triggers. But
`schema/community_write_fence.sql` still looped over every table with a
`community_id` column and attached the old fence trigger to it. That
meant a new table got a trigger on a fresh database and none on a
migrated one, so the two schema paths disagreed and new tenant tables
were blocked. The doc says this bootstrap change must land "before the
feature, or as the feature's first PR."

### What?

Deletes the blanket fence loop and its `\i` from `schema/schema.sql`.
Existing legacy tables keep their fences through their explicit
`schema/triggers/` files, so no current table loses its fence and
runtime behavior is unchanged. Adds a test that a new `community_id`
table stays unfenced while `events` and `users` stay fenced, and updates
`docs/database.md` and `ARCHITECTURE.md` to match. It stands on its own;
the media-sharing PRs (#8451, #8444) are the first to rely on it.

buzz-review-completed

---------

Signed-off-by: Nikolaj Goodger <ngoodger@squareup.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ngoodger-block
2026-10-28 09:26:41 +11:00
committed by GitHub
parent d4a07063c8
commit f33ebaafa1
7 changed files with 60 additions and 51 deletions

View File

@@ -504,6 +504,8 @@ shape is an explicit transition, not a flag day:
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.
- **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

View File

@@ -14,6 +14,10 @@ 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

View File

@@ -0,0 +1,39 @@
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}");
}
}

View File

@@ -100,7 +100,7 @@ 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*.
**Today this blocks new tenant-owned tables.** Startup and deletion validation
**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:
@@ -110,21 +110,19 @@ in `crates/buzz-db/src/store/deletion.rs`). Two things still matter:
(`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 run `schema/community_write_fence.sql`, which
attaches a fence trigger to every `community_id` table not excluded by
`community_write_fence_excluded_table`. A fresh desired-state database would
put a trigger on the new table and a migrated one would not, so the two
schema paths would diverge.
- 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.
So before a feature can add a tenant-owned `community_id` table, the
desired-state bootstrap must stop attaching fence triggers. That change belongs
to the fence-retirement work. Land it before the feature, or as the feature's
first PR. Do not work around it with a trigger, or with an entry in
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.
#### Checklist: adding a tenant-owned `community_id` table
After the desired-state change above has landed, all in the same PR:
All in the same PR:
- [ ] Every writer of the table goes through community admission
(`begin_community_write_transaction` or another constructor that returns

View File

@@ -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('<table>')` calls in
-- `triggers/<table>.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
$$;

View File

@@ -1,6 +1,6 @@
-- 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.
-- 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

View File

@@ -7,9 +7,9 @@
--
-- 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.
-- explicit legacy write-fence attachment), 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
@@ -213,6 +213,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