From 738655da08ec1dad8ec26432b80ead8f543e7e79 Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Fri, 28 Aug 2026 13:32:42 -0400 Subject: [PATCH] fix(buzz-db): drop stale relationship_revision_floor from invalidation guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The authorization_invalidation_floor_guard_v1 trigger compared NEW/OLD.relationship_revision_floor, but authorization_invalidation_floors has no such column — a later FI-DELEG field correctly trimmed from the Phase-A table when mining, yet left in the guard body. PL/pgSQL defers record-field resolution, so the function CREATEs and all catalog/parity tests pass, but the first real monotonic floor advancement aborts with 'record NEW has no field relationship_revision_floor', making the floor update path unusable. Remove both comparisons from the migration and its byte-matched schema.sql mirror, and add a behavioral regression test that advances a floor through the live trigger (forward generation and binding_version_floor commit; equal/regressive updates reject) — coverage a deferred PL/pgSQL failure structurally evades in catalog tests. Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> --- crates/buzz-db/src/runtime/migration.rs | 132 ++++++++++++++++++ .../0042_nip_fi_authorization_foundation.sql | 4 - schema/schema.sql | 4 - 3 files changed, 132 insertions(+), 8 deletions(-) diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index bdb8775a36..c6b0d7f510 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -2847,4 +2847,136 @@ mod tests { "expected immutability rejection, got: {rejected}" ); } + + /// NIP-FI monotonic invalidation-floor advancement must actually run + /// through the `BEFORE UPDATE` guard. PL/pgSQL defers record-field + /// resolution to execution, so a guard that references a column absent from + /// its Phase-A table passes every catalog/parity test yet aborts the first + /// real advancement. This test exercises live UPDATEs: legitimate forward + /// moves on `floor_generation` and `binding_version_floor` must commit, and + /// equal/regressive moves must be rejected. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn authorization_invalidation_floor_advances_through_guard() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(41, &pool) + .await + .expect("apply migrations 1-41"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("floor-guard-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + // Each floor state points at an operation receipt via + // (community_id, operation_id, request_fingerprint). Seed one receipt + // per operation the test advances through. + let operations: [(uuid::Uuid, u8); 4] = [ + (uuid::Uuid::new_v4(), 0x11), + (uuid::Uuid::new_v4(), 0x22), + (uuid::Uuid::new_v4(), 0x33), + (uuid::Uuid::new_v4(), 0x44), + ]; + for (operation_id, fp_byte) in operations { + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 12, $4, 1, $5)", + ) + .bind(community_id) + .bind(operation_id) + .bind(vec![fp_byte; 32]) + .bind(vec![0xAA_u8; 32]) + .bind(vec![0xBB_u8; 32]) + .execute(&pool) + .await + .expect("seed operation receipt"); + } + + // selector_kind 3 requires binding_version_floor, so this row exercises + // both monotonic dimensions the guard still governs. + let selector_fingerprint = vec![0xCC_u8; 32]; + sqlx::query( + "INSERT INTO authorization_invalidation_floors \ + (community_id, selector_kind, selector_fingerprint, floor_generation, \ + binding_version_floor, operation_id, request_fingerprint, updated_at) \ + VALUES ($1, 3, $2, 1, 1, $3, $4, '2026-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(&selector_fingerprint) + .bind(operations[0].0) + .bind(vec![operations[0].1; 32]) + .execute(&pool) + .await + .expect("insert initial invalidation floor"); + + let advance = + |generation: i64, binding_floor: i64, op_index: usize, updated_at: &'static str| { + sqlx::query( + "UPDATE authorization_invalidation_floors \ + SET floor_generation = $1, binding_version_floor = $2, \ + operation_id = $3, request_fingerprint = $4, updated_at = $5::timestamptz \ + WHERE community_id = $6 AND selector_kind = 3 AND selector_fingerprint = $7", + ) + .bind(generation) + .bind(binding_floor) + .bind(operations[op_index].0) + .bind(vec![operations[op_index].1; 32]) + .bind(updated_at) + .bind(community_id) + .bind(selector_fingerprint.clone()) + .execute(&pool) + }; + + // Forward generation advance commits. + advance(2, 1, 1, "2026-01-01T00:01:00Z") + .await + .expect("forward floor_generation advance must pass the guard"); + + // Forward binding_version_floor advance commits (generation unchanged). + advance(2, 2, 2, "2026-01-01T00:02:00Z") + .await + .expect("forward binding_version_floor advance must pass the guard"); + + // Regressive generation is rejected. + let regressive = advance(1, 2, 3, "2026-01-01T00:03:00Z") + .await + .expect_err("regressive floor_generation must be rejected"); + assert!( + regressive.to_string().contains("cannot move backward"), + "expected monotonic rejection, got: {regressive}" + ); + + // Equal floors with only a new operation is a rejected no-op advance. + let no_op = advance(2, 2, 3, "2026-01-01T00:03:00Z") + .await + .expect_err("equal-floor no-op advance must be rejected"); + assert!( + no_op.to_string().contains("cannot move backward"), + "expected no-op rejection, got: {no_op}" + ); + + // The committed state reflects only the two accepted advances. + let (generation, binding_floor): (i64, i64) = sqlx::query_as( + "SELECT floor_generation, binding_version_floor \ + FROM authorization_invalidation_floors \ + WHERE community_id = $1 AND selector_kind = 3 AND selector_fingerprint = $2", + ) + .bind(community_id) + .bind(&selector_fingerprint) + .fetch_one(&pool) + .await + .expect("read final floor state"); + assert_eq!( + (generation, binding_floor), + (2, 2), + "only the accepted forward advances may persist" + ); + } } diff --git a/migrations/0042_nip_fi_authorization_foundation.sql b/migrations/0042_nip_fi_authorization_foundation.sql index b83469dd81..586b34d9e9 100644 --- a/migrations/0042_nip_fi_authorization_foundation.sql +++ b/migrations/0042_nip_fi_authorization_foundation.sql @@ -381,14 +381,10 @@ BEGIN OR NEW.selector_fingerprint IS DISTINCT FROM OLD.selector_fingerprint OR NEW.floor_generation < OLD.floor_generation OR COALESCE(NEW.binding_version_floor, 0) < COALESCE(OLD.binding_version_floor, 0) - OR COALESCE(NEW.relationship_revision_floor, 0) - < COALESCE(OLD.relationship_revision_floor, 0) OR ( NEW.floor_generation = OLD.floor_generation AND COALESCE(NEW.binding_version_floor, 0) = COALESCE(OLD.binding_version_floor, 0) - AND COALESCE(NEW.relationship_revision_floor, 0) - = COALESCE(OLD.relationship_revision_floor, 0) ) OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id OR NEW.updated_at <= OLD.updated_at diff --git a/schema/schema.sql b/schema/schema.sql index 445b6d02bd..36968b4697 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -3120,14 +3120,10 @@ BEGIN OR NEW.selector_fingerprint IS DISTINCT FROM OLD.selector_fingerprint OR NEW.floor_generation < OLD.floor_generation OR COALESCE(NEW.binding_version_floor, 0) < COALESCE(OLD.binding_version_floor, 0) - OR COALESCE(NEW.relationship_revision_floor, 0) - < COALESCE(OLD.relationship_revision_floor, 0) OR ( NEW.floor_generation = OLD.floor_generation AND COALESCE(NEW.binding_version_floor, 0) = COALESCE(OLD.binding_version_floor, 0) - AND COALESCE(NEW.relationship_revision_floor, 0) - = COALESCE(OLD.relationship_revision_floor, 0) ) OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id OR NEW.updated_at <= OLD.updated_at