Files
buzz/crates/buzz-db/src/runtime/migration.rs
Hayt bd851f85e7 test(buzz-db): isolate event-side trigger and add applied lifecycle fixture
Two MINOR accuracy gaps from Thufir Pass 2:

1. Event-side trigger isolation
   The denied-receipt-then-event negative in
   denied_lifecycle_receipt_commits_without_audit_event queues both
   deferred triggers in one transaction; it does not prove the event-side
   trigger (authorization_event_receipt_cardinality) alone. Add
   denied_lifecycle_receipt_event_side_trigger_isolated: commit a denied
   receipt in auto-commit (no deferred trigger active), then open a new
   transaction that inserts only the mapped success-transition event and
   asserts COMMIT rejection with the exact constraint name. Rejection
   must come from the event-side trigger only.

2. Applied lifecycle coverage at migration 42
   No test exercised the outcome_code IN (1, 3) branch at migration 42.
   Add applied_lifecycle_receipt_requires_exactly_one_event: an applied
   enroll receipt + exactly one mapped event commits; the same setup
   without the event rejects with
   authorization_operation_receipt_event_cardinality. Uses the minimum
   valid circular identity/lifecycle setup (policy + history + receipt +
   binding + event), stops at migration 42 not 41.

3. Correct surrounding mutation/coverage comments
   - denied_lifecycle_receipt_commits_without_audit_event: update
     mutation note to name both new tests, remove the stale claim that
     the receipt-then-event negative covers both trigger directions.
   - The receipt-then-event negative comment is tightened to say only
     the receipt-side trigger fires in that transaction.

No production SQL changes. All 11 NIP-FI ignored PostgreSQL regressions
green (--test-threads=1).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
2026-08-31 15:43:27 -04:00

5000 lines
212 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Embedded SQLx migrations for Buzz.
//!
//! Fresh deployments apply the checked-in additive SQL files under
//! `migrations/`. The multi-tenant rewrite begins from a clean consolidated
//! `0001`; legacy single-tenant cutover/backfill is a separate operator script,
//! not startup migration state.
use std::future::Future;
use sqlx::{Connection, PgConnection, PgPool};
use crate::deletion::SCHEMA_DESTRUCTION_LOCK_KEY;
use crate::Result;
static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("../../migrations");
/// Run all pending Buzz database migrations.
///
/// The entire run holds the exclusive [`SCHEMA_DESTRUCTION_LOCK_KEY`] session
/// lock, serializing schema changes against destructive deletion transactions
/// (which take the shared counterpart while they validate the live catalog
/// and act on it). Every migration statement executes on the same backend
/// that owns the lock — see [`with_exclusive_schema_destruction_lock`] for
/// why that binding, not the explicit unlock, is the safety contract.
/// Migration execution must never bypass this wrapper — a source lint
/// (`migration_execution_cannot_bypass_schema_destruction_lock`) enforces
/// that `MIGRATOR.run` has no other call site.
pub async fn run_migrations(pool: &PgPool) -> Result<()> {
with_exclusive_schema_destruction_lock(pool, |mut conn| async move {
let outcome = run_migrations_locked(&mut conn).await;
(conn, outcome)
})
.await
}
#[cfg(test)]
pub(crate) async fn run_migrations_through(pool: &PgPool, target: i64) -> Result<()> {
with_exclusive_schema_destruction_lock(pool, |mut conn| async move {
let outcome = async {
reject_legacy_nip_rs_cardinality_ambiguity(&mut conn).await?;
MIGRATOR.run_to(target, &mut conn).await?;
Ok(())
}
.await;
(conn, outcome)
})
.await
}
async fn run_migrations_locked(conn: &mut PgConnection) -> Result<()> {
reject_legacy_nip_rs_cardinality_ambiguity(conn).await?;
MIGRATOR.run(&mut *conn).await?;
// The replica-fence proof (see `replica_fence`) requires the commit-time
// `created_at` floor trigger from migration 0021 — correctly shaped — on
// the `events` parent and every partition. `CREATE TABLE .. PARTITION OF`
// clones parent triggers, but a partition attached with `ATTACH
// PARTITION` or created by an older code path would silently escape the
// guard, so migration fails closed if any is missing. (The fence probe
// re-runs this same check at startup on non-migrating relays.)
crate::replica_fence::verify_floor_guard_catalog(&mut *conn).await?;
crate::channel::verify_channel_roster_fence_catalog(&mut *conn).await?;
Ok(())
}
/// Run `op` while holding the exclusive schema/destruction session lock.
///
/// `op` receives ownership of the detached connection that owns the advisory
/// lock and must run every statement on it, handing the same connection back
/// with its outcome. That same-backend lifetime — not the explicit unlock —
/// is the safety contract: PostgreSQL releases a session lock only when its
/// backend finishes, so cancelling this future (dropping the connection while
/// a migration statement is still executing server-side) cannot expose the
/// lock to shared destructive holders before that statement's backend
/// terminates. On completion the lock is explicitly released on the returned
/// connection (success and error alike) and the connection is closed, never
/// returning a locked session to the pool.
pub(crate) async fn with_exclusive_schema_destruction_lock<T, F, Fut>(
pool: &PgPool,
op: F,
) -> Result<T>
where
F: FnOnce(PgConnection) -> Fut,
Fut: Future<Output = (PgConnection, Result<T>)>,
{
let mut lock_conn = crate::observability::acquire(pool, crate::observability::PoolRole::Writer)
.await?
.detach();
// This dedicated connection intentionally waits for the current migration
// or schema-destruction owner and may then run long DDL. Exempt those two
// phases from runtime lock/statement budgets. Keep the idle-in-transaction
// timeout: a client wedged idle mid-migration is still a lock holder that
// should be reaped. The detached connection is closed below and never
// returns these session settings to the pool.
sqlx::raw_sql("SET lock_timeout = 0; SET statement_timeout = 0")
.execute(&mut lock_conn)
.await?;
crate::observability::observe_advisory_lock(
crate::observability::LockType::MigrationSchemaSafety,
sqlx::query("SELECT pg_advisory_lock($1)")
.bind(SCHEMA_DESTRUCTION_LOCK_KEY)
.execute(&mut lock_conn),
)
.await?;
let (mut lock_conn, outcome) = op(lock_conn).await;
let unlock = sqlx::query("SELECT pg_advisory_unlock($1)")
.bind(SCHEMA_DESTRUCTION_LOCK_KEY)
.execute(&mut lock_conn)
.await;
let _ = lock_conn.close().await;
let value = outcome?;
unlock?;
Ok(value)
}
/// Migration 0007 is checksum-frozen and predates exact NIP-RS tag-cardinality
/// enforcement. A populated database still on 0001-0006 must not let 0007
/// irreversibly purge duplicate-tag history. Fail before sqlx starts its
/// migration transaction so an operator can inspect and repair those rows.
async fn reject_legacy_nip_rs_cardinality_ambiguity(conn: &mut PgConnection) -> Result<()> {
let migrations_table: Option<String> =
sqlx::query_scalar("SELECT to_regclass('_sqlx_migrations')::text")
.fetch_one(&mut *conn)
.await?;
if migrations_table.is_none() {
return Ok(());
}
let applied: Option<i64> =
sqlx::query_scalar("SELECT max(version) FROM _sqlx_migrations WHERE success")
.fetch_one(&mut *conn)
.await?;
if applied.is_none_or(|version| version >= 7) {
return Ok(());
}
let ambiguous: bool = sqlx::query_scalar(
"SELECT EXISTS (\
SELECT 1 FROM events e \
WHERE e.kind = 30078 \
AND e.d_tag ~ '^read-state:[0-9a-f]{32}$' \
AND (\
jsonb_typeof(e.tags) IS DISTINCT FROM 'array' \
OR (\
EXISTS (\
SELECT 1 FROM jsonb_array_elements(\
CASE WHEN jsonb_typeof(e.tags) = 'array' THEN e.tags ELSE '[]'::jsonb END\
) tag \
WHERE tag = '[\"t\", \"read-state\"]'::jsonb\
) \
AND (\
(SELECT count(*) FROM jsonb_array_elements(\
CASE WHEN jsonb_typeof(e.tags) = 'array' THEN e.tags ELSE '[]'::jsonb END\
) tag \
WHERE jsonb_typeof(tag) = 'array' \
AND tag->0 = '\"d\"'::jsonb) <> 1 \
OR NOT EXISTS (\
SELECT 1 FROM jsonb_array_elements(\
CASE WHEN jsonb_typeof(e.tags) = 'array' THEN e.tags ELSE '[]'::jsonb END\
) tag \
WHERE jsonb_typeof(tag) = 'array' \
AND jsonb_array_length(tag) >= 2 \
AND jsonb_typeof(tag->1) = 'string' \
AND tag->>0 = 'd' \
AND tag->>1 = e.d_tag\
) \
OR (SELECT count(*) FROM jsonb_array_elements(\
CASE WHEN jsonb_typeof(e.tags) = 'array' THEN e.tags ELSE '[]'::jsonb END\
) tag WHERE tag = '[\"t\", \"read-state\"]'::jsonb) <> 1\
)\
)\
)\
)",
)
.fetch_one(conn)
.await?;
if ambiguous {
return Err(crate::DbError::InvalidData(
"NIP-RS migration blocked: pre-0007 database contains kind-30078 rows with ambiguous d/t tag cardinality; repair or remove those nonconforming rows before retrying"
.into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
collections::BTreeSet,
fs,
path::{Path, PathBuf},
};
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1
/// Connection parameters parsed out of a `postgres://user:pass@host:port/db`
/// URL so the parity test can pass them to the `bin/pgschema` binary, which
/// takes discrete `--host/--port/--user/--password/--db` flags rather than a
/// URL. Only the shapes this test emits (`BUZZ_TEST_DATABASE_URL` /
/// `DATABASE_URL` / `TEST_DB_URL`) are supported.
struct PgConn {
host: String,
port: u16,
user: String,
password: String,
}
fn parse_pg_url(url: &str) -> PgConn {
let opts: sqlx::postgres::PgConnectOptions =
url.parse().expect("parse postgres connection url");
PgConn {
host: opts.get_host().to_owned(),
port: opts.get_port(),
user: opts.get_username().to_owned(),
password: parse_pg_password(url),
}
}
/// `PgConnectOptions` intentionally does not expose the password via a
/// getter, so read it straight out of the URL authority. Falls back to the
/// `PGPASSWORD` env var, then empty.
fn parse_pg_password(url: &str) -> String {
url.split_once("://")
.and_then(|(_, rest)| rest.split_once('@'))
.map(|(authority, _)| authority)
.and_then(|authority| authority.split_once(':'))
.map(|(_, pass)| pass.to_owned())
.or_else(|| std::env::var("PGPASSWORD").ok())
.unwrap_or_default()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ConstraintKind {
ForeignKey,
PrimaryKey,
Unique,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ConstraintLint {
table: String,
kind: ConstraintKind,
description: String,
columns: Vec<String>,
}
/// Concatenated SQL of every embedded migration, in version order.
///
/// The tenant-isolation lints must cover objects introduced by *any*
/// migration, not just the consolidated `0001`. Concatenating keeps that
/// coverage honest as additive migrations (e.g. `0002_git_repo_names`) land.
fn migration_sql() -> String {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);
assert!(
!migrations.is_empty(),
"at least the initial migration must exist"
);
migrations
.iter()
.map(|migration| migration.sql.as_ref())
.collect::<Vec<&str>>()
.join("\n")
}
fn strip_sql_comments(sql: &str) -> String {
sql.lines()
.map(|line| line.split_once("--").map_or(line, |(before, _)| before))
.collect::<Vec<_>>()
.join("\n")
}
fn normalize_sql(sql: &str) -> String {
strip_sql_comments(sql)
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase()
}
fn split_sql_statements(sql: &str) -> Vec<String> {
let sql = strip_sql_comments(sql);
let bytes = sql.as_bytes();
let mut statements = Vec::new();
let mut start = 0usize;
let mut idx = 0usize;
let mut in_single_quote = false;
let mut in_dollar_quote = false;
while idx < bytes.len() {
match bytes[idx] {
b'\'' if !in_dollar_quote => {
in_single_quote = !in_single_quote;
idx += 1;
}
b'$' if !in_single_quote && idx + 1 < bytes.len() && bytes[idx + 1] == b'$' => {
in_dollar_quote = !in_dollar_quote;
idx += 2;
}
b';' if !in_single_quote && !in_dollar_quote => {
let statement = sql[start..idx].trim();
if !statement.is_empty() {
statements.push(statement.to_owned());
}
start = idx + 1;
idx += 1;
}
_ => idx += 1,
}
}
let tail = sql[start..].trim();
if !tail.is_empty() {
statements.push(tail.to_owned());
}
statements
}
fn find_matching_paren(sql: &str, open: usize) -> Option<usize> {
let mut depth = 0usize;
for (offset, byte) in sql.as_bytes()[open..].iter().enumerate() {
match byte {
b'(' => depth += 1,
b')' => {
depth = depth.checked_sub(1)?;
if depth == 0 {
return Some(open + offset);
}
}
_ => {}
}
}
None
}
fn split_top_level_csv(input: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut start = 0usize;
let mut depth = 0usize;
for (idx, byte) in input.bytes().enumerate() {
match byte {
b'(' => depth += 1,
b')' => depth = depth.saturating_sub(1),
b',' if depth == 0 => {
parts.push(input[start..idx].trim().to_owned());
start = idx + 1;
}
_ => {}
}
}
let tail = input[start..].trim();
if !tail.is_empty() {
parts.push(tail.to_owned());
}
parts
}
fn identifier_after_keyword(statement: &str, keyword: &str) -> Option<String> {
let lower = statement.to_ascii_lowercase();
let keyword_pos = lower.find(keyword)?;
let mut remainder = statement[keyword_pos + keyword.len()..].trim_start();
for prefix in ["if not exists", "if exists", "only"] {
if remainder.to_ascii_lowercase().starts_with(prefix) {
remainder = remainder[prefix.len()..].trim_start();
}
}
let identifier = remainder
.split(|ch: char| ch.is_whitespace() || ch == '(')
.next()?
.trim_matches('"')
.rsplit('.')
.next()?
.trim_matches('"')
.to_ascii_lowercase();
(!identifier.is_empty()).then_some(identifier)
}
fn first_parenthesized_columns(input: &str) -> Vec<String> {
let Some(open) = input.find('(') else {
return Vec::new();
};
let Some(close) = find_matching_paren(input, open) else {
return Vec::new();
};
split_top_level_csv(&input[open + 1..close])
.into_iter()
.filter_map(|column| {
let name = column
.trim()
.trim_matches('"')
.split_whitespace()
.next()?
.trim_matches('"')
.to_ascii_lowercase();
(!name.is_empty()).then_some(name)
})
.collect()
}
fn column_definition_name(definition: &str) -> Option<String> {
let trimmed = definition.trim();
let lower = trimmed.to_ascii_lowercase();
if lower.starts_with("constraint ")
|| lower.starts_with("primary key")
|| lower.starts_with("foreign key")
|| lower.starts_with("unique")
|| lower.starts_with("check ")
|| lower.starts_with("exclude ")
{
return None;
}
let name = trimmed
.split_whitespace()
.next()?
.trim_matches('"')
.to_ascii_lowercase();
(!name.is_empty()).then_some(name)
}
fn create_table_body(statement: &str) -> Option<(String, Vec<String>)> {
let table = identifier_after_keyword(statement, "create table")?;
let open = statement.find('(')?;
let close = find_matching_paren(statement, open)?;
Some((table, split_top_level_csv(&statement[open + 1..close])))
}
fn create_table_definitions(sql: &str) -> Vec<(String, Vec<String>)> {
split_sql_statements(sql)
.into_iter()
.filter_map(|statement| {
let normalized = statement.trim_start().to_ascii_lowercase();
if !normalized.starts_with("create table") || normalized.contains(" partition of ")
{
return None;
}
create_table_body(&statement)
})
.collect()
}
fn create_tables(sql: &str) -> BTreeSet<String> {
create_table_definitions(sql)
.into_iter()
.map(|(table, _)| table)
.collect()
}
fn table_has_not_null_community_id(definitions: &[String]) -> bool {
definitions.iter().any(|definition| {
column_definition_name(definition).as_deref() == Some("community_id")
&& normalize_sql(definition).contains("not null")
})
}
fn operator_global_tables(sql: &str) -> BTreeSet<String> {
let mut globals = BTreeSet::new();
let normalized = normalize_sql(sql);
let Some(insert_pos) = normalized.find("insert into _operator_global_tables") else {
return globals;
};
for value in [
"communities",
"rate_limit_violations",
"_operator_global_tables",
"push_gateway_challenges",
"push_gateway_installations",
"push_gateway_delegations",
"push_gateway_endpoint_quotas",
"push_gateway_delivery_auth_replays",
"push_gateway_delivery_request_replays",
"product_feedback",
"replica_heartbeat",
"community_deletion_requests",
"community_deletion_approvals",
"community_deletion_checkpoints",
"community_deletion_manifest_keys",
"storage_taxonomy_sweeps",
"community_serving_write_leases",
"community_deletion_executor_heartbeats",
"relay_operators",
"relay_admin_actions",
"relay_admin_outbox",
"relay_operator_audit",
] {
if normalized[insert_pos..].contains(&format!("'{value}'")) {
globals.insert(value.to_owned());
}
}
globals
}
fn scoped_tables(sql: &str) -> BTreeSet<String> {
let globals = operator_global_tables(sql);
create_tables(sql)
.into_iter()
.filter(|table| !globals.contains(table))
.collect()
}
fn constraint_lint_for_definition(table: &str, definition: &str) -> Option<ConstraintLint> {
let normalized = normalize_sql(definition);
let definition_without_name = if normalized.starts_with("constraint ") {
let after_constraint = definition
.trim_start()
.splitn(3, char::is_whitespace)
.nth(2)
.unwrap_or("");
normalize_sql(after_constraint)
} else {
normalized.clone()
};
if definition_without_name.starts_with("primary key") {
Some(ConstraintLint {
table: table.to_owned(),
kind: ConstraintKind::PrimaryKey,
description: definition.to_owned(),
columns: first_parenthesized_columns(&definition_without_name),
})
} else if definition_without_name.starts_with("unique") {
Some(ConstraintLint {
table: table.to_owned(),
kind: ConstraintKind::Unique,
description: definition.to_owned(),
columns: first_parenthesized_columns(&definition_without_name),
})
} else if definition_without_name.starts_with("foreign key") {
Some(ConstraintLint {
table: table.to_owned(),
kind: ConstraintKind::ForeignKey,
description: definition.to_owned(),
columns: first_parenthesized_columns(&definition_without_name),
})
} else if normalized.contains(" primary key") {
column_definition_name(definition).map(|column| ConstraintLint {
table: table.to_owned(),
kind: ConstraintKind::PrimaryKey,
description: definition.to_owned(),
columns: vec![column],
})
} else if normalized.contains(" references ") {
column_definition_name(definition).map(|column| ConstraintLint {
table: table.to_owned(),
kind: ConstraintKind::ForeignKey,
description: definition.to_owned(),
columns: vec![column],
})
} else if normalized.contains(" unique") {
column_definition_name(definition).map(|column| ConstraintLint {
table: table.to_owned(),
kind: ConstraintKind::Unique,
description: definition.to_owned(),
columns: vec![column],
})
} else {
None
}
}
fn table_constraints(sql: &str, scoped_tables: &BTreeSet<String>) -> Vec<ConstraintLint> {
create_table_definitions(sql)
.into_iter()
.filter(|(table, _)| scoped_tables.contains(table))
.flat_map(|(table, definitions)| {
definitions.into_iter().filter_map(move |definition| {
constraint_lint_for_definition(&table, &definition)
})
})
.collect()
}
fn alter_table_constraints(sql: &str, scoped_tables: &BTreeSet<String>) -> Vec<ConstraintLint> {
split_sql_statements(sql)
.into_iter()
.filter_map(|statement| {
let normalized = normalize_sql(&statement);
if !normalized.starts_with("alter table") {
return None;
}
let table = identifier_after_keyword(&statement, "alter table")?;
if !scoped_tables.contains(&table) {
return None;
}
let add_pos = normalized.find(" add ")?;
let definition = normalized[add_pos + " add ".len()..].trim();
constraint_lint_for_definition(&table, definition)
})
.collect()
}
fn unique_indexes(sql: &str, scoped_tables: &BTreeSet<String>) -> Vec<ConstraintLint> {
split_sql_statements(sql)
.into_iter()
.filter_map(|statement| {
let normalized = normalize_sql(&statement);
if !normalized.starts_with("create unique index") {
return None;
}
let lower_statement = statement.to_ascii_lowercase();
let on_pos = lower_statement.find(" on ")?;
let table = statement[on_pos + " on ".len()..]
.trim_start()
.split(|ch: char| ch.is_whitespace() || ch == '(')
.next()?
.trim_matches('"')
.rsplit('.')
.next()?
.trim_matches('"')
.to_ascii_lowercase();
scoped_tables.contains(&table).then(|| ConstraintLint {
table,
kind: ConstraintKind::Unique,
description: statement.clone(),
columns: first_parenthesized_columns(&statement[on_pos + " on ".len()..]),
})
})
.collect()
}
fn scoped_constraint_lints(sql: &str, scoped_tables: &BTreeSet<String>) -> Vec<ConstraintLint> {
let mut constraints = table_constraints(sql, scoped_tables);
constraints.extend(alter_table_constraints(sql, scoped_tables));
constraints.extend(unique_indexes(sql, scoped_tables));
constraints
}
fn is_allowed_partition_primary_key_exception(constraint: &ConstraintLint) -> bool {
constraint.table == "delivery_log"
&& constraint.kind == ConstraintKind::PrimaryKey
&& constraint.columns == ["delivered_at", "id"]
}
fn scoped_constraint_violations(sql: &str) -> Vec<ConstraintLint> {
let scoped_tables = scoped_tables(sql);
scoped_constraint_lints(sql, &scoped_tables)
.into_iter()
.filter(|constraint| {
if is_allowed_partition_primary_key_exception(constraint) {
return false;
}
constraint.columns.first().map(String::as_str) != Some("community_id")
})
.collect()
}
fn has_channels_community_id_immutability_guard(sql: &str) -> bool {
let normalized = normalize_sql(sql);
normalized.contains("create trigger")
&& normalized.contains("before update")
&& normalized.contains(" on channels")
&& normalized.contains("community_id")
&& normalized.contains("old.community_id")
&& normalized.contains("new.community_id")
&& normalized.contains("raise exception")
}
fn forbidden_channels_community_id_mutations(sql: &str) -> Vec<String> {
split_sql_statements(sql)
.into_iter()
.filter(|statement| {
let normalized = normalize_sql(statement);
let updates_channels =
identifier_after_keyword(statement, "update").as_deref() == Some("channels");
let update_assignments = normalized
.split_once(" set ")
.map(|(_, tail)| tail.split_once(" where ").map_or(tail, |(set, _)| set));
let mutates_with_update = updates_channels
&& update_assignments
.is_some_and(|assignments| assignments.contains("community_id"));
let alters_channels = identifier_after_keyword(statement, "alter table").as_deref()
== Some("channels");
let drops_channels = identifier_after_keyword(statement, "drop table").as_deref()
== Some("channels");
let drops_or_rewrites_column = alters_channels
&& (normalized.contains("drop column community_id")
|| normalized.contains("alter column community_id")
|| normalized.contains("rename column community_id")
|| normalized.contains("rename community_id")
|| normalized.contains("drop trigger")
|| normalized.contains("disable trigger"));
mutates_with_update || drops_or_rewrites_column || drops_channels
})
.collect()
}
#[test]
fn embedded_migrator_contains_consolidated_initial_schema() {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);
assert_eq!(migrations.len(), 42);
assert_eq!(migrations[0].version, 1);
assert_eq!(&*migrations[0].description, "initial schema");
assert!(migrations[0]
.sql
.as_str()
.contains("CREATE TABLE communities"));
assert!(migrations[0].sql.as_str().contains("CREATE TABLE channels"));
assert!(migrations[0]
.sql
.as_str()
.contains("CREATE TABLE scheduled_workflow_fires"));
assert!(migrations[0]
.sql
.as_str()
.contains("CREATE TABLE audit_log"));
assert!(migrations[0]
.sql
.as_str()
.contains("CREATE TABLE _operator_global_tables"));
assert!(migrations[0]
.sql
.as_str()
.contains("search_tsv TSVECTOR GENERATED ALWAYS"));
// The git repo-name registry is an additive migration, never folded into
// 0001 — folding it would change 0001's checksum and break brownfield
// startup (sqlx VersionMismatch). It must live in its own version, and
// 0001 must not carry it.
assert_eq!(migrations[1].version, 2);
assert!(migrations[1]
.sql
.as_str()
.contains("CREATE TABLE git_repo_names"));
assert!(!migrations[0].sql.as_str().contains("git_repo_names"));
// Same additive-migration rule for the per-community workspace icon
// (NIP-11 `icon`): its own version, never folded into 0001.
assert_eq!(migrations[2].version, 3);
assert!(migrations[2]
.sql
.as_str()
.contains("ALTER TABLE communities ADD COLUMN icon"));
assert!(!migrations[0].sql.as_str().contains("icon"));
// Same additive-migration rule for the e-tag containment GIN index
// (channel-window aux closure): its own version, never folded into 0001.
assert_eq!(migrations[3].version, 4);
assert!(migrations[3]
.sql
.as_str()
.contains("CREATE INDEX idx_events_tags_gin"));
assert!(!migrations[0].sql.as_str().contains("idx_events_tags_gin"));
// NIP-AM (kind 44200) FTS exclusion: additive migration, never folded
// into 0001 — folding would change 0001's checksum and break brownfield
// startup. Migration 5 drops and re-adds the generated `search_tsv`
// column with the extended kind-44200 exclusion. 0001 must NOT carry 44200.
assert_eq!(migrations[4].version, 5);
assert!(migrations[4].sql.as_str().contains("search_tsv"));
assert!(migrations[4].sql.as_str().contains("44200"));
assert!(!migrations[0].sql.as_str().contains("44200"));
// Community moderation (reports/bans/audit): additive migration, never
// folded into 0001 — same brownfield checksum rule as above.
assert_eq!(migrations[5].version, 6);
assert!(migrations[5]
.sql
.as_str()
.contains("CREATE TABLE moderation_reports"));
assert!(migrations[5]
.sql
.as_str()
.contains("CREATE TABLE community_bans"));
assert!(migrations[5]
.sql
.as_str()
.contains("CREATE TABLE moderation_actions"));
for action in crate::moderation::MODERATION_ACTION_CHECK_VOCAB {
assert!(
migrations[5].sql.as_str().contains(&format!("'{action}'")),
"migration 0006 moderation_actions.action CHECK must allow {action}"
);
}
assert!(!migrations[0].sql.as_str().contains("moderation_reports"));
// NIP-RS retention is additive and boot-safe: seed replay watermarks
// before deleting payload history, without rewriting search storage.
assert_eq!(migrations[6].version, 7);
assert!(migrations[6]
.sql
.as_str()
.contains("LOCK TABLE events IN SHARE ROW EXCLUSIVE MODE"));
assert!(migrations[6]
.sql
.as_str()
.contains("CREATE TABLE parameterized_event_watermarks"));
assert!(migrations[6]
.sql
.as_str()
.contains("INSERT INTO parameterized_event_watermarks"));
assert!(migrations[6]
.sql
.as_str()
.contains("CREATE INDEX idx_event_mentions_community_event"));
assert!(migrations[6]
.sql
.as_str()
.contains("NIP-RS retention blocked: deleted event outranks live head"));
assert!(migrations[6]
.sql
.as_str()
.contains("DELETE FROM events old"));
assert!(!migrations[6]
.sql
.as_str()
.contains("ALTER TABLE events DROP COLUMN search_tsv"));
// Fresh installs opt into the positive search allowlist without making
// populated databases rewrite their events heap during relay startup.
assert_eq!(migrations[7].version, 8);
assert!(migrations[7]
.sql
.as_str()
.contains("IF NOT EXISTS (SELECT 1 FROM events LIMIT 1)"));
assert!(migrations[7]
.sql
.as_str()
.contains("CASE WHEN kind IN (0, 9, 40002, 45001, 45003)"));
assert!(migrations[7].sql.as_str().contains("ELSE NULL::tsvector"));
// Mixed-version guards are additive because 0007/0008 may already be
// recorded by a running relay and their sqlx checksums are immutable.
assert_eq!(migrations[8].version, 9);
assert!(migrations[8]
.sql
.as_str()
.contains("CREATE TRIGGER trg_events_nip_rs_watermark"));
assert!(migrations[8]
.sql
.as_str()
.contains("stale NIP-RS event rejected by durable watermark"));
assert!(migrations[8]
.sql
.as_str()
.contains("CREATE TRIGGER trg_events_purge_soft_deleted_nip_rs"));
assert!(migrations[8]
.sql
.as_str()
.contains("CREATE TRIGGER trg_event_mentions_require_live_event"));
assert_eq!(migrations[9].version, 10);
assert!(migrations[9]
.sql
.as_str()
.contains("CREATE OR REPLACE FUNCTION guard_nip_rs_watermark"));
assert!(migrations[9].sql.as_str().contains("RETURN NULL"));
assert_eq!(migrations[10].version, 11);
assert!(migrations[10]
.sql
.as_str()
.contains("CREATE OR REPLACE FUNCTION guard_nip_rs_watermark"));
assert!(migrations[10]
.sql
.as_str()
.contains("CREATE OR REPLACE FUNCTION purge_soft_deleted_nip_rs"));
assert!(migrations[10].sql.as_str().contains("tag->>0 = 'd'"));
assert!(migrations[10].sql.as_str().contains(") = 1"));
// Push leases and their durable outbox are relay-owned and structurally
// community-scoped; the public gateway remains stateless.
assert_eq!(migrations[11].version, 12);
assert!(migrations[11]
.sql
.as_str()
.contains("CREATE TABLE push_leases"));
assert!(migrations[11]
.sql
.as_str()
.contains("CREATE TABLE push_wake_outbox"));
assert!(migrations[11]
.sql
.as_str()
.contains("PRIMARY KEY (community_id, author, installation_id)"));
assert!(!migrations[0].sql.as_str().contains("push_leases"));
assert_eq!(migrations[12].version, 13);
assert!(migrations[12]
.sql
.as_str()
.contains("ADD COLUMN endpoint_enabled"));
// Kind 30350 is author-only encrypted data, so its ciphertext is never
// indexed for NIP-50 search. Preserve the 0001 checksum and extend the
// generated expression additively.
assert_eq!(migrations[13].version, 14);
assert!(migrations[13].sql.as_str().contains("30350"));
assert!(migrations[13].sql.as_str().contains("search_tsv"));
assert!(!migrations[0].sql.as_str().contains("30350"));
// NIP-PMA kind:30179 FTS exclusion (0033): same wrap-the-existing-
// expression shape as 0014 so brownfield databases stop tokenizing
// private managed-agent ciphertext without a policy rewrite. (The
// migration itself still rewrites the events heap and rebuilds the
// GIN index — see the 0033 header for the operational cost.)
assert_eq!(migrations[32].version, 33);
assert!(migrations[32].sql.as_str().contains("kind = 30179"));
assert!(migrations[32].sql.as_str().contains("search_tsv"));
assert!(!migrations[0].sql.as_str().contains("30179"));
assert!(include_str!("../../../../schema/schema.sql")
.contains("kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200)"));
// Public push-gateway authority is intentionally deployment-global and
// durable: immediate revocation and hostile-relay admission cannot be
// honestly provided by a stateless gateway.
assert_eq!(migrations[14].version, 15);
assert!(migrations[14]
.sql
.as_str()
.contains("CREATE TABLE push_gateway_installations"));
assert!(migrations[14]
.sql
.as_str()
.contains("push_gateway_delegations"));
assert!(migrations[14]
.sql
.as_str()
.contains("_operator_global_tables"));
// Community archival and product feedback landed concurrently. Keep
// both additive migrations in a single, unambiguous sequence.
assert_eq!(migrations[15].version, 16);
assert!(migrations[15]
.sql
.as_str()
.contains("ADD COLUMN archived_at"));
// Product feedback is a deployment-private sidecar; community_id is
// provenance, not an operator-review authorization boundary.
assert_eq!(migrations[16].version, 17);
assert!(migrations[16]
.sql
.as_str()
.contains("CREATE TABLE product_feedback"));
assert!(migrations[16]
.sql
.as_str()
.contains("community_id UUID NOT NULL"));
assert!(migrations[16]
.sql
.as_str()
.contains("('product_feedback', 'deployment product inbox"));
assert!(!migrations[0].sql.as_str().contains("product_feedback"));
// Matching is driven from a parent-table trigger so all partition and
// internal insertion paths share the same crash-safe allowlist seam.
assert_eq!(migrations[17].version, 18);
let matcher = migrations[17].sql.as_str();
assert!(matcher.contains("CREATE TABLE push_match_queue"));
assert!(matcher.contains("AFTER INSERT ON events"));
assert!(matcher.contains("NEW.kind IN (7, 9, 1059, 40007, 46010)"));
assert!(!migrations[0].sql.as_str().contains("push_match_queue"));
// Mesh status is a heartbeat, not an audit stream. The additive
// migration removes accumulated soft-deleted payloads and covers old
// writers during rolling deploys without changing kind:30003 broadly.
assert_eq!(migrations[18].version, 19);
let mesh_retention = migrations[18].sql.as_str();
assert!(mesh_retention.contains("buzz-mesh-member-status:%"));
assert!(mesh_retention.contains("buzz-mesh-status"));
assert!(mesh_retention
.contains("CREATE TRIGGER trg_events_purge_soft_deleted_buzz_mesh_status"));
assert!(!migrations[0]
.sql
.as_str()
.contains("purge_soft_deleted_buzz_mesh_status"));
// Join policy acceptances landed concurrently with mesh status retention;
// keep both additive migrations in a single, unambiguous sequence.
assert_eq!(migrations[19].version, 20);
assert!(migrations[19]
.sql
.as_str()
.contains("CREATE TABLE join_policy_acceptances"));
// Replica-fence commit-time floor guard on channel-bearing events.
assert_eq!(migrations[20].version, 21);
assert!(migrations[20]
.sql
.as_str()
.contains("events_created_at_floor_guard"));
assert!(!migrations[0]
.sql
.as_str()
.contains("join_policy_acceptances"));
// Channel TTL refresh belongs to the event insertion transaction so a
// concurrent permanent -> ephemeral transition cannot be missed.
assert_eq!(migrations[21].version, 22);
let ttl_refresh = migrations[21].sql.as_str();
assert!(ttl_refresh.contains("CREATE CONSTRAINT TRIGGER events_refresh_channel_ttl"));
assert!(ttl_refresh.contains("AFTER INSERT ON events"));
assert!(ttl_refresh.contains("DEFERRABLE INITIALLY DEFERRED"));
assert!(ttl_refresh.contains("clock_timestamp()"));
assert!(ttl_refresh.contains("NEW.kind <> 9007"));
// T1b push gate: the match-queue trigger only enqueues when the
// community has an eligible lease, ordered against lease activations
// through the shared/exclusive per-community advisory lock.
assert_eq!(migrations[22].version, 23);
let push_gate = migrations[22].sql.as_str();
assert!(push_gate.contains("CREATE OR REPLACE FUNCTION enqueue_push_match_job"));
assert!(push_gate.contains("pg_advisory_xact_lock_shared"));
assert!(push_gate.contains("'buzz_push_gate:' || NEW.community_id::text"));
assert!(push_gate.contains("endpoint_enabled"));
// T1a repair: the TTL refresh trigger synchronizes on a shared
// per-channel advisory lock instead of FOR UPDATE on the channel row,
// so permanent-channel commits no longer serialize.
assert_eq!(migrations[23].version, 24);
let ttl_shared = migrations[23].sql.as_str();
assert!(ttl_shared
.contains("CREATE OR REPLACE FUNCTION refresh_channel_ttl_after_event_insert"));
assert!(ttl_shared.contains("pg_advisory_xact_lock_shared"));
assert!(ttl_shared.contains("'buzz_channel_ttl:' || NEW.community_id::text"));
// The row read must be a bare SELECT (comments describe the removed
// FOR UPDATE; the executable body must not reintroduce it).
assert!(ttl_shared.contains("SELECT ttl_seconds INTO channel_ttl"));
assert!(!strip_sql_comments(ttl_shared)
.to_lowercase()
.contains("for update"));
assert!(ttl_shared.contains("NEW.kind <> 9007"));
// Use-limited invite links: durable relay_invites table stores only
// the SHA-256 of an opaque v2 code, scoped by community_id. Never
// listed in _operator_global_tables — it is community-scoped.
assert_eq!(migrations[24].version, 25);
let relay_invites = migrations[24].sql.as_str();
assert!(relay_invites.contains("CREATE TABLE relay_invites"));
assert!(relay_invites
.contains("token_hash BYTEA NOT NULL CHECK (length(token_hash) = 32)"));
assert!(relay_invites.contains("PRIMARY KEY (community_id, id)"));
assert!(relay_invites.contains("UNIQUE (community_id, token_hash)"));
assert!(
relay_invites.contains("max_uses INTEGER CHECK (max_uses BETWEEN 1 AND 10000)")
);
assert!(relay_invites.contains("CHECK (max_uses IS NULL OR use_count <= max_uses)"));
assert!(relay_invites.contains("role = 'member'"));
assert!(relay_invites
.contains("CREATE INDEX relay_invites_expires_at_idx ON relay_invites (expires_at)"));
assert!(!relay_invites.contains("_operator_global_tables"));
let desired_schema = include_str!("../../../../schema/schema.sql");
assert!(
desired_schema.contains("CREATE TABLE join_policy_acceptances"),
"desired-state schema must include join-policy evidence used by invite claims",
);
// Replica heartbeat (this branch, renumbered to 0026 after
// 0025_relay_invites landed on main): the fence's portable read-side
// observation. A single CHECK'd row makes the token update the
// serialization point (multi-pod commit ordering), and the epoch
// column is what detects token resets — both are load-bearing for
// the routing proof.
assert_eq!(migrations[25].version, 26);
let heartbeat = migrations[25].sql.as_str();
assert!(heartbeat.contains("CREATE TABLE replica_heartbeat"));
assert!(heartbeat.contains("CHECK (id = 1)"));
assert!(heartbeat.contains("epoch"));
assert!(heartbeat.contains("INSERT INTO replica_heartbeat (id) VALUES (1)"));
assert!(heartbeat.contains("_operator_global_tables"));
// Channel-id lookup index (0027): serves tenant-independent channel lookups.
assert_eq!(migrations[26].version, 27);
let channel_id_index = migrations[26].sql.as_str();
assert!(channel_id_index.contains("idx_channels_id_live"));
assert!(channel_id_index.contains("INCLUDE (community_id)"));
assert!(channel_id_index.contains("WHERE deleted_at IS NULL"));
assert!(!channel_id_index.contains("CREATE UNIQUE INDEX"));
assert!(desired_schema.contains("idx_channels_id_live"));
// Main owns 0028 for long reaction payloads.
assert_eq!(migrations[27].version, 28);
let long_reactions = migrations[27].sql.as_str();
assert!(
long_reactions.contains("ALTER TABLE reactions ALTER COLUMN emoji TYPE VARCHAR(66)")
);
assert!(desired_schema.contains("emoji VARCHAR(66) NOT NULL"));
// Durable whole-community deletion control plane and universal DB fence.
assert_eq!(migrations[28].version, 29);
let deletion = migrations[28].sql.as_str();
assert!(deletion.contains("CREATE TABLE community_deletion_requests"));
assert!(deletion.contains("CREATE TABLE community_deletion_approvals"));
assert!(deletion.contains("CREATE TABLE community_deletion_checkpoints"));
assert!(deletion.contains("CREATE TABLE community_serving_write_leases"));
assert!(deletion.contains("CREATE TABLE community_deletion_executor_heartbeats"));
assert!(deletion.contains("CREATE FUNCTION community_write_allowed"));
assert!(deletion.contains("LANGUAGE plpgsql VOLATILE"));
assert!(deletion.contains("CREATE FUNCTION assert_community_write_allowed"));
assert!(deletion.contains("current_setting('transaction_isolation') <> 'read committed'"));
assert!(deletion.contains("ERRCODE = 'invalid_transaction_state'"));
assert!(deletion.contains("CREATE FUNCTION enforce_community_write_fence"));
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"));
assert!(deletion.contains("community tombstones are permanent"));
assert!(deletion.contains("SET LOCAL lock_timeout = '5s'"));
assert!(deletion.contains("'active', 'quiescing', 'fenced', 'tombstone'"));
assert!(deletion.contains("_operator_global_tables"));
assert!(deletion.contains("'submitted', 'inventoried', 'approved', 'fenced', 'drained'"));
assert!(deletion.contains("UNIQUE (id, community_id, inventory_digest)"));
assert!(deletion.contains("FOREIGN KEY (request_id, community_id, inventory_digest)"));
assert!(deletion.contains("prevent_community_deletion_request_retargeting"));
assert!(deletion.contains("prevent_community_deletion_approval_removal"));
assert!(deletion.contains("retry_stage TEXT CHECK"));
assert!(desired_schema.contains("retry_stage TEXT CHECK"));
// Recovery migration 0030 alters populated tables and must preserve
// the same fail-fast lock behavior as the deletion migration.
assert_eq!(migrations[29].version, 30);
let deletion_recovery = migrations[29].sql.as_str();
assert!(deletion_recovery.contains("SET LOCAL lock_timeout = '5s'"));
// Mixed-version channel-roster fence: old canonical replacement writers
// acquire their replacement key before INSERT; this trigger then takes
// the membership key and validates the exact active pubkey/role p-tag set.
assert_eq!(migrations[31].version, 32);
let roster_fence = migrations[31].sql.as_str();
assert!(roster_fence.contains("CREATE TRIGGER trg_events_guard_channel_roster_snapshot"));
assert!(roster_fence.contains("NEW.kind <> 39002"));
assert!(roster_fence.contains("'buzz_channel_membership:'"));
assert!(roster_fence.contains("cm.removed_at IS NULL"));
assert!(roster_fence.contains("cm.role::text"));
assert!(roster_fence.contains("jsonb_array_length(roster_tag.tag_json) <> 4"));
assert!(roster_fence.contains("roster_tag.tag_json->>3"));
assert!(roster_fence.contains("snapshot_members IS DISTINCT FROM canonical_members"));
assert!(roster_fence.contains("ERRCODE = '23514'"));
// Fresh desired-state bootstrap must install the identical executable
// fence as migration 0032. CI and isolated relay startup use schema.sql
// without running migrations, so drift reopens rolling-deploy races.
fn extract_roster_fence(sql: &str) -> &str {
let fence_start = "CREATE OR REPLACE FUNCTION guard_channel_roster_snapshot()";
let fence_end = " FOR EACH ROW EXECUTE FUNCTION guard_channel_roster_snapshot();";
let start = sql.find(fence_start).expect("roster fence function");
let relative_end = sql[start..].find(fence_end).expect("roster fence trigger");
&sql[start..start + relative_end + fence_end.len()]
}
assert_eq!(
extract_roster_fence(roster_fence),
extract_roster_fence(desired_schema)
);
// The single-row heartbeat table is updated continuously. Prevent
// autovacuum from truncating its heap so standby queries are not
// cancelled by the ACCESS EXCLUSIVE truncation lock replay.
assert_eq!(migrations[33].version, 34);
let heartbeat_vacuum = migrations[33].sql.as_str();
assert!(heartbeat_vacuum.contains("ALTER TABLE replica_heartbeat"));
assert!(heartbeat_vacuum.contains("vacuum_truncate = false"));
assert!(desired_schema.contains("vacuum_truncate = false"));
// pgschema intentionally reconciles DDL, not seed DML or table storage
// parameters. Its post-apply reconciliation must restore and verify
// both parts of the live heartbeat contract for fresh bootstraps.
let pgschema_reconciliation =
include_str!("../../../../scripts/reconcile-schema-after-pgschema.sql");
assert!(pgschema_reconciliation
.contains("ALTER TABLE replica_heartbeat SET (vacuum_truncate = false)"));
assert!(pgschema_reconciliation.contains("INSERT INTO replica_heartbeat (id) VALUES (1)"));
assert!(pgschema_reconciliation.contains("ON CONFLICT (id) DO NOTHING"));
assert!(pgschema_reconciliation.contains("pg_class"));
assert!(pgschema_reconciliation.contains("reloptions"));
assert_eq!(migrations[34].version, 35);
let relay_operators = migrations[34].sql.as_str();
assert!(
relay_operators.contains("CREATE TABLE relay_operators"),
"migration 35 must create relay_operators"
);
assert!(
relay_operators.contains("_operator_global_tables"),
"migration 35 must register relay_operators in _operator_global_tables"
);
assert!(
relay_operators.contains("actor_authority"),
"migration 35 must add actor_authority to moderation_actions"
);
assert!(
relay_operators.contains("processing"),
"migration 35 must add processing status to moderation_reports"
);
assert_eq!(migrations[35].version, 36);
let relay_admin_actions = migrations[35].sql.as_str();
assert!(
relay_admin_actions.contains("CREATE TABLE relay_admin_actions"),
"migration 36 must create relay_admin_actions"
);
assert!(
relay_admin_actions.contains("CREATE TABLE relay_admin_outbox"),
"migration 36 must create relay_admin_outbox"
);
assert!(
relay_admin_actions.contains("request_id"),
"migration 36 relay_admin_actions must include request_id for idempotency"
);
assert!(
relay_admin_actions.contains("step_marker"),
"migration 36 relay_admin_actions must include step_marker for crash recovery"
);
assert_eq!(migrations[36].version, 37);
let action_lease = migrations[36].sql.as_str();
assert!(
action_lease.contains("action_lease_token"),
"migration 37 must add action_lease_token to relay_admin_actions"
);
assert!(
action_lease.contains("action_lease_expires_at"),
"migration 37 must add action_lease_expires_at to relay_admin_actions"
);
assert!(
action_lease.contains("attempt_count"),
"migration 37 must add attempt_count to relay_admin_outbox"
);
assert!(
action_lease.contains("retry_after"),
"migration 37 must add retry_after to relay_admin_outbox"
);
assert_eq!(migrations[38].version, 39);
let operator_audit = migrations[38].sql.as_str();
assert!(
operator_audit.contains("CREATE TABLE relay_operator_audit"),
"migration 39 must create relay_operator_audit"
);
assert!(
operator_audit.contains("_operator_global_tables"),
"migration 39 must register relay_operator_audit in _operator_global_tables"
);
// NIP-FI core identity + base-lifecycle foundation (migration 0041) and
// final-admission foundation (0042). Both widen the single SQL source of
// truth `community_write_fence_excluded_table` so their durable,
// immutable ledger relations are never fence-attached, purged, or
// counted as tenant-scoped drift. schema.sql keeps one consolidated
// definition of that function whose body must match 0042's exactly.
assert_eq!(migrations[40].version, 41);
let identity_foundation = migrations[40].sql.as_str();
assert!(identity_foundation.contains("CREATE TABLE identity_bindings"));
assert!(identity_foundation.contains("CREATE TABLE identity_lifecycle_history"));
assert!(identity_foundation
.contains("CREATE OR REPLACE FUNCTION community_write_fence_excluded_table"));
assert!(identity_foundation.contains("'identity_bindings'"));
assert_eq!(migrations[41].version, 42);
let authorization_foundation = migrations[41].sql.as_str();
assert!(authorization_foundation.contains("CREATE TABLE authorization_events"));
assert!(authorization_foundation.contains("CREATE TABLE protected_object_authority"));
assert!(authorization_foundation.contains("CREATE TABLE authorization_admission_results"));
// The consolidated desired-state exclusion function must byte-match
// migration 0042's CREATE OR REPLACE body, or a future schema
// consolidation would silently drop NIP-FI relations from the ledger.
fn extract_excluded_table_array(sql: &str) -> &str {
let anchor = "community_write_fence_excluded_table(target NAME) RETURNS BOOLEAN";
let start = sql.find(anchor).expect("exclusion function definition");
let array_start = sql[start..].find("ARRAY[").expect("exclusion array") + start;
let array_end = sql[array_start..]
.find("]::TEXT[]")
.expect("exclusion array end")
+ array_start;
&sql[array_start..array_end]
}
assert_eq!(
extract_excluded_table_array(authorization_foundation),
extract_excluded_table_array(desired_schema),
"schema.sql exclusion list drifted from migration 0042"
);
}
#[test]
fn every_pgschema_apply_runs_post_apply_reconciliation() {
fn files_under(root: &Path) -> Vec<PathBuf> {
let mut pending = vec![root.to_owned()];
let mut files = Vec::new();
while let Some(path) = pending.pop() {
for entry in fs::read_dir(&path)
.unwrap_or_else(|error| panic!("could not read {}: {error}", path.display()))
{
let path = entry.expect("directory entry").path();
if path.is_dir() {
pending.push(path);
} else {
files.push(path);
}
}
}
files
}
let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let roots = [
repo_root.join("scripts"),
repo_root.join(".github/workflows"),
];
let mut apply_count = 0;
for path in roots.iter().flat_map(|root| files_under(root)) {
let Ok(contents) = fs::read_to_string(&path) else {
continue;
};
let lines: Vec<_> = contents.lines().collect();
for (index, line) in lines.iter().enumerate() {
if !line.contains("./bin/pgschema apply") {
continue;
}
apply_count += 1;
let following_lines = &lines[index + 1..(index + 7).min(lines.len())];
assert!(
following_lines.iter().any(|line| line.contains(
"scripts/reconcile-schema-after-pgschema.sql"
)),
"{} must run scripts/reconcile-schema-after-pgschema.sql immediately after pgschema apply",
path.display()
);
}
}
assert!(
apply_count > 0,
"expected at least one pgschema apply caller"
);
}
#[test]
fn workflow_run_error_codes_are_additive_and_backfilled_without_parsing_diagnostics() {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);
assert_eq!(migrations[30].version, 31);
let sql = migrations[30].sql.as_str();
assert!(sql.contains("ALTER TABLE workflow_runs ADD COLUMN error_code TEXT"));
assert!(sql.contains("SET error_code = 'legacy_unclassified'"));
assert!(sql.contains("status IN ('failed', 'cancelled')"));
assert!(!sql.contains("error_message LIKE"));
assert!(!MIGRATOR
.iter()
.find(|migration| migration.version == 1)
.expect("initial migration")
.sql
.as_str()
.contains("error_code"));
assert!(include_str!("../../../../schema/schema.sql").contains("error_code TEXT"));
}
#[test]
fn push_match_trigger_is_narrowed_to_message_kinds_additively() {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);
assert_eq!(migrations[39].version, 40);
let sql = migrations[39].sql.as_str();
assert!(sql.contains("CREATE OR REPLACE FUNCTION enqueue_push_match_job"));
assert!(sql.contains("NEW.kind IN (9, 40002, 45001, 45003)"));
assert!(!sql.contains("NEW.kind IN (7, 9, 1059, 40007, 46010)"));
let desired_schema = include_str!("../../../../schema/schema.sql");
assert!(desired_schema.contains("NEW.kind IN (9, 40002, 45001, 45003)"));
assert!(!desired_schema.contains("NEW.kind IN (7, 9, 1059, 40007, 46010)"));
}
#[test]
fn migration_lint_detects_tables_missing_community_id_by_default() {
let sql = r#"
CREATE TABLE communities (id UUID PRIMARY KEY);
CREATE TABLE widgets (id UUID PRIMARY KEY);
CREATE TABLE _operator_global_tables (table_name TEXT PRIMARY KEY, reason TEXT NOT NULL);
INSERT INTO _operator_global_tables (table_name, reason) VALUES
('communities', 'tenant registry'),
('_operator_global_tables', 'registry');
"#;
let definitions = create_table_definitions(sql);
let scoped = scoped_tables(sql);
let missing = definitions
.into_iter()
.filter(|(table, _)| scoped.contains(table))
.filter(|(_, definitions)| !table_has_not_null_community_id(definitions))
.map(|(table, _)| table)
.collect::<Vec<_>>();
assert_eq!(missing, vec!["widgets"]);
}
#[test]
fn migration_lint_detects_scoped_key_constraints_not_led_by_community_id() {
let sql = r#"
CREATE TABLE widgets (
community_id UUID NOT NULL,
id UUID PRIMARY KEY,
channel_id UUID REFERENCES channels(id),
slug TEXT,
CONSTRAINT widgets_name_unique UNIQUE (slug),
CONSTRAINT widgets_parent_fk FOREIGN KEY (channel_id) REFERENCES channels(id)
);
CREATE UNIQUE INDEX idx_widgets_slug ON widgets (slug);
ALTER TABLE widgets ADD CONSTRAINT widgets_alter_slug_unique UNIQUE (slug);
ALTER TABLE widgets ADD CONSTRAINT widgets_alter_parent_fk FOREIGN KEY (channel_id) REFERENCES channels(id);
CREATE TABLE _operator_global_tables (table_name TEXT PRIMARY KEY, reason TEXT NOT NULL);
INSERT INTO _operator_global_tables (table_name, reason) VALUES
('_operator_global_tables', 'registry');
"#;
let violations = scoped_constraint_violations(sql);
assert!(violations
.iter()
.any(|violation| violation.kind == ConstraintKind::PrimaryKey));
assert_eq!(
violations
.iter()
.filter(|violation| violation.kind == ConstraintKind::ForeignKey)
.count(),
3
);
assert_eq!(
violations
.iter()
.filter(|violation| violation.kind == ConstraintKind::Unique)
.count(),
3
);
}
#[test]
fn migration_lint_accepts_scoped_key_constraints_led_by_community_id() {
let sql = r#"
CREATE TABLE widgets (
community_id UUID NOT NULL,
id UUID NOT NULL,
channel_id UUID NOT NULL,
slug TEXT NOT NULL,
PRIMARY KEY (community_id, id),
UNIQUE (community_id, slug),
FOREIGN KEY (community_id, channel_id) REFERENCES channels(community_id, id)
);
CREATE UNIQUE INDEX idx_widgets_slug ON widgets (community_id, slug);
ALTER TABLE widgets ADD CONSTRAINT widgets_alter_slug_unique UNIQUE (community_id, slug);
ALTER TABLE widgets ADD CONSTRAINT widgets_alter_parent_fk FOREIGN KEY (community_id, channel_id) REFERENCES channels(community_id, id);
CREATE TABLE _operator_global_tables (table_name TEXT PRIMARY KEY, reason TEXT NOT NULL);
INSERT INTO _operator_global_tables (table_name, reason) VALUES
('_operator_global_tables', 'registry');
"#;
assert!(scoped_constraint_violations(sql).is_empty());
}
#[test]
fn all_non_operator_global_tables_have_not_null_community_id() {
let sql = migration_sql();
let sql = sql.as_str();
let scoped = scoped_tables(sql);
let missing = create_table_definitions(sql)
.into_iter()
.filter(|(table, _)| scoped.contains(table))
.filter(|(_, definitions)| !table_has_not_null_community_id(definitions))
.map(|(table, _)| table)
.collect::<Vec<_>>();
assert!(
missing.is_empty(),
"every table not listed in _operator_global_tables must carry NOT NULL community_id; missing: {}",
missing.join(", ")
);
}
#[test]
fn scoped_primary_key_unique_and_foreign_key_constraints_lead_with_community_id() {
let sql = migration_sql();
let sql = sql.as_str();
let violations = scoped_constraint_violations(sql)
.into_iter()
.map(|constraint| {
format!(
"{}. {:?} constraint must lead with community_id: {}",
constraint.table, constraint.kind, constraint.description
)
})
.collect::<Vec<_>>();
assert!(
violations.is_empty(),
"tenant-scoped tables are all tables not listed in _operator_global_tables; primary key, unique/FK constraints, and unique indexes on those tables must lead with community_id:\n{}",
violations.join("\n")
);
}
#[test]
fn channels_community_id_is_immutable_after_insert() {
let sql = migration_sql();
let sql = sql.as_str();
let forbidden_mutations = forbidden_channels_community_id_mutations(sql);
assert!(
forbidden_mutations.is_empty(),
"channels.community_id must not be re-tenanted after insert; forbidden migration statements:\n{}",
forbidden_mutations.join("\n---\n")
);
assert!(
has_channels_community_id_immutability_guard(sql),
"migrations define channels.community_id but no BEFORE UPDATE trigger/function guard that rejects OLD.community_id <> NEW.community_id was found"
);
}
#[test]
fn migration_execution_cannot_bypass_schema_destruction_lock() {
fn rust_sources(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf>) {
for entry in std::fs::read_dir(dir).expect("read workspace source dir") {
let path = entry.expect("read workspace source entry").path();
if path.is_dir() {
if path.file_name().is_some_and(|name| name == "target") {
continue;
}
rust_sources(&path, files);
} else if path.extension().is_some_and(|ext| ext == "rs") {
files.push(path);
}
}
}
fn count(haystack: &str, needle: &str) -> usize {
haystack.matches(needle).count()
}
// Build the needles so this test's own source never matches them.
let migrate_macro = ["sqlx", "::migrate!"].concat();
let migrator_run = ["MIGRATOR", ".run("].concat();
let migrator_run_to = ["MIGRATOR", ".run_to("].concat();
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let this_file = manifest_dir.join("src/runtime/migration.rs");
let crates_dir = manifest_dir.parent().expect("workspace crates dir");
// The push gateway migrates its own dedicated authority database; it
// never holds relay tenant tables, so it is exempt from the relay
// schema/destruction lock. The community_id check below keeps that
// exemption honest.
let push_gateway_exception = crates_dir.join("buzz-push-gateway/src/postgres.rs");
let push_gateway_migrations = crates_dir.join("buzz-push-gateway/migrations");
for entry in
std::fs::read_dir(&push_gateway_migrations).expect("read push gateway migrations")
{
let path = entry.expect("read push gateway migration entry").path();
let sql = std::fs::read_to_string(&path).expect("read push gateway migration");
assert!(
!sql.to_ascii_lowercase().contains("community_id"),
"{} defines community-scoped data; its migrator would bypass the \
schema/destruction lock and must move under buzz-db migrations",
path.display()
);
}
let mut files = Vec::new();
rust_sources(crates_dir, &mut files);
for path in &files {
let source = std::fs::read_to_string(path).expect("read rust source");
let (macro_hits, run_hits, run_to_hits) = (
count(&source, &migrate_macro),
count(&source, &migrator_run),
count(&source, &migrator_run_to),
);
if *path == this_file {
assert_eq!(
(macro_hits, run_hits, run_to_hits),
(1, 1, 1),
"migration.rs must embed the migrator once, run it once in production, \
and expose exactly one test-only bounded run"
);
} else if *path == push_gateway_exception {
continue;
} else {
assert_eq!(
(macro_hits, run_hits, run_to_hits),
(0, 0, 0),
"{} embeds or runs a SQLx migrator outside the schema/destruction \
lock contract; route migration execution through \
buzz_db migration::run_migrations",
path.display()
);
}
}
// Within migration.rs, the single run site must sit inside
// `run_migrations_locked`, and the only public entry point must wrap
// it in the exclusive session lock.
let source = std::fs::read_to_string(&this_file).expect("read migration.rs");
let entry = source
.find("pub async fn run_migrations(")
.expect("public migration entry point");
let locked = source
.find("async fn run_migrations_locked(")
.expect("locked migration body");
let wrapper = source
.find("async fn with_exclusive_schema_destruction_lock")
.expect("exclusive lock wrapper");
let run_site = source.find(&migrator_run).expect("migrator run site");
let run_to_site = source
.find(&migrator_run_to)
.expect("bounded test migrator run site");
assert!(
source[entry..locked].contains("with_exclusive_schema_destruction_lock("),
"run_migrations must delegate through the exclusive schema/destruction lock"
);
assert!(
run_site > locked && run_site < wrapper,
"the production migrator run site must live inside run_migrations_locked"
);
assert!(
run_to_site > entry
&& run_to_site < locked
&& source[entry..run_to_site].contains("#[cfg(test)]")
&& source[entry..run_to_site].contains("with_exclusive_schema_destruction_lock("),
"the bounded migrator run must remain test-only and use the exclusive lock wrapper"
);
assert!(
source[wrapper..].contains("pg_advisory_lock($1)")
&& source[wrapper..].contains("pg_advisory_unlock($1)"),
"the lock wrapper must acquire and explicitly release the session lock"
);
}
/// Structural parity between migration 0029's deletion surface and the
/// desired-state bootstrap schema (`schema/schema.sql`).
///
/// 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
/// 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
/// pre-parity schema.sql omitted `community_deletion_manifest_keys` (and
/// its immutability trigger) and `storage_taxonomy_sweeps` — booting
/// healthy, then wedging post-fence when the freeze stage first touched
/// the missing relation.
#[test]
fn deletion_surface_parity_between_migration_0029_and_schema_sql() {
use std::collections::BTreeMap;
#[derive(Default)]
struct DeletionSurface {
tables: BTreeMap<String, String>,
functions: BTreeMap<String, String>,
triggers: BTreeMap<String, String>,
indexes: BTreeSet<String>,
registry_rows: BTreeSet<(String, String)>,
fence_attachments: BTreeSet<String>,
communities_added_columns: BTreeSet<String>,
}
fn quoted_strings(statement: &str) -> Vec<String> {
let mut strings = Vec::new();
let mut current: Option<String> = None;
let mut chars = statement.chars().peekable();
while let Some(ch) = chars.next() {
match (&mut current, ch) {
(None, '\'') => current = Some(String::new()),
(Some(literal), '\'') => {
if chars.peek() == Some(&'\'') {
literal.push('\'');
chars.next();
} else {
strings.push(current.take().expect("open literal"));
}
}
(Some(literal), other) => literal.push(other),
(None, _) => {}
}
}
strings
}
fn surface(sql: &str) -> DeletionSurface {
let mut surface = DeletionSurface::default();
for statement in split_sql_statements(sql) {
let normalized = normalize_sql(&statement);
if normalized.starts_with("create table") {
let table = identifier_after_keyword(&statement, "create table")
.expect("table identifier");
surface.tables.insert(table, normalized.clone());
} else if normalized.starts_with("create function")
|| normalized.starts_with("create or replace function")
{
let function = identifier_after_keyword(&statement, "function")
.expect("function identifier");
surface.functions.insert(function, normalized.clone());
} else if normalized.starts_with("create trigger") {
let trigger = identifier_after_keyword(&statement, "create trigger")
.expect("trigger identifier");
surface.triggers.insert(trigger, normalized.clone());
} else if normalized.starts_with("create index")
|| normalized.starts_with("create unique index")
{
surface.indexes.insert(normalized.clone());
} else if normalized.starts_with("insert into _operator_global_tables") {
let literals = quoted_strings(&statement);
assert!(
literals.len().is_multiple_of(2),
"operator-global registry insert must be (table_name, reason) rows"
);
for row in literals.chunks(2) {
surface
.registry_rows
.insert((row[0].clone(), row[1].clone()));
}
} else if normalized.starts_with("alter table communities") {
for added in normalized.split("add column ").skip(1) {
let column = added
.split_whitespace()
.next()
.expect("added column name")
.to_owned();
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
}
let migration_0029: &str = MIGRATOR
.iter()
.find(|migration| migration.version == 29)
.expect("embedded migration 0029")
.sql
.as_ref();
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(std::path::Path::parent)
.expect("workspace root");
let schema_sql = std::fs::read_to_string(workspace_root.join("schema/schema.sql"))
.expect("read schema/schema.sql");
let migration = surface(migration_0029);
let schema = surface(&schema_sql);
assert_eq!(
migration.tables.len(),
7,
"0029 deletion control plane must define exactly the known tables: {:?}",
migration.tables.keys().collect::<Vec<_>>()
);
assert!(!migration.fence_attachments.is_empty());
assert!(!migration.registry_rows.is_empty());
for (table, definition) in &migration.tables {
let in_schema = schema
.tables
.get(table)
.unwrap_or_else(|| panic!("schema.sql is missing deletion table {table}"));
if table != "community_deletion_requests" {
assert_eq!(
in_schema, definition,
"schema.sql definition of {table} drifted from migration 0029"
);
}
}
for (function, definition) in &migration.functions {
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" {
assert_eq!(
in_schema, definition,
"schema.sql definition of {function}() drifted from migration 0029"
);
}
}
for (trigger, definition) in &migration.triggers {
let in_schema = schema
.triggers
.get(trigger)
.unwrap_or_else(|| panic!("schema.sql is missing deletion trigger {trigger}"));
assert_eq!(
in_schema, definition,
"schema.sql definition of trigger {trigger} drifted from migration 0029"
);
}
for index in &migration.indexes {
assert!(
schema.indexes.contains(index),
"schema.sql is missing (or drifted on) deletion index: {index}"
);
}
for row in &migration.registry_rows {
assert!(
schema.registry_rows.contains(row),
"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");
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
// exist there (exact definition equality is impossible across the
// ALTER/inline representations — behavior is pinned by the
// desired-state bootstrap deletion test).
let communities_columns = split_sql_statements(&schema_sql)
.into_iter()
.find_map(|statement| {
let (table, body) = create_table_body(&statement)?;
(table == "communities").then_some(body)
})
.expect("schema.sql defines communities");
let column_names: BTreeSet<String> = communities_columns
.iter()
.filter_map(|definition| column_definition_name(definition))
.collect();
for column in &migration.communities_added_columns {
assert!(
column_names.contains(column),
"schema.sql communities table is missing 0028 column {column}"
);
}
assert!(!migration.communities_added_columns.is_empty());
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn schema_destruction_lock_excludes_shared_holders_and_releases_on_both_paths() {
let pool = connect_test_pool().await;
async fn assert_exclusive_lock_free(pool: &PgPool) {
let mut probe = pool.acquire().await.expect("acquire lock probe");
let free: bool = sqlx::query_scalar("SELECT pg_try_advisory_lock($1)")
.bind(SCHEMA_DESTRUCTION_LOCK_KEY)
.fetch_one(&mut *probe)
.await
.expect("probe try-lock");
assert!(free, "schema/destruction session lock must be released");
sqlx::query("SELECT pg_advisory_unlock($1)")
.bind(SCHEMA_DESTRUCTION_LOCK_KEY)
.execute(&mut *probe)
.await
.expect("probe unlock");
}
let probe_pool = pool.clone();
with_exclusive_schema_destruction_lock(&pool, move |conn| async move {
// While a migration run is in flight, destructive transactions
// must be unable to take their shared counterpart.
let mut probe = probe_pool.acquire().await.expect("acquire shared probe");
let shared_available: bool =
sqlx::query_scalar("SELECT pg_try_advisory_lock_shared($1)")
.bind(SCHEMA_DESTRUCTION_LOCK_KEY)
.fetch_one(&mut *probe)
.await
.expect("probe shared try-lock");
assert!(
!shared_available,
"exclusive migration lock must exclude shared destructive holders"
);
(conn, Ok(()))
})
.await
.expect("locked migration op");
assert_exclusive_lock_free(&pool).await;
let failed: Result<()> = with_exclusive_schema_destruction_lock(&pool, |conn| async {
(
conn,
Err(crate::DbError::InvalidData(
"forced migration failure".into(),
)),
)
})
.await;
assert!(failed.is_err(), "op failure must propagate");
assert_exclusive_lock_free(&pool).await;
}
/// Cancellation must not release the exclusion contract while migration
/// SQL is still executing server-side.
///
/// The op parks an `ALTER TABLE` behind an ACCESS EXCLUSIVE table lock
/// held by another session, then the whole locked run is aborted. Because
/// the advisory lock lives on the same backend that runs the DDL,
/// dropping the client future cannot release it: the backend keeps the
/// session lock until it finishes the statement and dies on the closed
/// socket. The shared (destructive) counterpart must stay unavailable for
/// that entire interval — and the orphaned DDL really does commit after
/// cancellation, which is exactly the window the lock has to cover.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn cancelled_migration_cannot_expose_shared_lock_while_ddl_backend_lives() {
use std::time::Instant;
use sqlx::AssertSqlSafe;
// Dedicated database: the probe table and the orphaned backend must
// stay invisible to concurrent tests in the shared database.
let base_url = std::env::var("BUZZ_TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.unwrap_or_else(|_| TEST_DB_URL.to_owned());
let admin = PgPool::connect(&base_url)
.await
.expect("connect admin database");
let probe_db = format!("buzz_lock_cancel_{}", uuid::Uuid::new_v4().simple());
sqlx::query(AssertSqlSafe(format!("CREATE DATABASE {probe_db}")))
.execute(&admin)
.await
.expect("create probe database");
let (base_prefix, _) = base_url.rsplit_once('/').expect("database url has a path");
let pool = PgPool::connect(&format!("{base_prefix}/{probe_db}"))
.await
.expect("connect probe database");
sqlx::query("CREATE TABLE schema_lock_cancel_probe (id int)")
.execute(&pool)
.await
.expect("create probe table");
// Park the migration DDL server-side: the op's ALTER TABLE waits on
// this ACCESS EXCLUSIVE lock, pinning the backend mid-statement.
let mut blocker = pool.begin().await.expect("open blocker transaction");
sqlx::query("LOCK TABLE schema_lock_cancel_probe IN ACCESS EXCLUSIVE MODE")
.execute(&mut *blocker)
.await
.expect("hold probe table lock");
let (pid_tx, pid_rx) = tokio::sync::oneshot::channel::<i32>();
let task_pool = pool.clone();
let locked_run = tokio::spawn(async move {
with_exclusive_schema_destruction_lock(&task_pool, move |mut conn| async move {
let outcome: Result<()> = async {
let pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()")
.fetch_one(&mut conn)
.await?;
let _ = pid_tx.send(pid);
sqlx::query(
"ALTER TABLE schema_lock_cancel_probe \
ADD COLUMN committed_after_cancel int",
)
.execute(&mut conn)
.await?;
Ok(())
}
.await;
(conn, outcome)
})
.await
});
let ddl_pid = pid_rx.await.expect("locked op reports its backend pid");
let deadline = Instant::now() + std::time::Duration::from_secs(10);
loop {
let waiting: bool = sqlx::query_scalar(
"SELECT EXISTS (SELECT 1 FROM pg_stat_activity \
WHERE pid = $1 AND wait_event_type = 'Lock')",
)
.bind(ddl_pid)
.fetch_one(&pool)
.await
.expect("poll DDL wait state");
if waiting {
break;
}
assert!(
Instant::now() < deadline,
"migration DDL never parked on the table lock"
);
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
locked_run.abort();
let joined = locked_run.await;
assert!(
joined.is_err_and(|err| err.is_cancelled()),
"locked migration run must abort mid-statement"
);
// The client future is gone, but the DDL backend is alive: the shared
// destructive lock must remain unavailable for that whole interval.
for _ in 0..20 {
let backend_alive: bool =
sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE pid = $1)")
.bind(ddl_pid)
.fetch_one(&pool)
.await
.expect("poll DDL backend liveness");
assert!(
backend_alive,
"parked DDL backend must outlive client cancellation"
);
let shared_free: bool = sqlx::query_scalar("SELECT pg_try_advisory_lock_shared($1)")
.bind(SCHEMA_DESTRUCTION_LOCK_KEY)
.fetch_one(&pool)
.await
.expect("probe shared lock");
assert!(
!shared_free,
"cancellation must not expose the shared lock while migration DDL is executing"
);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
// Release the table lock: the orphaned backend finishes the ALTER,
// commits, then exits on the dead socket — only then may shared
// destructive holders enter.
blocker.rollback().await.expect("release probe table lock");
let mut probe = pool.acquire().await.expect("acquire shared-lock probe");
let deadline = Instant::now() + std::time::Duration::from_secs(30);
loop {
let shared_free: bool = sqlx::query_scalar("SELECT pg_try_advisory_lock_shared($1)")
.bind(SCHEMA_DESTRUCTION_LOCK_KEY)
.fetch_one(&mut *probe)
.await
.expect("probe shared lock after backend exit");
if shared_free {
sqlx::query("SELECT pg_advisory_unlock_shared($1)")
.bind(SCHEMA_DESTRUCTION_LOCK_KEY)
.execute(&mut *probe)
.await
.expect("release shared probe lock");
break;
}
assert!(
Instant::now() < deadline,
"shared lock must become available once the DDL backend exits"
);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
drop(probe);
// The cancelled statement committed after the client vanished —
// exactly the interval the same-backend lock covered.
let committed: bool = sqlx::query_scalar(
"SELECT EXISTS (SELECT 1 FROM information_schema.columns \
WHERE table_name = 'schema_lock_cancel_probe' \
AND column_name = 'committed_after_cancel')",
)
.fetch_one(&pool)
.await
.expect("inspect orphaned DDL outcome");
assert!(
committed,
"orphaned migration DDL commits after cancellation; the lock must cover it"
);
pool.close().await;
sqlx::query(AssertSqlSafe(format!(
"DROP DATABASE {probe_db} WITH (FORCE)"
)))
.execute(&admin)
.await
.expect("drop probe database");
}
async fn connect_test_pool() -> PgPool {
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.unwrap_or_else(|_| TEST_DB_URL.to_owned());
PgPool::connect(&database_url)
.await
.expect("connect to test DB")
}
async fn reset_public_schema(pool: &PgPool) {
sqlx::query("DROP SCHEMA IF EXISTS public CASCADE")
.execute(pool)
.await
.expect("drop public schema");
sqlx::query("CREATE SCHEMA IF NOT EXISTS public")
.execute(pool)
.await
.expect("create public schema");
}
async fn applied_versions(pool: &PgPool) -> Vec<i64> {
sqlx::query_scalar::<_, i64>(
"SELECT version FROM _sqlx_migrations WHERE success ORDER BY version",
)
.fetch_all(pool)
.await
.expect("read applied migrations")
}
/// The desired-state file (`schema/schema.sql`) and the incremental
/// migrations are two independent sources of the same schema. When a
/// migration mutates the admin tables, `schema.sql` must be hand-updated to
/// match — nothing enforces that automatically, and the lease/claim-token
/// migrations (0035/0036) once drifted for exactly this reason.
///
/// This bootstraps one probe database from `schema.sql` **through the real
/// `bin/pgschema apply` binary** — the exact path CI (`ci.yml`) and both
/// test-relay launchers take — and migrates another through 1–38, then
/// asserts the three admin tables have identical column definitions (name,
/// type, nullability, default) and identical index shapes, including each
/// key's catalog sort/null options (`pg_index.indoption`). Columns are keyed
/// by name, not ordinal, because migrations append via `ALTER TABLE` while
/// `schema.sql` declares them inline — positions legitimately differ, shapes
/// must not.
///
/// Driving the real binary is load-bearing: `pgschema` 1.7.4 discards
/// per-key `NULLS FIRST`/`NULLS LAST` when it re-emits an index, so a naive
/// `sqlx::raw_sql(schema.sql)` bootstrap would preserve ordering the actual
/// deployment path silently drops — the same false-confidence class as the
/// drift this test guards against. `indoption` (not just `indexdef` text) is
/// asserted so a resurrected `NULLS FIRST` in a migration that `pgschema`
/// cannot represent is caught.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn admin_schema_parity_between_desired_state_and_migrations() {
use sqlx::AssertSqlSafe;
async fn columns(
pool: &PgPool,
table: &str,
) -> Vec<(
String,
String,
String,
Option<String>,
String,
Option<String>,
)> {
sqlx::query_as(
"SELECT column_name, data_type, is_nullable, column_default, \
is_identity, identity_generation \
FROM information_schema.columns \
WHERE table_schema = 'public' AND table_name = $1 \
ORDER BY column_name",
)
.bind(table)
.fetch_all(pool)
.await
.expect("read column definitions")
}
// Index name + rendered definition + per-key sort/null options. indoption
// is a int2vector rendered as text (e.g. `{2,0}` = NULLS FIRST ASC on key
// 0, plain ASC on key 1) so ordering divergences that `indexdef` text may
// still show but `pgschema` cannot reproduce are compared structurally.
async fn index_shapes(pool: &PgPool, table: &str) -> Vec<(String, String, String)> {
sqlx::query_as(
"SELECT c.relname, pg_get_indexdef(i.indexrelid), i.indoption::int2[]::text \
FROM pg_class c \
JOIN pg_index i ON i.indexrelid = c.oid \
JOIN pg_class t ON t.oid = i.indrelid \
JOIN pg_namespace n ON n.oid = t.relnamespace \
WHERE n.nspname = 'public' AND t.relname = $1 \
ORDER BY c.relname",
)
.bind(table)
.fetch_all(pool)
.await
.expect("read index shapes")
}
let base_url = std::env::var("BUZZ_TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.unwrap_or_else(|_| TEST_DB_URL.to_owned());
let conn = parse_pg_url(&base_url);
let admin = PgPool::connect(&base_url)
.await
.expect("connect admin database");
let (base_prefix, _) = base_url.rsplit_once('/').expect("database url has a path");
let desired_db = format!("buzz_admin_desired_{}", uuid::Uuid::new_v4().simple());
let migrated_db = format!("buzz_admin_migrated_{}", uuid::Uuid::new_v4().simple());
sqlx::query(AssertSqlSafe(format!("CREATE DATABASE {desired_db}")))
.execute(&admin)
.await
.expect("create desired-state probe database");
sqlx::query(AssertSqlSafe(format!("CREATE DATABASE {migrated_db}")))
.execute(&admin)
.await
.expect("create migrated probe database");
// Bootstrap the desired-state probe through the real pgschema binary, the
// same invocation the test-relay launchers use. The freshly-created probe
// db doubles as pgschema's plan database (--plan-*), which avoids the
// embedded-Postgres download and matches start-relay-for-tests.sh.
let pgschema = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bin/pgschema");
let schema_file =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../schema/schema.sql");
let port = conn.port.to_string();
let apply = std::process::Command::new(&pgschema)
.args([
"apply",
"--auto-approve",
"--file",
schema_file.to_str().expect("schema path utf-8"),
"--host",
&conn.host,
"--port",
&port,
"--user",
&conn.user,
"--password",
&conn.password,
"--db",
&desired_db,
"--plan-host",
&conn.host,
"--plan-port",
&port,
"--plan-user",
&conn.user,
"--plan-password",
&conn.password,
"--plan-db",
&desired_db,
])
.output()
.expect("run bin/pgschema apply (hermit env required)");
assert!(
apply.status.success(),
"pgschema apply failed: {}\n{}",
String::from_utf8_lossy(&apply.stdout),
String::from_utf8_lossy(&apply.stderr),
);
let desired = PgPool::connect(&format!("{base_prefix}/{desired_db}"))
.await
.expect("connect desired-state probe database");
let migrated = PgPool::connect(&format!("{base_prefix}/{migrated_db}"))
.await
.expect("connect migrated probe database");
MIGRATOR
.run_to(39, &migrated)
.await
.expect("apply migrations 1-39");
for table in [
"relay_admin_actions",
"relay_admin_outbox",
"relay_operator_audit",
] {
assert_eq!(
columns(&desired, table).await,
columns(&migrated, table).await,
"column parity mismatch for {table}: schema.sql desired state has drifted \
from the migrations; update schema/schema.sql to match"
);
assert_eq!(
index_shapes(&desired, table).await,
index_shapes(&migrated, table).await,
"index-shape parity mismatch for {table}: the pgschema-bootstrapped desired \
state (including per-key indoption) has drifted from the migrations. If a \
migration uses a construct pgschema cannot represent (e.g. NULLS FIRST), the \
migration and schema.sql must both use a representable shape."
);
}
desired.close().await;
migrated.close().await;
for probe_db in [desired_db, migrated_db] {
sqlx::query(AssertSqlSafe(format!(
"DROP DATABASE {probe_db} WITH (FORCE)"
)))
.execute(&admin)
.await
.expect("drop probe database");
}
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn pre_0007_ambiguous_nip_rs_data_blocks_without_mutation_and_allows_retry() {
let pool = connect_test_pool().await;
reset_public_schema(&pool).await;
MIGRATOR
.run_to(6, &pool)
.await
.expect("apply migrations 1-6");
let community_id = uuid::Uuid::new_v4();
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
.bind(community_id)
.bind(format!("pre-0007-{}.example", community_id.simple()))
.execute(&pool)
.await
.expect("insert community");
let event_id = vec![1_u8; 32];
let pubkey = vec![2_u8; 32];
let d_tag = format!("read-state:{}", "a".repeat(32));
let ambiguous_tags = serde_json::json!([["d", d_tag], ["d", "other"], ["t", "read-state"]]);
sqlx::query(
"INSERT INTO events \
(community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, d_tag) \
VALUES ($1, $2, $3, NOW(), 30078, $4, 'ambiguous', $5, NOW(), $6)",
)
.bind(community_id)
.bind(&event_id)
.bind(&pubkey)
.bind(&ambiguous_tags)
.bind(vec![3_u8; 64])
.bind(&d_tag)
.execute(&pool)
.await
.expect("insert ambiguous NIP-RS row");
let before_versions = applied_versions(&pool).await;
let before_row: (serde_json::Value, String) =
sqlx::query_as("SELECT tags, content FROM events WHERE community_id=$1 AND id=$2")
.bind(community_id)
.bind(&event_id)
.fetch_one(&pool)
.await
.expect("read ambiguous row before blocked migration");
let blocked = run_migrations(&pool).await;
assert!(blocked.is_err(), "ambiguous pre-0007 data must fail closed");
assert_eq!(applied_versions(&pool).await, before_versions);
let after_row: (serde_json::Value, String) =
sqlx::query_as("SELECT tags, content FROM events WHERE community_id=$1 AND id=$2")
.bind(community_id)
.bind(&event_id)
.fetch_one(&pool)
.await
.expect("blocked migration must preserve source row");
assert_eq!(after_row, before_row);
let repaired_tags = serde_json::json!([["d", d_tag], ["t", "read-state"]]);
sqlx::query("UPDATE events SET tags=$1 WHERE community_id=$2 AND id=$3")
.bind(repaired_tags)
.bind(community_id)
.bind(&event_id)
.execute(&pool)
.await
.expect("repair ambiguous row");
run_migrations(&pool)
.await
.expect("retry succeeds after operator repair");
let latest_version = MIGRATOR
.iter()
.map(|migration| migration.version)
.max()
.expect("embedded migrator is non-empty");
assert_eq!(
applied_versions(&pool).await.last().copied(),
Some(latest_version)
);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn populated_upgrade_preserves_search_policy_except_for_private_kinds() {
let pool = connect_test_pool().await;
reset_public_schema(&pool).await;
MIGRATOR
.run_to(7, &pool)
.await
.expect("apply migrations 1-7");
let community_id = uuid::Uuid::new_v4();
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
.bind(community_id)
.bind(format!("pre-0008-{}.example", community_id.simple()))
.execute(&pool)
.await
.expect("insert community");
for (marker, kind) in [(1_u8, 1_i32), (2_u8, 30_350_i32), (3_u8, 30_179_i32)] {
sqlx::query(
"INSERT INTO events \
(community_id, id, pubkey, created_at, kind, tags, content, sig, received_at) \
VALUES ($1, $2, $3, NOW(), $4, '[]'::jsonb, 'brownfield needle', $5, NOW())",
)
.bind(community_id)
.bind(vec![marker; 32])
.bind(vec![marker + 10; 32])
.bind(kind)
.bind(vec![marker + 20; 64])
.execute(&pool)
.await
.expect("insert brownfield event");
}
MIGRATOR
.run_to(11, &pool)
.await
.expect("apply main migrations through 11");
let before: Vec<(i32, bool)> = sqlx::query_as(
"SELECT kind, search_tsv @@ plainto_tsquery('simple', 'needle') \
FROM events ORDER BY kind",
)
.fetch_all(&pool)
.await
.expect("read pre-push search behavior");
assert_eq!(before, vec![(1, true), (30_179, true), (30_350, true)]);
// 0014 fixes 30350 only. A brownfield database that stopped here still
// tokenized kind:30179 ciphertext — the gap 0033 closes.
MIGRATOR
.run_to(32, &pool)
.await
.expect("apply migrations through 32");
let pre_0033: Vec<(i32, Option<bool>)> = sqlx::query_as(
"SELECT kind, search_tsv @@ plainto_tsquery('simple', 'needle') \
FROM events ORDER BY kind",
)
.fetch_all(&pool)
.await
.expect("read pre-0033 search behavior");
assert_eq!(
pre_0033,
vec![(1, Some(true)), (30_179, Some(true)), (30_350, None)]
);
run_migrations(&pool)
.await
.expect("apply remaining migrations to populated database");
let after: Vec<(i32, Option<bool>)> = sqlx::query_as(
"SELECT kind, search_tsv @@ plainto_tsquery('simple', 'needle') \
FROM events ORDER BY kind",
)
.fetch_all(&pool)
.await
.expect("read post-upgrade search behavior");
assert_eq!(after, vec![(1, Some(true)), (30_179, None), (30_350, None)]);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn run_migrations_applies_consolidated_initial_schema_on_fresh_database() {
let pool = connect_test_pool().await;
reset_public_schema(&pool).await;
run_migrations(&pool).await.expect("run migrations");
// Every embedded migration must apply, in order. Derive the expected
// list from the MIGRATOR itself so this doesn't go stale as additive
// migrations land (it previously hardcoded [1, 2, 3] and rotted).
let expected: Vec<i64> = {
let mut versions: Vec<i64> = MIGRATOR.iter().map(|m| m.version).collect();
versions.sort_unstable();
versions
};
assert_eq!(applied_versions(&pool).await, expected);
let sql = migration_sql();
let tables = create_tables(sql.as_str());
for table in [
"communities",
"events",
"channels",
"scheduled_workflow_fires",
"audit_log",
] {
let exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1)",
)
.bind(table)
.fetch_one(&pool)
.await
.unwrap_or_else(|err| panic!("check table {table}: {err}"));
assert!(
tables.contains(table),
"migration parser should see {table}"
);
assert!(exists, "migration should create {table}");
}
let search_expression: String = sqlx::query_scalar(
"SELECT pg_get_expr(adbin, adrelid) \
FROM pg_attrdef \
WHERE adrelid = 'events'::regclass \
AND adnum = (SELECT attnum FROM pg_attribute \
WHERE attrelid = 'events'::regclass \
AND attname = 'search_tsv')",
)
.fetch_one(&pool)
.await
.expect("read fresh-install search expression");
assert!(
search_expression.contains("ARRAY[0, 9, 40002, 45001, 45003]"),
"fresh-install search allowlist has the wrong kinds: {search_expression}"
);
assert!(
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)
.await
.expect("create table before late alter");
sqlx::query("ALTER TABLE late_altered_scoped ADD COLUMN community_id UUID NOT NULL")
.execute(&pool)
.await
.expect("add late community id");
sqlx::query("SELECT attach_community_write_fence('late_altered_scoped'::regclass)")
.execute(&pool)
.await
.expect("attach late alter fence");
let attached: Vec<String> = 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)",
)
.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)
.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",
)
.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");
})
.await
.expect("opposite cross-tenant updates must not deadlock");
sqlx::query("DROP TABLE late_created_scoped, late_altered_scoped")
.execute(&pool)
.await
.expect("drop late-table fixtures");
}
/// NIP-FI intermediate state: migration 0041 (identity + base lifecycle)
/// alone must present a coherent catalog. Its five community-scoped ledger
/// relations are immutable and durable, so they are registered in the
/// write-fence exclusion — never counted as tenant-scoped drift, never
/// fence-attached — and the exact deletion catalog must still validate.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn migration_0041_identity_foundation_is_durable_ledger_after_migration_a() {
let pool = connect_test_pool().await;
reset_public_schema(&pool).await;
MIGRATOR
.run_to(41, &pool)
.await
.expect("apply migrations 1-41");
// The five identity relations exist.
let identity_tables = [
"authorization_operation_receipts",
"identity_enrollment_policies",
"identity_bindings",
"identity_lifecycle_history",
"identity_lifecycle_selectors",
];
for table in identity_tables {
let exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS (SELECT 1 FROM information_schema.tables \
WHERE table_schema = 'public' AND table_name = $1)",
)
.bind(table)
.fetch_one(&pool)
.await
.unwrap_or_else(|err| panic!("check table {table}: {err}"));
assert!(exists, "migration 0041 must create {table}");
}
// Migration B's relations must NOT exist yet.
for table in ["authorization_events", "protected_object_authority"] {
let exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS (SELECT 1 FROM information_schema.tables \
WHERE table_schema = 'public' AND table_name = $1)",
)
.bind(table)
.fetch_one(&pool)
.await
.unwrap_or_else(|err| panic!("check table {table}: {err}"));
assert!(!exists, "{table} belongs to migration 0042, not 0041");
}
// Every identity relation is excluded from the write fence: none may
// appear as tenant-scoped drift or carry the fence trigger.
let scoped_or_fenced: Vec<String> = sqlx::query_scalar(
"WITH scoped AS ( \
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 \
AND NOT community_write_fence_excluded_table(c.relname) \
) \
SELECT relname FROM scoped \
WHERE relname = ANY($1) ORDER BY relname",
)
.bind(&identity_tables[..])
.fetch_all(&pool)
.await
.expect("read scoped identity relations");
assert!(
scoped_or_fenced.is_empty(),
"identity ledger relations must be write-fence excluded, not scoped: {scoped_or_fenced:?}"
);
// The exact deletion catalog validates: the excluded ledger relations
// do not perturb the scoped-table/fence equality check.
crate::deletion::DeletionStore::new(pool.clone())
.validate_catalog()
.await
.expect("deletion catalog validates after migration 0041");
// The immutability contract is enforced, not merely declared. TRUNCATE
// fires the statement-level guard unconditionally, so this proves the
// rejection without constructing a fully valid ledger row.
let rejected = sqlx::query("TRUNCATE identity_lifecycle_selectors")
.execute(&pool)
.await
.expect_err("identity_lifecycle_selectors truncation must be rejected");
assert!(
rejected.to_string().contains("cannot be truncated"),
"expected immutability rejection, got: {rejected}"
);
}
/// NIP-FI full state: migrations 0041 + 0042 together must present a
/// coherent 15-relation catalog with zero dangling foreign keys, all
/// relations write-fence excluded, and an intact exact deletion catalog.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn nip_fi_foundation_is_a_closed_durable_ledger_after_migrations_a_and_b() {
let pool = connect_test_pool().await;
reset_public_schema(&pool).await;
MIGRATOR
.run_to(42, &pool)
.await
.expect("apply migrations 1-42");
let nip_fi_tables = [
"authorization_admission_results",
"authorization_authentication_denial_attempts",
"authorization_authority_epochs",
"authorization_event_capacity",
"authorization_events",
"authorization_invalidation_domains",
"authorization_invalidation_floors",
"authorization_operation_receipts",
"authorization_operation_version_delta_manifests",
"authorization_operation_version_deltas",
"identity_bindings",
"identity_enrollment_policies",
"identity_lifecycle_history",
"identity_lifecycle_selectors",
"protected_object_authority",
];
// All fifteen relations exist.
let present: Vec<String> = sqlx::query_scalar(
"SELECT table_name FROM information_schema.tables \
WHERE table_schema = 'public' AND table_name = ANY($1) ORDER BY table_name",
)
.bind(&nip_fi_tables[..])
.fetch_all(&pool)
.await
.expect("read NIP-FI table catalog");
let mut expected: Vec<String> = nip_fi_tables.iter().map(|t| t.to_string()).collect();
expected.sort();
assert_eq!(
present, expected,
"all NIP-FI relations must exist after 0042"
);
// Zero dangling foreign keys: every FK target is a live relation.
let invalid_fks: i64 = sqlx::query_scalar(
"SELECT count(*)::BIGINT FROM pg_constraint \
WHERE contype = 'f' AND NOT convalidated",
)
.fetch_one(&pool)
.await
.expect("read FK validity");
assert_eq!(
invalid_fks, 0,
"no NIP-FI foreign key may be left unvalidated"
);
// None of the fifteen appear as tenant-scoped drift; all are excluded.
let scoped: Vec<String> = 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 \
AND NOT community_write_fence_excluded_table(c.relname) \
AND c.relname = ANY($1) ORDER BY c.relname",
)
.bind(&nip_fi_tables[..])
.fetch_all(&pool)
.await
.expect("read scoped NIP-FI relations");
assert!(
scoped.is_empty(),
"all NIP-FI ledger relations must be write-fence excluded: {scoped:?}"
);
// The exact deletion catalog validates with the full ledger present.
crate::deletion::DeletionStore::new(pool.clone())
.validate_catalog()
.await
.expect("deletion catalog validates after migrations 0041 + 0042");
// A migration-B relation is immutable too. TRUNCATE fires the
// statement-level guard unconditionally.
let rejected = sqlx::query("TRUNCATE authorization_admission_results")
.execute(&pool)
.await
.expect_err("authorization_admission_results truncation must be rejected");
assert!(
rejected.to_string().contains("cannot be truncated"),
"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(42, &pool)
.await
.expect("apply migrations 1-42");
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"
);
}
/// NIP-FI identity FK contract: a binding's provenance is determined from
/// operation evidence and is independent of the enrollment policy's mode.
/// The corrected FK references only `(community_id, policy_revision)`;
/// the original composite FK `(community_id, policy_revision,
/// binding_provenance) → (community_id, policy_revision, enrollment_mode)`
/// would have rejected valid admissions such as TOFU policy +
/// attested-key provenance (NIP-FI.md §352, §424).
#[tokio::test]
#[ignore = "requires Postgres"]
async fn identity_binding_provenance_is_independent_of_enrollment_mode() {
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!("provenance-{}.example", community_id.simple()))
.execute(&pool)
.await
.expect("insert community");
// Enrollment policy: mode 3 (TOFU).
let policy_revision: i64 = 1;
sqlx::query(
"INSERT INTO identity_enrollment_policies \
(community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \
VALUES ($1, $2, 3, $3, '2026-01-01T00:00:00Z')",
)
.bind(community_id)
.bind(policy_revision)
.bind(vec![0xA0_u8; 32]) // policy_digest
.execute(&pool)
.await
.expect("insert TOFU enrollment policy");
// Insert a binding with provenance 1 (attested-key) under the TOFU
// policy. The circular deferred FK between identity_bindings and
// identity_lifecycle_history requires both to be committed in one
// transaction; all cross-table FKs in this pair are DEFERRABLE
// INITIALLY DEFERRED. A pinned connection is required so that BEGIN
// and each subsequent statement share the same session/transaction.
let binding_id = uuid::Uuid::new_v4();
let history_id = uuid::Uuid::new_v4();
let operation_id = uuid::Uuid::new_v4();
let request_fingerprint = vec![0xAB_u8; 32];
let mut conn = pool.acquire().await.expect("acquire connection");
sqlx::query("BEGIN")
.execute(&mut *conn)
.await
.expect("begin");
// Enrollment history must be inserted BEFORE the operation receipt:
// authorization_operation_receipt_history_guard_v1 fires AFTER INSERT
// on authorization_operation_receipts and checks that lifecycle receipts
// already have exactly one history row. The history → receipt FK is
// DEFERRABLE INITIALLY DEFERRED, so this order is safe.
sqlx::query(
"INSERT INTO identity_lifecycle_history \
(community_id, history_id, transition_kind, outcome_code, \
successor_binding_id, successor_binding_version, \
successor_lifecycle_revision, successor_state, \
operation_id, request_fingerprint, transition_digest) \
VALUES ($1, $2, 1, 1, $3, 1, 1, 1, $4, $5, $6)",
)
.bind(community_id)
.bind(history_id)
.bind(binding_id)
.bind(operation_id)
.bind(&request_fingerprint)
.bind(vec![0xAE_u8; 32])
.execute(&mut *conn)
.await
.expect("insert lifecycle history");
// Operation receipt: kind 1 (enroll), outcome 1 (applied).
// The receipt_history_cardinality trigger fires here and validates the
// history row inserted above.
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, 1, $4, 1, $5)",
)
.bind(community_id)
.bind(operation_id)
.bind(&request_fingerprint)
.bind(vec![0xAC_u8; 32])
.bind(vec![0xAD_u8; 32])
.execute(&mut *conn)
.await
.expect("insert operation receipt");
// Binding: provenance 1 (attested-key) under TOFU-mode policy.
// Before the FK fix this INSERT would fail at commit with a FK
// violation because 1 (attested-key) ≠ 3 (TOFU mode).
sqlx::query(
"INSERT INTO identity_bindings \
(community_id, binding_id, issuer, subject, \
principal_fingerprint, event_author_pubkey, \
binding_state, lifecycle_revision, binding_provenance, \
policy_revision, enrollment_evidence_digest, \
birth_history_id, creation_operation_id, \
creation_request_fingerprint) \
VALUES ($1, $2, 'https://issuer.example', 'sub-01', \
$3, $4, 1, 1, 1, $5, $6, $7, $8, $9)",
)
.bind(community_id)
.bind(binding_id)
.bind(vec![0xAF_u8; 32]) // principal_fingerprint
.bind(vec![0xB0_u8; 32]) // event_author_pubkey
.bind(policy_revision)
.bind(vec![0xB1_u8; 32]) // enrollment_evidence_digest
.bind(history_id)
.bind(operation_id)
.bind(&request_fingerprint)
.execute(&mut *conn)
.await
.expect("insert binding");
sqlx::query("COMMIT")
.execute(&mut *conn)
.await
.expect("attested-key binding under TOFU policy must commit — FK is on (community_id, policy_revision) only");
// Confirm the binding persisted with provenance 1, policy mode 3.
let (stored_provenance, stored_mode): (i16, i16) = sqlx::query_as(
"SELECT b.binding_provenance, p.enrollment_mode \
FROM identity_bindings b \
JOIN identity_enrollment_policies p \
ON p.community_id = b.community_id AND p.policy_revision = b.policy_revision \
WHERE b.community_id = $1 AND b.binding_id = $2",
)
.bind(community_id)
.bind(binding_id)
.fetch_one(&pool)
.await
.expect("read persisted binding");
assert_eq!(stored_provenance, 1, "provenance must be attested-key (1)");
assert_eq!(stored_mode, 3, "enrollment mode must be TOFU (3)");
assert_ne!(
stored_provenance, stored_mode,
"provenance and mode are independent: they must differ here"
);
// --- Negative half: absent policy revision ---
//
// Two-sided mutation sensitivity requires that a FK dropped or neutered
// entirely is also detected. A second otherwise-valid deferred
// transaction uses a nonexistent policy_revision (999) and must fail
// with SQLSTATE 23503 — the narrowed FK
// identity_bindings(community_id, policy_revision)
// → identity_enrollment_policies(community_id, policy_revision)
// rejects the row. This FK is not deferred, so it fires at INSERT
// time; a `COMMIT` is unnecessary and not reached. If the FK were
// absent the INSERT would succeed and this assertion would catch the
// regression.
let absent_binding_id = uuid::Uuid::new_v4();
let absent_history_id = uuid::Uuid::new_v4();
let absent_operation_id = uuid::Uuid::new_v4();
let absent_fp = vec![0xC0_u8; 32];
let nonexistent_policy_revision: i64 = 999;
let mut conn2 = pool.acquire().await.expect("acquire second connection");
sqlx::query("BEGIN")
.execute(&mut *conn2)
.await
.expect("begin absent-policy transaction");
// History first (receipt_history_cardinality guard fires on receipt
// insert and requires the history row to already exist).
sqlx::query(
"INSERT INTO identity_lifecycle_history \
(community_id, history_id, transition_kind, outcome_code, \
successor_binding_id, successor_binding_version, \
successor_lifecycle_revision, successor_state, \
operation_id, request_fingerprint, transition_digest) \
VALUES ($1, $2, 1, 1, $3, 2, 1, 1, $4, $5, $6)",
)
.bind(community_id)
.bind(absent_history_id)
.bind(absent_binding_id)
.bind(absent_operation_id)
.bind(&absent_fp)
.bind(vec![0xC1_u8; 32])
.execute(&mut *conn2)
.await
.expect("insert absent-policy lifecycle history");
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, 1, $4, 1, $5)",
)
.bind(community_id)
.bind(absent_operation_id)
.bind(&absent_fp)
.bind(vec![0xC2_u8; 32])
.bind(vec![0xC3_u8; 32])
.execute(&mut *conn2)
.await
.expect("insert absent-policy operation receipt");
// The policy FK is not deferred; it fires at INSERT, not COMMIT.
let absent_policy_err = sqlx::query(
"INSERT INTO identity_bindings \
(community_id, binding_id, issuer, subject, \
principal_fingerprint, event_author_pubkey, \
binding_state, lifecycle_revision, binding_provenance, \
policy_revision, enrollment_evidence_digest, \
birth_history_id, creation_operation_id, \
creation_request_fingerprint) \
VALUES ($1, $2, 'https://issuer.example', 'sub-02', \
$3, $4, 1, 1, 1, $5, $6, $7, $8, $9)",
)
.bind(community_id)
.bind(absent_binding_id)
.bind(vec![0xC4_u8; 32]) // principal_fingerprint (unique, different from first binding)
.bind(vec![0xC5_u8; 32]) // event_author_pubkey (unique, different from first binding)
.bind(nonexistent_policy_revision)
.bind(vec![0xC6_u8; 32]) // enrollment_evidence_digest
.bind(absent_history_id)
.bind(absent_operation_id)
.bind(&absent_fp)
.execute(&mut *conn2)
.await
.expect_err("binding with nonexistent policy_revision must be rejected by the FK");
assert!(
absent_policy_err
.as_database_error()
.map(|e| e.code().as_deref() == Some("23503"))
.unwrap_or(false),
"expected FK violation (23503) for absent policy_revision, got: {absent_policy_err}"
);
sqlx::query("ROLLBACK")
.execute(&mut *conn2)
.await
.expect("rollback absent-policy transaction");
}
/// NIP-FI policy-revision monotonicity: each new policy revision for a
/// community must strictly exceed the current maximum revision
/// (FI-INV-06 — stable assertion policy). `effective_at` ordering is
/// deliberately not enforced — the downstream constructor stamps every
/// immediately-effective revision with Unix epoch.
///
/// Mutation sensitivity is two-sided:
/// - neutering the guard lets a replayed or backfilled revision through
/// (the positive half detects insertion into a guarded table);
/// - leaving the guard intact rejects equal/regressive inserts (negative
/// halves detect that each rejection fires).
#[tokio::test]
#[ignore = "requires Postgres"]
async fn identity_enrollment_policy_revision_is_monotonic() {
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!("policy-mono-{}.example", community_id.simple()))
.execute(&pool)
.await
.expect("insert community");
// First insertion: no prior rows — should always succeed.
sqlx::query(
"INSERT INTO identity_enrollment_policies \
(community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \
VALUES ($1, 1, 1, $2, '2026-01-01T00:00:00Z')",
)
.bind(community_id)
.bind(vec![0xA1_u8; 32])
.execute(&pool)
.await
.expect("first policy insertion (revision 1) must succeed");
// Forward advance: revision 2.
sqlx::query(
"INSERT INTO identity_enrollment_policies \
(community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \
VALUES ($1, 2, 1, $2, '2026-06-01T00:00:00Z')",
)
.bind(community_id)
.bind(vec![0xA2_u8; 32])
.execute(&pool)
.await
.expect("forward advance to revision 2 must succeed");
// Seed a gap: skip from 2 to 100, then advance to 101.
sqlx::query(
"INSERT INTO identity_enrollment_policies \
(community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \
VALUES ($1, 100, 1, $2, '2027-01-01T00:00:00Z')",
)
.bind(community_id)
.bind(vec![0xA3_u8; 32])
.execute(&pool)
.await
.expect("jump to revision 100 must succeed");
sqlx::query(
"INSERT INTO identity_enrollment_policies \
(community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \
VALUES ($1, 101, 1, $2, '2027-06-01T00:00:00Z')",
)
.bind(community_id)
.bind(vec![0xA4_u8; 32])
.execute(&pool)
.await
.expect("advance to revision 101 must succeed");
// Negative: unused lower revision 99 — not a PK duplicate (never inserted),
// but the guard must reject it because 99 < MAX(100, 101). This is the
// case a plain PK constraint cannot catch; the named guard must fire.
let backfill_err = sqlx::query(
"INSERT INTO identity_enrollment_policies \
(community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \
VALUES ($1, 99, 1, $2, '2028-01-01T00:00:00Z')",
)
.bind(community_id)
.bind(vec![0xA5_u8; 32])
.execute(&pool)
.await
.expect_err("unused lower revision 99 must be rejected by the guard");
assert!(
backfill_err
.as_database_error()
.map(|e| e.code().as_deref() == Some("23514"))
.unwrap_or(false),
"expected check_violation (23514) from identity_enrollment_policy_revision_monotonic \
guard for backfilled revision 99, got: {backfill_err}"
);
// Negative: equal revision (101 <= 101). The PK is (community_id, policy_revision)
// so this is a PK duplicate regardless of policy_digest; either 23505 from the PK
// or 23514 from the guard fires first. This case is secondary — the load-bearing
// proof is the unused-99 case above, which is not a PK duplicate.
let replay_err = sqlx::query(
"INSERT INTO identity_enrollment_policies \
(community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \
VALUES ($1, 101, 2, $2, '2028-01-01T00:00:00Z')",
)
.bind(community_id)
.bind(vec![0xA6_u8; 32])
.execute(&pool)
.await
.expect_err("equal revision must be rejected");
// PK (23505) or guard (23514) — either proves the insert cannot commit.
assert!(
replay_err
.as_database_error()
.map(|e| {
let code = e.code();
let c = code.as_deref().unwrap_or("");
c == "23514" || c == "23505"
})
.unwrap_or(false),
"expected check_violation (23514) or unique_violation (23505) for replayed revision, \
got: {replay_err}"
);
// Concurrency regression: prove the advisory lock is load-bearing. The
// test uses a controlled two-connection schedule:
//
// 1. tx1 opens a transaction and inserts revision 102. The BEFORE INSERT
// trigger acquires `pg_advisory_xact_lock(lock_key)` and completes the
// INSERT — tx1 now holds the advisory lock until it commits.
// 2. tx2 opens a transaction on a second backend and issues INSERT for
// revision 103. The trigger fires and blocks inside
// `pg_advisory_xact_lock(lock_key)` waiting for tx1 to release.
// 3. We observe tx2's backend entering a Lock-wait state via
// pg_stat_activity (wait_event_type='Lock', wait_event='advisory'),
// with a bounded timeout — not a sleep. If the advisory-lock call is
// removed from the guard, the trigger returns immediately; tx2 never
// enters the advisory wait, and the poll times out, failing the test.
// This is the mutation-sensitivity guarantee.
// 4. tx1 commits, releasing the advisory lock. tx2 unblocks, its trigger
// reads the fresh MAX=102, and the INSERT succeeds (103 > 102).
// 5. tx2 commits. Both revisions 102 and 103 are present.
use std::time::Instant;
// tx1: open a transaction and insert revision 102. The INSERT returns after
// the trigger acquires the lock and succeeds; the advisory lock stays held
// until the transaction commits.
let mut conn1 = pool.acquire().await.expect("acquire conn1");
sqlx::query("BEGIN")
.execute(&mut *conn1)
.await
.expect("begin tx1");
sqlx::query(
"INSERT INTO identity_enrollment_policies \
(community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \
VALUES ($1, 102, 1, $2, '2029-01-01T00:00:00Z')",
)
.bind(community_id)
.bind(vec![0xB1_u8; 32])
.execute(&mut *conn1)
.await
.expect("tx1 INSERT revision 102 must succeed");
// tx1 holds the advisory lock. Do NOT commit yet.
// tx2: acquire a separate backend, record its PID, then issue the INSERT.
// The trigger will block on the advisory lock held by tx1.
let pool2 = pool.clone();
let pool3 = pool.clone();
let (pid_tx, pid_rx) = tokio::sync::oneshot::channel::<i32>();
let tx2_task = tokio::spawn(async move {
let mut conn2 = pool2.acquire().await.expect("acquire conn2");
// Report this backend's PID so the observer can poll pg_stat_activity.
let backend_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()")
.fetch_one(&mut *conn2)
.await
.expect("get conn2 backend pid");
let _ = pid_tx.send(backend_pid);
sqlx::query("BEGIN")
.execute(&mut *conn2)
.await
.expect("begin tx2");
// This INSERT will block inside the trigger waiting for tx1's advisory lock.
let insert_r = sqlx::query(
"INSERT INTO identity_enrollment_policies \
(community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \
VALUES ($1, 103, 1, $2, '2029-06-01T00:00:00Z')",
)
.bind(community_id)
.bind(vec![0xB2_u8; 32])
.execute(&mut *conn2)
.await;
let commit_r = sqlx::query("COMMIT").execute(&mut *conn2).await;
(insert_r, commit_r)
});
// Receive tx2's backend PID and wait until it enters an advisory-lock wait.
// Mutation proof: without pg_advisory_xact_lock in the guard, the trigger
// returns immediately; tx2 never parks on an advisory lock; the poll below
// times out and panics, making this test deterministically red.
let tx2_pid = pid_rx.await.expect("tx2 reports its backend pid");
let deadline = Instant::now() + std::time::Duration::from_secs(10);
loop {
let waiting: bool = sqlx::query_scalar(
"SELECT EXISTS (\
SELECT 1 FROM pg_stat_activity \
WHERE pid = $1 \
AND wait_event_type = 'Lock' \
AND wait_event = 'advisory'\
)",
)
.bind(tx2_pid)
.fetch_one(&pool3)
.await
.expect("poll tx2 advisory-lock wait");
if waiting {
break;
}
assert!(
Instant::now() < deadline,
"tx2 never entered advisory-lock wait — pg_advisory_xact_lock \
must be present in the guard for the lock to serialize writers"
);
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
// tx2 is observably blocked. Commit tx1, releasing the advisory lock.
sqlx::query("COMMIT")
.execute(&mut *conn1)
.await
.expect("tx1 COMMIT must succeed");
// tx2 unblocks: the trigger re-runs its SELECT MAX, sees committed 102,
// and INSERT 103 succeeds. Both the INSERT and COMMIT must complete.
let (insert2, commit2) = tx2_task.await.expect("tx2 task completed");
insert2.expect("tx2 INSERT revision 103 must succeed after tx1 commits");
commit2.expect("tx2 COMMIT must succeed");
// Both revisions 102 and 103 must be present (total: 1, 2, 100, 101, 102, 103).
let count: i64 = sqlx::query_scalar(
"SELECT count(*) FROM identity_enrollment_policies WHERE community_id = $1",
)
.bind(community_id)
.fetch_one(&pool)
.await
.expect("count persisted policy revisions");
assert_eq!(
count, 6,
"exactly six revisions must persist after the controlled concurrency sequence"
);
}
/// NIP-FI admission-result ↔ kind-11 receipt cardinality: a kind-11
/// (protected-mutation) receipt must commit with exactly one admission
/// result; an admission result must commit against a kind-11 receipt.
///
/// Mutation sensitivity is two-sided:
/// - the guard is load-bearing when a kind-11 receipt has no result row
/// (negative A) — without the guard this commits silently;
/// - the guard is load-bearing when a result attaches to a non-kind-11
/// receipt (negative B) — without the guard this commits silently.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn authorization_admission_result_requires_kind_11_receipt_bidirectional() {
let pool = connect_test_pool().await;
reset_public_schema(&pool).await;
MIGRATOR
.run_to(42, &pool)
.await
.expect("apply migrations 1-42");
let community_id = uuid::Uuid::new_v4();
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
.bind(community_id)
.bind(format!("adm-result-{}.example", community_id.simple()))
.execute(&pool)
.await
.expect("insert community");
// Capacity must exist for authorization_events inserts; admission-result
// tests exercise only authorization_operation_receipts and
// authorization_admission_results — no authorization_events rows are
// needed here, but insert capacity anyway to satisfy any trigger
// that reads the policy row defensively.
sqlx::query(
"INSERT INTO authorization_event_capacity \
(community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \
VALUES ($1, 1000, 16777216, 16384)",
)
.bind(community_id)
.execute(&pool)
.await
.expect("insert event capacity");
let mut conn = pool.acquire().await.expect("acquire connection");
// --- Positive: kind-11 receipt + admission result in one transaction ---
let op1 = uuid::Uuid::new_v4();
let fp1 = vec![0xB1_u8; 32];
sqlx::query("BEGIN")
.execute(&mut *conn)
.await
.expect("begin");
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, 11, $4, 1, $5)",
)
.bind(community_id)
.bind(op1)
.bind(&fp1)
.bind(vec![0xB2_u8; 32])
.bind(vec![0xB3_u8; 32])
.execute(&mut *conn)
.await
.expect("insert kind-11 receipt");
sqlx::query(
"INSERT INTO authorization_admission_results \
(community_id, operation_id, request_fingerprint, semantic_fingerprint, \
object_kind, object_key) \
VALUES ($1, $2, $3, $4, 1, $5)",
)
.bind(community_id)
.bind(op1)
.bind(&fp1)
.bind(vec![0xB4_u8; 32]) // semantic_fingerprint
.bind(vec![0xB5_u8; 32]) // object_key
.execute(&mut *conn)
.await
.expect("insert admission result");
sqlx::query("COMMIT")
.execute(&mut *conn)
.await
.expect("kind-11 receipt + result must commit");
drop(conn);
// --- Negative A: kind-11 receipt without result must be rejected ---
let op2 = uuid::Uuid::new_v4();
let fp2 = vec![0xC1_u8; 32];
let mut conn_a = pool.acquire().await.expect("acquire connection A");
sqlx::query("BEGIN")
.execute(&mut *conn_a)
.await
.expect("begin");
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, 11, $4, 1, $5)",
)
.bind(community_id)
.bind(op2)
.bind(&fp2)
.bind(vec![0xC2_u8; 32])
.bind(vec![0xC3_u8; 32])
.execute(&mut *conn_a)
.await
.expect("insert kind-11 receipt for negative A");
let no_result_err = sqlx::query("COMMIT")
.execute(&mut *conn_a)
.await
.expect_err("kind-11 receipt without result must be rejected at commit");
assert!(
no_result_err
.as_database_error()
.map(|e| e.code().as_deref() == Some("23514"))
.unwrap_or(false),
"expected check_violation (23514) for kind-11 without result, got: {no_result_err}"
);
drop(conn_a);
// --- Negative B: admission result against non-kind-11 receipt ---
// Use operation_kind 12 (invalidation) — no admission result should
// ever attach to it. The guard fires at COMMIT (deferred trigger).
let op3 = uuid::Uuid::new_v4();
let fp3 = vec![0xD1_u8; 32];
let mut conn_b = pool.acquire().await.expect("acquire connection B");
sqlx::query("BEGIN")
.execute(&mut *conn_b)
.await
.expect("begin");
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(op3)
.bind(&fp3)
.bind(vec![0xD2_u8; 32])
.bind(vec![0xD3_u8; 32])
.execute(&mut *conn_b)
.await
.expect("insert kind-12 receipt");
// The guard is deferred: the INSERT succeeds; the violation surfaces
// at COMMIT when the guard checks that the receipt is kind-11.
sqlx::query(
"INSERT INTO authorization_admission_results \
(community_id, operation_id, request_fingerprint, semantic_fingerprint, \
object_kind, object_key) \
VALUES ($1, $2, $3, $4, 1, $5)",
)
.bind(community_id)
.bind(op3)
.bind(&fp3)
.bind(vec![0xD4_u8; 32])
.bind(vec![0xD5_u8; 32])
.execute(&mut *conn_b)
.await
.expect("result insert must pass — deferred guard fires at commit, not here");
let wrong_kind_err = sqlx::query("COMMIT")
.execute(&mut *conn_b)
.await
.expect_err("result against non-kind-11 receipt must be rejected at commit");
assert!(
wrong_kind_err
.as_database_error()
.map(|e| e.code().as_deref() == Some("23514"))
.unwrap_or(false),
"expected check_violation (23514) for result against non-kind-11 receipt, got: {wrong_kind_err}"
);
drop(conn_b);
// --- Negative C: mismatched request_fingerprint rejected by composite FK ---
// The admission result table has an immediate composite FK
// (community_id, operation_id, request_fingerprint)
// REFERENCES authorization_operation_receipts(...)
// A result referencing a receipt that exists but with a different
// request_fingerprint must be rejected. This exercises the semantic half
// of Carl finding 2 — cardinality is handled by the deferred trigger;
// coordinate binding is handled by the structural FK.
let op4 = uuid::Uuid::new_v4();
let fp4_receipt = vec![0xE1_u8; 32]; // fingerprint stored in the receipt
let fp4_wrong = vec![0xE2_u8; 32]; // wrong fingerprint used in the result
let mut conn_c = pool.acquire().await.expect("acquire connection C");
sqlx::query("BEGIN")
.execute(&mut *conn_c)
.await
.expect("begin");
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, 11, $4, 1, $5)",
)
.bind(community_id)
.bind(op4)
.bind(&fp4_receipt)
.bind(vec![0xE3_u8; 32])
.bind(vec![0xE4_u8; 32])
.execute(&mut *conn_c)
.await
.expect("insert kind-11 receipt for negative C");
// The admission result FK is immediate (not deferred), so the INSERT
// itself rejects a fingerprint with no matching receipt row.
let wrong_fp_err = sqlx::query(
"INSERT INTO authorization_admission_results \
(community_id, operation_id, request_fingerprint, semantic_fingerprint, \
object_kind, object_key) \
VALUES ($1, $2, $3, $4, 1, $5)",
)
.bind(community_id)
.bind(op4)
.bind(&fp4_wrong) // wrong fingerprint — no matching receipt row
.bind(vec![0xE5_u8; 32])
.bind(vec![0xE6_u8; 32])
.execute(&mut *conn_c)
.await
.expect_err("result with mismatched request_fingerprint must be rejected at INSERT");
// Immediate composite FK fires as foreign_key_violation (23503).
assert!(
wrong_fp_err
.as_database_error()
.map(|e| e.code().as_deref() == Some("23503"))
.unwrap_or(false),
"expected foreign_key_violation (23503) for mismatched request_fingerprint, got: {wrong_fp_err}"
);
sqlx::query("ROLLBACK").execute(&mut *conn_c).await.ok();
}
/// NIP-FI denial-attempt ↔ kind-9 event cardinality: a kind-9
/// (pre-authentication denial) audit event must commit with exactly one
/// denial attempt; a denial attempt must commit with a matching kind-9
/// audit event.
///
/// Mutation sensitivity is two-sided:
/// - the event-side guard is load-bearing when a kind-9 event has no
/// attempt row (negative A) — without it this commits silently, making
/// replay reconstruction impossible;
/// - the attempt-side guard is load-bearing for semantic mismatches (negatives
/// B1–B3) — the old deferred FK only checks event existence/kind and would
/// not catch a correlation, reason_code, or attempt_id mismatch.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn authorization_denial_attempt_requires_kind_9_event_bidirectional() {
let pool = connect_test_pool().await;
reset_public_schema(&pool).await;
MIGRATOR
.run_to(42, &pool)
.await
.expect("apply migrations 1-42");
let community_id = uuid::Uuid::new_v4();
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
.bind(community_id)
.bind(format!("denial-attempt-{}.example", community_id.simple()))
.execute(&pool)
.await
.expect("insert community");
// Capacity is required by the authorization_events BEFORE INSERT trigger.
sqlx::query(
"INSERT INTO authorization_event_capacity \
(community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \
VALUES ($1, 1000, 16777216, 16384)",
)
.bind(community_id)
.execute(&pool)
.await
.expect("insert event capacity");
let mut conn = pool.acquire().await.expect("acquire connection");
// --- Positive: kind-9 event + denial attempt in one transaction ---
let op1 = uuid::Uuid::new_v4();
let event1 = uuid::Uuid::new_v4();
let corr1 = uuid::Uuid::new_v4();
let attempt1_id = uuid::Uuid::new_v4();
sqlx::query("BEGIN")
.execute(&mut *conn)
.await
.expect("begin");
// Insert denial attempt first (FKs are deferred).
sqlx::query(
"INSERT INTO authorization_authentication_denial_attempts \
(community_id, operation_id, correlation_id, semantic_fingerprint, \
denial_reason, expected_revision, action, reason_code, \
attempt_id, audit_event_id, audit_event_kind) \
VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)",
)
.bind(community_id)
.bind(op1)
.bind(corr1)
.bind(vec![0xE1_u8; 32]) // semantic_fingerprint
.bind(attempt1_id)
.bind(event1)
.execute(&mut *conn)
.await
.expect("insert denial attempt before event");
// Insert the kind-9 event (actor_kind 4, no request_fingerprint).
sqlx::query(
"INSERT INTO authorization_events \
(community_id, event_id, event_kind, outcome_code, reason_code, \
actor_kind, operation_id, correlation_id, attempt_id, \
semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \
VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \
$6, '2026-01-01T00:00:00Z', $7, $8)",
)
.bind(community_id)
.bind(event1)
.bind(op1)
.bind(corr1)
.bind(attempt1_id)
.bind(vec![0xE1_u8; 32]) // semantic_fingerprint matches denial attempt
.bind(vec![0xE2_u8; 64]) // canonical_envelope (≤16384 bytes)
.bind(vec![0xE3_u8; 32]) // envelope_digest
.execute(&mut *conn)
.await
.expect("insert kind-9 event");
sqlx::query("COMMIT")
.execute(&mut *conn)
.await
.expect("kind-9 event + denial attempt must commit");
drop(conn);
// --- Negative A: kind-9 event alone must be rejected at commit ---
let op2 = uuid::Uuid::new_v4();
let event2 = uuid::Uuid::new_v4();
let corr2 = uuid::Uuid::new_v4();
let attempt2_id = uuid::Uuid::new_v4();
let mut conn_a = pool.acquire().await.expect("acquire connection A");
sqlx::query("BEGIN")
.execute(&mut *conn_a)
.await
.expect("begin");
sqlx::query(
"INSERT INTO authorization_events \
(community_id, event_id, event_kind, outcome_code, reason_code, \
actor_kind, operation_id, correlation_id, attempt_id, \
semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \
VALUES ($1, $2, 9, 2, 1, 4, $3, $4, $5, \
$6, '2026-01-01T00:00:00Z', $7, $8)",
)
.bind(community_id)
.bind(event2)
.bind(op2)
.bind(corr2)
.bind(attempt2_id)
.bind(vec![0xF0_u8; 32]) // semantic_fingerprint (non-zero)
.bind(vec![0xF1_u8; 64])
.bind(vec![0xF2_u8; 32])
.execute(&mut *conn_a)
.await
.expect("insert kind-9 event without attempt");
let no_attempt_err = sqlx::query("COMMIT")
.execute(&mut *conn_a)
.await
.expect_err("kind-9 event without denial attempt must be rejected at commit");
assert!(
no_attempt_err
.as_database_error()
.map(|e| e.code().as_deref() == Some("23514"))
.unwrap_or(false),
"expected check_violation (23514) for kind-9 event without attempt, got: {no_attempt_err}"
);
drop(conn_a);
// --- Negatives B1-B3: semantic coordinate mismatches, each attributed to
// the named guard (23514), not the old deferred FK (23503). Each case
// inserts a valid event then a denial attempt that matches everywhere
// except one coordinate; the guard must fire for that mismatch.
// B1: correlation_id mismatch — attempt carries a different correlation
// than the event it references.
let op_b1 = uuid::Uuid::new_v4();
let event_b1 = uuid::Uuid::new_v4();
let corr_b1_event = uuid::Uuid::new_v4();
let corr_b1_wrong = uuid::Uuid::new_v4(); // different from corr_b1_event
let attempt_b1 = uuid::Uuid::new_v4();
let mut conn_b1 = pool.acquire().await.expect("acquire connection B1");
sqlx::query("BEGIN")
.execute(&mut *conn_b1)
.await
.expect("begin B1");
// Insert the event first (deferred FK allows this ordering).
sqlx::query(
"INSERT INTO authorization_events \
(community_id, event_id, event_kind, outcome_code, reason_code, \
actor_kind, operation_id, correlation_id, attempt_id, \
semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \
VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \
$6, '2026-01-01T00:00:00Z', $7, $8)",
)
.bind(community_id)
.bind(event_b1)
.bind(op_b1)
.bind(corr_b1_event)
.bind(attempt_b1)
.bind(vec![0xB3_u8; 32]) // semantic_fingerprint matches denial attempt
.bind(vec![0xB1_u8; 64])
.bind(vec![0xB2_u8; 32])
.execute(&mut *conn_b1)
.await
.expect("insert kind-9 event for B1");
sqlx::query(
"INSERT INTO authorization_authentication_denial_attempts \
(community_id, operation_id, correlation_id, semantic_fingerprint, \
denial_reason, expected_revision, action, reason_code, \
attempt_id, audit_event_id, audit_event_kind) \
VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)",
)
.bind(community_id)
.bind(op_b1)
.bind(corr_b1_wrong) // wrong correlation_id
.bind(vec![0xB3_u8; 32])
.bind(attempt_b1)
.bind(event_b1)
.execute(&mut *conn_b1)
.await
.expect("insert denial attempt with wrong correlation_id (guard deferred)");
let corr_err = sqlx::query("COMMIT")
.execute(&mut *conn_b1)
.await
.expect_err("mismatched correlation_id must be rejected at commit");
assert!(
corr_err
.as_database_error()
.map(|e| e.code().as_deref() == Some("23514"))
.unwrap_or(false),
"expected check_violation (23514) from authorization_denial_attempt_semantic_binding \
for correlation_id mismatch, got: {corr_err}"
);
drop(conn_b1);
// B2: reason_code mismatch — attempt carries denial_reason=1 (MissingCredential,
// requires reason_code=2 per canonical mapping), but event carries reason_code=1.
// The attempt INSERT passes (denial_reason=1↔reason_code=2 is a valid mapping pair),
// then the deferred guard fires at commit because event reason_code=1 ≠ attempt
// reason_code=2.
let op_b2 = uuid::Uuid::new_v4();
let event_b2 = uuid::Uuid::new_v4();
let corr_b2 = uuid::Uuid::new_v4();
let attempt_b2 = uuid::Uuid::new_v4();
let mut conn_b2 = pool.acquire().await.expect("acquire connection B2");
sqlx::query("BEGIN")
.execute(&mut *conn_b2)
.await
.expect("begin B2");
sqlx::query(
"INSERT INTO authorization_events \
(community_id, event_id, event_kind, outcome_code, reason_code, \
actor_kind, operation_id, correlation_id, attempt_id, \
semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \
VALUES ($1, $2, 9, 2, 1, 4, $3, $4, $5, \
$6, '2026-01-01T00:00:00Z', $7, $8)",
)
.bind(community_id)
.bind(event_b2)
.bind(op_b2)
.bind(corr_b2)
.bind(attempt_b2)
.bind(vec![0xC3_u8; 32]) // semantic_fingerprint matches denial attempt
.bind(vec![0xC1_u8; 64])
.bind(vec![0xC2_u8; 32])
.execute(&mut *conn_b2)
.await
.expect("insert kind-9 event for B2 (reason_code=1)");
sqlx::query(
"INSERT INTO authorization_authentication_denial_attempts \
(community_id, operation_id, correlation_id, semantic_fingerprint, \
denial_reason, expected_revision, action, reason_code, \
attempt_id, audit_event_id, audit_event_kind) \
VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)",
// reason_code = 2 but event has reason_code = 1
)
.bind(community_id)
.bind(op_b2)
.bind(corr_b2)
.bind(vec![0xC3_u8; 32])
.bind(attempt_b2)
.bind(event_b2)
.execute(&mut *conn_b2)
.await
.expect(
"insert denial attempt with wrong reason_code (deferred guard will fire at commit)",
);
let reason_err = sqlx::query("COMMIT")
.execute(&mut *conn_b2)
.await
.expect_err("mismatched reason_code must be rejected at commit");
assert!(
reason_err
.as_database_error()
.map(|e| e.code().as_deref() == Some("23514"))
.unwrap_or(false),
"expected check_violation (23514) from authorization_denial_attempt_semantic_binding \
for reason_code mismatch, got: {reason_err}"
);
drop(conn_b2);
// B3: attempt_id mismatch — the denial attempt's attempt_id FK references
// a different event (attempt_b3_wrong) than the one being paired (event_b3).
// The attempt_id FK on the denial attempt table binds
// (community_id, operation_id, audit_event_kind, attempt_id)
// -> authorization_events(community_id, operation_id, event_kind, attempt_id)
// so using a different attempt_id that doesn't exist for this operation
// will be caught as a FK violation (23503) at commit.
let op_b3 = uuid::Uuid::new_v4();
let event_b3 = uuid::Uuid::new_v4();
let corr_b3 = uuid::Uuid::new_v4();
let attempt_b3_correct = uuid::Uuid::new_v4();
let attempt_b3_wrong = uuid::Uuid::new_v4(); // not registered for this operation
let mut conn_b3 = pool.acquire().await.expect("acquire connection B3");
sqlx::query("BEGIN")
.execute(&mut *conn_b3)
.await
.expect("begin B3");
sqlx::query(
"INSERT INTO authorization_events \
(community_id, event_id, event_kind, outcome_code, reason_code, \
actor_kind, operation_id, correlation_id, attempt_id, \
semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \
VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \
$6, '2026-01-01T00:00:00Z', $7, $8)",
)
.bind(community_id)
.bind(event_b3)
.bind(op_b3)
.bind(corr_b3)
.bind(attempt_b3_correct)
.bind(vec![0xD3_u8; 32]) // semantic_fingerprint (matches denial attempt)
.bind(vec![0xD1_u8; 64])
.bind(vec![0xD2_u8; 32])
.execute(&mut *conn_b3)
.await
.expect("insert kind-9 event for B3");
sqlx::query(
"INSERT INTO authorization_authentication_denial_attempts \
(community_id, operation_id, correlation_id, semantic_fingerprint, \
denial_reason, expected_revision, action, reason_code, \
attempt_id, audit_event_id, audit_event_kind) \
VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)",
)
.bind(community_id)
.bind(op_b3)
.bind(corr_b3)
.bind(vec![0xD3_u8; 32])
.bind(attempt_b3_wrong) // wrong attempt_id — no matching UNIQUE row on events
.bind(event_b3)
.execute(&mut *conn_b3)
.await
.expect("insert denial attempt with wrong attempt_id (FK is deferred)");
let attempt_err = sqlx::query("COMMIT")
.execute(&mut *conn_b3)
.await
.expect_err("mismatched attempt_id must be rejected at commit");
// The attempt_id FK is deferred and fires as foreign_key_violation (23503).
assert!(
attempt_err
.as_database_error()
.map(|e| e.code().as_deref() == Some("23503"))
.unwrap_or(false),
"expected foreign_key_violation (23503) for attempt_id mismatch \
(deferred FK on denial attempt), got: {attempt_err}"
);
// B4: denial_reason ↔ reason_code mapping violation — the denial attempt
// row carries denial_reason=2 (InvalidCredential) but reason_code=2
// (Missing). The canonical mapping requires InvalidCredential(2)↔Invalid(3);
// reason_code=2 is only valid for MissingCredential(denial_reason=1).
// The immediate CHECK constraint authorization_denial_reason_reason_code_binding
// fires at INSERT, not commit. Mutation-sensitive: removing the CHECK lets
// this INSERT succeed (the guard does not compare denial_reason; only the
// paired event's reason_code is checked at commit).
let op_b4 = uuid::Uuid::new_v4();
let event_b4 = uuid::Uuid::new_v4();
let corr_b4 = uuid::Uuid::new_v4();
let attempt_b4 = uuid::Uuid::new_v4();
let mut conn_b4 = pool.acquire().await.expect("acquire connection B4");
sqlx::query("BEGIN")
.execute(&mut *conn_b4)
.await
.expect("begin B4");
// Insert the matching kind-9 event first.
sqlx::query(
"INSERT INTO authorization_events \
(community_id, event_id, event_kind, outcome_code, reason_code, \
actor_kind, operation_id, correlation_id, attempt_id, \
semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \
VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \
$6, '2026-01-01T00:00:00Z', $7, $8)",
)
.bind(community_id)
.bind(event_b4)
.bind(op_b4)
.bind(corr_b4)
.bind(attempt_b4)
.bind(vec![0xE4_u8; 32]) // semantic_fingerprint
.bind(vec![0xE5_u8; 64])
.bind(vec![0xE6_u8; 32])
.execute(&mut *conn_b4)
.await
.expect("insert kind-9 event for B4");
// Insert denial attempt with denial_reason=2 (InvalidCredential) but
// reason_code=2 (Missing) — violates the canonical mapping (requires reason_code=3).
let denial_reason_err = sqlx::query(
"INSERT INTO authorization_authentication_denial_attempts \
(community_id, operation_id, correlation_id, semantic_fingerprint, \
denial_reason, expected_revision, action, reason_code, \
attempt_id, audit_event_id, audit_event_kind) \
VALUES ($1, $2, $3, $4, 2, 1, 1, 2, $5, $6, 9)",
// denial_reason=2 (InvalidCredential) requires reason_code=3; reason_code=2 is wrong
)
.bind(community_id)
.bind(op_b4)
.bind(corr_b4)
.bind(vec![0xE4_u8; 32])
.bind(attempt_b4)
.bind(event_b4)
.execute(&mut *conn_b4)
.await
.expect_err("denial_reason/reason_code mapping violation must be rejected at INSERT");
assert!(
denial_reason_err
.as_database_error()
.map(|e| e.code().as_deref() == Some("23514"))
.unwrap_or(false),
"expected check_violation (23514) from \
authorization_denial_reason_reason_code_binding for denial_reason mismatch, \
got: {denial_reason_err}"
);
// B5: semantic_fingerprint mismatch — the event carries semantic_fingerprint
// 0xB5…B5 while the denial attempt carries 0xB6…B6. correlation_id, reason_code,
// and attempt_id all match; only the fingerprint differs. The deferred guard
// authorization_denial_attempt_guard_v1 fires at COMMIT on the denial-attempt
// side, compares found_semantic_fingerprint (from the event) with
// NEW.semantic_fingerprint (from the attempt), and raises 23514 with named
// constraint authorization_denial_attempt_semantic_binding.
// Mutation-sensitive: removing the semantic_fingerprint comparison block from
// the guard function lets this transaction commit.
let op_b5 = uuid::Uuid::new_v4();
let event_b5 = uuid::Uuid::new_v4();
let corr_b5 = uuid::Uuid::new_v4();
let attempt_b5 = uuid::Uuid::new_v4();
let fp_event_b5 = vec![0xB5_u8; 32]; // event semantic_fingerprint
let fp_attempt_b5 = vec![0xB6_u8; 32]; // mismatched attempt semantic_fingerprint
let mut conn_b5 = pool.acquire().await.expect("acquire connection B5");
sqlx::query("BEGIN")
.execute(&mut *conn_b5)
.await
.expect("begin B5");
// Insert the kind-9 event with fingerprint 0xB5…B5.
sqlx::query(
"INSERT INTO authorization_events \
(community_id, event_id, event_kind, outcome_code, reason_code, \
actor_kind, operation_id, correlation_id, attempt_id, \
semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \
VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \
$6, '2026-01-01T00:00:00Z', $7, $8)",
)
.bind(community_id)
.bind(event_b5)
.bind(op_b5)
.bind(corr_b5)
.bind(attempt_b5)
.bind(fp_event_b5)
.bind(vec![0xB7_u8; 64]) // canonical_envelope
.bind(vec![0xB8_u8; 32]) // envelope_digest
.execute(&mut *conn_b5)
.await
.expect("insert kind-9 event for B5");
// Insert denial attempt with the WRONG semantic_fingerprint (0xB6…B6).
// correlation_id, reason_code=2, denial_reason=1 (MissingCredential↔Missing),
// and attempt_id all match the event — only semantic_fingerprint differs.
sqlx::query(
"INSERT INTO authorization_authentication_denial_attempts \
(community_id, operation_id, correlation_id, semantic_fingerprint, \
denial_reason, expected_revision, action, reason_code, \
attempt_id, audit_event_id, audit_event_kind) \
VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)",
)
.bind(community_id)
.bind(op_b5)
.bind(corr_b5)
.bind(fp_attempt_b5) // 0xB6…B6 ≠ event's 0xB5…B5
.bind(attempt_b5)
.bind(event_b5)
.execute(&mut *conn_b5)
.await
.expect(
"insert denial attempt with mismatched fingerprint (deferred guard fires at commit)",
);
let fp_mismatch_err = sqlx::query("COMMIT")
.execute(&mut *conn_b5)
.await
.expect_err("commit with mismatched semantic_fingerprint must be rejected");
assert!(
fp_mismatch_err
.as_database_error()
.map(|e| e.code().as_deref() == Some("23514"))
.unwrap_or(false),
"expected check_violation (23514) from \
authorization_denial_attempt_semantic_binding for semantic_fingerprint mismatch, \
got: {fp_mismatch_err}"
);
}
/// NIP-FI authenticated kind-9 OperatorDenied denial: an authenticated
/// kind-9 event (actor_kind 1–3, non-null request_fingerprint) must commit
/// without an authorization_authentication_denial_attempts row and must
/// reject any attempt to attach one.
///
/// Mutation sensitivity:
/// - Removing the `actor_kind <> 4` guard from the event-side trigger makes
/// positive A red: the COMMIT fails because the guard now requires a
/// denial-attempt row for the authenticated event and none is present.
/// - Removing the `actor_kind <> 4` shape guard from the attempt-side
/// trigger makes negative B red: the COMMIT is rejected by the pre-existing
/// `authorization_denial_attempt_semantic_binding` guard instead (non-null
/// attempt `semantic_fingerprint` vs. null on the authenticated event), so
/// `assert_eq!` on the constraint name fails. The exact constraint name
/// assertion is therefore the load-bearing proof that the new shape guard —
/// not the pre-existing semantic-binding check — is what fires.
///
/// The unresolved pre-auth positive path (actor_kind 4) is exercised in
/// `authorization_denial_attempt_requires_kind_9_event_bidirectional` and
/// is unchanged by this fix.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn authenticated_kind_9_denial_commits_without_denial_attempt() {
let pool = connect_test_pool().await;
reset_public_schema(&pool).await;
MIGRATOR
.run_to(42, &pool)
.await
.expect("apply migrations 1-42");
let community_id = uuid::Uuid::new_v4();
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
.bind(community_id)
.bind(format!("auth-denial-{}.example", community_id.simple()))
.execute(&pool)
.await
.expect("insert community");
sqlx::query(
"INSERT INTO authorization_event_capacity \
(community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \
VALUES ($1, 1000, 16777216, 16384)",
)
.bind(community_id)
.execute(&pool)
.await
.expect("insert event capacity");
// Seed an operation receipt for the authenticated denial. Use
// operation_kind = 12 (invalidation) with outcome_code = 2 (denied):
// this satisfies the receipt CHECK constraints without triggering the
// lifecycle history guard (expected_count = 0 for non-lifecycle kinds)
// and without requiring a lifecycle event (expected_event_kind = NULL).
// The authorization_events FK on (community_id, operation_id,
// request_fingerprint) requires a receipt row.
let op_auth = uuid::Uuid::new_v4();
let fp_auth = vec![0xA1_u8; 32];
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, 2, $5)",
// operation_kind 12 (invalidation), outcome_code 2 (denied)
)
.bind(community_id)
.bind(op_auth)
.bind(&fp_auth)
.bind(vec![0xA2_u8; 32]) // actor_fingerprint
.bind(vec![0xA3_u8; 32]) // result_digest
.execute(&pool)
.await
.expect("seed authenticated denial receipt");
let event_auth = uuid::Uuid::new_v4();
let corr_auth = uuid::Uuid::new_v4();
let attempt_auth = uuid::Uuid::new_v4();
// --- Positive A: authenticated kind-9 denial (actor_kind = 1) commits
// without any denial-attempt row. The semantic_fingerprint must be NULL
// per the corrected shape CHECK. The deferred cardinality guard must
// skip this event because actor_kind ≠ 4.
let mut conn = pool.acquire().await.expect("acquire connection");
sqlx::query("BEGIN")
.execute(&mut *conn)
.await
.expect("begin");
sqlx::query(
"INSERT INTO authorization_events \
(community_id, event_id, event_kind, outcome_code, reason_code, \
actor_kind, actor_fingerprint, operation_id, request_fingerprint, \
correlation_id, attempt_id, semantic_fingerprint, \
occurred_at, canonical_envelope, envelope_digest) \
VALUES ($1, $2, 9, 2, 4, 1, $3, $4, $5, $6, $7, NULL, \
'2026-01-01T00:00:00Z', $8, $9)",
)
.bind(community_id)
.bind(event_auth)
.bind(vec![0xA4_u8; 32]) // actor_fingerprint (required for actor_kind 1)
.bind(op_auth)
.bind(&fp_auth) // non-null request_fingerprint (authenticated shape)
.bind(corr_auth)
.bind(attempt_auth)
// semantic_fingerprint = NULL: authenticated kind-9 must not carry one
.bind(vec![0xA5_u8; 64]) // canonical_envelope
.bind(vec![0xA6_u8; 32]) // envelope_digest
.execute(&mut *conn)
.await
.expect("insert authenticated kind-9 event");
sqlx::query("COMMIT").execute(&mut *conn).await.expect(
"authenticated kind-9 denial must commit without a denial-attempt row \
— the event-side cardinality guard must skip actor_kind 1",
);
drop(conn);
// Confirm no denial attempt was needed: the table must have zero rows
// for this event.
let attempt_count: i64 = sqlx::query_scalar(
"SELECT count(*) FROM authorization_authentication_denial_attempts \
WHERE community_id = $1 AND audit_event_id = $2",
)
.bind(community_id)
.bind(event_auth)
.fetch_one(&pool)
.await
.expect("count denial attempts for authenticated event");
assert_eq!(
attempt_count, 0,
"no denial-attempt row should exist for an authenticated kind-9 event"
);
// --- Negative B: a denial attempt cannot bind to the authenticated kind-9
// event. The attempt-side shape guard must reject this at commit because
// the referenced event has actor_kind = 1 (not 4). The rejection must
// name the exact shape constraint (authorization_denial_attempt_event_kind)
// rather than merely returning 23514, proving the new actor/request-fingerprint
// guard fires — not the pre-existing semantic_fingerprint equality check
// (which would fire as authorization_denial_attempt_semantic_binding if
// the shape guard were absent, because the attempt carries a non-null
// semantic_fingerprint while the authenticated event has null).
//
// Reuse attempt_auth from the committed event so the deferred attempt_id
// FK resolves (wrong attempt_id would activate that FK first and make the
// negative non-isolated to the new guard).
let mut conn_b = pool.acquire().await.expect("acquire connection B");
sqlx::query("BEGIN")
.execute(&mut *conn_b)
.await
.expect("begin B");
// Insert the denial attempt referencing the authenticated event.
// The attempt table FKs are deferred, so this INSERT succeeds;
// the shape guard fires at COMMIT.
sqlx::query(
"INSERT INTO authorization_authentication_denial_attempts \
(community_id, operation_id, correlation_id, semantic_fingerprint, \
denial_reason, expected_revision, action, reason_code, \
attempt_id, audit_event_id, audit_event_kind) \
VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)",
)
.bind(community_id)
.bind(op_auth)
.bind(corr_auth)
.bind(vec![0xA7_u8; 32]) // semantic_fingerprint on the attempt (non-null)
.bind(attempt_auth) // reuse the event's attempt_id — FK isolation
.bind(event_auth) // references the authenticated event (actor_kind = 1)
.execute(&mut *conn_b)
.await
.expect("attempt INSERT must pass — shape guard is deferred");
let cross_shape_err = sqlx::query("COMMIT")
.execute(&mut *conn_b)
.await
.expect_err(
"denial attempt binding to authenticated kind-9 event must be rejected at commit",
);
// The exact constraint name must be authorization_denial_attempt_event_kind —
// the new actor/request_fingerprint shape guard. If the shape guard were
// removed, the pre-existing semantic_fingerprint equality check would fire
// instead, named authorization_denial_attempt_semantic_binding. Requiring
// the exact name makes the mutation reliably red.
assert_eq!(
cross_shape_err
.as_database_error()
.and_then(|e| e.constraint()),
Some("authorization_denial_attempt_event_kind"),
"rejection must be attributed to authorization_denial_attempt_event_kind \
shape guard (not an incidental FK or semantic-binding check), \
got: {cross_shape_err}"
);
}
/// NIP-FI denied lifecycle receipt: a denied core lifecycle receipt
/// (outcome_code = 2) must commit without a paired audit event. Requiring
/// one would falsely record that the lifecycle transition occurred.
///
/// Mutation sensitivity:
/// - Removing the `outcome_code IN (1, 3)` branch entirely (or replacing it with a
/// blanket early-return) makes the positive case red — the denied enroll receipt
/// cannot commit alone because the guard then demands a paired enroll audit event
/// (expected_event_kind = 1) that is absent.
/// - Removing the `ELSIF outcome_code = 2` zero-event branch makes the
/// receipt-then-event negative below green (COMMIT succeeds when it must not),
/// failing `expect_err`. The event-side isolation in
/// `denied_lifecycle_receipt_event_side_trigger_isolated` independently confirms
/// the same branch using only the `authorization_event_receipt_cardinality`
/// trigger direction.
/// Applied/no-op lifecycle cardinality is exercised by
/// `applied_lifecycle_receipt_requires_exactly_one_event`.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn denied_lifecycle_receipt_commits_without_audit_event() {
let pool = connect_test_pool().await;
reset_public_schema(&pool).await;
MIGRATOR
.run_to(42, &pool)
.await
.expect("apply migrations 1-42");
let community_id = uuid::Uuid::new_v4();
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
.bind(community_id)
.bind(format!(
"denied-lifecycle-{}.example",
community_id.simple()
))
.execute(&pool)
.await
.expect("insert community");
// Denied enroll receipt (operation_kind = 1, outcome_code = 2) must commit
// without any paired authorization_events row. The guard must skip it
// because outcome_code = 2 is not in (1, 3).
//
// The receipt history guard (migration 0041) uses `outcome_code IN (1, 3)`
// for lifecycle receipts, so a denied enroll receipt (outcome_code = 2)
// expects zero lifecycle history rows — no history setup is needed.
let op_denied = uuid::Uuid::new_v4();
let fp_denied = vec![0xB1_u8; 32];
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, 1, $4, 2, $5)",
// operation_kind 1 (enroll), outcome_code 2 (denied)
)
.bind(community_id)
.bind(op_denied)
.bind(&fp_denied)
.bind(vec![0xB2_u8; 32])
.bind(vec![0xB3_u8; 32])
.execute(&pool)
.await
.expect("denied enroll receipt must commit without a paired audit event");
// No audit event for this operation; confirm the table is empty for it.
let event_count: i64 = sqlx::query_scalar(
"SELECT count(*) FROM authorization_events \
WHERE community_id = $1 AND operation_id = $2",
)
.bind(community_id)
.bind(op_denied)
.fetch_one(&pool)
.await
.expect("count events for denied receipt");
assert_eq!(
event_count, 0,
"no audit event should be required or present for a denied lifecycle receipt"
);
// --- Negative: denied enroll receipt paired with its mapped success-
// transition event (event_kind = 1, enrolled) must be rejected at COMMIT.
// The receipt-side deferred trigger fires here (receipt was inserted in
// this same transaction). The event-side trigger direction is isolated in
// `denied_lifecycle_receipt_event_side_trigger_isolated`.
//
// Seed event capacity; the authorization_events BEFORE INSERT trigger
// requires a capacity row. No lifecycle history is needed: denied receipts
// (outcome_code = 2) expect zero history rows per the history guard.
sqlx::query(
"INSERT INTO authorization_event_capacity \
(community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \
VALUES ($1, 1000, 16777216, 16384)",
)
.bind(community_id)
.execute(&pool)
.await
.expect("insert event capacity");
let op_neg = uuid::Uuid::new_v4();
let fp_neg = vec![0xD1_u8; 32];
let event_neg = uuid::Uuid::new_v4();
let corr_neg = uuid::Uuid::new_v4();
let attempt_neg = uuid::Uuid::new_v4();
let mut conn_neg = pool.acquire().await.expect("acquire connection neg");
sqlx::query("BEGIN")
.execute(&mut *conn_neg)
.await
.expect("begin neg");
// Denied enroll receipt — no history row needed (outcome_code = 2).
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, 1, $4, 2, $5)",
)
.bind(community_id)
.bind(op_neg)
.bind(&fp_neg)
.bind(vec![0xD2_u8; 32])
.bind(vec![0xD3_u8; 32])
.execute(&mut *conn_neg)
.await
.expect("insert denied receipt — event guard is deferred");
// Insert the mapped success-transition event (event_kind = 1, enrolled).
// actor_kind = 1 requires a non-null actor_fingerprint and a matching
// receipt FK (satisfied by the denied receipt above, which shares the
// same (community_id, operation_id, request_fingerprint)).
sqlx::query(
"INSERT INTO authorization_events \
(community_id, event_id, event_kind, outcome_code, reason_code, \
actor_kind, actor_fingerprint, operation_id, request_fingerprint, \
correlation_id, attempt_id, semantic_fingerprint, \
occurred_at, canonical_envelope, envelope_digest) \
VALUES ($1, $2, 1, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \
'2026-01-01T00:00:00Z', $8, $9)",
// event_kind 1 (enrolled) — the mapped success transition for enroll
)
.bind(community_id)
.bind(event_neg)
.bind(vec![0xD4_u8; 32]) // actor_fingerprint
.bind(op_neg)
.bind(&fp_neg)
.bind(corr_neg)
.bind(attempt_neg)
.bind(vec![0xD5_u8; 64]) // canonical_envelope
.bind(vec![0xD6_u8; 32]) // envelope_digest
.execute(&mut *conn_neg)
.await
.expect("event INSERT must pass — deferred guard fires at COMMIT");
let contradiction_err = sqlx::query("COMMIT")
.execute(&mut *conn_neg)
.await
.expect_err(
"denied receipt + mapped success event must be rejected at COMMIT \
— contradictory durable facts must not be permitted",
);
assert_eq!(
contradiction_err
.as_database_error()
.and_then(|e| e.constraint()),
Some("authorization_denied_lifecycle_receipt_no_success_event"),
"expected authorization_denied_lifecycle_receipt_no_success_event constraint \
rejection for denied receipt + success event, got: {contradiction_err}"
);
}
/// NIP-FI event-side trigger isolation: when a denied enroll receipt is already
/// committed (auto-commit via pool), a new independent transaction that inserts
/// only the mapped success-transition event must be rejected at COMMIT by
/// `authorization_event_receipt_cardinality` (the event-side deferred trigger).
///
/// This isolates the `authorization_event_receipt_cardinality` trigger path.
/// In `denied_lifecycle_receipt_commits_without_audit_event`'s receipt-then-event
/// negative, the receipt-side trigger (`authorization_operation_receipt_event_cardinality`)
/// also fires. Here the committed receipt produces no deferred trigger, so rejection
/// can only come from the event-side trigger.
///
/// Mutation sensitivity: disabling the
/// `authorization_event_receipt_cardinality` trigger (DROP or ALTER TABLE
/// DISABLE TRIGGER) makes this negative green — the COMMIT succeeds when it
/// must not, so `expect_err` panics.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn denied_lifecycle_receipt_event_side_trigger_isolated() {
let pool = connect_test_pool().await;
reset_public_schema(&pool).await;
MIGRATOR
.run_to(42, &pool)
.await
.expect("apply migrations 1-42");
let community_id = uuid::Uuid::new_v4();
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
.bind(community_id)
.bind(format!("evt-side-{}.example", community_id.simple()))
.execute(&pool)
.await
.expect("insert community");
// Seed event capacity before any event insert.
sqlx::query(
"INSERT INTO authorization_event_capacity \
(community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \
VALUES ($1, 1000, 16777216, 16384)",
)
.bind(community_id)
.execute(&pool)
.await
.expect("insert event capacity");
// Commit a denied enroll receipt in auto-commit mode (no explicit BEGIN).
// This receipt produces no deferred trigger — the receipt-side deferred
// trigger only fires within the transaction that inserts the receipt row.
let op_id = uuid::Uuid::new_v4();
let fp = vec![0xE1_u8; 32];
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, 1, $4, 2, $5)",
)
.bind(community_id)
.bind(op_id)
.bind(&fp)
.bind(vec![0xE2_u8; 32])
.bind(vec![0xE3_u8; 32])
.execute(&pool)
.await
.expect("denied receipt must commit alone in auto-commit mode");
// Now open a NEW transaction and insert only the mapped success-transition
// event (event_kind = 1, enrolled). The receipt is already committed and
// its deferred trigger is no longer active. Rejection at COMMIT must come
// from authorization_event_receipt_cardinality (the event-side trigger).
let event_id = uuid::Uuid::new_v4();
let corr_id = uuid::Uuid::new_v4();
let attempt_id = uuid::Uuid::new_v4();
let mut conn = pool
.acquire()
.await
.expect("acquire connection for event-side test");
sqlx::query("BEGIN")
.execute(&mut *conn)
.await
.expect("begin event-side transaction");
sqlx::query(
"INSERT INTO authorization_events \
(community_id, event_id, event_kind, outcome_code, reason_code, \
actor_kind, actor_fingerprint, operation_id, request_fingerprint, \
correlation_id, attempt_id, semantic_fingerprint, \
occurred_at, canonical_envelope, envelope_digest) \
VALUES ($1, $2, 1, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \
'2026-01-01T00:00:00Z', $8, $9)",
)
.bind(community_id)
.bind(event_id)
.bind(vec![0xE4_u8; 32]) // actor_fingerprint
.bind(op_id)
.bind(&fp)
.bind(corr_id)
.bind(attempt_id)
.bind(vec![0xE5_u8; 64]) // canonical_envelope
.bind(vec![0xE6_u8; 32]) // envelope_digest
.execute(&mut *conn)
.await
.expect("event INSERT must pass — event-side deferred guard fires at COMMIT");
let event_side_err = sqlx::query("COMMIT").execute(&mut *conn).await.expect_err(
"mapping a success-transition event to a committed denied receipt \
must be rejected at COMMIT by the event-side trigger",
);
assert_eq!(
event_side_err
.as_database_error()
.and_then(|e| e.constraint()),
Some("authorization_denied_lifecycle_receipt_no_success_event"),
"event-side trigger must reject with authorization_denied_lifecycle_receipt_no_success_event, \
got: {event_side_err}"
);
}
/// NIP-FI applied lifecycle receipt: an applied core lifecycle enroll receipt
/// (outcome_code = 1) requires exactly one mapped success-transition event
/// (event_kind = 1, enrolled). This exercises the `outcome_code IN (1, 3)`
/// branch of `authorization_operation_receipt_event_guard_v1` at migration 42.
///
/// Mutation sensitivity:
/// - Removing/bypassing the applied/no-op branch (replacing it with a blanket
/// RETURN NULL) makes the positive transaction commit without an event, leaving
/// the contract silently unenforced. The negative below requires the cardinality
/// constraint to fire when the event is absent.
/// - Removing the negative assertion: the absent-event case would commit when it
/// must not.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn applied_lifecycle_receipt_requires_exactly_one_event() {
let pool = connect_test_pool().await;
reset_public_schema(&pool).await;
MIGRATOR
.run_to(42, &pool)
.await
.expect("apply migrations 1-42");
let community_id = uuid::Uuid::new_v4();
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
.bind(community_id)
.bind(format!(
"applied-lifecycle-{}.example",
community_id.simple()
))
.execute(&pool)
.await
.expect("insert community");
// Enrollment policy (TOFU, mode 3).
let policy_revision: i64 = 1;
sqlx::query(
"INSERT INTO identity_enrollment_policies \
(community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \
VALUES ($1, $2, 3, $3, '2026-01-01T00:00:00Z')",
)
.bind(community_id)
.bind(policy_revision)
.bind(vec![0xF0_u8; 32])
.execute(&pool)
.await
.expect("insert enrollment policy");
// Event capacity — required by authorization_event_capacity_before_insert_v1.
sqlx::query(
"INSERT INTO authorization_event_capacity \
(community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \
VALUES ($1, 1000, 16777216, 16384)",
)
.bind(community_id)
.execute(&pool)
.await
.expect("insert event capacity");
// --- Positive: applied enroll commits with exactly one mapped event ---
//
// All cross-table FKs between identity_lifecycle_history, identity_bindings,
// authorization_operation_receipts, and authorization_events are
// DEFERRABLE INITIALLY DEFERRED — insert order within the transaction is
// flexible, but a pinned connection is required for BEGIN/COMMIT to share
// the same session. The receipt_history_cardinality trigger (migration 0041)
// fires at COMMIT and requires exactly one history row for applied enroll.
let op_id = uuid::Uuid::new_v4();
let binding_id = uuid::Uuid::new_v4();
let history_id = uuid::Uuid::new_v4();
let fp = vec![0xF1_u8; 32];
let event_id = uuid::Uuid::new_v4();
let corr_id = uuid::Uuid::new_v4();
let attempt_id = uuid::Uuid::new_v4();
let mut conn = pool
.acquire()
.await
.expect("acquire connection for positive case");
sqlx::query("BEGIN")
.execute(&mut *conn)
.await
.expect("begin positive transaction");
// History first: the receipt_history_cardinality AFTER INSERT trigger
// on authorization_operation_receipts is DEFERRED and checks at COMMIT
// time, but inserting history before receipt is idiomatic.
// successor_binding_version = 1 because binding_version is an identity
// sequence starting at 1 per community; this is the first binding.
sqlx::query(
"INSERT INTO identity_lifecycle_history \
(community_id, history_id, transition_kind, outcome_code, \
successor_binding_id, successor_binding_version, \
successor_lifecycle_revision, successor_state, \
operation_id, request_fingerprint, transition_digest) \
VALUES ($1, $2, 1, 1, $3, 1, 1, 1, $4, $5, $6)",
)
.bind(community_id)
.bind(history_id)
.bind(binding_id)
.bind(op_id)
.bind(&fp)
.bind(vec![0xF2_u8; 32])
.execute(&mut *conn)
.await
.expect("insert lifecycle history");
// Applied enroll receipt (operation_kind = 1, outcome_code = 1).
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, 1, $4, 1, $5)",
)
.bind(community_id)
.bind(op_id)
.bind(&fp)
.bind(vec![0xF3_u8; 32])
.bind(vec![0xF4_u8; 32])
.execute(&mut *conn)
.await
.expect("insert applied enroll receipt");
// Binding — birth_history_id FK is deferred; binding_version is generated.
sqlx::query(
"INSERT INTO identity_bindings \
(community_id, binding_id, issuer, subject, \
principal_fingerprint, event_author_pubkey, \
binding_state, lifecycle_revision, binding_provenance, \
policy_revision, enrollment_evidence_digest, \
birth_history_id, creation_operation_id, \
creation_request_fingerprint) \
VALUES ($1, $2, 'https://issuer.example', 'sub-applied', \
$3, $4, 1, 1, 1, $5, $6, $7, $8, $9)",
)
.bind(community_id)
.bind(binding_id)
.bind(vec![0xF5_u8; 32]) // principal_fingerprint
.bind(vec![0xF6_u8; 32]) // event_author_pubkey
.bind(policy_revision)
.bind(vec![0xF7_u8; 32]) // enrollment_evidence_digest
.bind(history_id)
.bind(op_id)
.bind(&fp)
.execute(&mut *conn)
.await
.expect("insert identity binding");
// Mapped success-transition event (event_kind = 1, enrolled).
// actor_kind = 1 requires non-null actor_fingerprint and matching receipt FK.
sqlx::query(
"INSERT INTO authorization_events \
(community_id, event_id, event_kind, outcome_code, reason_code, \
actor_kind, actor_fingerprint, operation_id, request_fingerprint, \
correlation_id, attempt_id, semantic_fingerprint, \
occurred_at, canonical_envelope, envelope_digest) \
VALUES ($1, $2, 1, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \
'2026-01-01T00:00:00Z', $8, $9)",
)
.bind(community_id)
.bind(event_id)
.bind(vec![0xF8_u8; 32]) // actor_fingerprint
.bind(op_id)
.bind(&fp)
.bind(corr_id)
.bind(attempt_id)
.bind(vec![0xF9_u8; 64]) // canonical_envelope
.bind(vec![0xFA_u8; 32]) // envelope_digest
.execute(&mut *conn)
.await
.expect("insert mapped success-transition event");
sqlx::query("COMMIT").execute(&mut *conn).await.expect(
"applied enroll receipt + exactly one mapped event must commit — \
authorization_operation_receipt_event_guard_v1 applied/no-op branch",
);
// Confirm exactly one event committed for this operation.
let event_count: i64 = sqlx::query_scalar(
"SELECT count(*) FROM authorization_events \
WHERE community_id = $1 AND operation_id = $2",
)
.bind(community_id)
.bind(op_id)
.fetch_one(&pool)
.await
.expect("count events for applied receipt");
assert_eq!(
event_count, 1,
"exactly one audit event must be present for an applied enroll receipt"
);
// --- Negative: applied enroll receipt without a mapped event must reject ---
//
// A second applied enroll transaction that commits receipt + history + binding
// but no event must be rejected with authorization_operation_receipt_event_cardinality.
let op_neg = uuid::Uuid::new_v4();
let binding_neg = uuid::Uuid::new_v4();
let history_neg = uuid::Uuid::new_v4();
let fp_neg = vec![0xFB_u8; 32];
let mut conn_neg = pool
.acquire()
.await
.expect("acquire connection for negative case");
sqlx::query("BEGIN")
.execute(&mut *conn_neg)
.await
.expect("begin negative transaction");
sqlx::query(
"INSERT INTO identity_lifecycle_history \
(community_id, history_id, transition_kind, outcome_code, \
successor_binding_id, successor_binding_version, \
successor_lifecycle_revision, successor_state, \
operation_id, request_fingerprint, transition_digest) \
VALUES ($1, $2, 1, 1, $3, 2, 1, 1, $4, $5, $6)",
// successor_binding_version = 2: second binding in this community
)
.bind(community_id)
.bind(history_neg)
.bind(binding_neg)
.bind(op_neg)
.bind(&fp_neg)
.bind(vec![0xFC_u8; 32])
.execute(&mut *conn_neg)
.await
.expect("insert negative lifecycle history");
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, 1, $4, 1, $5)",
)
.bind(community_id)
.bind(op_neg)
.bind(&fp_neg)
.bind(vec![0xFD_u8; 32])
.bind(vec![0xFE_u8; 32])
.execute(&mut *conn_neg)
.await
.expect("insert negative applied receipt");
sqlx::query(
"INSERT INTO identity_bindings \
(community_id, binding_id, issuer, subject, \
principal_fingerprint, event_author_pubkey, \
binding_state, lifecycle_revision, binding_provenance, \
policy_revision, enrollment_evidence_digest, \
birth_history_id, creation_operation_id, \
creation_request_fingerprint) \
VALUES ($1, $2, 'https://issuer.example', 'sub-applied-neg', \
$3, $4, 1, 1, 1, $5, $6, $7, $8, $9)",
)
.bind(community_id)
.bind(binding_neg)
.bind(vec![0xE7_u8; 32]) // principal_fingerprint (distinct from positive)
.bind(vec![0xE8_u8; 32]) // event_author_pubkey (distinct from positive)
.bind(policy_revision)
.bind(vec![0xE9_u8; 32])
.bind(history_neg)
.bind(op_neg)
.bind(&fp_neg)
.execute(&mut *conn_neg)
.await
.expect("insert negative binding — no event inserted");
// Commit without the mapped event — guard must reject.
let absent_event_err = sqlx::query("COMMIT")
.execute(&mut *conn_neg)
.await
.expect_err(
"applied enroll receipt without a mapped success-transition event \
must be rejected at COMMIT",
);
assert_eq!(
absent_event_err
.as_database_error()
.and_then(|e| e.constraint()),
Some("authorization_operation_receipt_event_cardinality"),
"expected authorization_operation_receipt_event_cardinality rejection \
for applied receipt without event, got: {absent_event_err}"
);
}
}