diff --git a/TESTING.md b/TESTING.md index 00794e091b..13fed7310b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -151,12 +151,14 @@ the same machine. New invitations are durable v2 bearers. A community ban permanently revokes outstanding v2 invitations issued by that principal or its recorded agents. Unban does not restore them; an unrestricted issuer can mint new invitations. -Existing durable rows default to legacy admission. Historical repair revokes a -legacy row when any retained `community_bans` row exists for its canonical -issuer or currently recorded owner, regardless of whether the restriction is -active or whether the invite was created before it. New mints explicitly mark -their rows as protocol-native; active bans still revoke those rows. Audit and -action records alone do not establish enforcement. +Existing durable rows default to legacy admission. Until the separately +reviewed historical repair processes them, any retained `community_bans` row +for a canonical issuer or its currently recorded owner permanently revokes a +legacy row, including expired, lifted, and timeout-only restrictions. The +repair clears that legacy marker after deciding the row's historical state. +Afterward, only an active ban permanently revokes it; timeouts and mutes block +claims only while active. New mints explicitly mark their rows as protocol +native. Audit and action records alone do not establish enforcement. An issuer or verified owner timeout also blocks claims on existing invitations while it lasts, without durable revocation. Claims resume after timeout expiry. Claim credentials are verified without user writes. Successful claims commit @@ -185,21 +187,14 @@ This change does not choose or deploy a cutoff. Migration 0067 is an expand step: it adds `revoked_at` and `legacy_admission`, and does not rewrite existing invite rows. Before applying it, reconcile any previously applied invitation migration version with the -schema and migration history. For the rollout, pause invitation minting and -claiming and ban lifts; apply migration 0067; deploy the new relay and drain all -old writers; then run the fenced historical repair while admission remains -paused: - -```bash -psql -X -v ON_ERROR_STOP=1 -f scripts/maintenance/backfill_invite_revocations.sql -``` - -Resume minting, claiming, and ban lifts only after the script reports zero -unrepaired invites and only new relay code is serving. The repair is bounded -and idempotent; rerun it after interruption before reopening admission. A -community outside the active state is skipped and remains fenced. A rollback -to code that ignores `revoked_at` also requires invitation admission to remain -paused. Legacy owner attribution uses the agent's currently recorded +schema and migration history. Pause invitation minting and claiming and ban +lifts; apply migration 0067; deploy the new relay and drain all old writers; +then keep admission paused until the separately reviewed, fenced historical +repair completes. Resume only after it reports zero unrepaired invites and +only new relay code is serving. The repair processes active communities and +leaves a skipped community's legacy markers in place until it can be repaired. +A rollback to code that ignores `revoked_at` also requires invitation admission +to remain paused. Legacy owner attribution uses the agent's currently recorded `agent_owner_pubkey`; deleting an owner user row out of band clears that link through its foreign key, so a later repair cannot recover the former relationship. Reconcile before any external user-row cleanup that could erase diff --git a/crates/buzz-db/TESTING.md b/crates/buzz-db/TESTING.md index 057a1f3be4..2bcaad515b 100644 --- a/crates/buzz-db/TESTING.md +++ b/crates/buzz-db/TESTING.md @@ -64,5 +64,6 @@ The runner creates one desired-state source database per invocation and clones it for ordinary tests. Migration-mode tests start empty. Cleanup retries transient disconnect races before reporting a warning. -Invite migration-upgrade tests also execute the operator repair script with -`psql`; make sure the PostgreSQL client is installed and resolves on `PATH`. +Invite upgrade tests start from the complete schema through migration 0066 and +exercise both migration and desired-state paths against populated data. The +historical repair is validated separately from the application/schema change. diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index fd28914fda..cdf41baea5 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -741,20 +741,7 @@ mod postgres_tests { .to_ascii_uppercase() .contains("CREATE FUNCTION")); assert!(!desired_schema.contains("invite_admission_lock")); - assert!(!desired_schema.contains("backfill_invite_revocations")); - let invite_repair = - include_str!("../../../../scripts/maintenance/backfill_invite_revocations.sql"); - assert!(invite_repair.contains("pg_advisory_xact_lock_shared")); - assert!(invite_repair.contains("'invite-admission:'")); - assert!(invite_repair.contains("deletion_state = 'active' AND deleted_at IS NULL")); - assert!(invite_repair.contains("LIMIT 500")); - assert!(invite_repair.contains("FOR UPDATE OF invite")); - assert!(invite_repair.contains("COMMIT;")); - assert!(invite_repair.contains("SELECT count(*) AS unrepaired_invites")); - assert!(!invite_repair - .to_ascii_uppercase() - .contains("CREATE FUNCTION")); assert_eq!(migrations[58].version, 59); assert!(migrations[58].sql.as_str().contains("buzz.push_enabled")); assert_eq!(migrations[59].version, 60); diff --git a/crates/buzz-db/src/store/relay_invite.rs b/crates/buzz-db/src/store/relay_invite.rs index 6a321efbda..7b37dcb702 100644 --- a/crates/buzz-db/src/store/relay_invite.rs +++ b/crates/buzz-db/src/store/relay_invite.rs @@ -241,8 +241,9 @@ pub(crate) async fn principal_restricted( } /// Whether a restriction row permanently revokes an invite from this principal. -/// Legacy invites treat every retained row as history; new invites only treat -/// a currently active ban as permanent revocation. +/// Unrepaired legacy rows treat every retained restriction as history; the +/// maintenance repair clears that marker after its historical decision. New +/// and repaired rows only treat a currently active ban as permanent revocation. async fn has_revoking_restriction( tx: &mut sqlx::PgConnection, community: CommunityId, diff --git a/crates/buzz-db/src/store/relay_invite/revocation_postgres_tests.rs b/crates/buzz-db/src/store/relay_invite/revocation_postgres_tests.rs index aec5cb9362..54da3cf174 100644 --- a/crates/buzz-db/src/store/relay_invite/revocation_postgres_tests.rs +++ b/crates/buzz-db/src/store/relay_invite/revocation_postgres_tests.rs @@ -317,7 +317,6 @@ async fn stalled_production_mint_ignores_lifted_history_and_later_audit_touches( .unwrap(); } - super::upgrade_postgres_tests::run_invite_repair_script(&pool).await; let revoked: Option> = sqlx::query_scalar("SELECT revoked_at FROM relay_invites WHERE community_id=$1 AND id=$2") .bind(community.as_uuid()) @@ -345,7 +344,7 @@ async fn stalled_production_mint_ignores_lifted_history_and_later_audit_touches( #[tokio::test] #[ignore = "requires Postgres"] -async fn historical_backfill_still_revokes_new_protocol_rows_for_active_bans() { +async fn active_ban_lazily_revokes_new_protocol_rows() { let (pool, community, issuer, claimant) = fixture().await; let invite = mint_relay_invite(&pool, community, &issuer, 3600, None) .await @@ -370,19 +369,6 @@ async fn historical_backfill_still_revokes_new_protocol_rows_for_active_bans() { .await .unwrap(); - super::upgrade_postgres_tests::run_invite_repair_script(&pool).await; - let revoked: bool = sqlx::query_scalar( - "SELECT revoked_at IS NOT NULL FROM relay_invites WHERE community_id=$1 AND id=$2", - ) - .bind(community.as_uuid()) - .bind(invite.invite_id) - .fetch_one(&pool) - .await - .unwrap(); - assert!( - revoked, - "active restriction repair applies to new-protocol rows" - ); assert_eq!( claim_relay_invite_outcome( &pool, @@ -395,6 +381,15 @@ async fn historical_backfill_still_revokes_new_protocol_rows_for_active_bans() { .unwrap(), ClaimOutcome::Revoked ); + let revoked: bool = sqlx::query_scalar( + "SELECT revoked_at IS NOT NULL FROM relay_invites WHERE community_id=$1 AND id=$2", + ) + .bind(community.as_uuid()) + .bind(invite.invite_id) + .fetch_one(&pool) + .await + .unwrap(); + assert!(revoked, "active bans durably revoke new-protocol rows"); } #[tokio::test] diff --git a/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs b/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs index 88bd8460d2..2a482c4484 100644 --- a/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs +++ b/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs @@ -4,7 +4,6 @@ use uuid::Uuid; struct LegacyInviteCase { community: Uuid, - revoked: bool, } struct RelayAdminActionFixture<'a> { @@ -335,68 +334,19 @@ async fn legacy_fixture() -> (PgPool, Vec) { set_deletion_state(&pool, community, "tombstone").await; } - cases.push(LegacyInviteCase { - community, - // The maintenance repair must not write through a deletion fence. - revoked: index < 8 && !matches!(index, 1 | 2), - }); + cases.push(LegacyInviteCase { community }); } (pool, cases) } -pub(super) async fn run_invite_repair_script(pool: &PgPool) -> String { - let database_url = crate::test_support::database_url(); - let options: sqlx::postgres::PgConnectOptions = database_url.parse().unwrap(); - let host = options.get_host().to_owned(); - let port = options.get_port().to_string(); - let user = options.get_username().to_owned(); - let password = database_url - .split_once("://") - .and_then(|(_, rest)| rest.split_once('@')) - .and_then(|(authority, _)| authority.split_once(':')) - .map(|(_, password)| password.to_owned()) - .or_else(|| std::env::var("PGPASSWORD").ok()) - .unwrap_or_default(); - let database: String = sqlx::query_scalar("SELECT current_database()") - .fetch_one(pool) - .await - .unwrap(); - let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../scripts/maintenance/backfill_invite_revocations.sql"); - let output = std::process::Command::new("psql") - .args(["-X", "-v", "ON_ERROR_STOP=1"]) - .arg("--host") - .arg(host) - .arg("--port") - .arg(port) - .arg("--username") - .arg(user) - .arg("--dbname") - .arg(database) - .arg("--file") - .arg(script) - .env("PGPASSWORD", password) - .output() - .unwrap(); - assert!( - output.status.success(), - "invite repair script failed:\n{}\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); - assert!( - stdout.lines().any(|line| line.trim() == "0"), - "repair validation did not return zero unrepaired invites:\n{stdout}" - ); - stdout -} - -async fn invite_revocation_state(pool: &PgPool) -> Vec<(Uuid, Option>)> { - sqlx::query_as("SELECT community_id, revoked_at FROM relay_invites ORDER BY community_id") - .fetch_all(pool) - .await - .unwrap() +async fn invite_revocation_state(pool: &PgPool) -> Vec<(Uuid, Option>, bool)> { + sqlx::query_as( + "SELECT community_id, revoked_at, legacy_admission \ + FROM relay_invites ORDER BY community_id", + ) + .fetch_all(pool) + .await + .unwrap() } async fn assert_admission_column(pool: &PgPool) { @@ -428,55 +378,14 @@ async fn assert_admission_column(pool: &PgPool) { ); } -async fn assert_repaired_and_fenced(pool: &PgPool, cases: &[LegacyInviteCase]) { - assert_admission_column(pool).await; - for case in cases { - let (revoked, legacy): (bool, bool) = sqlx::query_as( - "SELECT revoked_at IS NOT NULL, legacy_admission \ - FROM relay_invites WHERE community_id = $1", - ) - .bind(case.community) - .fetch_one(pool) - .await - .unwrap(); - assert_eq!(revoked, case.revoked, "tenant {}", case.community); - assert!(legacy, "old-code inserts retain the legacy default"); - } - let unfenced_error = - sqlx::query("UPDATE relay_invites SET use_count = use_count + 1 WHERE community_id = $1") - .bind(cases[1].community) - .execute(pool) - .await - .unwrap_err(); - assert!( - unfenced_error - .to_string() - .contains("community write fenced"), - "{unfenced_error}" - ); - let invitation_triggers: i64 = sqlx::query_scalar( - "SELECT count(*) FROM pg_trigger WHERE NOT tgisinternal \ - AND tgname IN ('invite_restriction_lock', 'invite_issuer_revocation', \ - 'invite_owner_lock', 'invite_owner_revocation', \ - 'community_write_fence_relay_invites')", - ) - .fetch_one(pool) - .await - .unwrap(); - assert_eq!( - invitation_triggers, 1, - "only the community deletion fence remains" - ); -} - async fn apply_pgschema_to_existing_database(pool: &PgPool) { // The pinned migration history has retired triggers on the partitioned // events parent and auto-named deletion-request checks that the desired // schema replaces. pgschema orders child-trigger drops before their parent // and schedules a replacement check before dropping its same-named copy. // Remove only those stale objects in this disposable fixture; pgschema - // still applies desired state; the historical repair remains a separate - // operator script required after deployment. + // still applies desired state; historical repair is a separate reviewed + // operation after deployment. sqlx::raw_sql( "DROP TRIGGER IF EXISTS trg_events_guard_nip_rs_hard_delete ON events; \ DROP TRIGGER IF EXISTS trg_events_nip_rs_watermark ON events; \ @@ -553,7 +462,7 @@ async fn apply_pgschema_to_existing_database(pool: &PgPool) { #[tokio::test] #[ignore = "requires Postgres"] -async fn migration_schema_backfill_repairs_retained_restrictions_and_ignores_audit_only_history() { +async fn migration_schema_0067_adds_invite_columns_without_rewriting_history() { let (pool, cases) = legacy_fixture().await; crate::migration::run_migrations(&pool).await.unwrap(); assert_admission_column(&pool).await; @@ -567,11 +476,35 @@ async fn migration_schema_backfill_repairs_retained_restrictions_and_ignores_aud 0, "schema migration must not rewrite historical rows" ); - run_invite_repair_script(&pool).await; - let repaired = invite_revocation_state(&pool).await; - assert_repaired_and_fenced(&pool, &cases).await; - run_invite_repair_script(&pool).await; - assert_eq!(invite_revocation_state(&pool).await, repaired); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT count(*) FROM relay_invites WHERE legacy_admission IS DISTINCT FROM true" + ) + .fetch_one(&pool) + .await + .unwrap(), + 0, + "pre-migration rows remain legacy-admitted" + ); + + insert_legacy_invite( + &pool, + cases[8].community, + 100, + &hex::encode([1u8; 32]), + Utc::now(), + ) + .await; + let old_code_insert_default: bool = sqlx::query_scalar( + "SELECT legacy_admission FROM relay_invites \ + WHERE community_id=$1 AND token_hash=$2", + ) + .bind(cases[8].community) + .bind([100u8; 32].as_slice()) + .fetch_one(&pool) + .await + .unwrap(); + assert!(old_code_insert_default); } #[tokio::test] @@ -580,7 +513,6 @@ async fn migration_schema_legacy_claim_lazily_revokes_history_after_a_fenced_com { let (pool, cases) = legacy_fixture().await; crate::migration::run_migrations(&pool).await.unwrap(); - run_invite_repair_script(&pool).await; let community = cases[1].community; let token_hash = [2u8; 32]; @@ -633,6 +565,16 @@ async fn migration_schema_legacy_claim_lazily_revokes_history_after_a_fenced_com revoked, "reactivated legacy history is durably revoked on claim" ); + let legacy_admission: bool = + sqlx::query_scalar("SELECT legacy_admission FROM relay_invites WHERE community_id = $1") + .bind(community) + .fetch_one(&pool) + .await + .unwrap(); + assert!( + legacy_admission, + "claim does not stand in for maintenance repair" + ); } #[tokio::test] @@ -663,15 +605,18 @@ async fn migration_schema_desired_pgschema_reconcile_matches_populated_upgrade() let reconcile = include_str!("../../../../../scripts/reconcile-schema-after-pgschema.sql"); sqlx::raw_sql(reconcile).execute(&pool).await.unwrap(); assert_eq!(invite_revocation_state(&pool).await, before_reconcile); - // Reconciliation is schema-only and idempotent; the explicit maintenance - // script below owns historical data repair in both schema modes. + // Reconciliation is schema-only and idempotent; no historical data is + // rewritten by the desired-state path. sqlx::raw_sql(reconcile).execute(&pool).await.unwrap(); assert_eq!(invite_revocation_state(&pool).await, before_reconcile); - - run_invite_repair_script(&pool).await; - let repaired = invite_revocation_state(&pool).await; - assert_repaired_and_fenced(&pool, &cases).await; - run_invite_repair_script(&pool).await; - assert_eq!(invite_revocation_state(&pool).await, repaired); - assert_repaired_and_fenced(&pool, &cases).await; + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT count(*) FROM relay_invites WHERE legacy_admission IS DISTINCT FROM true" + ) + .fetch_one(&pool) + .await + .unwrap(), + 0, + "pgschema preserves the legacy default for populated rows" + ); } diff --git a/scripts/maintenance/backfill_invite_revocations.sql b/scripts/maintenance/backfill_invite_revocations.sql deleted file mode 100644 index e8d00523bb..0000000000 --- a/scripts/maintenance/backfill_invite_revocations.sql +++ /dev/null @@ -1,141 +0,0 @@ --- Run after schema migration 0067 and after the new relay is fully rolled out --- and all old writers are drained. Keep invite mint/claim and ban lifts paused --- until this repair and its final validation succeed. --- --- Each community batch commits independently, takes the same lock order as the --- application writers, and skips a community unless it is active and has no --- deletion timestamp. The script is idempotent; rerun it after a failure. -\set ON_ERROR_STOP on -SET lock_timeout = '5s'; -SET statement_timeout = '2min'; - -DO $$ -DECLARE - last_community UUID; - target_community UUID; - community_active BOOLEAN; - affected BIGINT; -BEGIN - LOOP - SELECT id INTO target_community - FROM communities - WHERE last_community IS NULL OR id > last_community - ORDER BY id - LIMIT 1; - EXIT WHEN NOT FOUND; - last_community := target_community; - - LOOP - PERFORM pg_advisory_xact_lock_shared( - community_deletion_lock_key(target_community) - ); - PERFORM pg_advisory_xact_lock( - hashtextextended('invite-admission:' || target_community::text, 0) - ); - SELECT deletion_state = 'active' AND deleted_at IS NULL - INTO community_active - FROM communities - WHERE id = target_community; - - IF community_active IS DISTINCT FROM TRUE THEN - COMMIT; - EXIT; - END IF; - - WITH candidates AS ( - SELECT invite.id - FROM relay_invites AS invite - CROSS JOIN LATERAL ( - SELECT CASE - WHEN octet_length(invite.created_by) = 64 - AND invite.created_by !~ '[^0-9a-f]' - THEN decode(invite.created_by, 'hex') - END AS pubkey - ) AS issuer - WHERE invite.community_id = target_community - AND invite.revoked_at IS NULL - AND ( - EXISTS ( - SELECT 1 - FROM community_bans AS restriction - WHERE restriction.community_id = invite.community_id - AND ( - restriction.pubkey = issuer.pubkey - OR restriction.pubkey = ( - SELECT user_row.agent_owner_pubkey - FROM users AS user_row - WHERE user_row.community_id = invite.community_id - AND user_row.pubkey = issuer.pubkey - ) - ) - AND ( - invite.legacy_admission - OR ( - restriction.banned - AND (restriction.ban_expires_at IS NULL - OR restriction.ban_expires_at > clock_timestamp()) - ) - ) - ) - OR (invite.legacy_admission AND issuer.pubkey IS NULL) - ) - ORDER BY invite.id - LIMIT 500 - FOR UPDATE OF invite - ) - UPDATE relay_invites AS invite - SET revoked_at = clock_timestamp() - FROM candidates - WHERE invite.community_id = target_community - AND invite.id = candidates.id - AND invite.revoked_at IS NULL; - GET DIAGNOSTICS affected = ROW_COUNT; - - COMMIT; - EXIT WHEN affected = 0; - END LOOP; - END LOOP; -END -$$; - --- Expected result: 0. This includes active bans for every invite and every --- retained restriction row (including lifted and timeout-only rows) for legacy --- issuers or their currently recorded owners in active communities. -SELECT count(*) AS unrepaired_invites -FROM relay_invites AS invite -JOIN communities AS community ON community.id = invite.community_id -CROSS JOIN LATERAL ( - SELECT CASE - WHEN octet_length(invite.created_by) = 64 - AND invite.created_by !~ '[^0-9a-f]' - THEN decode(invite.created_by, 'hex') - END AS pubkey -) AS issuer -WHERE community.deletion_state = 'active' - AND community.deleted_at IS NULL - AND invite.revoked_at IS NULL - AND ( - EXISTS ( - SELECT 1 - FROM community_bans AS restriction - WHERE restriction.community_id = invite.community_id - AND ( - restriction.pubkey = issuer.pubkey - OR restriction.pubkey = ( - SELECT user_row.agent_owner_pubkey - FROM users AS user_row - WHERE user_row.community_id = invite.community_id - AND user_row.pubkey = issuer.pubkey - ) - ) - AND ( - invite.legacy_admission - OR ( - restriction.banned - AND (restriction.ban_expires_at IS NULL - OR restriction.ban_expires_at > clock_timestamp()) - ) - ) - ) - OR (invite.legacy_admission AND issuer.pubkey IS NULL) - );