Files
buzz/crates/buzz-db
Luke Tornquist 97e1f6a082 fix(db): validate channel roster snapshots in the app writer (#8316)
## Summary

Phase 1 of retiring the channel roster snapshot trigger
(`trg_events_guard_channel_roster_snapshot`, migration 0032).

Every admitted writer that can store a kind 39002 roster snapshot now
runs the same check the trigger runs, on the same transaction and under
the same lock:

- `LockedMemberSnapshot::replace_member_event` (relay's live roster
publisher) validates the snapshot it already holds the membership lock
for, before soft-deleting the old head.
- `insert_event_on` and `insert_event_with_thread_metadata_tx` (generic
event writes).
- `replace_parameterized_event_in_transaction_impl` (inside its existing
savepoint) and `Db::replace_addressable_event` (admin reconciliation
path), after dominance checks and before the old head is deleted.

The validator (`validate_channel_roster_snapshot`) is a direct port of
`guard_channel_roster_snapshot()`: four-field `p` tags, 64-char hex
pubkey, empty relay field, valid role, and an exact match against active
`channel_members` rows. It raises the same `23514` messages, so callers
still see `DbError::Sqlx(sqlx::Error::Database(..))` with the same code
and text.

**Lock order:** replacement key, then channel-membership key, then the
canonical read, then the old-head `UPDATE` and the write. Legacy writers
took the membership key later, at the trigger's INSERT, after the
old-head row locks. Taking it before the `UPDATE` is not the same order,
but it is strictly safer: it removes the row-lock/membership-key cycle.
The membership writers in `channel_members.rs` take the same membership
key before they change rows. Not every membership writer does yet:
`execute_kick_with_marker` (reached from report enforcement) and
`deploy_kick_member` in `relay_admin_actions.rs` set `removed_at`
without the key, so a kick can commit after a roster passes validation
and before that roster commits, leaving the removed member listed. The
retained trigger has the same gap, so this PR neither causes nor closes
it. When the trigger also runs it re-takes a key this transaction
already holds; transaction advisory locks are re-entrant, so this adds
no wait.

The validator is one verdict query on the happy path. On failure it
raises the trigger's exact error via a `DO` block, so mixed-version
behavior is identical; phase 2 can switch to a typed `DbError`.

**Unchanged on purpose:** the trigger, its function, migration 0032, and
the startup probe `verify_channel_roster_fence_behavior`. Pods still on
the old version depend on them. Phase 2 drops them once this version is
running everywhere.

## Tests

New PG tests in `channel_members.rs` `postgres_tests`, each with only
the roster trigger disabled (the helper asserts the catalog check then
fails):

- `app_roster_guard_matches_trigger_for_roster_input_table`: 19 shape
and membership cases (3/5-field tags, short and non-hex pubkeys, relay
field, unknown role, duplicate, role mismatch, missing/extra member,
uppercase hex, non-`p` tags, empty rosters, all roles). For each one,
the app outcome through `Db::replace_addressable_event` must equal the
trigger's outcome on a raw INSERT, including SQLSTATE and message.
-
`app_roster_guard_locked_writer_rejects_stale_and_accepts_current_without_trigger`
-
`app_roster_guard_covers_generic_and_parameterized_writers_without_trigger`
-
`app_roster_guard_rechecks_after_concurrent_membership_commit_without_trigger`:
the 0032 race. A newer membership commits while an older snapshot waits
on the membership key. The barrier is `pg_locks` with a bound, not
sleeps.

`stale_legacy_roster_cannot_replace_new_locked_snapshot`
(`replaceable.rs`) and `desired_schema_rejects_stale_legacy_roster_role`
(`channel_members.rs`) now simulate the old binary inline (replacement
key, old-head `UPDATE`, raw INSERT) instead of calling the app API, so
they test the retained trigger again. The replaceable.rs one keeps its
one-connection pool, so it still catches self-deadlock.

`observability_source.rs` gains
`every_production_event_insert_runs_the_roster_guard`, which requires a
real guard call in every production `INSERT INTO events` function, with
explicit exceptions (NIP-43 membership and push lease rows, both
`channel_id` NULL, and the two rolled-back startup probes), plus a
self-test.

Falsified: removing one guard call fails the source policy; dropping the
role clause or `lower()` fails the parity table; disabling the trigger
fails both legacy tests. The PG tests are picked up by the `postgres-ci`
nextest filter (`test(/postgres_tests::/)`); the source policy runs in
`just test-unit`.

### Coverage limits (review follow-up)

- `every_production_event_insert_runs_the_roster_guard` is a presence
check. It rejects missing, commented, and quoted calls, but it does not
prove a call is awaited, reachable, or passed the event's real kind and
channel. Behavior is covered by the PG tests above. The test now says
this in its doc comment.
- Exemptions match by function header. A kind 39002 insert added inside
one of the four exempt functions would pass the policy. This is
documented next to the exemption list.
- The 19-case parity table and the race test drive
`Db::replace_addressable_event` only. The generic, parameterized, and
locked writers get the mismatch case only, and the parameterized path's
membership-key-before-delete ordering is not exercised concurrently. All
five writers call the same validator, so this PR doesn't widen these
tests.
- `disable_roster_trigger` commits `DISABLE TRIGGER` on the test
database. That is safe under the per-test database harness the
`postgres-ci` profile uses, but not against a shared database. Its doc
comment now says so.

## Validation

Run on a Blox workstation (private PG 16 and Redis) against a tree
byte-identical to this commit:

- `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets
-- -D warnings`, `cargo nextest run -p buzz-db --lib`: pass.
- `cargo nextest run -p buzz-db --test observability_source`: 19 passed.
- `scripts/postgres-test-run.sh -p <pkg>` for buzz-admin, buzz-audit,
buzz-deletion, buzz-push-gateway, buzz-relay, buzz-search,
buzz-workflow: pass.
- buzz-db: the full run passed 513 and was stopped at a 240 s deadline
on `writer_pool_rejects_non_read_committed_database_default`, which
hangs the same way on the unmodified base. A second run excluding the
three host-flaky runtime tests already reproduced on the base passed all
511 others.

## Simpler?

Not yet. While the trigger is still in place, the check runs twice.
Phase 2 deletes the trigger, its function, the startup catalog and
behavior probes, the migration.rs equivalence asserts, and the pgschema
reconcile entry, which leaves one check in Rust.

## Phase 2 (separate PR, after fleet rollout)

**Prerequisite before claiming complete roster serialization (phase 2 or
anywhere else):** make both kick paths take the channel membership key
before they change rows, and add a concurrent kick-vs-roster-publication
test. The retained trigger does not prove this; it has the same gap.


A new migration drops the trigger from `events` and its partitions, then
drops `guard_channel_roster_snapshot()`. It also removes the schema
files and includes, `verify_channel_roster_fence*` and the relay startup
call, the migration.rs asserts, and the probe tests, and updates
`scripts/reconcile-schema-after-pgschema.sql`.

---------

Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-10-16 17:07:25 -04:00
..