Skip to content

fix(relay): revoke invitations and restrict claims - #8237

Merged
TheSentinel454 merged 45 commits into
mainfrom
codex/buzz-268-draft-prep
Oct 28, 2026
Merged

TheSentinel454 merged 45 commits into
mainfrom
codex/buzz-268-draft-prep

Conversation

@bradseiler

@bradseiler bradseiler commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

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_mismatch and a storage failure while recording the owner link returns a retryable 503. Claim storage failures still return 500 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.sql is schema-only. It adds relay_invites.revoked_at and relay_invites.legacy_admission and 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.

  1. Reconcile migration history first. Earlier versions of this branch numbered the invitation migration 0067, 0068, 0069, 0071, 0072 and then 0074. Each of those numbers now belongs to a different migration on main. Any shared or staging database that applied one needs its history reconciled before it applies 0075.
  2. Keep invitation mint and claim, ban lifts, and new timeout or mute writes stopped across every writer during the drain and repair. If existing controls can't enforce that, use a maintenance window that stops every writer.
  3. Apply schema 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.
  4. Rolling back to code that ignores revocation requires admission to stay paused.

Testing

Merge c4101383 merges main d7270330. The merge renumbers the invitation migration from 0069 to 0071, because main added 0069 and 0070 for entity-deletion approval. It also adopts main's OwnerLink result (#8401) for owner linking. For an owner named in a NIP-OA auth tag, invite mint maps Conflict to 403 invite_owner_mismatch and Unavailable to 503. A NIP-FI agent token's owner conflict is refused earlier, during membership admission, with NIP-FI's uniform 403 authorization denied, the same as on main. The PR's separate Result<bool, DbError> owner path is removed. 8dad74fa runs the invitation upgrade test's pgschema apply from the repository root, so .pgschemaignore applies as it does for every other caller. 5af546bf adds the infra-free destructive_fixture_rejects_shared_database_urls guard to the unit lane, where no CI lane had selected it before.

Merge 6916ef18 merges main 0e36dbc8. 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.

9ae85092 corrects the claim recovery text. invite_restricted only fires when the claimant, or the account that owns it, is restricted, so the message no longer suggests the invite issuer. A restricted issuer gets invite_invalid.

Merge 1163e202 merges main 834dca26. Main's #8605 took 0072 and 0073 for the user deletion target, so the invitation migration is now 0074_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:

  • 91305ecb keeps a pending mesh receive alive across housekeeping ticks in the relay's run_demo_echo (mesh_boot.rs).
  • e1afe121 synchronizes the ACP run_task.rs local task signal tests with pending input.
  • 88aaa4d2 and 51b8e9c3 synchronize and disarm the Tauri bounded_command.rs descendant-timeout fixture.

Merge c16538dc merges main 2c28e071. Main's #8632 took 0074 for user deletion fences, so the invitation migration is now 0075_invite_revocation.sql, byte-identical. The upgrade fixtures start from main's schema through 0074, and the migrator test expects 75 contiguous migrations.

989412f3 addresses review. A v2 claim now checks that its code exists, with an indexed read on relay_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 locked FOR UPDATE read is still authoritative, and a restricted claimant presenting an unknown code gets invite_invalid. unknown_claim_code_is_rejected_without_waiting_on_admission holds the lock and requires an unknown code to be rejected within 5 seconds. The mint owner-link 503 now returns OWNER_LINK_ERROR, the same as the NIP-FI path. TESTING.md notes that rolling back past the invitation migration also requires BUZZ_AUTO_MIGRATE off (docs/database.md Rule 5).

36103818 moves 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 b0c230ef merges main 0a362eef. Main added no migrations; the only conflict was the relay unit-lane Justfile filter, which keeps main's new selector alongside this branch's destructive_fixture_rejects_shared_database_urls selector.

Merge 12f462cc merges main 3ed07e5c. Main added no migrations at that point. Main's #8564 replaced this branch's test-only test_state_with_database_url_and_acquire_timeout helper with test_state_with_database_url_and_pool_options, and the merge takes main's version.

49db2d8c changes only docs and comments. TESTING.md now describes invite_restricted as the claiming account or the account that owns it, and the stale materialize_nip_oa_owner comment is corrected.

Human verification on 2026-10-20 at 28fdf29d passed. Brad Seiler ran the old build (pinned main 91b1cc47) and then the candidate against one isolated local database, using signed HTTP mints and claims:

  1. On the old build, admin A minted L1 and owner O minted L2. O banned A, then unbanned A.
  2. On the candidate, the claim of L1 returned 403 invite_invalid and the claim of L2 joined.
  3. A minted L3. One claim of L3 joined. O banned A, and the next claim of L3 returned 403 invite_invalid.
  4. After the unban and a relay restart, L3 still returned 403 invite_invalid.
  5. A new link L4, minted after the unban, joined.

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, and buzz-review-completed will be added once it passes. Earlier agent validation receipts are in this description's edit history.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

🔐 Codex Security Review

Note: This is an automated, security-focused review generated by Codex.
Use it as a supplement to human review; false positives are possible.

Scope

  • Exact PR diff: 0a362eef150ab16453993d442a8037baad766797...b0c230ef4b13d7a575d99db0ebd49aaec7c2505c
  • Model: gpt-6.1-sol

💡 Click "edited" above to see earlier reviews for this PR.


Review Summary

Overall Risk: NONE

No concrete security, correctness, or reliability findings were identified in the authorized PR range.

Findings

No concrete security, correctness, or reliability findings were identified.

Notes

  • Static review only; builds, tests, and repository scripts were not executed.
  • Deployment depends on the documented maintenance controls and separately reviewed historical invite repair, which is outside this PR.

Generated by Codex Security Review |
Requested by: @TheSentinel454 |
Workflow run

@TheSentinel454

Copy link
Copy Markdown
Contributor

The body says "Draft only: human testing … outstanding", but the PR is marked ready for review. Worth flipping it back to draft until that's done (same for #8239 and #8242).

Comment thread migrations/0061_invite_revocation.sql Outdated
@github-actions github-actions Bot removed the codex-security-review-current The posted Codex security review matches its recorded range. label Oct 13, 2026

@wpfleger96 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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:

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_echo cancellation fix in mesh_boot, the multi-metric scrape wait in boot_lifecycle, the event_follow_up timestamp 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. Pulling claim_owner.rs and revocation.rs out keeps the claim path readable.
  • Correctness 6/10: the two backfill defects above. The live paths hold up. Every production community_bans writer that sets a ban or timeout (moderation::ban_member/timeout_member, relay_admin_actions ban/timeout) takes the admission lock and revokes in the same transaction. The INSERT INTO community_bans statements in api/admin/mod.rs are all inside #[cfg(test)]. Production owner writes all go through set_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 502 buzz-db PostgreSQL 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.rs L202–212: principal_restricted returns InvalidData for a created_by that isn't 64 hex characters, so claiming such an invitation is a 500 instead of a clean invite_invalid. hex::decode also 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-completed line.
  • The "Run Codex Security Review" check at this head timed out after 45 minutes. That's an infra failure, not a finding.

Comment thread migrations/0065_invite_revocation.sql Outdated
@bradseiler
bradseiler force-pushed the codex/buzz-268-draft-prep branch from 4c37923 to d01b649 Compare October 15, 2026 23:34
@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Oct 15, 2026
@bradseiler bradseiler changed the title BUZZ-268: Durable invitation revocation and restricted claims fix(relay): revoke invitations and restrict claims Oct 15, 2026

@wpfleger96 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.rs L199–210: principal_restricted still returns InvalidData (a 500) for a created_by that isn't 64 hex characters, and hex::decode accepts uppercase while the backfill matches only ^[0-9a-f]{64}$. Treating an undecodable issuer as an invalid invitation would make both paths agree.

TheSentinel454 added a commit that referenced this pull request Oct 16, 2026
## 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>
@bradseiler
bradseiler marked this pull request as draft October 16, 2026 19:14
bradseiler and others added 6 commits October 17, 2026 17:20
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>
@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Oct 26, 2026
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>
@github-actions github-actions Bot removed the codex-security-review-current The posted Codex security review matches its recorded range. label Oct 28, 2026
@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Oct 28, 2026

@loganj loganj left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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_pubkey and relay_invites takes lock_admission right after begin_community_write_transaction and 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 other INSERT INTO community_bans sites in api/admin/mod.rs and operator_listener.rs are 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 / users writes → membership snapshot lock. No path asks for admission while holding a domain row lock. The lock-free set_agent_owner fast path is sound, because the owner only ever moves from NULL to set.
  • 0074 is additive DDL only. In a PostgreSQL 17 probe, rows inserted by the old relay before and after the migration both got legacy_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 at now >= cutoff. Mint has a single production caller. A rejected claim creates no users rows.

Other notes, not blocking

  • advice (#8529): at companion head d0c5b7d1, its instructions and scripts/maintenance/backfill_invite_revocations.sql:2 still say to apply migration 0069. On this branch the columns arrive in 0074. 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 CommunitySwitcher change is optional.

Comment thread crates/buzz-db/src/store/relay_invite.rs
Comment thread crates/buzz-db/src/store/relay_invite.rs
Comment thread crates/buzz-db/src/store/relay_invite/revocation.rs
Comment thread crates/buzz-db/src/store/relay_invite/claim_owner.rs
Comment thread crates/buzz-relay/src/api/invites.rs Outdated
Comment thread crates/buzz-relay/src/api/invites.rs
Comment thread TESTING.md
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>
@github-actions github-actions Bot added codex-security-review-current The posted Codex security review matches its recorded range. and removed codex-security-review-current The posted Codex security review matches its recorded range. labels Oct 28, 2026
… 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>
@github-actions github-actions Bot removed the codex-security-review-current The posted Codex security review matches its recorded range. label Oct 28, 2026

@wpfleger96 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.rs L428–435 runs the SELECT EXISTS with .fetch_one(pool), so that checkout carries no operation label. The source guard p0_pool_acquisitions_use_typed_operation_pairs_without_other catches it (unit CI, observability_source.rs L627). 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 with crate::observability::acquire_writer(pool, WriterOperation::Authorization) (like mint at L316), run the lookup on it, and drop it before begin_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.rs L198–208 claims the unseeded hash [3; 32] and expects the deletion-fence error. The new check correctly returns Invalid first, so relay_invite_writers_require_application_admission panics 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 returns Invalid with 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 returns Invalid before 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.rs L363–377 holds admission in a separate transaction and requires the forged claim to return Invalid within five seconds, then releases the lock. It passes in CI.
  • Mint 503. invites.rs L330–335 still fails closed before minting, and the existing failure-injection test asserts the OWNER_LINK_ERROR response and an unchanged invite count.
  • Renumber. The 0075 file is byte-identical to the old 0074, and main's user-deletion-fence 0074 is preserved. The migration count (75) and index (74) assertions, the populated upgrade fixture, desired-state reconciliation coverage, both testing docs and the rollback VersionMissing(75) note are all updated. I found no stale references to the invite migration as 0074.

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>
@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Oct 28, 2026
@TheSentinel454
TheSentinel454 requested a review from loganj October 28, 2026 19:59
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>
@github-actions github-actions Bot added codex-security-review-current The posted Codex security review matches its recorded range. and removed codex-security-review-current The posted Codex security review matches its recorded range. labels Oct 28, 2026
@TheSentinel454

Copy link
Copy Markdown
Contributor

🤖 Thanks for the review. Both regressions are fixed in 36103818. We kept the unknown-code check but moved it inside the admitted transaction, after the deletion fence and WriterOperation::Authorization attribution and still before lock_admission. We chose that over a separate pre-fence checkout for three reasons. It keeps one writer checkout per claim, so two connections never overlap. The fence still runs first for every claim, so relay_invite_writers_require_application_admission covers the claim path unchanged. And a forged code takes only the deletion guard's shared advisory lock before it's rejected, so forged claims don't serialize. unknown_claim_code_is_rejected_without_waiting_on_admission still fails against the old claim code. Happy to switch to your two-checkout version if you'd still prefer it.

@wpfleger96 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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 with WriterOperation::Authorization (relay_invite.rs L426–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 guard p0_pool_acquisitions_use_typed_operation_pairs_without_other passes.
  • 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 returns Invalid without queuing behind claims, bans or timeouts. In a live run, with the invite lock held, an unknown code got 403 invite_invalid in 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 UPDATE read. relay_invite_writers_require_application_admission passes 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 UPDATE before 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 Justfile unit-lane filter keeps main's handlers::coalesced_refresh::tests selector and this branch's destructive_fixture_rejects_shared_database_urls selector, and drops nothing. scripts/run-tests.sh keeps both sides' additions. Main added no migrations, so 0075 stands.

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 loganj left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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 locked FOR UPDATE read stays authoritative. unknown_claim_code_is_rejected_without_waiting_on_admission holds the lock and checks that an unknown code still returns Invalid.
  • Other items: addressed. Mint's 503 now uses OWNER_LINK_ERROR. The defensive revocation call and the issuer-timeout invite_invalid choice both have comments. TESTING.md requires BUZZ_AUTO_MIGRATE off 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.

@TheSentinel454
TheSentinel454 merged commit d6196a1 into main Oct 28, 2026
104 checks passed
@TheSentinel454
TheSentinel454 deleted the codex/buzz-268-draft-prep branch October 28, 2026 22:42

This branch was successfully deployed

1 active deployment
codex-review — b0c230ef Deployed Oct 28, 2026 by TheSentinel454 via Run Codex Security Review #10263
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

codex-security-review-current The posted Codex security review matches its recorded range.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants