Skip to content

BUZZ-413: Fence shared event database writes - #8658

Draft
ravarora2 wants to merge 14 commits into
mainfrom
codex/user-deletion-event-guards
Draft

ravarora2 wants to merge 14 commits into
mainfrom
codex/user-deletion-event-guards

Conversation

@ravarora2

@ravarora2 ravarora2 commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Make existing user-deletion restrictions apply at the two shared event insertion functions. A fenced signer gets a refusal; the event and its transactional changes roll back. This covers every event kind reaching those functions, including messages, replies, reactions, edits, channel changes, deletion events and artifacts.

The transaction takes shared identity locks and checks the signer's global and community restrictions with one query. It retains the locks through commit or rollback. Stored owner identity coordinates locks; it is not an extra restriction check. Ownership changes produce a retryable internal error. Unrelated database errors keep their existing handling.

Identity archive/unarchive requests now persist the request, archive change and relay delta in one transaction. Notifications follow commit. Duplicate retries do not repeat the mutation. Snapshot refresh still runs after commit: failures propagate, and a retry repairs a missing or stale snapshot. If the snapshot already matches current archive state, it retains its event ID and timestamp and is not broadcast again. The no-op path rechecks canonical state for concurrent changes. There is no durable background retry queue for that refresh.

Some event callers hold domain locks before the identity guard, and some insert events from multiple signers. The documented worker contract now reflects that behavior: exclusive fencing transactions must not touch serving-domain rows. Cleanup belongs in later transactions. Per-call identity sorting alone does not prove every future multi-key workflow safe.

Scope remains event persistence. There are no migrations, user-specific leases, authentication changes, ownership-assignment changes or restriction-insertion behavior changes. Separate replaceable-event writers, read state, uploads, git, audio, ephemeral activity and deletion-worker coordination remain follow-up work. The real user-deletion worker must remain disabled.

This affects a critical shared write path and adds measurable database overhead, even while the restriction table is empty. See the local measurements below and the transaction rules and boundaries.

Related issue

Part of BUZZ-413. Follows merged #8632. No duplicate message-fencing PR found; #8489 covers workflow scaffolding and #8645 covers owned-community handlers.

Testing

Local database benchmark at 4aef33b95, before merging the newer main: 49,152 successful timed writes, excluding warmups. A temporary harness called Db::insert_event and Db::insert_event_with_thread_metadata with kind 9 events, human/agent signers, and one/eight concurrent writers. Signing occurred before timing; timing included pool acquisition, community admission, persistence and commit.

Environment: Apple M1 Max, 64 GiB RAM, PostgreSQL 17.11 in local Docker, TCP loopback, Cargo test profile (unoptimized), empty restriction table. Each case used 64 warmups and 512 measured writes per round. Compared the original two-query guard, the combined query, and a temporary no-op guard. Ran three rounds per variant and repeated the combined version. All temporary changes were restored; no benchmark bypass is committed.

Single-writer latency, in milliseconds. Cells show the median of each run's p50/p95 values; combined-query figures use six rounds, others use three.

Insert function Signer No guard p50/p95 Two queries p50/p95 Combined p50/p95
insert_event human 3.41/4.31 6.33/7.83 5.71/7.58
insert_event agent 3.52/4.96 6.11/7.72 5.82/7.63
insert_event_with_thread_metadata human 3.23/4.03 6.07/8.77 5.46/7.02
insert_event_with_thread_metadata agent 3.23/4.02 6.46/10.79 5.84/7.30

Combining the lookups reduced single-writer median latency about 5–10% versus two queries in these runs. The full guard still added about 2.2–2.6 ms (65–81%) versus no guard. This is a material cost, not evidence that fencing is free or ready for production capacity assumptions. Actions that also persist relay-generated events pay the guard cost for each additional insert; this benchmark does not measure complete HTTP actions.

Eight concurrent writers, same channel; median throughput across rounds:

Insert function Signer No guard events/s Two queries events/s Combined events/s
insert_event human 1220 692 698
insert_event agent 1310 593 672
insert_event_with_thread_metadata human 1269 543 740
insert_event_with_thread_metadata agent 1181 646 718

These are local debug measurements, not a production benchmark or capacity estimate. No injected network latency or populated restriction-table load was included in timing. Separate query-plan checks used a temporary table with the same two partial indexes; empty and 50,000-row cases used both indexes. The earlier 510-case lab matrix covered a broader revision and is not evidence for this version; no new UI lab run is claimed.

Generated with Codex

Signed-off-by: Ravneet Arora <rarora@squareup.com>
@presentyourlove

Copy link
Copy Markdown

Rebase & Conflict Resolution Report for PR #8658

Target PR: block/buzz#8658 — BUZZ-413: Fence stream and forum message writes
Author: @ravarora2 (Ravneet Arora)
Branch: fix/fence-stream-forum-writes-8658
Commit: 59097c94ca0fcb615988facc6877cdc467449c7d
Patch: 59097c94ca0fcb615988facc6877cdc467449c7d.patch


1. Problem & Root Cause

PR #8658 was marked as dirty (mergeable: false) on GitHub due to conflicts introduced when commit d6196a16c1 (#8237, revoke invitations and restrict claims) merged into main:

  1. crates/buzz-db/src/error.rs:

    • main added InviteRestricted and InviteOwnerMismatch variants.
    • PR BUZZ-413: Fence shared event database writes #8658 added UserDeletionRestricted.
    • Resolution: Kept all variants so invitation restriction handling and user deletion restriction errors coexist seamlessly.
  2. crates/buzz-db/src/store/user.rs:

    • In set_agent_owner_with_operation:
      • main added crate::relay_invite::lock_admission(tx.conn(), community_id).await?; to acquire the invitation admission lock on community write transactions.
      • PR BUZZ-413: Fence shared event database writes #8658 added tx.guard_agent_owner_assignment(agent_pubkey, owner_pubkey).await?; to verify user deletion fences and lock agent/owner identities.
    • Resolution: Kept both locks in canonical lock hierarchy order:
      let mut tx = crate::begin_community_write_transaction(pool, community_id, operation).await?;
      crate::relay_invite::lock_admission(tx.conn(), community_id).await?;
      tx.guard_agent_owner_assignment(agent_pubkey, owner_pubkey).await?;
      This ensures invitation admission and claim restrictions are enforced, user deletion fences guard agent ownership changes, and the conditional update remains atomic without deadlock.

2. GitHub PR Comment Draft (for @ravarora2)

Hi @ravarora2,

I rebased PR #8658 onto the latest `main` branch to resolve the merge conflict with commit `d6196a16c1` (#8237, *revoke invitations and restrict claims*):

- In `crates/buzz-db/src/error.rs`: Preserved both the new invitation restriction error variants (`InviteRestricted`, `InviteOwnerMismatch`) and `UserDeletionRestricted`.
- In `crates/buzz-db/src/store/user.rs`: In `set_agent_owner_with_operation`, preserved `lock_admission` alongside `guard_agent_owner_assignment` in canonical order so both invitation admission gating and user-deletion ownership fencing are held before the atomic `UPDATE`.

You can pull the rebased commit directly into your branch:

```bash
git fetch https://github.com/presentyourlove/buzz.git fix/fence-stream-forum-writes-8658
git rebase 59097c94ca0fcb615988facc6877cdc467449c7d

Or apply the patch:

curl -sL https://github.com/presentyourlove/buzz/commit/59097c94ca0fcb615988facc6877cdc467449c7d.patch | git am

Signed-off-by: Ravneet Arora <rarora@squareup.com>
Signed-off-by: Ravneet Arora <rarora@squareup.com>
Signed-off-by: Ravneet Arora <rarora@squareup.com>
@ravarora2 ravarora2 changed the title BUZZ-413: Fence stream and forum message writes BUZZ-413: Fence user and agent activity Oct 29, 2026
@ravarora2
ravarora2 marked this pull request as ready for review October 30, 2026 14:32
@ravarora2
ravarora2 requested a review from a team as a code owner October 30, 2026 14:32
@ravarora2
ravarora2 marked this pull request as draft October 30, 2026 14:32
Signed-off-by: Ravneet Arora <rarora@squareup.com>
…vent-guards

* origin/main: (29 commits)
  Restyle community invitations (#8728)
  feat(relay): add incremental artifact reads (#8523)
  fix(mobile): show agent DM typing and balance reply bubble padding (#8731)
  perf(relay): route author- and #p-scoped global subscriptions by selector (#8666)
  fix(desktop): fix the product races behind four E2E flakes (#8680)
  test(scripts): stop file-size fixtures leaking git repos and fsmonitor daemons (#8727)
  test(mobile): keep late callbacks and fixed sleeps from failing tests (#8678)
  fix(mobile): resolve iOS pushes on a fresh connection and retry a stale one (#8722)
  ci(loadgen): publish buzz-loadgen as ghcr.io/block/buzz-loadgen (#8717)
  test(relay): fix main's build after the test-state helper rename (#8718)
  fix(db): keep lease releases durable and deflake PostgreSQL lane tests (#8676)
  test: dedupe collect_until_eose by event id and uncap two shell tests (#8677)
  ci: cache the Tauri apt .debs and fit a cold Windows Rust run in its cap (#8679)
  test(desktop): gate flaky E2E specs on the state they assume (#8681)
  test(desktop): settle composer edits and press hover-only row actions by keyboard (#8682)
  test(desktop): measure inline video controls against their player (#8683)
  relay: open the search pool lazily so a dead reader can't block boot (#8662)
  fix(mobile): address #8650 second-pass review (#8691)
  Revert "fix(mobile): push every message in your own Solo chats (#8615)" (#8690)
  fix: don't notify workflow owners through their attribution tag (#8694)
  ...

Signed-off-by: Ravneet Arora <rarora@squareup.com>
@ravarora2 ravarora2 changed the title BUZZ-413: Fence user and agent activity BUZZ-413: Fence shared event database writes Oct 30, 2026
@ravarora2
ravarora2 marked this pull request as ready for review October 30, 2026 17:31

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

🤖 Review at 0c3ebcb53. The core guard is sound: it checks only the signer, under both the global and community scopes, regardless of profile rows, rechecks the owner after locking, and holds the locks through commit. Five blocking issues, three minor, all inline:

IMPORTANT

  1. Kind 9035/9036 archive/unarchive requests change archived_identities and publish deltas before the guard runs, so a restricted author's request takes effect and is then refused (event.rs L1884).
  2. The new AccessDenied arm on the generic insert path sends community-admission errors to clients as restricted: (ingest.rs L3730).
  3. The ownership-change refusal is AccessDenied, so clients see restricted:, invalid: or an internal error depending on the path. None of these tells them to retry (user_deletion_write_guard.rs L32).
  4. Some callers take identity locks after row locks, against the ordering rules in user_deletion_fences.rs (event.rs L480).
  5. About nine extra round trips on every event insert, with no measurement (user_deletion_write_guard.rs L66).

MINOR
6. The refusal mapping is duplicated.
7. The design doc reads as a PR narrative.
8. The test modules are chained through nested #[path] attributes.

return Err(DbError::EphemeralEventRejected(kind_u16));
}

tx.guard_event_author(event).await?;

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.

🤖 IMPORTANT — archive/unarchive takes effect despite the refusal. For kinds 9035/9036, ingest.rs L3082–3089 calls handle_identity_archive_event before the request reaches this inserter. That handler commits archived_identities changes in its own transaction and publishes the relay-signed 8002/8003 deltas and the 13535 snapshot. A restricted author's request therefore changes archive state, then gets refused here and is never stored. The published delta's ["e", request_id] then points at an event that doesn't exist.

Fix: apply the archive change and insert the request in one transaction that runs this guard, and publish after commit. A separate check before the handler would still race a restriction being installed. Please also add archive/unarchive to the HTTP/WS before-and-after-restriction tests, with archived_identities in the unchanged-state snapshot.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Fixed in 4688052, included in head 572e30e. Archive/unarchive validation now produces a plan without changing state. The guarded request insert, archived_identities mutation, and relay delta persist in one transaction; notification dispatch follows commit. A rejected request or failed delta insert rolls all three back. Duplicate requests do not repeat the mutation.

Added HTTP/WebSocket before-and-after-fence regressions, unchanged archive/snapshot assertions, delta-failure rollback, and fence commit/rollback races. These passed in the merged tree's PostgreSQL suite (1,189 tests).

Remaining boundary: the snapshot refresh is post-commit. Its failure propagates, and a duplicate retry refreshes it without repeating the archive mutation. There is no durable background refresh queue, so a crash after commit can leave the snapshot stale until a later refresh. This limitation is documented.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Follow-up fixed in 3564dc2: replaying an already-processed archive/unarchive request now leaves an already-current snapshot unchanged, including its event ID and timestamp, and does not broadcast a replacement. The publisher compares the saved snapshot with canonical archive state and rechecks that state before taking the no-op path. Missing/stale snapshots are still repaired after a failed publication; the existing post-write drift protection remains.

The new replay regression failed against the previous code, then passed with the fix. It covers repeated archive/unarchive requests and replay after a later opposite operation, asserting that all database rows remain unchanged. Recovery tests cover both missing and stale snapshots and a further retry after repair. A deterministic concurrency test changes canonical state while the publisher is paused before its no-op decision and verifies repair.

Validation: all 6 PostgreSQL archive/fencing tests and 8 existing archive consent/publisher tests passed, as did the normal pre-push hooks. The PostgreSQL fixture exercises the HTTP route and authenticated WebSocket event handler; I did not rerun the separate live WebSocket AUTH simulation. The full repository just ci run was not restarted.

buzz_db::DbError::UserDeletionRestricted => IngestError::Rejected(
"restricted: identity is restricted for user deletion".into(),
),
buzz_db::DbError::AccessDenied(reason) => {

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.

🤖 IMPORTANT — this arm changes behavior outside this PR's scope. On this path, AccessDenied also comes from community admission (guard_community_write / lock_community_deletion_shared). Examples: community <uuid> is write-fenced (<state>), missing or tombstoned, and deletion-lock admission requires read committed isolation, got …. Those used to surface as error: database error. With this arm, clients see internal state and config text under a restricted: prefix. Once the ownership-change case below has its own variant, nothing in this PR needs the arm. Suggest removing it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Fixed in f1570c4, included in head 572e30e. Removed the generic AccessDenied-to-restricted arm. Only UserDeletionRestricted produces the user-deletion refusal. Other database errors, including community-admission errors, retain their existing caller fallback rather than exposing the internal reason under restricted:. Error-mapping and transport regressions passed.

// Restart in a new transaction: adding another owner lock now could
// invert the global-before-scoped ordering. No write has occurred.
return Err(DbError::AccessDenied(
"agent ownership changed; retry the write".into(),

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.

🤖 IMPORTANT — the ownership-change refusal can't be told apart from a permanent refusal. As AccessDenied, it surfaces three ways depending on the caller:

  • restricted: on the generic insert path
  • invalid: on the put-user and edit paths
  • Internal through event_write_error, artifact acceptance and command persistence

None of these tells the client to retry, which contradicts the message. begin_community_sweep_transaction also already treats AccessDenied as "community fenced, skip". Suggest a dedicated DbError variant, mapped once in event_write_error to a retryable error: refusal.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Fixed in f1570c4, included in head 572e30e. Added DbError::EventAuthorOwnershipChanged and mapped it centrally through event_write_error. An ownership change now returns the retryable error category: WebSocket error: internal server error, or HTTP 500 with the sanitized internal server error message. It no longer uses AccessDenied or becomes restricted:/invalid:.

The PostgreSQL transport regressions force ownership to change while the writer waits for its identity lock, cover generic/membership/edit paths over HTTP and WebSocket, and verify a retry succeeds. This permits a client retry; it does not implement automatic retry behavior.

return Err(DbError::EphemeralEventRejected(kind_u16));
}

tx.guard_event_author(event).await?;

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.

🤖 IMPORTANT — identity locks taken after domain row locks. The ordering rules in user_deletion_fences.rs are: community admission, then all global user locks in one call, then community-scoped agent locks. Because the guard runs at the insert call rather than when the transaction starts, some callers lock rows first. store/artifact.rs accept writes artifact_revisions and upserts artifact_heads before inserting the author's event. On a Move, it then inserts the relay-signed removal marker, which takes a second global user lock after the author's community-scoped lock. Nothing deadlocks today, because normal writers take only shared locks and no exclusive locker touches these rows. But the deletion worker will depend on this order, and once it holds exclusive identity locks and writes these rows, these paths will deadlock and abort.

Two ways to fix it: run the guard before any domain row lock (at admission, or at the top of each caller), or rewrite the ordering rules to match what callers actually do and state the worker-side requirement that follows.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Addressed using the second option in 21f8dc6, included in head 572e30e. The documentation now describes the actual call order: domain locks may precede the guard, and later relay-signed inserts can acquire another global identity lock after the first scoped lock. Per-call sorting is explicitly not a transaction-wide guarantee.

The future exclusive fencing transaction must write only restriction/request/checkpoint state while holding identity locks. It must not lock or mutate serving-domain rows; cleanup must use later transactions. Complete exclusive identity sets must also be audited against multi-signer transactions before enabling the worker.

Added production artifact Move concurrency regressions for a fence winning first and a Move waiting for the relay identity lock while a user fence queues. The tested interleavings complete with the expected persisted/rolled-back state and zero recorded database deadlocks. The channel-TTL race also passes. These tests do not establish universal deadlock safety; the real deletion worker remains disabled.

// Read the signer's restrictions, not the owner's. Check both possible
// scopes even if the profile/ownership row is absent: those mutable rows
// must not determine whether a permanent restriction still applies.
if read_locked(tx, FenceIdentity::user(signer))

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.

🤖 IMPORTANT — cost on the hottest write path, with no measurement. Each persistent event insert now adds roughly nine round trips: the owner read twice, reading, setting and restoring lock_timeout, 2–3 advisory locks, and these two restriction reads. That includes relay-signed side-effect events. Until the deletion worker is enabled, the restriction table is empty in production, so this is pure cost. Two asks:

  1. Merge these reads into one query: WHERE pubkey = $1 AND (scope_community_id IS NULL OR scope_community_id = $2).
  2. Add an insert-latency measurement before and after this change to the PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Combined the two restriction lookups in eb2c2c8 and measured the cost. The query is now pubkey = $1 AND (scope_community_id IS NULL OR scope_community_id = $2); it still checks the signer, not the owner's restriction.

Completed 49,152 successful timed writes at 4aef33b, before the main merge, comparing no guard, the original two-query guard, and the combined query. Covered both shared insert APIs, human/owned-agent signers, and one/eight concurrent writers. Full methodology and tables are in the updated PR description.

Single-writer p50 improved about 5–10% versus the two-query guard. The complete guard still added about 2.2–2.6 ms (65–81%) versus no guard. That is a material remaining cost, not a claim that overhead is solved. Measurements used local PostgreSQL, Cargo's unoptimized test profile, and an empty restriction table; they are not production capacity estimates. All temporary benchmark variants were restored; no bypass is committed.

}

/// Keep durable identity restrictions as client refusals on every insert path.
pub(crate) fn event_write_error(error: buzz_db::DbError) -> IngestError {

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.

🤖 MINOR — duplicated mapping. The string restricted: identity is restricted for user deletion is written here and again at L3728. The downcast_ref sites also rebuild DbError::UserDeletionRestricted only to pass it back in here. Taking &DbError and returning Option<IngestError> (with the retryable ownership-change variant handled here too) would replace five match arms with one call per site.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Fixed in f1570c4, included in head 572e30e. event_write_error now takes &DbError and returns Option. It centralizes UserDeletionRestricted and EventAuthorOwnershipChanged mapping. Ingest, artifact, and command persistence callers use the helper and preserve their existing fallback for unrelated errors. The repeated refusal string and reconstructed error variants are removed. Mapping/transport regressions passed.

@@ -0,0 +1,39 @@
# User-deletion fencing: event database writes

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.

🤖 MINOR — the doc describes the PR, not the system. It reads like a PR description: "This PR checks…", "The earlier 510-case live matrix validated a broader revision…", "Current validation results belong in the PR description." That goes stale on merge. Suggest rewriting it to state the rules that hold (which writes are fenced, the lock order, what is still unfenced, and what the worker must do before it's enabled), or moving it into the PR body.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Rewritten in 4aef33b, included in head 572e30e. The document now describes lasting behavior: guarded insert functions, transaction flow, signer/owner semantics, errors and atomicity, actual lock order, future exclusive-worker requirements, and remaining unfenced paths. PR history and the old live-matrix narrative are removed. Benchmark methodology/results are in the PR description.

}
}

#[path = "user_deletion_expanded_postgres_tests.rs"]

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.

🤖 MINOR — nested #[path] chain. The test modules are chained message → expanded → lifecycle here, and write_guard → lock_order in buzz-db. Registering each one directly in its mod.rs (as with user_deletion_message_postgres_tests) makes them easy to find and keeps them independent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Fixed in b76879c, included in head 572e30e. Each PostgreSQL test module is now registered directly in its package's mod.rs. Shared setup lives in user_deletion_test_support modules instead of depending on chained test-module imports. PostgreSQL test discovery passed across 638 Rust source files, and all 1,189 database/relay PostgreSQL tests passed after merging main.

Signed-off-by: Ravneet Arora <rarora@squareup.com>
Signed-off-by: Ravneet Arora <rarora@squareup.com>
Signed-off-by: Ravneet Arora <rarora@squareup.com>
Signed-off-by: Ravneet Arora <rarora@squareup.com>
Signed-off-by: Ravneet Arora <rarora@squareup.com>
Signed-off-by: Ravneet Arora <rarora@squareup.com>
Signed-off-by: Ravneet Arora <rarora@squareup.com>
Signed-off-by: Ravneet Arora <rarora@squareup.com>
@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Oct 30, 2026
@github-actions

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: f3a7bab098a530ba12b909cb7121b4fa9ce70731...3564dc2dcdaec4c9007b95114d07fa21960f3d41
  • 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

  • Review used read-only source and history inspection. Builds and tests were not run, as instructed.

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

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

🤖 Re-review at 3564dc2dc. All eight findings from the review at 0c3ebcb53 are fixed. Archive/unarchive now stores the request, applies the archived_identities change and stores the delta in one guarded transaction. event_write_error matches only the two new error types. Ownership changes have their own retryable error. The lock order is documented as callers actually take it, with the requirements a future worker must meet. The lookup is one query and the PR now includes measurements. The doc describes the system rather than the PR, and the test modules are registered directly.

The atomic archive rewrite introduced one blocking issue and two minor ones:

IMPORTANT

  1. The archive request and its 8002/8003 delta are no longer indexed in event_mentions, so #p queries for the target can't find them (archived_identities.rs L212, inline).

MINOR
2. The relay-signed delta now takes its created_at from the client's request instead of relay time (identity_archive.rs L95, inline).
3. The comment at ingest.rs L622 still says consent is checked "inside handle_identity_archive_event". That function is now a #[cfg(test)] wrapper, and production validation happens in validate_identity_archive_event. Please update the comment.

) -> Result<(StoredEvent, bool, Option<StoredEvent>)> {
let mut tx = self.begin_event_write_transaction(community).await?;
let (stored, inserted) =
crate::event::insert_event_in_transaction(&mut tx, request, None).await?;

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.

🤖 IMPORTANT — archive requests and deltas are missing from event_mentions. Before this change, the request was stored through Db::insert_event_with_thread_metadata and the delta through Db::insert_event. Both wrappers call insert_mentions_in_transaction after a new insert (event.rs L2289–2292 and L2829–2831). This function calls the low-level insert_event_in_transaction for both events and never indexes their p tags. Single-#p queries and counts use an INNER JOIN event_mentions (event.rs L628–641, L851–861; the relay pushes #p filters down to this path in req.rs L1220–1231). As a result, newly accepted 9035/9036 requests and their 8002/8003 deltas no longer show up in a #p: [target] subscription or history query, which is how a client replays one identity's archive history.

Fix: call crate::insert_mentions_in_transaction(&mut tx, event, None) for the request when inserted, and for the delta when it's inserted, inside this transaction. Please add a regression test that queries #p = target through the production path and expects both events.

&event.content,
reason.as_deref(),
replaced_by.as_deref(),
// Use the request timestamp for its corresponding relay notification.

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.

🤖 MINOR — the relay-signed delta now uses the client's timestamp. build_nipia_delta used to stamp 8002/8003 with relay time. It now copies event.created_at from the request, which ingest accepts anywhere within ±900 s of server time (ingest.rs L2643). So a client can backdate or postdate a relay-signed event by up to 15 minutes. An archive followed by an unarchive can produce deltas whose timestamps are in the wrong order, and a backdated delta can fall before a subscriber's since. The NIP-IA vectors stamp the delta after the request (request + 1). The transaction doesn't need a deterministic timestamp, because a duplicate request never stores a second delta. Suggest building the delta with Timestamp::now().

matt2e added a commit that referenced this pull request Nov 2, 2026
…ests

Two buzz-relay test fixes from the 23-30 Oct flake audit. Neither is
covered on main (still fae3f03) or by an open PR.

thread_window_bridge_restarts_both_aux_hops_after_replica_failure
(3 runs)

Runs: 40799617319 (elrond/connect-burst), 40924179886
(elrond/acp-resume-cursor) and 40930100994 (#8697, docs only), all on
10-29 in PostgreSQL Tests, attempt 1 (jobs 123208043033, 123632125866
and 123651641392). None of the branches touches thread_window, and each
run passed on a re-run. Signature: failure_postgres_tests.rs:46,
"barrier must precede closure completion", left: 2, right: 1.

Cause: pause_after_aux_page polls the request until a thread_window_aux
span closes, then expects exactly one. The fixture's 1,001 reactions
make two replica pages at AUX_LIMIT 1000, and each page is one round
trip on the transaction already held. If the loaded runner deschedules
the test thread after page 2's query is written, Postgres answers first.
The same poll then reads page 2 as well, so both spans close before the
test looks.

Change: buzz-relay's test_support gains a PausingProxy. It is a trimmed
copy of buzz-db's with only the hold-everything switch, because buzz-db's
is private to its own unit tests. The replica reader pool connects
through it, and AuxPages pauses it when the first aux span closes. That
runs inside the request's poll, after page 1's response is consumed and
before page 2's query is written. So the proxy holds page 2's query and
the page stays pending however fast Postgres answers. The held-
transaction check, the writer inserts and pg_terminate_backend use the
direct pool. The test resumes the proxy after terminating the reader's
backend, so the held page sees the termination and the closure restarts
on the writer, as before. The other three pause_after_aux_page callers
read from the writer and pass None.

Verification: all four pause_after_aux_page tests pass when run alone
against local Postgres through scripts/postgres-test-run.sh. Forced
check, not committed: after the barrier, the test gave page 2 another
500 ms over ten re-polls and asserted the page count stayed at 1. With
the pause the test passed. With the pause removed (None) it failed with
left: 2, right: 1, the CI signature. A true same-poll burst needs the
OS to preempt between sqlx's write and read, so that was not forced
directly.

api::media tests that need Postgres (1 run, fails every time it is
selected)

Run: 40954304982 (#8658, codex/user-deletion-event-guards), 10-29,
Flake check (Rust), attempt 1 (job 123730434146). #8658 changes
media.rs product code but no tests. Signature: all six test_state()
tests panic at the community-seed expect (media.rs:1737 on that branch)
after the 30 s sqlx acquire timeout, and the job ends with "flake-check:
iteration 1/10 FAILED".

Cause: Flake Check selects every test in a file when a hunk lands
outside a test body. Its infraTest skips only postgres_tests::,
postgres_* binaries and external_infra, and the flake-rust job has no
database. These six tests seed relay.example through Postgres but sat in
plain `mod tests` without #[ignore]. So any PR that changes non-test
code in media.rs fails that lane, and at least 8 open PRs touch the
file. No other lane ran them either: the Justfile already noted that
api::media's non-ignored tests need Postgres.

Change: move test_state, media_get_auth_router, media_get_auth_header,
media_get_tags_for, media_request and the six tests into
tests::postgres_tests. Mark each test #[ignore = "requires Postgres"],
and point test_state at test_support::database_url(), as the module's
other states do. The PostgreSQL lane now runs them and Flake Check skips
them. Also drop the imports `mod tests` no longer uses, and update the
Justfile comment that gave api::media as the reason the infra-free lane
is scoped to api::admin.

Staging media.rs makes the pre-commit secret scan read the whole file.
It flags the module's existing public ES256 test key (TEST_EC_PKCS8_PEM),
so the key is now written on one line with a sadscan marker, as
invites.rs already does. Its bytes are unchanged.

Verification: check-postgres-test-discovery.py accepts the touched
files and all 621 files under crates/, and
scripts/test-postgres-test-discovery.sh passes. Each of the six tests
passes when run alone against local Postgres through
scripts/postgres-test-run.sh. They have never run in CI, so the
PostgreSQL lane on the PR is their first hosted run.

Both: cargo fmt --check and cargo clippy -p buzz-relay --all-targets
-- -D warnings are clean under 1.98.1. The full lanes were not run
locally. Hosted CI is the check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
wesbillman pushed a commit that referenced this pull request Nov 2, 2026
…ests (#8740)

## Summary

Two buzz-relay test fixes from the 23–30 Oct flake audit. Both are
test-only. This is one of six PRs split from the reviewed `flakey-tests`
branch (siblings below).

### `thread_window_bridge_restarts_both_aux_hops_after_replica_failure`
(3 runs)

- **Runs:** 40799617319, 40924179886 and 40930100994 (#8697, docs only),
all on 10-29 in PostgreSQL Tests, attempt 1 (jobs 123208043033,
123632125866, 123651641392). None of the branches touches thread_window,
and each run passed on a re-run. Signature:
`failure_postgres_tests.rs:46`, "barrier must precede closure
completion", left: 2, right: 1.
- **Cause:** `pause_after_aux_page` polls the request until a
`thread_window_aux` span closes, then expects exactly one. The fixture's
1,001 reactions make two replica pages at `AUX_LIMIT` 1000, and each
page is one round trip on the transaction already held. A loaded runner
can deschedule the test thread after page 2's query is written. Postgres
then answers first, and the same poll reads page 2 too.
- **Change:** `test_support` gains a `PausingProxy`, a trimmed copy of
buzz-db's private one with only the hold-everything switch. The replica
reader pool connects through it. `AuxPages` pauses it when the first aux
span closes. That runs inside the request's poll, after page 1's
response is consumed and before page 2's query is written, so page 2
stays pending however fast Postgres answers. The test resumes the proxy
after it terminates the reader's backend. The held page then sees the
termination, and the closure restarts on the writer, as before. The
other three `pause_after_aux_page` callers read from the writer and pass
`None`.

### `api::media` tests that need Postgres (fail every time Flake Check
selects them)

- **Run:** 40954304982 (#8658), 10-29, Flake check (Rust), job
123730434146. All six `test_state()` tests panic at the community-seed
`expect` after the 30 s sqlx acquire timeout.
- **Cause:** when a hunk lands outside a test body, Flake Check selects
every test in the file. It skips only `postgres_tests::`, `postgres_*`
binaries and `external_infra`, and its job has no database. These six
tests seed `relay.example` through Postgres but sat in plain `mod tests`
without `#[ignore]`. So any PR that changes non-test code in `media.rs`
fails that lane, and at least 8 open PRs touch the file. No other lane
ran these tests either.
- **Change:** move `test_state`, the `media_get_*` helpers,
`media_request` and the six tests into `tests::postgres_tests`. Mark
each test `#[ignore = "requires Postgres"]`, and point `test_state` at
`test_support::database_url()`, as the module's other states do. The
PostgreSQL lane now runs them, and Flake Check skips them. The change
also drops imports `mod tests` no longer uses, and updates the Justfile
comment that gave `api::media` as the reason the infra-free lane is
scoped to `api::admin`.
- When `media.rs` is staged, the pre-commit secret scan reads the whole
file and flags the module's existing public ES256 test key
(`TEST_EC_PKCS8_PEM`). The key is now written on one line with a sadscan
marker, as `invites.rs` already does. Its bytes are unchanged.

### Related issue

None found. **Conflicts with #8451 and #8444**, which both already
conflict with `main`. The one-line `TEST_EC_PKCS8_PEM` rewrite is the
only new conflict hunk with each. The test move merges cleanly with
both.

### Testing

Verified locally:
- Each of the four `pause_after_aux_page` tests passes when run alone
against local Postgres through `scripts/postgres-test-run.sh`.
- A forced check, not committed: after the barrier, the test gave page 2
another 500 ms over ten re-polls and asserted the page count stayed at
1. It passed with the pause and failed with `None`, with the CI
signature (left: 2, right: 1). A true same-poll burst needs the OS to
preempt between sqlx's write and read, so it was not forced directly.
- `check-postgres-test-discovery.py` accepts the touched files and all
621 files under `crates/`, and `scripts/test-postgres-test-discovery.sh`
passes. Each of the six media tests passes when run alone against local
Postgres.
- `cargo fmt --check` and `cargo clippy -p buzz-relay --all-targets --
-D warnings` are clean under 1.98.1.

Only hosted CI can show the full lanes, which were not run locally. The
six media tests have never run in CI, so this PR's PostgreSQL lane is
their first hosted run.

Pushed with `git push --no-verify`, because the pre-push suites fail
when the laptop is under load.

### Readiness

An agent review of the combined `flakey-tests` branch (b432453af) found
no blockers. `buzz-review-completed` is not added, because the human
test step (AGENTS.md "Before opening a PR", step 3) hasn't happened yet.

### Siblings

- #8739 ci: Hermit fetch deadline and sccache fallback
- #8741 test(mobile): RunnerTests title fade and alert wait
- #8742 fix(desktop): deep-linked message hold (product change)
- #8743 test(desktop): four specs racing scroll settles and focus
- #8744 test(desktop): five settle classes and remote-owned-mentions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@presentyourlove

Copy link
Copy Markdown

Hi @ravarora2! 👋

I've merged the latest main into a branch to resolve the merge conflicts on PR #8658:

Reconciled Changes:

  1. crates/buzz-db/src/store/mod.rs: Preserved both pub mod verified_profiles; from main and your user deletion lock order tests.
  2. crates/buzz-relay/src/handlers/command_executor.rs & side_effects.rs: Restored event_write_error identity-fencing mapping while using e.kind() for internal error formatting.
  3. crates/buzz-relay/src/handlers/ingest.rs: Reconciled BanRefusal::banned with event_write_error, and preserved identity-fencing error handling across reaction, channel create, atomic deletion, edit metadata, and thread writes alongside the latest ChannelNameTaken checks.

Rebased/Merged Branch:

  • Branch: https://github.com/presentyourlove/buzz/tree/fix/fence-shared-event-writes-8658
  • To fetch and inspect directly:
    git fetch https://github.com/presentyourlove/buzz.git fix/fence-shared-event-writes-8658
    git merge FETCH_HEAD

Hope this helps move PR #8658 forward! 🚀

@ravarora2
ravarora2 marked this pull request as draft November 6, 2026 18:38

This branch was previously deployed

1 inactive deployment
codex-review — 3564dc2d Deployed Oct 30, 2026 by ravarora2 via Run Codex Security Review #11105
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.

3 participants