fix(buzz-db): drop stale relationship_revision_floor from invalidation guard

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>
This commit is contained in:
Hayt
2026-08-28 13:32:42 -04:00
parent bafdff958a
commit 738655da08
3 changed files with 132 additions and 8 deletions

View File

@@ -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"
);
}
}

View File

@@ -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

View File

@@ -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