Repository navigation
fix(relay): revoke invitations and restrict claims - #8237
Conversation
🔐 Codex Security Review
Review SummaryOverall Risk: NONE
FindingsNo concrete security, correctness, or reliability findings were identified. Notes
Generated by Codex Security Review | |
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 Thanks for this. Two blocking problems, both in the upgrade backfill: it can permanently revoke invitations from people who were never banned, or who were unbanned before minting them.
Blocking
1. Migration 0061 counts a ban decision as a committed ban (correctness). 0061_invite_revocation.sql L55–57 permanently revokes an invitation when any moderation_actions row with action IN ('ban', 'resolve:ban') exists for the issuer after it was created. resolve:ban is a decision-only audit row, not evidence that a ban ran:
- The kind-9044 path resolves through
resolve_report_decision_only(moderation_commands.rsL447–468), andenforcement_audit_actionmapsbantoresolve:ban(report_resolution.rsL148–165). No ban is executed. - HTTP report enforcement inserts the
resolve:banaudit row at claim time, in the same transaction as thependingrelay_admin_actionsrow and before any mutation (relay_admin_actions.rsL275–344). That action can still fail or be cancelled before the ban commits (L1139–1180).
In both cases an issuer who was never banned loses every invitation on upgrade, and again on each reconcile run. revoked_at never clears, so this can't be undone. It also runs against VISION_MODERATION.md, which keeps decisions separate from enforcement. schema/functions/backfill_invite_revocations.sql L47–49 has the same predicate.
Fix: remove 'resolve:ban' from the moderation_actions clause in both files. Committed HTTP bans are covered by the relay_admin_actions clause, once its timestamp is fixed (next item). Add negative upgrade and reconcile cases for a kind-9044 ban decision and for a failed or cancelled HTTP ban, next to a positive control where a ban does commit.
2. The HTTP-ban clause uses a timestamp that keeps moving (correctness). 0061_invite_revocation.sql L59–62 treats relay_admin_actions.updated_at >= i.created_at as "the ban came after this invitation." updated_at isn't when the ban committed. Lease release (L402–412), recovery claims (L1598–1608), and successful finalization (L875–877) all rewrite it. A committed ban stays recoverable when live revocation fails before finalization (report_resolution.rs L670–709), and recovery skips the mutation that already committed.
Sequence: the ban commits at T0. An admin unbans at T1 (available through api/admin/mod.rs L1234–1272). The issuer mints a new invitation at T2. Recovery or finalization of the old action touches its row at T3. The next backfill sees T3 ≥ T2 and permanently revokes the new invitation, with no new ban. backfill_invite_revocations.sql L51–54 repeats the comparison.
Fix: compare against a timestamp written once, in the ban-mutation transaction. One way is a mutation_committed_at column set next to step_marker = 'mutation_committed'. Switching to the action's created_at isn't equivalent, because the mutation can run well after the action is created. Say explicitly how existing rows without the new timestamp are handled. Add a negative test (ban → unban → new invitation → delayed recovery or finalization → backfill) and an invitation-before-ban positive control, through both the migration and the reconcile function.
Scores
- Minimalism 8/10: the PR also bundles changes unrelated to invitations (the
run_demo_echocancellation fix in mesh_boot, the multi-metric scrape wait in boot_lifecycle, theevent_follow_uptimestamp fix, the mesh_demo assertion message, and the Justfile/run-tests additions). They look correct, but splitting them out would keep this security change reviewable on its own. Optional. - Elegance 9/10: every restriction and owner writer follows the same order (deletion shared lock, then
invite_admission_lock, then row locks) and revokes in the same commit. Pullingclaim_owner.rsandrevocation.rsout keeps the claim path readable. - Correctness 6/10: the two backfill defects above. The live paths hold up. Every production
community_banswriter that sets a ban or timeout (moderation::ban_member/timeout_member,relay_admin_actionsban/timeout) takes the admission lock and revokes in the same transaction. TheINSERT INTO community_bansstatements inapi/admin/mod.rsare all inside#[cfg(test)]. Production owner writes all go throughset_agent_owner_with_operation, and the claimant's owner commits atomically with membership and use accounting. Rejected v2 claims write nothing, and existing-member or conflict branches don't consume a use. Migration and desired-schema bodies match, and reconcile runs the repair and checks it converged. All 502buzz-dbPostgreSQL tests pass at this head, and CI's PostgreSQL job runs the new test groups by name. None of those tests cover the two historical cases above.
Optional
relay_invite.rsL202–212:principal_restrictedreturnsInvalidDatafor acreated_bythat isn't 64 hex characters, so claiming such an invitation is a 500 instead of a cleaninvite_invalid.hex::decodealso accepts uppercase, while the backfill only matches^[0-9a-f]{64}$, so live and backfill would disagree on an uppercase issuer. Production mints lowercase hex, so this only affects older or hand-inserted rows. Treating an undecodable issuer as an invalid invitation would make both paths agree.- The description has no
buzz-review-completedline. - The "Run Codex Security Review" check at this head timed out after 45 minutes. That's an infra failure, not a finding.
4c37923 to
d01b649
Compare
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 Blocking. This head rebases the code from my last review onto current main, but the two upgrade-backfill defects from that review are unchanged. Both can permanently revoke invitations from people who were never banned, or who were unbanned before minting them. The migration body is byte-identical to the old 0061, only renumbered to 0065.
Blocking
1. A ban decision still counts as a committed ban (correctness). 0065_invite_revocation.sql L55–57 and backfill_invite_revocations.sql L47–49 still match action IN ('ban', 'resolve:ban') in moderation_actions. On current main, resolve:ban is still written by the decision-only path (resolve_report_decision_only, used from moderation_commands.rs and api/admin/mod.rs) and at HTTP enforcement claim time, before the ban runs and while it can still fail or be cancelled. Fix: drop 'resolve:ban' from both predicates, and add negative upgrade and reconcile cases for a kind-9044 ban decision and a failed or cancelled HTTP ban, next to a positive control where the ban commits.
2. The HTTP-ban clause still compares against updated_at (correctness). 0065_invite_revocation.sql L58–62 and backfill_invite_revocations.sql L50–54 treat relay_admin_actions.updated_at >= i.created_at as "the ban came after this invitation." Lease release (relay_admin_actions.rs L410–420 on main), recovery, and finalization all still rewrite updated_at. So ban at T0, unban at T1, new invitation at T2, recovery touches the old action at T3: the next backfill permanently revokes the T2 invitation with no new ban. Fix: compare against a timestamp written once in the ban-mutation transaction (for example a mutation_committed_at set alongside step_marker = 'mutation_committed'), say how existing rows without it are handled, and test ban → unban → new invitation → delayed recovery → backfill, plus an invitation-before-ban positive control, through both the migration and the reconcile function.
What changed in the rebase
The restriction writers in moderation.rs and relay_admin_actions.rs now take invite_admission_lock and revoke inside main's begin_community_write_transaction instead of their own transactions. I didn't find a problem with that integration, but I stopped there because the two items above decide the verdict.
Optional (carried over)
relay_invite.rsL199–210:principal_restrictedstill returnsInvalidData(a 500) for acreated_bythat isn't 64 hex characters, andhex::decodeaccepts uppercase while the backfill matches only^[0-9a-f]{64}$. Treating an undecodable issuer as an invalid invitation would make both paths agree.
## Summary This drops the channel-TTL event trigger. The application already refreshes ephemeral-channel deadlines in the event transaction (`event_follow_up::refresh_channel_ttls`, from #8135), so the trigger only repeats that work. This PR originally retired both follow-up triggers. #8352 has since retired the push-match trigger in migration 0063, so after the rebase this PR covers only the TTL half. Migration `0065_retire_channel_ttl_trigger.sql`: - `SET LOCAL lock_timeout = '5s'` (the same bound as 0063) - drops `events_refresh_channel_ttl` and `refresh_channel_ttl_after_event_insert()` Dropping the trigger on the partitioned parent also drops its partition clones. Published migrations, including 0022 and 0024, stay checksum-frozen. The same objects are removed from `schema/` (`schema/functions/…`, `schema/triggers/events.sql`, `schema/schema.sql`) and from the pgschema reconcile script. After this, migrated and desired-state databases have no event follow-up triggers. Tests: - `follow_up_retirement_tests.rs` (renamed from `push_retirement_tests.rs`): the push-retirement upgrade test now stops at 0063. A new test upgrades 0064 → 0065 and checks that the TTL trigger is gone from the parent and every partition, that the function is gone, and that the published checksums haven't changed. - `event_follow_up_postgres_tests.rs`: the contract now has two arms. `AppOnly` is the shipped schema, where both triggers are absent. `Dual` reconstructs the historical overlap of both triggers. The `PushRetired` arm (the push trigger dropped, the TTL trigger kept) was the shipped state before this PR. Its tests are removed because `Dual` and `AppOnly` together cover it. - `embedded_migrator_contains_consolidated_initial_schema` pins the 0065 statements and checks that both objects are absent from the desired schema. **Deploy gate:** do not run 0065 against a database still served by a relay older than #8135. After 0065, raw SQL writers no longer refresh TTL deadlines. `docs/push-gateway-deployment.md` is updated to match. ### What got simpler The trigger and the app no longer both refresh TTL deadlines; the app is now the only producer. One plpgsql function and one trigger are gone from both schema paths, and 8 per-partition drops are gone from the reconcile script. The follow-up contract drops from three arms to two. ### Related issue BUZZ-313 (Linear), part of the trigger-retirement work. ### Testing Head is `4f7d0baf0`. Results below are at `678927730` unless noted. `678927730..4f7d0ba` changes only one test in `crates/buzz-db/src/store/event.rs`, described in the first bullet. - **Fixed test, `permanent_channel_event_commits_do_not_lock_the_channel_row`** (`4f7d0baf0`). - **Why it changed:** after 0065, its raw-pool inserts no longer ran any TTL refresh, so the test passed whatever `refresh_channel_ttls` did. This was raised in review. - **What it does now:** it inserts through the production `AdmittedTx` path and keeps the barrier and the tuple-lock probe. It commits all N transactions while an external transaction holds `FOR SHARE` on the channel row. It also asserts that no `buzz_db_channel_ttl_refresh_failures_total` is counted. - **Mutation check:** with `SELECT … FOR UPDATE` injected into `refresh_channel_ttl`, the new test fails (`must not wait on the channel row`, timeout at 10s). The old test passes under the same mutation. - **Blox:** fmt and `buzz-db` clippy pass. - **GitHub CI at `4f7d0baf0`:** every required check passes (56 pass, 0 fail). CI builds the merge ref with `main` `93ffc37cb`. - **New and renamed tests, checked by name in the CI logs at `4f7d0baf0`.** These pass in PostgreSQL Domain / PostgreSQL Tests ([117033725006](https://github.com/block/buzz/actions/runs/38915998692/job/117033725006)): - `permanent_channel_event_commits_do_not_lock_the_channel_row` - `migration_schema_ttl_retirement_drops_every_partition_trigger_and_keeps_checksums` - `migration_schema_push_retirement_preserves_ttl_and_published_checksums` - `event_insert_ttl_refresh_handles_permanent_ephemeral_duplicate_and_activation_race` - `{desired,migration_schema}_event_follow_up_contract_{dual,app_only}` (four tests) `embedded_migrator_contains_consolidated_initial_schema` passes in Rust / Unit Tests ([117024125423](https://github.com/block/buzz/actions/runs/38915998692/job/117024125423)). No `*_push_retired` test runs anywhere. - **Blox gates at `678927730`** (private PG16 + Redis, on a workstation shared with other sessions): - `cargo fmt --check`, workspace `clippy -D warnings` and `buzz-db --lib` all pass. - The `buzz-admin`, `buzz-audit`, `buzz-deletion`, `buzz-push-gateway`, `buzz-search` and `buzz-workflow` Postgres packages pass. - `buzz-db`: 517 of 519 tests passed. The 2 that hung, both `writer_pool_*` tests, were killed. `buzz-relay`: 413 of 426 passed. The 13 failures are covered below. - **Uncontended reruns at `4f7d0baf0`** on a fresh, idle workstation (PG16.15, `competing=0` for every arm): - **`writer_pool_rejects_non_read_committed_database_default` predates this PR.** It timed out 3/3 at head and 3/3 at `main` `93ffc37cb`, with a 300s timeout per run. - **`writer_pool_metrics_stop_after_session_timeout_setup_failure` also predates this PR.** It timed out 2/3 at head and 2/3 at `main`, and passed in the remaining run on each side. - Both tests pass in CI. The gate accepts them as a named exception for this PR, as a pre-existing hang on the PG16 test environment. - **`buzz-db`:** 518 of 519 tests passed. The single failure is `writer_pool_rejects_non_read_committed_database_default`, killed after it hung (the exception above). - **`buzz-relay`: still open.** 413 of 426 tests passed. The same 13 `handlers::group_state_postgres_tests` failed as on the shared box, each panicking at once in its fixture's `ensure community` step with `Sqlx(PoolTimedOut)`. Contention does not explain them. The fixture's community setup is in `buzz-db`, which this PR changes. A head/`main` comparison of the full `buzz-relay` package on the same idle box is running to find out whether this predates the PR. All 13 pass in CI at this head. - **E2E (Gimli, Blox)** at `050e593af`. `050e593af..6789277` changes only tests and docs; the migration, schema and runtime code are byte-identical. Gimli's audited verdict: **EXERCISED AND WORKS**, for DB and relay runtime only. Gimli ran it at `050e593af`; this head's runtime is byte-identical but was not freshly executed. - **Upgrade:** a real relay startup took the schema from 0064 to 0065. That removed the TTL function and the trigger from the parent table and from all 8 partitions. Checksums for migrations 1–64 are unchanged. - **Lock-timeout rollback:** with a real lock held, the migration failed with `55P03` at 5.0007s and the schema rolled back fully to 64. Once the lock was released, a retry applied 65. - **Fresh desired schema:** after pgschema plus reconcile, the retired objects are absent. - **Real signed kind-9 sends:** on both schema paths, these refreshed ephemeral-channel deadlines to about +600s. Permanent channels stayed NULL. - **Raw SQL insert:** at 64 it refreshed the deadline. After 65 it left the deadline unchanged, on both the upgraded and the fresh schema. - **Not covered:** native or device testing, and deploy. - **Review:** Legolas found 0 P0 and 0 P1. His three optional P2s are fixed in `678927730`. - **No human app test:** this is a DB-only change. - **Migration numbering:** #8237 and #8372 also add a `0065_*` migration. Whichever PR merges second must renumber. --------- Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Keep v1 natural drain with an optional UTC cutoff. Serialize claim and mint policy reads with restriction and owner writes. Share historical revocation repair across migration and desired-state upgrades while retaining deletion fences. Co-authored-by: Codex <codex@openai.com> Signed-off-by: Brad Seiler <seiler@squareup.com> Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz>
Co-authored-by: Codex <codex@openai.com> Signed-off-by: Brad Seiler <seiler@squareup.com> Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz>
Co-authored-by: Codex <codex@openai.com> Signed-off-by: Brad Seiler <seiler@squareup.com> Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz>
Add a deterministic production-consumer regression, cancellation-safety documentation, and deadline diagnostics for mesh receive handling. Co-authored-by: Codex <codex@openai.com> Signed-off-by: Brad Seiler <seiler@squareup.com> (cherry picked from commit f1674a2) Signed-off-by: Brad Seiler <seiler@squareup.com> Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz>
Co-authored-by: Codex <codex@openai.com> Signed-off-by: Brad Seiler <seiler@squareup.com> (cherry picked from commit 3d02a44) Signed-off-by: Brad Seiler <seiler@squareup.com> Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz>
Co-authored-by: Codex <codex@openai.com> Signed-off-by: Brad Seiler <seiler@squareup.com> (cherry picked from commit d43b957) Signed-off-by: Brad Seiler <seiler@squareup.com> Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz>
Main's #8564 replaced the branch's test-only acquire-timeout helper with `test_state_with_database_url_and_pool_options`; take main's helper and group-state fixture. No new migrations on main, so 0074 is unchanged. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com>
loganj
left a comment
There was a problem hiding this comment.
🤖 Larry, Auto and Cerveau: an independent adversarial review of 12f462cc. Source review only, plus two throwaway-Postgres probes; we did not rerun the Rust suites or a live relay.
Verdict: approve. We found no correctness defect in revocation, claim admission, owner persistence, the migration, or the v1 cutoff. One item (error, inline on relay_invite.rs:430) should be fixed before this deploys: an unauthenticated caller can cheaply serialize a community's claims behind the new admission lock. The fix is small and doesn't need another full review.
Split: Auto took writer coverage, lock order and performance. Cerveau took the schema, upgrade, the strict legacy rule, the v1 cutoff and the rollout. Larry took claim and mint outcomes, error mapping, client text and tests.
Checked and found correct
- Every production writer of
community_bans,users.agent_owner_pubkeyandrelay_invitestakeslock_admissionright afterbegin_community_write_transactionand revokes in the same commit:ban_member/timeout_member(including signed kind 9040), the admin-action marker executors,set_agent_owner_with_operation,persist_claim_owner, mint and claim. The otherINSERT INTO community_banssites inapi/admin/mod.rsandoperator_listener.rsare all inside test modules. Unban and untimeout take no lock, which is safe because they only loosen restrictions. - Lock order is always shared deletion lock → admission → invite row
FOR UPDATE/userswrites → membership snapshot lock. No path asks for admission while holding a domain row lock. The lock-freeset_agent_ownerfast path is sound, because the owner only ever moves from NULL to set. 0074is additive DDL only. In a PostgreSQL 17 probe, rows inserted by the old relay before and after the migration both gotlegacy_admission = true, new mints got false, and the table was not rewritten. The strict legacy rule matches the companion SQL and is documented.- Every production v1 verification goes through
verify_invite_with_cutoff, which rejects atnow >= cutoff. Mint has a single production caller. A rejected claim creates nousersrows.
Other notes, not blocking
- advice (#8529): at companion head
d0c5b7d1, its instructions andscripts/maintenance/backfill_invite_revocations.sql:2still say to apply migration 0069. On this branch the columns arrive in0074. Refresh those references before the companion is approved. - advice: a timed-out admin who tries to mint sees only the generic "Couldn’t create an invite link." toast (
InviteLinkSection.tsx). Optional. - Splitting out the unrelated flake fixes and the
CommunitySwitcherchange is optional.
A v2 claim took the community-wide admission lock before looking up its token, so forged `v2.` codes from fresh keys could serialize a community's claims, bans and timeouts and hold shared writer-pool connections while queued. Check that the code exists with an indexed read first; the locked `FOR UPDATE` read stays authoritative. A restricted claimant presenting an unknown code now gets `invite_invalid`, which reveals nothing. Also from review: mint's owner-link 503 reuses `OWNER_LINK_ERROR` like the NIP-FI path; comments explain the admission wait behind membership snapshots, the defensive claim-owner revocation, and why issuer restrictions map to `invite_invalid`; TESTING.md notes that a rollback past 0074 needs `BUZZ_AUTO_MIGRATE` off (docs/database.md Rule 5). Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com>
… 0075 Main's #8632 took 0074 for user deletion fences, so the invitation migration moves to 0075 (byte-identical). Upgrade fixtures now start at pinned main through 0074, the migrator test expects 75 contiguous versions with the invite assertions at index 74, and both TESTING.md files name 0075. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com>
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 Blocking. Since my last review (at 1163e202), this head adds the check that rejects unknown invite codes before the claim takes the community admission lock, moves mint's owner-link 503 onto OWNER_LINK_ERROR, and renumbers the invite migration to 0075 after main took 0074. The approach holds up, but the new check broke two existing tests, and both fail in CI at this head.
Blocking
- The pre-lock lookup bypasses pool operation attribution.
relay_invite.rsL428–435 runs theSELECT EXISTSwith.fetch_one(pool), so that checkout carries no operation label. The source guardp0_pool_acquisitions_use_typed_operation_pairs_without_othercatches it (unit CI,observability_source.rsL627). It also means forged-code lookups, the exact traffic this change is separating from admission, don't show up in the checkout metrics. Fix: take the connection withcrate::observability::acquire_writer(pool, WriterOperation::Authorization)(like mint at L316), run the lookup on it, and drop it beforebegin_community_write_transaction. Holding it across the second checkout could exhaust a small pool (a one-connection pool would deadlock). The lookup should stay outside the lock. - The community-fence test now claims a code that doesn't exist.
admission_postgres_tests.rsL198–208 claims the unseeded hash[3; 32]and expects the deletion-fence error. The new check correctly returnsInvalidfirst, sorelay_invite_writers_require_application_admissionpanics with "invite claim must reject before row lock: Invalid" (Postgres CI), and the claim path no longer has coverage through the fence. Fix: seed a matching invite before fencing so the test still proves an existing code is stopped by the fence, and assert separately that an absent code returnsInvalidwith no effects. I wouldn't move the check behind admission just to keep the old error order. This is lost coverage, not a write getting through the fence.
What holds up
- Claim ordering. The lookup is only an early rejection. A known code still enters community deletion admission, takes the invitation admission lock, runs the claimant and owner restriction checks, and re-reads the invite
FOR UPDATE(L441–473) before expiry, revocation, issuer checks or any write. A delete or revoke between the two reads can't turn into an admission. An unknown code returnsInvalidbefore any restriction or fence error and writes no owner, membership, policy evidence or consumption. The(community_id, token_hash)unique index covers the lookup. - The new test.
writer_protocol_postgres_tests.rsL363–377 holds admission in a separate transaction and requires the forged claim to returnInvalidwithin five seconds, then releases the lock. It passes in CI. - Mint
503.invites.rsL330–335 still fails closed before minting, and the existing failure-injection test asserts theOWNER_LINK_ERRORresponse and an unchanged invite count. - Renumber. The
0075file is byte-identical to the old0074, and main's user-deletion-fence0074is preserved. The migration count (75) and index (74) assertions, the populated upgrade fixture, desired-state reconciliation coverage, both testing docs and the rollbackVersionMissing(75)note are all updated. I found no stale references to the invite migration as0074.
Scores
- Minimalism 9/10: a small rejection fast path, no second authorization mechanism.
- Elegance 9/10: the locked path stays authoritative, and mint now uses the shared error string.
- Correctness 8/10: the two CI regressions above.
989412f ran the pre-lock `SELECT EXISTS` on the bare pool before `begin_community_write_transaction`. That answered claims on fenced (quiescing/deleting) tenants with `invite_invalid` instead of the fence rejection, and acquired a writer connection outside a typed `WriterOperation`. Open the admitted transaction first, which enforces the tenant fence and attribution without taking the invite admission lock, then check the code on `tx.conn()` before `lock_admission`. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Main's #8635 rewrote the `test-unit` nextest filter; keep its new `handlers::coalesced_refresh::tests` selector and this branch's `destructive_fixture_rejects_shared_database_urls` selector. No new migrations, so 0075 stands. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com>
|
🤖 Thanks for the review. Both regressions are fixed in |
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 Clear. Since my last review (at c16538dc), this head adds 36103818, which moves the unknown-code check inside the claim's admitted transaction, and merges main 0a362eef. Both blockers from that review are fixed, and I found nothing new.
What holds up
- Writer attribution. The existence check now runs on
tx.conn()of the transaction opened withWriterOperation::Authorization(relay_invite.rsL426–443), so the claim no longer reads from the bare pool. In a live run, one unknown-code claim raised the writer authorization acquisition count by exactly one. The unit CI guardp0_pool_acquisitions_use_typed_operation_pairs_without_otherpasses. - Forged codes still skip the invite lock. The admitted transaction takes only the shared community deletion lock that every writer takes. It does not take the
invite-admission:advisory lock (L205–217), so an unknown code holds a connection for one indexed lookup, rolls back and returnsInvalidwithout queuing behind claims, bans or timeouts. In a live run, with the invite lock held, an unknown code got403 invite_invalidin about 13 ms. A valid code waited on the lock and joined once it was released. - Fence ordering. Claims now hit the tenant fence first, then the unknown-code check, then the claimant restriction checks, then the authoritative
FOR UPDATEread.relay_invite_writers_require_application_admissionpasses again unchanged. Because the fence runs before any invite read, that test covers every claim, whether or not its code exists. - Authority unchanged. Known codes still take the invite lock, check claimant and owner restrictions, and re-read the invite
FOR UPDATEbefore expiry, revocation, issuer checks or any write. In a live run, a ban still revoked an outstanding invite, the invite stayed dead after the unban, and a new invite minted after the unban joined. - Merge. The
Justfileunit-lane filter keeps main'shandlers::coalesced_refresh::testsselector and this branch'sdestructive_fixture_rejects_shared_database_urlsselector, and drops nothing.scripts/run-tests.shkeeps both sides' additions. Main added no migrations, so0075stands.
CI is green at this head.
Scores
- Minimalism 9/10: reuses the existing admitted transaction. Only the lookup's placement and connection change, plus an explicit rollback.
- Elegance 9/10: the fast path sits where every writer is already fenced and attributed, and the locked read stays authoritative.
- Correctness 9/10: both regressions are fixed, and the lock bypass, fence ordering and revocation all held in source review and a live relay run at this head.
loganj
left a comment
There was a problem hiding this comment.
🤖 Larry: re-approving at b0c230ef after reviewing the delta from our approval at 12f462cc.
- Lock-flood finding: fixed. A claim now rejects an unknown code inside its admitted transaction, before it takes the
invite-admission:lock (36103818). The fix keeps the tenant fence and writer attribution, and the lockedFOR UPDATEread stays authoritative.unknown_claim_code_is_rejected_without_waiting_on_admissionholds the lock and checks that an unknown code still returnsInvalid. - Other items: addressed. Mint's 503 now uses
OWNER_LINK_ERROR. The defensive revocation call and the issuer-timeoutinvite_invalidchoice both have comments.TESTING.mdrequiresBUZZ_AUTO_MIGRATEoff for rollback. - Migration: now
0075_invite_revocation.sql, which is still the next number on main.
Still open in companion #8529 at d0c5b7d1: its script header and PR body still say migration 0069 (and 0067/0068), and should say 0075.
Source review of the delta only; I didn't rerun the suites. Checks are green at this head.
Summary
When an issuer is banned, its outstanding v2 invitations are now permanently revoked. Invitations issued by the banned issuer's recorded agents are revoked too, including an agent whose owner link is verified after the ban. Banning an agent does not revoke its owner's invitations. An unban lets the issuer mint new invitations, but revoked links stay revoked.
A banned or timed-out claimant is refused with
403 invite_restricted. An issuer timeout blocks claims only until the timeout expires. Mint, claim, restriction and owner writes are serialized. A successful claim commits ownership, membership and invite consumption in one transaction.When minting, a verified owner conflict returns
403 invite_owner_mismatchand a storage failure while recording the owner link returns a retryable503. Claim storage failures still return500 internal_error, as on main. Web, desktop and both mobile claim paths show recovery text and keep the invitation so the user can retry.Migration
0075_invite_revocation.sqlis schema-only. It addsrelay_invites.revoked_atandrelay_invites.legacy_admissionand rewrites no rows. Until the companion repair runs, new code checks each legacy invitation lazily at claim time and revokes it if the issuer or recorded owner has any retained restriction. Stateless v1 tokens still need a fleet drain or a configured cutoff.This PR also adds bounded readiness waits to some inherited ACP and Tauri test fixtures.
Related issue
BUZZ-268. Replaces draft #8162. The historical repair companion #8529 is stacked on this PR and must be approved before this deploys. Related work: #8239, #8235, #8241, #8242.
Rollout
Follow the invitation section of
TESTING.md.0075.0075. Run the fix(db): repair historical invitation revocation #8529 operator script twice. Before reopening, the first run must leave zero unrepaired invitations in active communities, and the second run must change nothing. Keep skipped or fenced communities closed until they are repaired.Testing
Merge
c4101383merges maind7270330. The merge renumbers the invitation migration from 0069 to 0071, because main added 0069 and 0070 for entity-deletion approval. It also adopts main'sOwnerLinkresult (#8401) for owner linking. For an owner named in a NIP-OA auth tag, invite mint mapsConflictto403 invite_owner_mismatchandUnavailableto503. A NIP-FI agent token's owner conflict is refused earlier, during membership admission, with NIP-FI's uniform403 authorization denied, the same as on main. The PR's separateResult<bool, DbError>owner path is removed.8dad74faruns the invitation upgrade test's pgschema apply from the repository root, so.pgschemaignoreapplies as it does for every other caller.5af546bfadds the infra-freedestructive_fixture_rejects_shared_database_urlsguard to the unit lane, where no CI lane had selected it before.Merge
6916ef18merges main0e36dbc8. Main's #8579 took 0071 for the unlisted channel setting, so the invitation migration moved to 0072. The upgrade fixtures start from main's schema through 0071, and the migrator test expects 72 contiguous migrations.Validation at
6916ef18: GitHub CI is green (79 passed, 25 skipped). The PostgreSQL lane passed 1117/1117 and the relay unit lane 585/585. Agent review found no blockers, and the one P1 and one P2 it raised are fixed. A relay built at this head enforced ban revocation, owner-to-agent revocation, restricted claimants, and NIP-FI owner conflicts as intended.9ae85092corrects the claim recovery text.invite_restrictedonly fires when the claimant, or the account that owns it, is restricted, so the message no longer suggests the invite issuer. A restricted issuer getsinvite_invalid.Merge
1163e202merges main834dca26. Main's #8605 took 0072 and 0073 for the user deletion target, so the invitation migration is now0074_invite_revocation.sql. The upgrade fixtures start from main's schema through 0073, and the migrator test expects 74 contiguous migrations. At this head, CI is green after one rerun of jobs that hit install timeouts and a timing flake in files this PR doesn't touch. The Blox gate passed, and Gimli's relay exercise of all four scenarios passed.This branch also includes
b577ac26, a small desktop fix. The community switcher cancels pending hover timers when its actions open or close explicitly, so the menu stays steady while the pointer moves between items.Four commits fix test or runtime flakes that kept this branch's CI red. None of them touch invitations:
91305ecbkeeps a pending mesh receive alive across housekeeping ticks in the relay'srun_demo_echo(mesh_boot.rs).e1afe121synchronizes the ACPrun_task.rslocal task signal tests with pending input.88aaa4d2and51b8e9c3synchronize and disarm the Tauribounded_command.rsdescendant-timeout fixture.Merge
c16538dcmerges main2c28e071. Main's #8632 took 0074 for user deletion fences, so the invitation migration is now0075_invite_revocation.sql, byte-identical. The upgrade fixtures start from main's schema through 0074, and the migrator test expects 75 contiguous migrations.989412f3addresses review. A v2 claim now checks that its code exists, with an indexed read onrelay_invites (community_id, token_hash), before it takes the community-wide admission lock. Forged codes therefore can't serialize a community's claims, bans and timeouts or hold shared writer-pool connections while they wait. The lockedFOR UPDATEread is still authoritative, and a restricted claimant presenting an unknown code getsinvite_invalid.unknown_claim_code_is_rejected_without_waiting_on_admissionholds the lock and requires an unknown code to be rejected within 5 seconds. The mint owner-link503now returnsOWNER_LINK_ERROR, the same as the NIP-FI path.TESTING.mdnotes that rolling back past the invitation migration also requiresBUZZ_AUTO_MIGRATEoff (docs/database.mdRule 5).36103818moves that existence check inside the claim's community write transaction, after the tenant deletion fence and typed writer attribution and before the admission lock, so an unknown code in a fenced community is still refused by the fence and the check's connection is attributed like every other writer checkout.Merge
b0c230efmerges main0a362eef. Main added no migrations; the only conflict was the relay unit-laneJustfilefilter, which keeps main's new selector alongside this branch'sdestructive_fixture_rejects_shared_database_urlsselector.Merge
12f462ccmerges main3ed07e5c. Main added no migrations at that point. Main's #8564 replaced this branch's test-onlytest_state_with_database_url_and_acquire_timeouthelper withtest_state_with_database_url_and_pool_options, and the merge takes main's version.49db2d8cchanges only docs and comments.TESTING.mdnow describesinvite_restrictedas the claiming account or the account that owns it, and the stalematerialize_nip_oa_ownercomment is corrected.Human verification on 2026-10-20 at
28fdf29dpassed. Brad Seiler ran the old build (pinned main91b1cc47) and then the candidate against one isolated local database, using signed HTTP mints and claims:403 invite_invalidand the claim of L2 joined.403 invite_invalid.403 invite_invalid.That run predates the rollout and recovery-text changes, so it does not cover them. Agent review and agent exercise (checklist steps 1–2) are complete at
b0c230ef, including the lock fix: an unknown code is rejected in milliseconds while the admission lock is held, and a valid code waits for the lock and then joins. The human test of the current build (step 3) is pending, andbuzz-review-completedwill be added once it passes. Earlier agent validation receipts are in this description's edit history.🤖 Generated with Claude Code