Skip to content

fix(relay): close legacy sockets after community FI upgrades - #8991

Merged
bradseiler merged 4 commits into
mainfrom
seiler/BUZZ-512/session-activation
Nov 6, 2026
Merged

bradseiler merged 4 commits into
mainfrom
seiler/BUZZ-512/session-activation

Conversation

@bradseiler

@bradseiler bradseiler commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Summary

When a community starts requiring NIP-FI, the periodic community revalidator now closes its existing legacy root and audio WebSockets with Close 1008. It reads current policy from the writer by immutable community ID, with a 500 ms budget per read. Failed reads retry on later sweeps; late registrations are caught by the next pass.

Enforced FI admissions survive. Optional-shadow admissions remain legacy. Off and repair mode retain their existing behavior. No client request or Redis notification is needed.

The default sweep interval is 30 seconds, not a deadline during database failure, overload or paused processes. Already-running requests and descendant work may finish. Receipts prove policy acceptance and remain pending. The pending_reasons value changes from registry_baseline_unverified to connection_closure_unverified; clients must tolerate unknown reasons. This change does not certify fleet-wide completion.

Related issue

BUZZ-512, under the revised eventual-closure scope. Builds on merged #8985 and #8988. The replacement starts from main and adds no activation schema or migration.

Rollout and limits

There is no new flag. Count already-required communities before deployment: each relay closes their legacy sockets on its first successful sweep. Closures within a community can cause simultaneous reconnects. Keep new upgrades disabled until every serving relay runs this version.

Observe buzz_community_auth_policy_checks_total{outcome} and buzz_community_auth_policy_disconnects_total per relay, and verify real idle root/audio Close 1008 probes plus FI reconnects. Counters record checks and cancellation requests, not fleet completion. A binary rollback requires no migration and restores the old pending reason, but loses eventual closure; it does not undo durable policy. See the operator contract.

Testing

The real-socket journey runs a local relay against isolated PostgreSQL and Redis. It covers idle root/audio closure after a durable change without pubsub, actual read timeouts and recovery, late registration on both routes, FI reconnects, unrelated sockets, and Off/repair/Enforce controls.

With local PostgreSQL and Redis configured per the repository's test guide, run:

CARGO_TARGET_DIR=target/fi-closure-review scripts/postgres-test-run.sh -p buzz-relay --lib -E 'test(sweep_closes_idle_root_audio_and_late_registrations_after_read_recovery)'

Expected: one passing journey, covering both late-registration routes and all socket controls.

The journey passed at 1c74532516f697c2f06498646326f8c479574b14 in a branch-specific build target (5.56 seconds). Independent source review has no remaining P1/P2 findings. Earlier shared-target results are unreliable and are not acceptance evidence. All mandatory push hooks passed in the same isolated target, including Rust unit tests, both Tauri Clippy modes, Tauri tests and file-size checks. Hosted CI passed at this head. The full local just ci chain was not completed. Human runtime acceptance remains open. No merge or deployment is included.

Generated with Codex

@bradseiler
bradseiler added this pull request to stack #8989 November 3, 2026 11:08
@bradseiler
bradseiler force-pushed the seiler/BUZZ-512/session-activation branch from a66fea3 to 55f6db2 Compare November 4, 2026 04:35
@bradseiler
bradseiler force-pushed the seiler/BUZZ-512/session-activation branch from 55f6db2 to 0f80f07 Compare November 4, 2026 16:27
@bradseiler
bradseiler force-pushed the seiler/BUZZ-512/session-activation branch from 0f80f07 to a9f9237 Compare November 4, 2026 19:31
bradseiler added a commit that referenced this pull request Nov 4, 2026
## Summary

Record that a community requires NIP-FI and provide an idempotent
operator API to enable it and recover the original receipt. Acceptance
checks the current owner, host, lifecycle and existing issuer trust in
one transaction. It never creates trust. Receipts survive community
deletion so an operation UUID cannot be reused.

New acceptances default to disabled through
`BUZZ_NIP_FI_UPGRADES_ENABLED`. Disabled calls return HTTP 503
`upgrades_disabled`; identical retries and authenticated readback still
recover existing receipts. Policy is permanent: stopping new calls or
rolling back code does not clear it. Last-issuer removal is refused for
required communities, and bulk backfill leaves missing bindings for
explicit operator repair.

Apply additive migration 0082 before code. Existing communities remain
optional. This layer records acceptance and reports only `pending` or
`unavailable`; deploy and verify [per-request
enforcement](#8988) and [session
activation](#8991) before enabling
acceptance. The global enforce floor remains. See [the operator
contract](https://github.com/block/buzz/blob/seiler/BUZZ-510/relay-auth-upgrade/docs/community-auth-upgrades.md)
for signing, recovery and containment.

### Related issue


[BUZZ-510](https://linear.app/squareup/issue/BUZZ-510/relay-persist-community-nip-fi-requirements-and-expose-idempotent).
Base of native stack `8989`; retains trunk's enforcement telemetry from
[#8974](#8974). Includes a separate
commit that repairs an observer timing flake in a desktop test required
by the repository gate.

### Testing

Controlled local regressions detect the old disabled error code and
removal of the last issuer with `allow_last=true`; intended source is
restored. Full PostgreSQL validation and the updated live relay/admin
exercise remain pending. Human acceptance remains open. No deployment or
production activation.

Generated with Codex

---------

Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
@bradseiler
bradseiler force-pushed the seiler/BUZZ-512/session-activation branch 3 times, most recently from c43e17f to 6967612 Compare November 4, 2026 23:19
@bradseiler
bradseiler force-pushed the seiler/BUZZ-512/session-activation branch from 6967612 to 6c67f9e Compare November 5, 2026 01:06
Base automatically changed from seiler/BUZZ-511/relay-policy-enforcement to main November 5, 2026 03:17
bradseiler added a commit that referenced this pull request Nov 5, 2026
## Summary

Apply relay mode consistently across protected HTTP, root/audio
WebSocket, Git, media and V1 admission. Off requires no NIP-FI,
including for stored required communities. Shadow enforces only stored
community requirements and observes other communities. Enforce requires
NIP-FI for every community. Durable requirements survive all mode
changes.

Off retains ordinary tenant routing and authentication. It skips FI
policy/dependency reads for admission, uses cached WebSocket tenant
routing, and loads no FI binding snapshot at startup. Discovery reports
`requires_nip_fi=false` and no FI capability in Off. Evaluating modes
retain same-row WebSocket binding and immutable HTTP policy tenants,
including host remaps, pending deletion and unarchive races. Global
Enforce discovery retains its existing compatibility behavior.

Host-specific root NIP-11 and `/info` expose the additive
`requires_nip_fi` field with no-store. In evaluating modes, an
unavailable policy returns 503 and an unknown or non-serving host
returns 404. Off uses ordinary routing; a discovery lookup failure
returns 404. Stored requirements in Shadow require usable FI
dependencies.

Admission does not establish activation. BUZZ-512 owns existing-session
application, drain and mode-aware readback; its separate revision must
report stored requirements as suspended in Off. Production baseline
verification and human acceptance remain open.

Community status now shares the bounded host cache: 10,000 entries,
one-second leases measured before checkout, coalesced fills, and no
cached misses or errors. Git reuses one request policy. Root/audio
retain the final primary binding check: two reads on a cold successful
upgrade, one on a cache hit. Off creates no verifier or JWKS source and
reads no FI policy. Proof metrics count stored-required Shadow
enforcement.

Acceptance locks and validates the community row, takes the matching
host advisory lock, and drains optional leases for 1.1 seconds before
commit. Reads begun after commit see required across participating relay
instances, even without Redis invalidation. The row lock closes single
and double remap races. Admissions already in flight remain subject to
BUZZ-512 activation fencing.

### Related issue


[BUZZ-511](https://linear.app/squareup/issue/BUZZ-511/relay-enforce-per-community-nip-fi-policy-and-publish-compatible).
[BUZZ-510 / #8985](#8985) is merged;
this PR now targets main.

The restack retains main’s #8974 denial telemetry and artifact
capabilities. Related work includes #8526 tenant lookup errors and #8444
media authorization.

### Rollout and containment

Keep `BUZZ_NIP_FI_UPGRADES_ENABLED=false`. [The rollout
plan](docs/community-auth-upgrades.md#staging-and-containment) starts in
staging with zero required rows and a 30-minute before/after traffic
comparison. Initial abort limits include any policy timeout/503,
policy-read p99 above 50 ms, writer acquisition p99 above 20 ms or twice
baseline, and a five-minute 503 increase above 0.1 percentage point.
These are initial limits to validate against measured staging results,
not production capacity claims.

Verify signed/unsigned free controls, real required root/audio upgrades,
dependency failures, restart behavior and discovery before expansion.
Also watch binding timeouts/errors and their current generic 404
responses on known serving hosts alongside writer-pool pressure;
evaluating root/audio binding has a 500 ms budget; Off uses ordinary
cached routing. In Shadow, a pre-511 rollback loses enforcement of
accepted community requirements. Stop new upgrade calls, retain accepted
policy and receipts, and repair with an enforcing binary or
operator-approved containment. Never clear durable policy to restore
access. Off deliberately suspends FI enforcement and must be an explicit
operator mode choice. No production activation is claimed.

All acceptance paths must implement the lease protocol before upgrades
are enabled. Keep upgrades disabled through a mixed-version rollout. For
rollback, disable new acceptance before removing the protocol while
caching pods remain. A cold policy fill uses a six-statement transaction
and a 400 ms server timeout within the 500 ms lookup budget. A contended
policy fill returns 503. During the drain, archive, deletion, ownership,
issuer-binding and competing acceptance writers for that community may
wait; ordinary serving reads and writes retain their existing behavior.
Host-binding changes retain the existing one-second routing bound. Local
and Redis invalidation accelerate refresh; safety does not depend on
delivery. Deploy the verified Shadow revocation behavior from #8991
before accepting required communities in Shadow.

### Testing

Published review-fix head: `39dfe79b1fc04f10ee3eb94d6ea42cf686bafe1e`,
tree `1e2e8d3abd39ee1d6821defe8c8d34de73726e08`. McKeon cleared this
cache delta. Full affected auth (291), DB (192), pubsub (31) and relay
(1,682) package tests passed, including integration binaries. Two
previously documented hardcoded-Redis audit tests remained excluded.
All-target clippy, all 1,297 native PostgreSQL tests and the full pubsub
Redis suite passed. The full ACP package and `just ci` passed after
rebuilding cached test executables that embedded a removed checkout
path. No ACP source changed. Tauri's generated lockfile delta was
archived and restored; it is excluded from this candidate.

Both positive lease regressions passed. Four deliberately broken
variants failed at the intended assertions: removing the drain left an
optional lease alive after 202.935667 ms; skipping the fill lock failed
contention refusal; moving the timestamp after the read failed the
anchor check; taking the host lock before the row failed the
remap-blocking check. Exact source was restored after each mutation.
These are local validation results, not a deployed-fleet acceptance
claim. All applicable normal push hooks passed. GitHub reports the exact
tested head and tree; the published code has no delta from the reviewed
candidate.

Earlier mode-correction evidence follows for context.

Current mode-correction head:
`aa9abf07284b56a88eb78a2014e590f39d705288`, on main
`af54258f19ac32667c027f889652bdeb479c8e13`. Its code equals reviewed
`f1e7da5fc5`, plus the independently reviewed operator documentation
follow-up; all 19 earlier remote patches are preserved. McKeon verified
the correction and its mapping to the tested candidate.

The mode-correction candidate passed full auth (291) and relay (1,679)
package tests, clippy, and four explicit native PostgreSQL policy
workflows. There were 623 ignored relay tests and two excluded Redis
tests. Real PostgreSQL checks cover an ordinary unsigned caller on a
stored-required host in Off with no FI token or dependencies, discovery
false, durable policy retained, root/audio upgrades, host remaps and the
unarchive race in evaluating modes. The `STORED_OFF` mutation fails the
enum truth table only; `OFF_PRIMARY` fails cached upgrade routing.
Restored source hashes match. Earlier failed compile/fixture attempts
remain recorded.

The first normal push hook attempt passed Tauri checks but failed the
gateway startup verifier test (82/83 gateway tests passed). The remote
remained unchanged. The retry rebuilt gateway artifacts with separate
root and Tauri caches, then passed every normal push hook and published
this head. No gateway source changes or hook bypasses were needed.
Current-head hosted CI is tracked separately. Human acceptance and the
staging binding-timeout/404 workflow remain open. The following retained
validation and controls are historical and describe behavior before the
mode correction; their Off-enforcement statements are superseded by the
Summary above.


Published head: `9a717fa89725f9430ff4a8bb78cd600affb8836d`, on main
`1055f79d048ef22c878d02a7afb04f49c753564f`. CI, three schema checks,
2,136 ordinary tests, all 291 auth tests, 12 Redis tests, all 1,300
native PostgreSQL tests and discovery passed. The required wrapper
passed units, then exited 1 at unavailable Docker bootstrap. BUZZ-510 is
complete at `95c8d31c835d6f7ff6e432fb1ed248771d4f06dd`: CI, three schema
checks, 2,127 ordinary tests, 12 Redis tests, all 1,295 native Postgres
tests and discovery passed; its wrapper passed units and failed at
unavailable Docker bootstrap.

The first native run at historical BUZZ-510 `5380cb72` passed 1,291
tests and failed two event-follow-up queue assertions and the scale-sync
case. All three passed serially, then the full 1,294-test run passed
with normal concurrency. The original cause remains unresolved; failed
runs and resource samples are retained.

Standalone BUZZ-511 checks exposed access-slot callers, a Clippy warning
and early HTTP 503 attribution that had remained in the child. All are
now corrected in the parent. The unchanged real-router refusal
regression passed before the latest commit. BUZZ-512's final tree is
unchanged by moving these parent prerequisites. The remaining 46-file
child delta was audited; no additional parent prerequisite was found.
Earlier compile and native failures remain archived.

The following earlier-head results and controls are historical; the
current-head validation is reported above.

At `48388834f99b8d045e243b619904cb3e27b47146`, based on BUZZ-510
`130f14a675edd7cbe6a69d1c9d7818bd1a51f46c` and reconciled main
`152441ae0a56b204b42b6faa56a36ad2b98218b1`:

- `just ci`: passed.
- Four-package ordinary suite: 2,069 passed; full `buzz-auth` package:
291 passed.
- Native Redis: 12 passed. Native PostgreSQL: 1,261 passed. PostgreSQL
discovery checks passed.
- Required `just test`: units passed, then exited 1 because Docker
service bootstrap was unavailable. Native service results above are
separate.
- Real-router remap checks reject an A-only member under B policy, avoid
A data after B admission, and advertise B in discovery despite a stale A
cache. Removing each tenant pin separately fails its intended witness;
restored source passes. An unmapped host cannot reuse a cached tenant.
- Deadline witnesses advance their clock during real HTTP signature
verification and retain socket primary-lookup barriers. Removing either
final enforced or shadow HTTP deadline check separately fails its
intended witness; restored source passes both deadlines and the
single-resolution control.
- Telemetry regression: unsigned dev proofs now expect the earlier
NIP-98 `missing` denial; signed mismatched-key proofs retain pairing
denial and signer attribution. Removing unsigned-proof rejection fails
the exact two-counter assertion. Restored full relay package: 1,654
passed.
- Preserved failed runs: two prior bottom-layer desktop gates failed an
immediate inventory assertion, repaired with a bounded wait for the same
observable arrays and verified with delayed-notification and
frozen-provider controls. An earlier BUZZ-511 native run passed 1,242
tests and failed two deadline fixtures that relied on the tenant lookup
removed by the policy pin; the test-only adaptation above replaces that
seam. These failures remain archived. The first native run on
bottom-layer `130f14a6` passed 1,255 tests and failed partition
contention; its focused original test and fresh full 1,256-test run
passed without source changes. The cause remains unresolved. The first
CI run at `d8d88433` failed the old telemetry expectation; the test-only
correction above preserves exact denial counts and attribution.

Earlier evidence at `dddf05d8be8195d567db0f989f70825b1246a6c1`
(historical head):

- `cargo nextest run -p buzz-auth -p buzz-db -p buzz-relay --lib
--tests`: 2,139/2,139 passed.
- `scripts/postgres-test-run.sh -p buzz-db -p buzz-relay --lib --tests
--success-output final`: 1,236/1,236 passed with native PostgreSQL 16
and Redis. Docker's legacy `just test` wrapper was unavailable.
- `just ci`: passed, including repository checks, tests and client
builds.
- **Pre-correction behavior (superseded):** Real ES256 assertions
through TCP root/audio upgrades: valid evidence upgrades; missing
evidence denies in Off/Shadow, including submitted/inventoried/approved
deletion stages. Restoring the old query makes the regression fail on an
assertion-less Off/submitted/root HTTP 101.
- **Pre-correction behavior (superseded):** Deterministic unarchive
race: Notify barriers pause an actual archived-host policy miss; the
same required row becomes serving before binding. Both root/audio deny
missing assertions and admit valid ES256 assertions in Off/Shadow.
Ignoring bound-row policy makes this test fail on an assertion-less HTTP
101.
- The full rerun exposed an existing cancellation fixture whose
synthetic five-second replica fence expired between probes. Refreshing
that test fence per operation preserves its timeout assertions; the full
suite now passes.
- Local probe: 100 serial uncached primary policy reads took 12.33 ms.
Production latency is unmeasured.

No packaged-app, human or deployed-fleet acceptance run. Live activation
awaits BUZZ-512 and the staging verification tickets.

Published with the three-layer stack in one atomic push through normal
hooks. The published stack includes main
`1055f79d048ef22c878d02a7afb04f49c753564f`, including access logging,
storage reporting and mobile read-state changes. Access-slot integration
and early HTTP refusal attribution belong to BUZZ-511; activation
callback refusal close owners belong to BUZZ-512. Every changed layer
requires review at its published head and new-head CI. Fresh trunk,
remote membership, queue and immutable-lease guards passed before
publication. All nine normal pre-push hook lanes passed; no hooks were
bypassed. Remote heads, chained bases, membership, ready/draft states
and native tracking were verified after publication.

Trunk’s merged 0081 is unchanged. The parent upgrade expansion is 0082
and activation is 0083, with contiguous migrator versions, updated
documentation and a populated-upgrade fixture starting at 81. Trunk’s
storage-test repair supersedes our earlier local repair.

Before publication, main advanced three commits from
`1055f79d048ef22c878d02a7afb04f49c753564f` to `ba7d7825ea` (desktop
deep-link settling and mobile pairing/sign-in). The normal branch-skew
guard passed. Those later trunk commits are outside this validated
published head; current-trunk CI and delta review remain required before
merge.

Generated with Codex

---------

Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
@bradseiler
bradseiler marked this pull request as ready for review November 5, 2026 03:25
@bradseiler
bradseiler requested a review from a team as a code owner November 5, 2026 03:25

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

🤖 Automated review of 6c67f9ee: changes requested. This head can't merge into current main, and one of the problems won't show up as a git conflict.

IMPORTANT: two migrations would share version 83. This PR adds migrations/0083_community_auth_activation.sql. Main has since merged migrations/0083_channels_listed_name_unique.sql (#8787). The filenames differ, so a rebase applies cleanly and git won't flag it. sqlx tracks applied migrations by version number alone (sqlx-core 0.9.0, Migrator::run_direct). Any database that has already run one 0083 fails startup with VersionMismatch(83) when it reaches the other. On a fresh database, the second insert of version 83 into _sqlx_migrations fails. Fix: renumber this migration to the next free version (0084 on current main) while rebasing, and update the "Migration 0083 is additive" line in docs/community-auth-upgrades.md to match.

Rebase needed: GitHub reports this PR as conflicting. The head still carries the 21 BUZZ-511 commits that main squash-merged as #8988. A merge against current main conflicts in 15 files: Justfile, crates/buzz-db/src/store/community_auth.rs and its Postgres tests, crates/buzz-pubsub/src/{cache_invalidation,lib}.rs, and in crates/buzz-relay/src/: api/git/transport.rs, api/operator.rs, audio/handler.rs, nip11.rs, nip_fi_policy.rs with both of its test files, router.rs and state.rs, plus docs/community-auth-upgrades.md. The description also says a main integration with the fleet activation lock correction isn't published yet. A source review of this head would cover integration code that the rebase will replace, so the full source review and live verification will run on the rebased head instead.

matt2e added a commit that referenced this pull request Nov 5, 2026
Problem:
`thread_window_batch_discards_earlier_windows_after_revocation` failed
on main at failure_postgres_tests.rs:55, "barrier must precede closure
completion": left 2, right 1. Postgres lanes run with no retries.
- main (d3a4909):
  https://github.com/block/buzz/actions/runs/41693681008/job/126127000284
The test and the code it covers are unchanged on current main.

Cause:
This is the race #8740 (322fdbf) fixed for the replica test only.
`pause_after_aux_page` polls the request until a `thread_window_aux`
span closes, then expects exactly one closed. `query` runs the
closure's two aux hops (reactions and edits, then their deletions)
back to back, one span each. On the writer, each hop is a pool acquire
with its idle ping, BEGIN, two SET LOCALs, the SELECT and ROLLBACK.
#8740 left the writer callers on `None`, so only the request returning
Pending on a reply that had not arrived yet held hop 2. sqlx tries each
read before it waits for readiness, and tokio leaves the socket marked
readable after the previous reply, so a reply Postgres has already sent
is consumed in the same poll. If each of hop 2's replies lands before
the test thread reads it, for example when a loaded runner deschedules
that thread after its writes, hop 2 closes in the poll that closed hop
1 and the barrier counts 2. `assert_aux_access_change`, behind the
grant and revocation tests, uses the same `None` path.

Fix:
`proxied_state` gives the paused request a copy of the fixture's state
whose production writer connects through a `PausingProxy`.
`production_db_at` is `production_db` with a URL override. `AuxPages`
pauses the proxy when hop 1's span closes. That runs inside the
request's poll, before hop 2's first bytes are written, so hop 2 stays
pending however fast Postgres answers. Each test resumes the proxy
after its access change, just before `request.await`. Only the request
uses the proxied state: `assert_aux_access_change` reads through the
fixture's direct `state.db` while the request is held, and through the
proxy that read would deadlock. No caller passes `None` any more, so
the `Option` goes. Only tests change. No assertions are loosened, and
no sleeps, retries or timeouts are added.

Draft #8991 (BUZZ-512) also edits `AuxPages` and
`pause_after_aux_page`, adding a waker for its detached HTTP handler.
It also moves these two callers onto a proxy, through its own
`proxy_request_pool`, which replaces `f.state.db` with a raw pool.
Rebasing it onto this keeps its waker on the non-`Option` proxy field
and can use `proxied_state` for those two callers.

Validation:
Local Linux workstation, Postgres 17 in docker, one test per process
as nextest runs them. "Before" is the test as on main.
- Adversarial ordering, using a temporary, uncommitted change as in
  #8740: after the barrier, ten more polls of the request over 500 ms,
  then the barrier's count assertion. Before, the batch, grant and
  revocation tests failed 30/30 at that assertion with left 2, right
  1, the CI signature, and the replica test passed 10/10. After,
  40/40 passed.
- Natural contention: the three tests and a private
  postgres:17-alpine shared 2 pinned CPUs with 4 busy loops, 3
  processes at a time. Before, 450/450 passed, so the same-poll burst
  did not occur locally. After, 450/450 passed.
- Production mutation: skipping `query_batch`'s final implicit
  access-set check fails all three tests with 200 instead of 503.
- All 19 `api::bridge::thread_window` Postgres tests pass, run
  serially against one database. `cargo fmt --check`, `cargo clippy
  -p buzz-relay --all-targets -- -D warnings` (1.98.1), `git diff
  --check` and `check-postgres-test-discovery.py` are clean.

Running several of these tests in one libtest process can fail them
at :41 with no aux span seen, on main too. tracing-core builds a new
callsite's interest from the registering thread's default while only
one scoped dispatcher exists. nextest runs one test per process, so CI
is not exposed. The runs above use one process per test.

A green run doesn't prove the flake is gone. Hosted CI covers broad
validation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: mtoohey <mtoohey@squareup.com>
(cherry picked from commit 4297cf6147da103cdce9a55feb47146490869fae)
matt2e added a commit that referenced this pull request Nov 5, 2026
…#9098)

## Summary

Three tests asserted on outcomes that depended on how fast something
unordered happened. The first depended on dial latency inside a 150 ms
acquire budget, the second on whether two aux-page replies landed in one
poll, and the third on whether NIP-42 admission auto-joined the joiner
before the invite claim did. Each test now sets up the state it asserts
before the racing actor can run: it fills the reader warm floor first,
pauses the writer's proxy after the first aux page, or claims the invite
before the joiner connects. Only tests change. No assertions are
loosened, and no sleeps, retries or timeouts are added.

### test(db): fill the reader warm floor outside the acquire budget
first

#### Problem

`reader_warm_at_production_budget_ends_partial_when_dials_stall` failed
on main at reader_pool_postgres_tests.rs:177, "one partial wave, then
one wave that opens nothing": left (15, 0, 5), right (15, 3, 7).
Postgres lanes run with no retries.
- main (d3a4909):
-
https://github.com/block/buzz/actions/runs/41693681008/job/126127000284

The test is unchanged on current main.

#### Cause

The test builds the reader pool with the production constructor, so
every warm acquire, dial included, gets the 150 ms
`READER_ACQUIRE_TIMEOUT`. It relayed the proxy's first 3 sessions and
stalled the rest, so it needed the first wave's 3 relayed dials (TCP
through the proxy, backend fork, SCRAM, `after_connect`) to finish
inside that budget. On a loaded runner (4 vCPUs, nextest test-threads=8)
none did. All 5 first-wave acquires missed, and the pass ended after one
empty wave as the product should, which gives (15, 0, 5). The warm pass
was correct, but the test's outcome depended on dial latency.

The session counts had a second race. The proxy accepts connections on
the test's current-thread runtime. If that thread is descheduled past
the budget, the timed-out acquires and the listener's readiness are
handled in the same tick. So `proxy.sessions() == 10`, checked right
after the pass, could miss a stalled dial still waiting to be accepted.

#### Fix

Fill the warm floor before stalling anything, as boot does.
`Db::read_pool_boot_ping_once` closes its probe connection, and sqlx's
floor maintenance then dials the 3 floor connections under its own 300 s
deadline, outside the acquire budget. The test waits for them with
`await_idle_floor`, then stalls every session from the recorded baseline
(`proxy.stall_from(proxy.sessions())`) instead of a hardcoded 3. Wave 1
now holds the 3 idle connections without dialing, and its 2 dials stall.
Wave 2's 5 dials stall, so it makes no progress and the pass ends. That
gives (15, 3, 7) at any dial latency. The session count is now relative
to the baseline and asserted once, after the existing settle. That
single check covers both "the pass stops dialing" and "nothing
re-dials". The report assertion, the `elapsed < 2s` bound and the
production pool are unchanged. Only the test changes.

The setup still needs one of the boot ping's five attempts (spaced over
about 4 s by `READER_BOOT_PROBE_BACKOFF`) to dial within 150 ms, the
same exposure as `reader_boot_ping_warms_pool_to_min_connections`.
Before, all 3 concurrent first-wave dials had to make it on their first
try.

#### Validation

Local Linux workstation, on a private postgres:17-alpine. The test
processes and Postgres shared 4 pinned CPUs, with 8 parallel workers.
"Before" is the test as on main.
- CPU stress, no busy loops: before, 2/40 failed with (15, 0, 5) at
:177, the CI signature. After, 120/120 passed.
- Plus 4 busy loops on those CPUs: before, 80/80 failed with (15, 0, 5).
After, 80/80 passed. In 80 more instrumented runs (all passed), the boot
ping retried 16 times: 15 runs needed 2 attempts and 1 needed 3.
- Adversarial latency, using a temporary, uncommitted proxy hook that
delays chosen sessions' first server reply by 200 ms (past the budget),
10 runs each:
- every relayed session slow: before, 10/10 (15, 0, 5). After, 10/10
failed at "boot ping must reach the reader", the bound above.
- the first 4 sessions slow: before, 10/10 (15, 0, 5). After, 10/10
passed, with the ping succeeding on its 5th attempt.
- every session but the first slow: before, 10/10 (15, 1, 9). After,
10/10 passed.
- Production mutations, each of which fails the test: dropping the
no-progress break gives (15, 3, 27). A 2 s acquire budget fails the
elapsed bound at 4.0 s. Closing the warmed connections instead of
releasing them leaves the floor at 0 idle.
- All 7 `reader_pool_postgres_tests` pass. `cargo fmt --check`, `cargo
clippy -p buzz-db --all-targets -- -D warnings` and `git diff --check`
are clean.

A green run doesn't prove the flake is gone. Hosted CI covers broad
validation.

**Rebased onto origin/main `64d12e297` (this branch):** Linux
workstation, private `postgres:16` container with the desired-state
schema applied as in the PostgreSQL lane (pgschema plus the reconcile
script).

- `cargo test -p buzz-db`: 191 lib, 21 `observability_source` and 5 doc
tests passed.
- `cargo test -p buzz-db --lib reader_pool_postgres_tests -- --ignored
--test-threads=1`: 7/7 passed.
`reader_warm_at_production_budget_ends_partial_when_dials_stall` alone:
20/20 passed.
- `cargo clippy -p buzz-db -p buzz-relay -p buzz-test-client
--all-targets -- -D warnings`, `cargo fmt -p buzz-db -p buzz-relay -p
buzz-test-client -- --check` and `git diff --check` are clean.

### test(relay): hold the writer's second aux page too

#### Problem

`thread_window_batch_discards_earlier_windows_after_revocation` failed
on main at failure_postgres_tests.rs:55, "barrier must precede closure
completion": left 2, right 1. Postgres lanes run with no retries.
- main (d3a4909):
-
https://github.com/block/buzz/actions/runs/41693681008/job/126127000284

The test and the code it covers are unchanged on current main.

#### Cause

This is the race #8740 (322fdbf) fixed for the replica test only.
`pause_after_aux_page` polls the request until a `thread_window_aux`
span closes, then expects exactly one closed. `query` runs the closure's
two aux hops (reactions and edits, then their deletions) back to back,
one span each. On the writer, each hop is a pool acquire with its idle
ping, BEGIN, two SET LOCALs, the SELECT and ROLLBACK. #8740 left the
writer callers on `None`, so only the request returning Pending on a
reply that had not arrived yet held hop 2. sqlx tries each read before
it waits for readiness, and tokio leaves the socket marked readable
after the previous reply, so a reply Postgres has already sent is
consumed in the same poll. If each of hop 2's replies lands before the
test thread reads it, for example when a loaded runner deschedules that
thread after its writes, hop 2 closes in the poll that closed hop 1 and
the barrier counts 2. `assert_aux_access_change`, behind the grant and
revocation tests, uses the same `None` path.

#### Fix

`proxied_state` gives the paused request a copy of the fixture's state
whose production writer connects through a `PausingProxy`.
`production_db_at` is `production_db` with a URL override. `AuxPages`
pauses the proxy when hop 1's span closes. That runs inside the
request's poll, before hop 2's first bytes are written, so hop 2 stays
pending however fast Postgres answers. Each test resumes the proxy after
its access change, just before `request.await`. Only the request uses
the proxied state: `assert_aux_access_change` reads through the
fixture's direct `state.db` while the request is held, and through the
proxy that read would deadlock. No caller passes `None` any more, so the
`Option` goes. Only tests change. No assertions are loosened, and no
sleeps, retries or timeouts are added.

Draft #8991 (BUZZ-512) also edits `AuxPages` and `pause_after_aux_page`,
adding a waker for its detached HTTP handler. It also moves these two
callers onto a proxy, through its own `proxy_request_pool`, which
replaces `f.state.db` with a raw pool. Rebasing it onto this keeps its
waker on the non-`Option` proxy field and can use `proxied_state` for
those two callers.

#### Validation

Local Linux workstation, Postgres 17 in docker, one test per process as
nextest runs them. "Before" is the test as on main.
- Adversarial ordering, using a temporary, uncommitted change as in
#8740: after the barrier, ten more polls of the request over 500 ms,
then the barrier's count assertion. Before, the batch, grant and
revocation tests failed 30/30 at that assertion with left 2, right 1,
the CI signature, and the replica test passed 10/10. After, 40/40
passed.
- Natural contention: the three tests and a private postgres:17-alpine
shared 2 pinned CPUs with 4 busy loops, 3 processes at a time. Before,
450/450 passed, so the same-poll burst did not occur locally. After,
450/450 passed.
- Production mutation: skipping `query_batch`'s final implicit
access-set check fails all three tests with 200 instead of 503.
- All 19 `api::bridge::thread_window` Postgres tests pass, run serially
against one database. `cargo fmt --check`, `cargo clippy -p buzz-relay
--all-targets -- -D warnings` (1.98.1), `git diff --check` and
`check-postgres-test-discovery.py` are clean.

Running several of these tests in one libtest process can fail them at
:41 with no aux span seen, on main too. tracing-core builds a new
callsite's interest from the registering thread's default while only one
scoped dispatcher exists. nextest runs one test per process, so CI is
not exposed. The runs above use one process per test.

A green run doesn't prove the flake is gone. Hosted CI covers broad
validation.

**Rebased onto origin/main `64d12e297` (this branch):** same database
setup as above.

- Each of the 19 `api::bridge::thread_window` Postgres tests ran in its
own process and its own database cloned from the desired-state template,
as nextest runs them: 19/19 passed.
- The batch, grant, revocation and replica tests, 5 more runs each in
the same way: 20/20 passed.

### test(relay): claim the invite before the joiner connects

#### Problem

`test_invite_claim_auto_joins_default_channel`
(crates/buzz-test-client/tests/e2e_relay.rs) failed in "Relay and
PostgreSQL / Relay E2E" at :363 with "recv kind:44100 notification for
the default channel: Timeout". Two of the three reruns passed.

- run 41568808045 attempt 1 (rerun passed):
-
https://github.com/block/buzz/actions/runs/41568808045/job/125701098947
- run 41657074260:
-
https://github.com/block/buzz/actions/runs/41657074260/job/125997465078
- run 41716543775 attempt 1 (rerun passed):
-
https://github.com/block/buzz/actions/runs/41716543775/job/126206390776

#### Cause

The e2e relay runs without `BUZZ_REQUIRE_RELAY_MEMBERSHIP`, so every
human NIP-42 admission spawns `auto_join_never_joined_channels`
(handlers/auth.rs). The test connected the joiner and sent its
kind:44100 REQ before minting and claiming the invite. The admission
therefore joined the joiner to the flagged channel before the claim did.
The claim's `auto_join_default_channels` then got `AlreadyMember` and
emitted nothing, so the admission notice was the only kind:44100.

All three relay logs show this. The joiner's "auto-joined default
channel on admission" appears 39, 52 and 106 ms after its NIP-42 auth,
before "relay member added via v2 invite". The claim logs no join for
the joiner.

When the admission notice reached the client before EOSE,
`collect_until_eose` consumed it, and the test discarded that result.
The test then waited 5 s for a live event that never came. When the
notice arrived after EOSE, the test passed, but on the admission path.
Either way, the test never covered the claim-time auto-join it is named
for.

#### Fix

Only the test changes. The joiner opens no WebSocket until the claim
returns. Over NIP-98, the claim awaits the join, the kind:39002 roster
and the kind:44100 before its 200. The test now:

- checks the roster before the joiner connects, so only the claim can
have joined them;
- then connects and REQs kind:44100 `#p=joiner` with `since` taken
before the claim (minus the file's usual 5 s margin);
- asserts that the events `collect_until_eose` returns hold exactly one
notice for the channel, signed by the relay's NIP-11 `self` key.

Nothing depends on EOSE-vs-live ordering any more. The admission that
follows skips the channel, because the claim already added a member row.
No timeouts change, no retries are added, and no assertions are
loosened. The repeat-claim and cleanup steps are unchanged.

No open PR touches this test. Draft #9045 (default-channel recovery)
changes invites.rs, but not this test or this ordering.

#### Validation

Local Linux workstation, fresh database, relay started by
scripts/start-relay-for-tests.sh. Redis was the host's own, and
`BUZZ_METRICS_PORT` was moved off 9102, which bloxlet owns. Each
iteration ran CI's `e2e_relay invite` selection (8 tests in parallel).

- Unmodified test: 12/60 failed at :363 with the CI message.
- Adversarial ordering, using temporary, uncommitted changes: a 500 ms
sleep between connect and REQ, plus logging of what `collect_until_eose`
returned. 20/20 failed with the CI signature, and each time
`collect_until_eose` had returned the channel's notice. In the relay
log, the admission join came 28 ms after auth, the REQ queued one event,
and the claim emitted nothing.
- Fixed test: 100/100 passed. With the same 500 ms sleep: 30/30 passed,
each with exactly one notice, from the claim. Four concurrent loops of
25: 100/100 passed.
- Falsifiability, using a temporary relay build without the claim-time
`auto_join_default_channels` call: the fixed test failed 10/10 at the
roster assertion (`None`). The old test passed 18/20 against that build;
its two failures were its own :363 flake.
- The full CI "Relay E2E tests" step list passed once.
- `cargo fmt --check`, `cargo clippy -p buzz-test-client --tests -D
warnings`, `just file-size-check` and `git diff --check` are clean.

A green run doesn't prove the flake is gone. Hosted CI covers broad
validation.

**Rebased onto origin/main `64d12e297` (this branch):** the relay was
built from this branch with `--profile ci` (its relay code is identical
to main) and run with CI's environment. It used a fresh desired-state
database with the deployment community seeded, the host's Redis, and a
private MinIO-compatible container (the image docker-compose pins) for
the startup git probe. `BUZZ_METRICS_PORT` was moved off 9102.

- CI's `e2e_relay invite` selection: 30/30 iterations passed, 8 tests
each.
- CI's full "Relay E2E tests" step list, once: 10 of 11 steps passed.
`test_nip29_announce_channel_wire` failed at e2e_relay.rs:3862: the
kind:39000 metadata still carried `auto_join` right after the relay
owner cleared it. This branch doesn't touch that test or any relay code.
Run alone against the same relay, that test failed 8 of 20 times.
Re-running the whole step against one database isn't meaningful, because
`test_nip29_announce_channel_wire_overlong_name_is_refused_before_storage`
reuses a fixed channel name.

## Coordination

- #8991 ("BUZZ-512: Track session application and drain") edits
`AuxPages` and `pause_after_aux_page` in
`crates/buzz-relay/src/api/bridge/thread_window/postgres_tests/failure_postgres_tests.rs`,
and `git merge-tree` shows a conflict there. #8991 already conflicts
with main in 15 files, so it needs a rebase anyway. The second commit's
message describes that rebase: keep #8991's waker on the non-`Option`
proxy field, and use `proxied_state` for the two callers it moves onto a
proxy.
- #6642 and #7569 touch `crates/buzz-test-client/tests/e2e_relay.rs` and
merge cleanly with this branch.
- #7852 touches `thread_window/postgres_tests.rs` and `e2e_relay.rs`. It
already conflicts with main, and this branch adds no new conflicted file
to it. Older open PRs that touch `e2e_relay.rs` also already conflict
with main, and none gains a new conflict from this branch.

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

---------

Signed-off-by: mtoohey <mtoohey@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@bradseiler
bradseiler force-pushed the seiler/BUZZ-512/session-activation branch from 6c67f9e to 276ed15 Compare November 5, 2026 22:32
@bradseiler
bradseiler force-pushed the seiler/BUZZ-512/session-activation branch from 276ed15 to 44d6dd4 Compare November 5, 2026 22:38

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

🤖 Automated review of 44d6dd4e, combining two independent source reviews. This is a comment, not a request for changes. I didn't run anything locally; CI was green when I looked, with Codex Security Review and Mesh Lifecycle Smoke still running.

The shared-ticket design is easy to follow, ACKs are exact on operation, boot and generation, and I couldn't find a path where a relay ACKs a drain it hasn't finished through tracked work. The problems are in what tracking doesn't cover, and in a few ways a single bad event can leave upgrades pending forever. Since production activation is hard-disabled, none of this is live yet, but most of it would bite as soon as tracking is armed.

Blocking, inline below:

  • historical receipt issuer overrides current issuer trust, so issuer rotation rejects valid reconnects
  • boot registered before listener binds and never retired on those errors
  • a coalesced-refresh timeout leaves an entry that blocks every later drain
  • ACK and retire wait on per-community row locks while holding the fleet-wide scope lock
  • obligations are inserted for every upgrade ever accepted and never pruned, so retire cost grows forever
  • require() scans every tracked ticket on every request to a required community
  • TicketBody::drop drains the inner body to EOF, so a cancelled git read keeps running

Blocking, in files this PR doesn't touch, so it can't go inline: session-descended work still escapes tracking through plain tokio::spawn. run_frame (connection.rs:1343) moves a cancelled in-flight frame into tokio::spawn(frame), which drops the WORK task-local. That frame then runs ticketless, enforce_current() returns true without a ticket (auth_activation.rs:440-443), and anything it spawns is untracked. The same thing happens in artifact_sync.rs:311 (publish retry), side_effects.rs:2897-2917 (kind:9008 delete-group member notifications, which insert events at side_effects.rs:1405-1409 after the parent returns) and verified_profiles.rs:49-90 (profile persistence after admission). If one of those stalls after the parent ticket drops, reconcile at auth_activation.rs:579-590 can ACK while it can still write, and boot retirement has the same gap. Fix: use auth_activation::spawn or an explicitly retained ticket for these session-owned descendants (not every background service). A test through kind:9008 that holds the notice barrier at side_effects.rs:1390-1391, finishes the parent, and checks the ACK stays blocked would pin it.

Design question, nonblocking for this PR but needed before rollout: only a boot can retire itself (retire_auth_boot), and snapshot copies every unretired boot into new obligations (community_auth/activation.rs:81). The doc treats an OOM or SIGKILL'd boot staying pending as intended. In practice one OOM'd pod makes every later upgrade boot_drain_pending forever, and community_auth_boots has no host or pod column for an operator to tell which row is dead. I think this needs an authenticated operator path to retire a boot with evidence its workload is gone, plus something that identifies the pod.

Nonblocking, not inline:

  • docs/community-auth-upgrades.md:113-116 lists boot_application_pending and boot_unreachable, which nothing emits, and leaves out boot_drain_pending (community_auth.rs:127) and relay_mode_off (operator.rs:1245), which the code does emit.
  • PR title needs a conventional-commit type since it becomes the squash subject (CONTRIBUTING.md:48), e.g. feat(relay): track session application and drain for community auth upgrades.
  • the two polling loops in activation_postgres_tests.rs re-query Postgres with only yield_now() between iterations; a short sleep would avoid hammering the shared CI database.

Comment thread crates/buzz-relay/src/main.rs Resolved
// A newly registered boot must install all requirements before serving.
// Including it conservatively also prevents a registration/enable gap.
sqlx::query(
"INSERT INTO community_auth_obligations(operation_id,boot_id,policy_generation)

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: every boot registration inserts an obligation for every upgrade ever accepted, including completed, superseded and deleted-target ones, and nothing deletes obligation or boot rows. retire_auth_boot then runs lock_operation once per obligation in one transaction under the scope lock, so shutdown retire grows with restarts × upgrades. Eventually it outlasts the pod's grace period, the boot stays unretired, and it blocks every future operation. snapshot (:81) also scans community_auth_boots WHERE retired_at IS NULL with no index on a table that's never pruned, again under the fleet lock. Fix: skip already-activated or gone operations at registration (or prune obligations of retired boots), have retire touch only the boot's own rows since complete re-checks under row locks anyway, and add a partial index on unretired boots.

if tokio::time::Instant::now() >= retry_deadline {
return Err(crate::DbError::AuthActivationBusy);
}
tokio::task::yield_now().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.

🤖 nit: the 1.1s drain only runs when the community doesn't require FI yet, so for one that already does, a failed try_lock rolls back and retries with just yield_now(), opening writer transactions in a tight loop for the full 5s while an ACK holds scope. A small sleep between attempts would fix it.

"activation already registered".into(),
));
}
let image = std::env::var("BUZZ_RELAY_IMAGE_DIGEST")

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.

🤖 nit: BUZZ_RELAY_IMAGE_DIGEST isn't in .env.example or the doc, and nothing outside a test sets it, so every production boot records unverified-version:<pkg version>. The doc tells operators to compare registered images, so it's worth documenting where image comes from and that the fallback isn't evidence of the image.


/// Invalidate current completion under the same lock; historical timestamps
/// remain intact. This does NOT clear required policy or restore enforcement.
pub async fn invalidate_auth_baseline(&self) -> Result<()> {

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.

🤖 nit, latent since nothing outside tests calls these yet: invalidate_auth_baseline is an UPDATE only, so it's a no-op if no boot has registered yet (the row is created lazily in prepare_auth_boot). Re-verifying also doesn't re-run complete, so a receipt stays pending after invalidate plus re-verify until some relay ACKs again, and relays stop ACKing once satisfied_at is set. Worth handling before a production verifier lands.

Comment thread crates/buzz-db/src/runtime/migration.rs Outdated
// Listed channel names (0083): a bounded contract index that refuses
// to build over unrepaired duplicates. The desired schema carries the
// same plain index.
assert_eq!(migrations[83].version, 84);

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.

🤖 nit: the 0084 assertions landed inside the "Listed channel names (0083)" comment block, so that comment now describes the wrong thing. They need their own comment.

Comment thread docs/community-auth-upgrades.md Outdated

The new tables are operator-global evidence and remain after target deletion.
They add no triggers, functions or foreign keys. Migration 0084 is additive:
old relays ignore the new tables and nullable receipt columns. Desired-state

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.

🤖 nit: activation_history is JSONB NOT NULL DEFAULT '[]', so not all the new receipt columns are nullable. Something like "additive columns, one with a constant default".

@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 of 9ce0bd66, against the earlier review at 44d6dd4e. I read the six fix commits, the merge from main and the two test-fixture commits; I didn't run anything locally. CI had no failures, but the Rust and Postgres jobs hadn't run yet at this head. This isn't an approval yet: two blocking threads from the last review haven't been touched, and one new blocking issue turned up.

Fixed, and the threads are resolved:

  • issuer rotation: the tracker now fences only on generation, and root and audio re-check the current issuer bindings in the same statement as the policy. The new rotation tests drive the real HTTP, root and audio handlers.
  • listeners bind before the boot registers, so a failed bind can't strand a boot row.
  • a coalesced-refresh timeout no longer keeps its ticket forever.
  • a dropped response stream now drops the inner body, so StreamingGit::drop kills and reaps the git child with the ticket still held.
  • ACK and retire give up on a busy community row after 25ms and release the fleet scope.
  • the None-issuer spurious cancel is gone along with the issuer slot.
  • cancelled frames, kind:9008 delete notices and verified-profile publication keep their parent ticket.
  • the merge from main resolves cleanly (only the Justfile test selection conflicted, and both sides' tests are kept), and 0084 is still the next free migration.

Still open from the last review, unchanged at this head:

  • blocking: WorkTracker::require still walks every work entry for the host under the global mutex on every policy read, even when the floor didn't change (auth_activation.rs:37-51).
  • blocking: prepare_auth_boot still adds an obligation for every upgrade ever accepted, nothing prunes obligations or boots, and retire cost keeps growing (community_auth/activation.rs:142).
  • blocking: the artifact publish retry (handlers/artifact_sync.rs:311-320) is still a plain tokio::spawn. It isn't an independent background service: tracked dispatch (handlers/event.rs:462-505, via handlers/artifact.rs:51-54,81-87) creates it for one admitted event. During a Redis outage it can outlive the parent ticket, so reconcile can ACK (auth_activation.rs:539-548) and shutdown can retire while it can still publish once Redis recovers. It doesn't write to Postgres again, but the doc's producer-completion contract (docs/community-auth-upgrades.md:422-427) covers async producers too. Keeping the originating ticket (or handing it to an owner that's part of drain) fixes it. A test that fails the first publish, finishes the parent and checks the drain stays pending would pin it.
  • nonblocking, still open: the activation tracking flag isn't parsed with parse_bool (config.rs:1511), shutdown doesn't override the policy close reason (state.rs:219), acceptance retries on a busy scope lock with only yield_now() (community_auth.rs:368-374), BUZZ_RELAY_IMAGE_DIGEST isn't documented, invalidate_auth_baseline and re-verify don't re-run complete, the 0084 assertions sit under the 0083 comment (migration.rs:912), the doc's pending_reasons list (docs/community-auth-upgrades.md:113-116) and "nullable receipt columns" line (:431) don't match the code, and the PR title still needs a conventional-commit type.

New in this pass, inline below: shutdown retirement gives up on the first database error (this was already true at 44d6dd4e, and I missed it last time), plus two nits on the new code.

tx: &mut Transaction<'_, Postgres>,
operation: Uuid,
) -> Result<bool> {
sqlx::query("SET LOCAL lock_timeout = '25ms'")

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.

🤖 nit: SET LOCAL keeps the 25ms lock_timeout for the rest of the transaction, including complete() and the boot UPDATE, and a 55P03 there comes back as Err rather than Ok(false). It's unlikely since those rows are already locked by this transaction, but resetting the timeout after the row locks (or mapping 55P03 the same way everywhere) would make the contract hold for the whole transaction.

drop(ticket);
let (noticed, listed) = at_receipt(&mut barrier, async |_| {
assert!(
!tracker.is_drained(&tenant.community().to_string(), 1),

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.

🤖 nit: ingest also starts the tracked post-commit dispatcher before returning (handlers/ingest.rs:3851-3859), and it holds this same ticket. So this assertion can still pass while that task is alive, even if the delete notice lost its ticket. Waiting for the dispatcher to finish before checking the held notice would make the test fail without the side_effects.rs fix.

bradseiler and others added 4 commits November 5, 2026 19:32
Revalidate durable community policy in the existing lifecycle sweep. Retry unavailable reads on later passes and preserve enforced FI admissions. Receipts report policy acceptance without certifying connection closure.

Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
Place the journey in an explicit postgres_tests module so the source-discovery gate and CI runner both find it.

Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
Count bounded policy-read outcomes and legacy cancellation requests without tenant labels. Document per-relay probes and reconnect/rollback limits; reuse the existing read budget.

Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
Keep the test module outside the existing comment, format the policy branch for reading, and describe only actual legacy socket closures.

Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>

@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 of 29d46edc, against the previous re-review at 9ce0bd66. I read the two new commits (db71bd39, 29d46edc) at source and the CI logs for this head; I didn't run anything locally. Requesting changes: hosted CI is red at this head on code this PR adds or changes, the branch conflicts with main again, and three blocking threads are still untouched.

Fixed in this push (thread resolved): shutdown retirement no longer gives up on the first database error. drain_and_retire_auth_activation now retries pool-unavailable errors and the lock/deadlock/admin-shutdown SQLSTATEs (55P03, 40P01, 57P01-57P03) with capped, jittered backoff, and still returns permanent errors. The new tests cover both the classification and an unreachable pool that stays pending until the caller's hard backstop cancels it.

Blocking: CI at this head (run 42136441052). CI at 9ce0bd66 and db71bd39 was cancelled, so this is the first full run since 44d6dd4e. Main is green at this PR's base cec47c20 and on every completed main run since.

  • Rust Lint: cargo fmt --check fails at crates/buzz-relay/src/handlers/coalesced_refresh.rs:335 (the assert!(tracker.is_drained("community", 1), ...) line needs wrapping).
  • Windows Rust: clippy -D warnings fails on clippy::redundant_async_block at router/tests/postgres_tests/activation_postgres_tests.rs:60 and clippy::single_match at router/tests/postgres_tests/socket_activation_postgres_tests.rs:332. The Linux lint stopped at fmt before clippy ran, so these will most likely fail there too.
  • PostgreSQL Tests, four failures:
    • boot_lifecycle.rs:664 in tcp_only_sigterm_retires_only_after_real_held_work_drains and audio_sigterm_retires_only_after_real_held_teardown_drains: "health must not bind before registration". The listener-ordering fix now binds listeners before the boot registers, and these tests still assert the opposite. Decide which contract you want (if health must not answer before registration, the fix needs to keep health unbound or not ready until then) and make the code and tests agree.
    • nip_fi_resolver/tests/router_tests.rs:885 (every_reachable_handler_resolves_its_host_exactly_once) and :895 (snapshot_going_stale_before_final_admission_admits_nowhere): every handler returns 503 authorization unavailable instead of being reached. This PR doesn't modify router_tests.rs, and these tests passed on main and at 44d6dd4e, so something between 44d6dd4e and this head now denies valid admission in Enforce. The issuer-binding re-check that root and audio now do alongside the policy read is the likeliest candidate.

Blocking: conflicts with main. GitHub reports the PR as conflicting; git merge-tree against current main conflicts in Justfile only.

Still open from earlier reviews, unchanged at this head (the push only touched auth_activation.rs retirement and rotation_postgres_tests.rs):

  • blocking: WorkTracker::require still walks every work entry for the host under the global mutex on every policy read (auth_activation.rs:37).
  • blocking: prepare_auth_boot still adds an obligation for every upgrade ever accepted, and nothing prunes obligations or boots (community_auth/activation.rs:142).
  • blocking: the artifact publish retry (handlers/artifact_sync.rs:311-320) is still a plain tokio::spawn that can outlive its parent ticket.
  • The nonblocking threads from the earlier reviews are all still open.

The PR description still names 9ce0bd66 as the published head and says current-head CI is pending. It should describe this head and these two commits.

@bradseiler
bradseiler force-pushed the seiler/BUZZ-512/session-activation branch from 29d46ed to 1c74532 Compare November 6, 2026 01:14
@bradseiler
bradseiler deployed to codex-review November 6, 2026 01:15 — with GitHub Actions Active
@bradseiler bradseiler changed the title BUZZ-512: Track session application and drain fix(relay): close legacy sockets after community FI upgrades Nov 6, 2026
@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Nov 6, 2026
@github-actions

github-actions Bot commented Nov 6, 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: bb3c5f7175041907db50df9640d6377a69cf9be9...1c74532516f697c2f06498646326f8c479574b14
  • 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 issues found in the authorized PR range.

Findings

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

Notes

  • Read-only review traced policy reads, admission markers, connection registration, and root/audio cleanup paths. Builds and tests were not executed.

Generated by Codex Security Review |
Requested by: @bradseiler |
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.

🤖 Approving the rewrite at 1c745325. Two of us reviewed it independently (source reads, nothing run locally), and both found no blocking issues. CI is green on Rust unit, PostgreSQL and relay E2E; one desktop E2E shard was still running when I looked.

Every blocking finding from the earlier reviews was in the activation layer (work tickets, boot and obligation tables, drain ACKs, the issuer slot), which this version removes, so none of them carry over. What we checked in the new code:

  • the mode gates match admission: Off and repair stay lifecycle-only, optional Shadow stays legacy, and required Shadow and global Enforce close legacy sockets
  • root and audio both attach the enforced FI marker before registering, so valid FI sessions survive the sweep, and a later NIP-42 AUTH can't turn a legacy socket into an FI one
  • archived and deleted communities still fall through to the existing row-locked inactive fence, since community_auth_policy uses the same lifecycle predicate as is_community_active
  • a slow or failed read keeps the sockets and retries on the next sweep instead of guessing policy
  • the doc is upfront that this is eventual closure, in-flight requests may finish, and receipts stay pending

Nonblocking nits, two inline plus one here: some source comments still promise the old drain. nip_fi_policy.rs:1-3 ("existing work is drained separately by BUZZ-512"), nip_fi_policy.rs:114-117 ("activation fences its work") and buzz-db/src/store/community_auth.rs:64 ("Pending until BUZZ-512 supplies applied-and-drained evidence") should describe the closure-only contract now.


/// Enforced FI admission is attached before registration. Later NIP-42 AUTH
/// cannot turn a legacy or observed-shadow socket into an FI admission.
fn disconnect_auth_policy(&self, community_id: CommunityId) -> usize {

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.

🤖 nit: this walks every local socket, and the sweep calls it once per effectively-required community, so each pass is O(required communities × sockets). That keeps going after every legacy socket is gone, and under global Enforce every bound community counts. Probably fine at today's sizes, but collecting the required IDs and closing in one pass over the registry would make it O(sockets) per sweep.

Required sessions admitted under global Shadow are real enforced FI sessions.
The existing global-Shadow admin disconnect path still only observes would-close;
BUZZ-512 must close these enforced sessions independently of global rollout mode,
A separate revocation change must close these enforced sessions independently of global rollout mode,

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.

🤖 nit: this line continues the sentence after the semicolon on the previous line, so it should start with a lowercase "a".

@bradseiler
bradseiler merged commit 4e28176 into main Nov 6, 2026
90 checks passed
@bradseiler
bradseiler deleted the seiler/BUZZ-512/session-activation branch November 6, 2026 03:01

This branch was successfully deployed

1 active deployment
codex-review — 1c745325 Deployed Nov 6, 2026 by bradseiler via Run Codex Security Review #13321
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.

2 participants