Commit Graph

3290 Commits

Author SHA1 Message Date
Tom Brow
cec918ae7a perf(mobile): show a static placeholder for posterless videos (#9138)
Video messages without a NIP-71 poster (older events, or uploads where
poster generation failed) used to start a network
`VideoPlayerController` for each bubble, seek to 100 ms and show that
paused frame. Nothing cached the frame, and the decoder was disposed
whenever the bubble left the tree, so every scroll back opened a new
stream and decoded again.

Those bubbles now show the existing static "Video attachment"
placeholder with the play button. Nothing loads until the user taps, and
the full-screen viewer behaves as before. Videos that have a poster are
unchanged.

### Validation

- Full mobile Flutter test suite passes, and `flutter analyze` is clean.
- The change is almost pure deletion: the frame loader, its provider and
test seam, and the tests that covered it are removed.
- Limitation: no simulator run. A cloned golden sim built from this
branch signed in to the internal community, but its channel list never
finished loading, so I couldn't open a channel with a posterless video.

buzz-review-completed

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
2026-11-05 14:36:05 -08:00
Matt Toohey
bdd76baea5 fix(desktop): compensate head trims from the unclamped scroll offset (#9094)
## Summary

Re-entering a channel trims older pages from the head of the cached
timeline, and our Virtua patch compensates for each trim from the live
DOM `scrollTop`. When the reader was near the floor, the browser had
already clamped `scrollTop` to the shorter list, so the removal was
counted twice and the reader could be thrown to the top of history. That
is the `message-target-highlight.spec.ts` flake in Desktop Smoke E2E.
The patched scroller now remembers the last offset it observed or wrote,
and compensates from that offset when the live one was clamped to the
floor.

### fix(desktop): compensate head trims from the unclamped scroll offset

#### Problem

`message-target-highlight.spec.ts:79` ("message target flashes: ...")
flaked in Desktop Smoke E2E, always at :179: the linked row had left the
viewport 2.2 s after it flashed.
- main:
-
https://github.com/block/buzz/actions/runs/41568206043/job/125695139845
-
https://github.com/block/buzz/actions/runs/41669669478/job/126035103096
-
https://github.com/block/buzz/actions/runs/41669740241/job/126035480466
-
https://github.com/block/buzz/actions/runs/41694874145/job/126123865714
-
https://github.com/block/buzz/actions/runs/41728489172/job/126240193479
-
https://github.com/block/buzz/actions/runs/41737019256/job/126269365379
- branches:
-
https://github.com/block/buzz/actions/runs/41575774131/job/125719148353
-
https://github.com/block/buzz/actions/runs/41636043464/job/125918005733
-
https://github.com/block/buzz/actions/runs/41751914119/job/126319694104
-
https://github.com/block/buzz/actions/runs/41544910958/job/125618626645
-
https://github.com/block/buzz/actions/runs/41714110046/job/126190515793

All eleven runs predate #8742, and each failed in a case that picks
`mounted[2]`: the bottom-settle mode #8742 fixed by holding the target.
Run 41694874145 also failed once in "other channel, row near the
bottom", and that mode survives #8742: on current main it fails 40 of 60
local runs with tracing on, as CI's retries run.

#### Cause

Re-entering a channel refreshes its newest window, which drops the older
pages from the head of the cached timeline. #8660 passes that head trim
to Virtua as a `shift`, and Virtua compensates by the removed height in
a layout effect. Our Virtua patch (#2855) applies shift compensation to
the live DOM `scrollTop` so that corrections landing before the next
scroll event accumulate. But by then the browser has laid out the
shorter list and clamped `scrollTop` to the new floor whenever the
reader was within the removed height of it, so the removal is counted
twice. In a failing run the view sat 6px above the floor: trimming the
412px channel intro moved it 406px too far, and trimming 3054px of older
rows then wrote `scrollTop` -907, pinning it to the top.
`onStartReached` loaded older history and the target never came back. A
reader scrolled slightly above the bottom loses their place the same
way.

#### Fix

The patched Virtua scroller remembers the last offset it observed in a
scroll event or wrote itself. When a shift compensation finds the live
offset below that and pinned to the floor, the browser clamped it, so
the compensation starts from the remembered offset. Otherwise it keeps
#2855's live-offset base. The ESM and CJS builds change identically, and
the lockfile carries the new patch hash.

#### Validation

- New `virtuaShiftClampPatch.jsdom-test.mjs` drives the real patched
`VList` over a scroller that clamps like a browser. Head trims far from,
near and at the floor keep the reader's rows in place, a trim keeps a
scroll whose event has not fired yet, and a prepend then a trim before
any scroll event accumulate. Each part of the new condition fails a case
when removed: the previous patch line fails the near-floor, at-floor and
accumulation cases (3500 for 3900 and 4000, 3000 for 4000); upstream's
store-offset base fails the unreported-scroll and accumulation cases;
always using the remembered offset, or dropping the floor check, fails
the unreported-scroll case (2500 for 1900).
- "other channel, row near the bottom" with `--repeat-each=30
--workers=4 --trace=retain-on-failure`: 40/60 failed at :179 on current
main, 0/60 failed (60/60 passed) with the fix, and 25/30 failed again
with the previous patch reinstalled.
- All five cases, 20 traced runs each: 100/100 passed.
- scroll-history, virtualization, timeline-no-shift,
overscroll-boundary, dm-history-live-regression and
live-broadcast-reply-timeline, 3 runs each: 117/117 passed.
- Full smoke project: 1485 passed, 3 skipped, 2 failed.
`mentions.spec.ts:807` passed 5/5 on rerun. `messaging.spec.ts:2785`
(own-message avatar) fails the same way on a current-main build here
(3/3).
- `pnpm test`: 6933 unit and 176 jsdom tests passed.

**Rebased onto origin/main `64d12e297` (this branch):** Linux
workstation.

- `pnpm install --frozen-lockfile` installs virtua with the new patch
hash (`f4df71c39f6c...`).
- `pnpm typecheck` and `biome check --error-on-warnings` on the new
jsdom test are clean.
- Unit tests under `src/features/messages` and `src/features/channels`:
1990/1990. jsdom tests there: 12/12. Full desktop `pnpm test`: 6933 unit
and 176 jsdom tests passed.
- Playwright smoke after `pnpm build:e2e`, `--workers=4 --retries=0`:
messaging, send-channel-binding, scroll-history, virtualization,
timeline-no-shift, overscroll-boundary, live-broadcast-reply-timeline
and message-target-highlight, 137 passed and 1 failed. The failure is
`messaging.spec.ts:2785` (own-message avatar), which fails the same way
on main here, as noted above. `dm-history-live-regression.spec.ts` isn't
in the smoke project, so this run left it out.
- `message-target-highlight.spec.ts`, `--repeat-each=10`: 50/50 passed.
"other channel, row near the bottom", `--repeat-each=30`: 30/30 passed.

## Coordination

No open PR edits `patches/virtua@0.49.3.patch`. The overlaps are all in
`pnpm-lock.yaml`:

- #5547 ("fix(desktop): ship a bundle that parses on WebKit without
lookbehind (Intel/macOS 12)") and #7074 ("chore(deps): update react
monorepo (patch)") merge cleanly with main but conflict with this branch
on the `virtua@0.49.3` patch-hash lines. #5547 adds a patched dependency
next to the virtua entry, and #7074 changes the React peer versions in
the same lines. Whichever lands second resolves it by taking both sides'
package changes and re-running `pnpm install` so the lockfile records
`patch_hash=f4df71c39f6c...` for virtua.
- #7073, #7080, #7633, #8218 and #8643 also touch `pnpm-lock.yaml` and
merge cleanly with this branch. Older open PRs that touch it 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>
2026-11-06 09:35:10 +11:00
Matt Toohey
1a42b5c39c fix(relay): read kind:39000/39001 inputs behind the replacement lock (#9035)
## Problem

`test_nip29_announce_channel_wire` flaked in CI (9/30 local solo runs
failed). `emit_group_discovery_events` read the channel, its members and
the latest live `created_at` *before* taking the coordinate's
replacement lock, and only locked for the write. Two publishers for one
channel could therefore store in the reverse order of their reads, or
land on the same second where NIP-16's lower-id tie-break picks a random
winner. Either way a kind:39000/39001 built from stale state could stay
live.

In the test, the relay owner reconnects to send an `auto_join=false`
edit, gets auto-joined into the channel, and the join's kind:39000 —
built from the old `auto_join=true` state — replaces the edit's.

## Fix

- **buzz-db:** `lock_group_state_snapshot` takes the replacement lock
first, then reads the channel, the members the kind actually publishes
(full roster for a DM's kind:39000, owners/admins for kind:39001, none
otherwise) and the latest timestamp; `LockedGroupStateSnapshot::replace`
stores on the same transaction. This is the fence kind:39002 already had
via `LockedMemberSnapshot`. The lock is taken once per write
(`take_replacement_lock`), and the write keeps its
`ReplaceAddressableEvent` transaction timer and writer population so
metrics stay continuous.
- **buzz-relay:** `emit_addressable_discovery_event` builds tags and
`created_at` (live + 1s) from that snapshot.

## Tests

- buzz-db: `group_state_snapshot_reads_behind_the_replacement_lock`,
`group_state_snapshot_reads_the_members_its_kind_publishes`,
`group_state_replacement_takes_its_lock_once`.
- buzz-relay: `a_publisher_that_read_before_an_edit_cannot_replace_it`,
with `BeforeLock` and `AfterRead` legs driven by new
`nip_fi_test_hooks`.
- Every new assertion was confirmed to fail when its guard is reverted.
- `test_nip29_announce_channel_wire` solo against a relay built from
this tree: 30/30 passed (9/30 failed before).

Merged with current `main`; the only conflict kept main's bounded
`channel state write failed: {kind}` error mapping on the new snapshot
write.

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

---------

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 09:34:33 +11:00
Matt Toohey
580caca667 fix(db): unlock the storage-accounting lease before its session closes (#9097)
## Summary

`StorageAccountingLeader` holds a session-level advisory lock on a
detached connection. Dropping it only closes the socket, and PostgreSQL
frees the lock when the backend exits, which can be after the next lock
attempt. `release(self)` now unlocks on the leader's own session before
closing it. The relay storage-sweep test releases its worker between
iterations instead of dropping it, which fixes the
`worker_publication_activates_reader_without_restart_or_mode_change`
flake. The follow-up commit makes `release` report success only when the
unlock actually ran on the session that held the lease.

### fix(db): unlock the storage-accounting lease before its session
closes

#### Problem

The PostgreSQL lane's
`storage_sweep::postgres_tests::worker_publication_activates_reader_without_restart_or_mode_change`
failed with "called `Option::unwrap()` on a `None` value" at
crates/buzz-relay/src/storage_sweep/postgres_tests.rs:66:74 after 5.53
s, on the unrelated branch elrond/cli-credential-chain:
-
https://github.com/block/buzz/actions/runs/41730818977/job/126253029211

#### Cause

Line 66 is `db.try_lock_storage_accounting().await.unwrap().unwrap()`
inside the loop over three metrics modes. `Ok(None)` means
`pg_try_advisory_lock` returned false. A passing run takes about 15 s,
roughly 5 s per iteration, so the failure is the start of iteration 2.
Each test process gets its own database and advisory locks are
per-database, so the holder is iteration 1's own worker.

`StorageAccountingLeader` holds a session-level advisory lock on a
detached connection. The test ended each iteration with `drop(worker)`,
which only closes the socket. PostgreSQL frees session locks during
backend exit, after it notices EOF, so the lock can outlive the drop on
a loaded server. Iteration 2's lock attempt raced that exit. The
db-crate test `lost_lock_session_cannot_overwrite_successor_snapshot`
already polls for this release. The relay test didn't.

#### Fix

Add `StorageAccountingLeader::release(self)`. It runs
`pg_advisory_unlock` on the leader's own session and then closes it, so
the lease is free when it returns. This follows the unlock-then-close
pattern in `migration.rs`. The relay test now releases the worker at the
end of each iteration instead of dropping it. No assertions, timeouts or
retries change.

buzz-admin's `storage-snapshot` is left as is. It runs once per pod, and
its CronJob uses `concurrencyPolicy: Forbid`, `backoffLimit: 0` and
`restartPolicy: Never`, so the next lock attempt comes from a later
scheduled run, not from the same process.

`released_lease_admits_successor_before_old_session_exits` covers the
new method. A closing backend drops its temp tables before it frees
session locks. So the test locks a temp table owned by the leader's
session from another transaction, which keeps the old backend alive
after release. It then asserts that a fresh `try_lock` succeeds at once
and that the old backend has not exited.

Open PRs #9040 (edits this relay test and keeps `drop(worker)`) and
#9043 (changes the `sqlx` import in storage_accounting.rs) will see
small textual conflicts.

#### Validation

Local Linux workstation, throwaway `postgres:16-alpine` (CI uses
`postgres:16`), desired-state schema applied as in the PostgreSQL lane:

- Adversarial ordering, using temporary, uncommitted changes: the
leader's connection was made public and the relay test held the worker
backend's exit for 1 s with the same temp-table lock. Without the fix:
15/15 failed at postgres_tests.rs:66:74 after 5.1-5.3 s, the CI
signature. With the fix: 10/10 passed.
- Natural contention: Postgres, the test processes and busy loops were
pinned to 4 CPUs. With 8 parallel runs and 4 busy loops, the unmodified
test passed 24/24 without the fix. With 16 parallel runs and 16 busy
loops, it passed 48/48 without the fix and 48/48 with it. Load alone did
not open the window locally.
- New db test: 50/50 passed. With the unlock removed from `release`,
leaving only `close`, it fails with "a released lease must admit a
successor at once". All four storage_accounting Postgres tests pass.
- `cargo fmt --all -- --check`, `cargo clippy -p buzz-db -p buzz-relay
-p buzz-admin --all-targets -- -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 test,
run in a fresh database cloned from the desired-state template each
time, passed 8/8 at the branch head, which includes this commit. The
full rebased results are under the next commit.

#### Rebase note

#9040, which this commit's message lists as an open PR, has since merged
to main. It removed the per-community storage metric, including the
`buzz_community_storage_bytes` assertion just above `drop(worker)` in
`crates/buzz-relay/src/storage_sweep/postgres_tests.rs`. The cherry-pick
onto current main conflicted on that hunk. The resolution keeps main's
version of the test and changes only `drop(worker)` to
`worker.release().await.expect("release worker lease")` with its
comment. Nothing else in the commit changed, and no semantic adjustment
was needed. The commit message is unchanged, so it still describes #9040
as open.

### fix(db): report a storage-accounting release only once its unlock
succeeded

#### Problem

Second review of the branch (nit) on 51c27212a ("fix(db): unlock the
storage-accounting lease before its session closes"),
`StorageAccountingLeader::release` in storage_accounting.rs. That commit
cites the unlock-then-close pattern in runtime/migration.rs but handles
errors differently:

- A failed unlock returned early with `?`, skipping the graceful close.
The dropped connection then closed only the socket.
- A failed close after a successful unlock was returned as an error,
although the lease was already free.
- `pg_advisory_unlock` returning false, which means this session didn't
hold the lock, was ignored and reported as success.

So the doc comment's "Once this returns, the lease is already free" did
not hold for every `Ok`.

#### Cause

`release` propagated the unlock's error with `?` before the close,
propagated the close's error after a successful unlock, and ignored the
boolean that `pg_advisory_unlock` returns. So a failed unlock skipped
the graceful close, a failed close was reported although the lease was
already free, and an unlock that returned false because this session no
longer held the lock was reported as success.

#### Fix

Follow migration.rs's `with_schema_destruction_lock`. Run the unlock as
`query_scalar::<bool>` and keep its result, always attempt the close and
ignore its error, then return the unlock's error, or `InvalidData` if
the unlock returned false. The doc comment now says that `Ok` means the
unlock ran on the lease's own session, so the lease is free even if the
close failed, and that an error means the unlock failed or the session
no longer held the lease.

`released_lease_admits_successor_before_old_session_exits` gains a final
step: it unlocks the successor's lease on the successor's own session,
then expects `release` to fail. Its successor check is now an `expect`
with the same message.

#### Validation

Local Linux workstation, the `postgres:16` container used earlier on
this branch, `BUZZ_TEST_DATABASE_URL` pointing at it:
- `cargo test -p buzz-db --lib storage_accounting -- --ignored`: 4/4
storage_accounting Postgres tests pass.
- With the unlock's boolean ignored, as before this change, the new step
fails with "releasing a lease the session lost must fail".
- `cargo fmt -p buzz-db -- --check`, `cargo clippy -p buzz-db
--all-targets -- -D warnings` and `git diff --check` are clean.

**Rebased onto origin/main `64d12e297` (this branch, both commits):**
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. The 674 Postgres-backed tests are `#[ignore]`d there.
- `cargo test -p buzz-db --lib storage_accounting -- --ignored
--test-threads=1`: 4/4 passed.
`released_lease_admits_successor_before_old_session_exits` alone: 20/20
passed.
-
`storage_sweep::postgres_tests::worker_publication_activates_reader_without_restart_or_mode_change`,
each run in a fresh database cloned from the desired-state template: 8/8
passed.
- `cargo clippy -p buzz-db --all-targets -- -D warnings`, `cargo clippy
-p buzz-relay --all-targets -- -D warnings`, `cargo fmt -p buzz-db -p
buzz-relay -- --check` and `git diff --check` are clean.

## Coordination

- #9043 (draft, "feat(relay): serve storage usage as an operator pull
feed") edits `crates/buzz-db/src/store/storage_accounting.rs`. `git
merge-tree` against its current head shows two small textual conflicts.
First, both sides rewrite the `sqlx` import to the same `use
sqlx::{postgres::PgConnection, Connection};`, and #9043 also drops `use
uuid::Uuid;`. Second, #9043 replaces
`usage_delivery_reads_snapshot_time_and_community_states` at the point
where this branch adds
`released_lease_admits_successor_before_old_session_exits`. Keeping both
sides resolves each one.
- #7899 also touches `storage_accounting.rs`. It already conflicts with
main, and this branch adds no new conflicted file to it.

🤖 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>
2026-11-06 09:28:55 +11:00
Matt Toohey
a22ff133b4 test(relay,db): remove three timing races from Postgres and E2E tests (#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 (d3a4909cc):
-
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 (d3a4909cc):
-
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 (322fdbf08) 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>
2026-11-06 09:28:38 +11:00
Luke Tornquist
47be2e2d3f fix(db): keep busy-reader history reads off the writer and cap them per pod (#9132)
## Problem

A routed read that misses the reader's 150ms acquire budget
(`reader_acquire_timeout`) falls back to the writer, whatever the read
is. Under a burst of subscription-history reads (REQ history, channel
and thread windows, feeds, search hydration, COUNT), the reader pool
fills, and every miss moves a heavy history query onto the writer. There
it competes with the authentication, tenant-resolution and moderation
lookups that only the writer serves, and their latency climbs with it.
Nothing bounds how many history reads a pod sends to the reader at once,
so the reader queue grows without limit as well.

Separately, the compatibility families
`buzz_db_pool_acquire_wait_seconds` and
`buzz_db_pool_acquisitions_total` have no `operation` label, so a
breakdown by operation reports every sample as unlabelled.

## Changes

1. **History reads no longer spill onto the writer when the reader is
busy** (`Db::route_read`). `route_read` now returns
`Result<RouteDecision>`. For `ReaderOperation::SubscriptionHistory`, a
`reader_acquire_timeout` on a saturated reader pool rejects the read
with `sqlx::Error::PoolTimedOut`. Saturated means every connection the
pool may hold exists and none is idle (`Db::reader_saturated`). The
check is needed because sqlx also reports a reader that refuses dials as
`PoolTimedOut`, and that reader is down, not busy. The relay already
answers that with its generic retryable `CLOSED` `error: database
error`, so there is no new client contract. A reader that is wrong
rather than busy (unreachable, validation failure, proof timeout, stale
token) and prechecks that never reach the reader still fall back to the
writer. Authorization reads (`is_relay_member`) keep the writer fallback
unchanged.
2. **Per-pod cap on reader-served history reads**
(`runtime/history_reads.rs`). A FIFO `tokio::sync::Semaphore` admits at
most the reader pool size less one fifth (at least one) history reads at
once. For example, the default pool of 20 admits 16. The remaining
connections are normally free for authorization reads. A read waits at
most 1s for a permit and is otherwise rejected the same way. The permit
is owned by the reader transaction (`ReaderTx`) and released when the
transaction is dropped; routed reads drop it before re-running a failed
replica read on the writer. That holds whether the transaction is a
per-call routed read or a `ReadSession` the relay keeps for follow-up
queries. The cap bounds history transactions, not connections: sqlx
returns a dropped transaction's connection asynchronously through the
reader release check. Released connections can therefore briefly occupy
the free slots, for one round trip normally and at most the 5s release
timeout when sockets stall. In that window, authorization reads fall
back to the writer as before, and history reads on the saturated pool
are rejected, not spilled.
3. **Rejections are observable**:
`buzz_db_route_decision{decision="rejected",reason="history_read_cap"|"reader_acquire_timeout"}`.
4. **Legacy pool families gain `operation`.** The population is
unchanged, and summing over `operation` gives the original series.
5. **HTTP reads answer 503 `database unavailable` when the database
can't be reached** (`api::db_read_error`). This covers a pool timeout,
closed pool or socket failure on `POST /query`, `/count` and every
bridge `/query` branch that serves a history read (channel window, the
three feeds, thread replies), including a shed history read. These used
to return a generic 500. The thread-window path already answered 503 for
pool timeouts; this applies the same mapping to the shared helper.

Pool sizes are unchanged on purpose. A larger pool adds lock contention
on the writer rather than relieving it.

Docs: `deploy/charts/buzz/README.md` (operator contract) and
`PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md` (fail-closed exceptions).

## What got simpler

The concept count is roughly flat. Instead of a caller-side permit, the
permit lives in the one type every replica transaction already flows
through (`RouteDecision::Replica` / `ReadSessionInner::Replica`). The 11
routed call sites change only by a `?`, and the compiler enforces that
change: a call site cannot silently fall through to the writer on a
rejection. No new error variant, CLOSED reason or env var.

## Tests

-
`postgres_tests::saturated_reader_spends_one_acquire_budget_and_rejects_history_reads`
(rewrites the former
`routed_fallback_spends_one_acquire_budget_when_aurora_cache_is_cold`).
A size-1 reader is held saturated with a cold Aurora cache. A history
read (`count_events_routed`) is rejected with `PoolTimedOut` within one
acquire budget, records `rejected/reader_acquire_timeout`, and releases
its permit. An authorization read on the same reader still falls back as
`writer/reader_acquire_timeout`.
- `postgres_tests::match_nothing_reads_take_no_connection` (unchanged):
its control still requires a history read against an unreachable reader
to fall back to the writer. It caught the first cut of this change,
which rejected that read.
-
`postgres_tests::history_permit_follows_the_read_session_and_a_full_gate_rejects`:
a held replica `ReadSession` keeps its permit. With the gate full, the
next history read waits the full permit budget and is then rejected as
`rejected/history_read_cap`, with no writer route recorded. Dropping the
session frees the permit and the next read routes to the replica.
- `history_reads::tests`: a cap sizing table, plus bounded-wait
rejection and reopen under paused time.
- `observability` label tests now require `operation` on both legacy
families.
-
`api::db_read_error_tests::unavailable_database_maps_to_retryable_503`:
pool timeout, closed pool and I/O map to 503 `database unavailable`.
Other errors stay 500.
-
`postgres_tests::shed_history_read_maps_to_503_on_every_bridge_branch`:
with the reader saturated, `/query` channel-window, mentions,
needs_action, activity and thread-reply requests each answer 503
`database unavailable` (each serves 200 beforehand).
- Mutation-checked on Blox: re-enabling the writer spill, forcing the
saturation check either way, skipping the gate, dropping the permit
early and removing the legacy label each fail a test.

## Residual

- During a reader failover whose old sockets stall, both slots sit in
the release check, so the pool reads as saturated. History reads are
shed as retryable for up to the release bound (5s) instead of loading
the writer.
`stalled_reader_sessions_release_their_slots_for_new_connections` now
retries through that window and still requires recovery onto the reader.
- A reader that accepts no TCP at all can fill the pool with in-flight
dials and look saturated. Its history reads are then rejected rather
than moved to the writer, which is the same outcome as a full gate.
- Shedding relies on clients backing off on a retryable `CLOSED` (RPR
#9). WebSocket clients already treat `error: database error` as
retryable.

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

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:12:30 +00:00
Tom Brow
7ac16b2917 perf(mobile): memoize npub labels (#9137)
Opening a big channel on mobile stalls while member labels resolve. On
an iPhone 12 mini (wired Profile build of main 120d75d976), opening a
5,892-member channel had frames of 217, 113 and 101 ms. The channel
identity scope recomputed 13 times during the open as profiles arrived,
25 to 35 ms each: 235 ms of 1,755 ms UI CPU, 190 ms of it bech32
encoding. Every recompute labels each member without a profile name by
npub (`missingOwnerProfiles` → `IdentityNameSources.factFor` →
`shortPubkey` → `fullNpub`), and encoded the same keys again. The same
chain cost 152 ms in a 15 s resume with the channel open.

`fullNpub` now memoizes encodings by hex key, bounded at 8,192 entries.
Once full it keeps what it has, so a roster larger than the limit still
reuses most of its encodings. Labels are unchanged.

### Validation

- Added tests: `fullNpub reuses the encoding of a key it has already
encoded` fails without the memo; `a roster larger than the cache still
reuses its encodings` fails if the cache clears when full.
- Host benchmark, `shortPubkey` over 5,900 random keys: 53.5 ms on the
first pass, about 3.1 ms on later passes.
- Full mobile suite passes except the 10 known notification/push
failures and `push_lease_revocation_outbox_test`, which also fail on
main. Analyze clean.
- Automated simulator run of this branch (debug build, Builderlab
community): searched "studio", opened a channel result and came back;
people results show the same short npub labels as main (for example
`npub194z...aq3k`).
- No post-change device capture. The scope still recomputes as profiles
arrive; this removes the encoding cost from each recompute.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
2026-11-05 13:49:09 -08:00
Tom Brow
1ce831645c mobile: stop re-parsing messages on every resume (#9140)
Resuming the app with a busy channel open stalled for several frames
(149, 136, 109 and 102 ms on an iPhone 12 mini), because every visible
message re-parsed its markdown, repeatedly.

The app widget rebuilds on every lifecycle change, and each rebuild
created new light and dark `ThemeData`. A new `ThemeData` never equals
the previous one, so `MaterialApp` animated between two identical themes
for its full theme transition. Each frame of that animation produced a
new markdown theme, and every mounted message re-parsed its markdown in
response (`_MdWidgetState.didChangeDependencies` and `didUpdateWidget`).
In a 15 s resume on a large channel that re-parse took 373 ms of
main-isolate time.

The themes are now built only when the community accent changes, so a
lifecycle rebuild keeps the same theme objects and starts no transition.

### Validation

- Added test: `an app rebuild keeps its themes and markdown theme`
rebuilds the app and checks that the theme and the markdown theme stay
identical. It fails without the change.
- Full mobile suite: only the known failures that also fail on main
(notification toggle, push bootstrap, push bridge, push lease revocation
outbox).
- Automated simulator run (iPhone 17 Pro, debug build): opened a channel
with markdown messages, backgrounded and resumed the app several times,
and switched between light and dark appearance while the app was open.
Messages render, resume works, and the app follows appearance changes
live. A channel's long-press action sheet keeps its light background
after a switch to dark mode on both this build and main, so that is
unrelated to this change.
- Profile evidence (iPhone 12 mini, Profile build, before the change):
the stalls and re-parse samples above came from a resume with a large
channel open.

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

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
2026-11-05 13:48:23 -08:00
Tom Brow
1b9b307176 fix(mobile): renew expired assertions for native media and drop retired-session avatars (#9134)
Follow-up to [#9069 media
auth](https://github.com/block/buzz/pull/9069), which merged at its
approved head. This PR carries the fixes for the last review round on
that PR.

### Changes

- **Native media reads renew an expired assertion** (Carl's [recommended
blocker](https://github.com/block/buzz/pull/9069#discussion_r4485183400)).
Voice notes and posterless video previews hand headers to native code.
They now use `freshNativeMediaHeadersProvider`, which awaits
`HostedRelayAuth.headersForUrl` and so asks for a new assertion once the
cached one has expired. Before, while the socket stayed up they sent
none and the relay refused the read. If the renewal fails, the read
falls back to cached headers and the relay decides. The voice-note
player's `headers` callback is now `FutureOr`. Both the iOS
authenticated download and the Android native load await it, then
recheck their generation, so a cancel during a slow renewal never starts
the stale load.
- **Notification avatars drop a retired organization session** ([review
thread](https://github.com/block/buzz/pull/9069#discussion_r4485125609)).
When an avatar download finishes after the organization session that
issued its assertion was replaced, BuzzPushKit removes the cached
response and refuses the avatar.
- **Test:** cached reads never present a signed-out or replaced
account's assertion ([optional
note](https://github.com/block/buzz/pull/9069#discussion_r4485183412)).

Not changed: native reaction-detail avatars still use the cached
assertion. After it expires they fall back to initials.

### Validation

- Each new test was checked to fail with the code it guards reverted:
- `replacedOrganizationSessionRefusesAvatar` (BuzzPushKit): a session
replaced mid-download must be refused.
- `fresh native media headers renew an expired assertion`: covers
renewal and the fallback when renewal fails.
- `remote voice notes renew an expired relay assertion`: a journey
through `VoiceNoteAttachment`; it fails when the attachment is switched
back to the synchronous provider.
- `Android defers remote auth until playback starts`: now cancels during
a slow renewal and expects no native load.
- `an issued assertion serves cached reads for the relay`: now signs out
and restores a replacement account. It fails without the account check.
- `swift test` (69 tests) passes. The full mobile suite (`just
mobile-install mobile-check mobile-test`) passes with these commits on
main as of [#9069 media auth](https://github.com/block/buzz/pull/9069).
- The automated full-app simulator run from [#9069 media
auth](https://github.com/block/buzz/pull/9069) covers the surrounding
flows. This change is limited to the header source for voice notes and
video previews, and none of the test communities enforce the hosted
assertion, so a simulator run would not show the difference. No
end-to-end run against a relay that enforces the assertion.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:44:54 -08:00
Luke Tornquist
74abe2f0fe perf(db): split newest-first events reads at a month floor so Postgres prunes partitions (#9091)
## Summary

Newest-first `events` page reads are now split at the start of the
previous UTC month, so Postgres can prune the older partitions whenever
the newest ones fill the page. Results are unchanged.

**Why.** `events` is range-partitioned by month, and every partition
carries the full index set. A page read with no `created_at` lower bound
can't prune, so Postgres plans and locks every partition and every
partition index: about 200 relations per read with 19 partitions. A
backend has 16 fast-path lock slots, and every lock past those goes
through the shared lock table. On a production-shaped load rig at pool
200, active backends spent 36% of their time waiting on
`LWLock:LockManager` and 1.6% on disk.

**What changes.** `query_events_on` (REQ history, bridge `/query`,
mentions, rosters, notices) and `get_channel_window_on` (channel
windows) now read in up to two passes on the same connection, through
one helper (`store/recent_pass.rs`):

1. **Recent pass:** the same read plus `created_at >= <floor>`, where
the floor is the start of the previous UTC month. When it returns a full
page, that page is the answer, because every row at or above the floor
sorts before every row below it.
2. **Older pass,** only when the recent pass comes up short: `created_at
< <floor>`, limited to the rows still missing, appended after the recent
rows. The ranges don't overlap, so no row is read twice and the page is
exact. For channel windows the limit includes the +1 probe row, so
`has_more` stays exact.

**One snapshot, one deadline.** The two passes run in one `REPEATABLE
READ, READ ONLY` transaction (`ReadTransaction`), so both read the same
snapshot. Under `READ COMMITTED`, a replacement committing between the
passes (a replaceable event, an artifact head) could move the live row
from below the floor to above it, and it would land in neither pass.
Inside a transaction the passes run in a savepoint instead; the only
callers that read inside a transaction are routed replica reads, which
are already `REPEATABLE READ`. Each pass after the first runs under
whatever is left of the 20 s read deadline, so a split read never gets
more time than an unsplit one. Single-statement reads (COUNT, unsplit
pages) keep their old path, now through the same type. Channel windows
now take the read deadline too. On a routed reader that is the reader's
session deadline. On the writer they used to run under the writer's own
session timeout (2 minutes), and now get the 20 s read deadline, like
every other event read there.

The floor is a SQL literal, not a bind parameter, so Postgres prunes
when it plans the statement, cached generic plans included. It changes
once a month, so the statement cache holds one statement per pass and
shape. `#p` reads put the same bound on
`event_mentions.event_created_at`.

These reads are not split:
- Reads whose own bounds already prune (`since` at or after the floor)
or fall entirely before the floor (`until` before it).
- `OFFSET` reads, since the offset spans both sides of the floor.
- Lookups that match a fixed set of rows wherever they sit: `ids`, `#e`,
`#d`, artifact `d` tags.
- Fetch-everything reads (`max_limit`, the COUNT fallback).
- Reads limited to replaceable or addressable kinds. Those are
latest-state rows, often old and few, so a recent pass would rarely fill
the page.

No protocol, schema, index or client change.

**Not covered:** COUNT and the per-row aux `#e` reads, neither of which
has a page to fill. The recent pass also has no upper bound, because
rows dated in the future must stay visible. So partitions ahead of the
current month (the manager keeps 6) and the catch-all are still planned.

## Evidence

A production-shaped load rig (6,600 desktop clients connecting cold at
100/s, against a 19-partition `events` table), comparing `main` with
this branch. Each row is one arm, so expect run-to-run noise of about
±10%.

| Arm | Postgres exec time (top statements) | Errors | Relation locks
outside fast path (avg) |
|---|---|---|---|
| Pool 200 + channel-first index, `main` | 13,217 s | 4.75% | 9,481 |
| Pool 200 + channel-first index, this PR | 10,931 s | 3.68% | 4,988 |
| Default pool, no extra index, `main` | 6,705 s | 5.96% | — |
| Default pool, no extra index, this PR | 6,145 s | 5.15% | — |
| Default pool + index, `main` (repeat) | 1,070 s | 0.99% | — |
| Default pool + index, this PR (repeat) | 1,013 s | 0.93% | — |

Pages that fill above the floor stop planning old partitions, and that
halves the locks taken outside the fast path. `LWLock:LockManager` stays
at about 41% of active backend samples at pool 200, though. The dominant
remaining cost there is the cold unread repair: a multi-channel read
with no `since` and a page that never fills. That read needs its own fix
(a lookback bound or a better plan) and is out of scope here.

An earlier version re-ran the whole read after a short recent pass. That
doubled the work on non-filling reads: errors went from 4.75% to 11.88%
at pool 200. This version reads only the missing older rows.

## Tests

- `recent_pass::tests`: floor arithmetic (including the year wrap), the
predicate literals, an eligibility table, and an in-memory exactness
test that drives the passes at every limit and pins which passes run
with which limits.
-
`e_tag_filter_shape_tests::split_passes_bound_created_at_with_a_literal`
/ `recent_pass_skips_lookups`: SQL shape (both the `e.` and `m.` sides
for `#p`, no bound on unsplit reads or COUNT) and the exclusions,
including offset.
- `postgres_tests::split_page_reads_match_the_unsplit_read` (buzz-db
event): rows on both sides of the floor. A full recent page is answered
alone; short pages at limits 4, 5 and 6 add exactly the missing older
rows; offset reads across the floor are not split; a cursor below the
floor works. It reads `pg_prepared_statements` to show which passes ran.
With another session locking a partition only the older pass plans (and
a short `lock_timeout`), a full recent page still answers and a short
one returns `Err` rather than a partial page.
- `postgres_tests::channel_window_split_matches_the_unsplit_read`
(buzz-db thread): the same checks for channel windows, including
`has_more` when the recent pass has no probe row, and an `Err` when the
older pass fails.
- `split_read_passes_share_one_snapshot` (event, both deadline modes)
and `channel_window_passes_share_one_snapshot` (thread): another session
holds a lock that only the older pass needs, so the read stops between
its passes. A replacement then commits that deletes a row below the
floor and makes its replacement live above it. The page must still be
the first pass's snapshot: the replaced row is present and its
replacement is absent.
- `read_statements_share_one_deadline`: per-read, session and
inside-a-transaction reads. The second statement runs under less than
the full deadline, and finishing the read restores the connection's own
timeout.
- `channel_window_passes_share_one_deadline` (thread): through the
production writer entry. The recent pass waits 8 s on a lock and the
older pass is blocked for good. The window must be cancelled within the
one shared deadline, not a full deadline after its older pass starts.
- `channel_scoped_mention_reads_share_one_prepared_statement` now
expects one recent and one older statement, shared by every channel
count.

Mutation-checked. Each of these mutants fails a new Postgres test:
dropping the older pass, giving the older pass the full limit, removing
the split, splitting offset reads, dropping the window's probe row from
the split limit, removing the window's split, swallowing an older-pass
error in either path, opening the split read `READ COMMITTED` (events
and windows), opening it with a plain `BEGIN`, giving every pass the
full deadline, and running a window with no read deadline.

## What got simpler

Nothing got simpler overall. This adds one small module
(`store/recent_pass.rs`) shared by both read paths. The single-statement
deadline helper is now a thin wrapper over `ReadTransaction`, so the
begin, savepoint and tighten logic lives in one place. The alternative,
a `since` in every client plus a new index per read shape, means more
code in more places and does nothing for app versions already shipped.

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

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:03:15 -05:00
Luke Tornquist
9b4b73eb08 fix(relay): bound mesh dials and keep failing pods out of the mesh (#8752)
## Problem

A rolling deploy can stall relay startup for 30s per dead mesh peer.

- A pod publishes its mesh ready record during `mesh_boot` and clears it
only on graceful shutdown. If a later fatal startup check kills the pod,
the record lingers until it expires.
- Dialing that address never gets an answer, so `MeshEndpoint::connect`
waits out QUIC's default 30s idle timeout. `reconcile_once` dials
candidates one at a time, so every dead peer adds another 30s, both in
the boot-time `mesh_initial_reconcile` step and in every later reconcile
pass.
- The fatal check that killed those pods was the git conformance probe.
A transient S3 `500 InternalError` on one racer was treated as a
conformance failure.

## Changes

1. **Bounded, concurrent mesh dials**
(`buzz-relay-mesh/src/runtime.rs`). Each connect attempt is wrapped in
`DIAL_TIMEOUT` (2s; in-cluster handshakes take milliseconds), and a
reconcile pass dials its candidates concurrently. A pass with dead peers
now costs about one dial timeout instead of 30s per peer. The candidate
set is bounded by the membership table.
2. **Conformance probe before mesh boot** (`buzz-relay/src/main.rs`).
This only moves an existing block, so a pod that fails the probe exits
before it publishes a ready record peers would dial.
3. **5xx racers are unknown outcomes**
(`buzz-relay/src/api/git/store.rs`). The race phases already drop
socket/send failures from the observer set as "outcome unknown". A 5xx
is the backend declining to classify the write, so it now joins them
through one shared `racer_outcome_unknown` helper. 4xx answers,
including 412, still count as evidence. A backend that 5xxs every racer
still fails closed on the two-observer floor. Because a dropped racer's
write may still have landed, every `if_match_race` round now reads the
pointer back and requires the winner's exact body and ETag. Otherwise a
backend that commits a stale write and then answers 5xx would pass in
the last round. §Conformance steps 2–3 in
`docs/git-on-object-storage.md` now state this drop-and-floor rule.

## What got simpler

The two duplicated drop-classification match arms in the probe now share
one helper. The probe/mesh fix is a reorder of existing startup steps,
not a new mechanism or rollback path.

## Tests

- `runtime::tests::reconcile_pass_bounds_dials_to_unresponsive_peers`:
three black-hole peers (bound UDP sockets that never read) plus one live
peer. The pass must finish within `2 × DIAL_TIMEOUT` (a serial pass
would take at least `3 × DIAL_TIMEOUT`, and without the timeout about
30s), the live peer must connect, and each dead peer must end
`Disconnected`.
- `store::tests::probe_drops_5xx_racers_but_keeps_4xx_as_evidence`: pins
the classification boundary (5xx and I/O are unknown; 400/403/412/499
are evidence).
- `store::fault_probe::*`: runs `run_conformance_probe` against an
in-process S3 fake that injects racer faults at the HTTP boundary, so
responses pass through rust-s3's `fail-on-err` mapping and retry. One
5xx racer in either race phase is dropped and admitted (`transport_drops
== 1`). Too few classified observers fail closed: one observer in either
phase, and all-5xx in `if_none_match_race`. An unexpected 4xx racer
fails closed in both phases. A racer whose stale write lands and is then
answered 5xx fails closed on the post-round readback
(`dropped_racer_whose_stale_write_lands_fails_closed`). A clean fake
passes with zero drops.
- CI lane: no `api::git::store` test ran in any CI lane before,
including the existing ones. The `just test-unit` nextest filter and the
`scripts/run-tests.sh` fallback now select `api::git::store::tests` and
`::fault_probe`. The live-MinIO `::probe` module stays opt-in.

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

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:56:07 -05:00
Tom Brow
97ede701c0 fix(mobile): send hosted relay assertion from native media and tombstone paths (#9069)
Follow-up to https://github.com/block/buzz/pull/9056, from an audit for
other places that miss the hosted relay assertion. Hosted and
organization relays require `Nostr-Federated-Identity` on every
non-exempt HTTP request and WebSocket upgrade. The Dart
`HostedRelayHttpClient` adds it, but anything that downloads or connects
outside that client has to add it itself. The notification service
extension and the iOS emoji picker both missed it, and these places do
too:

- **Voice notes** (`just_audio`, mainly Android) and **video preview
first frames** fetch media natively with only the Blossom
`Authorization` header. Both now use `nativeMediaHeadersProvider` from
https://github.com/block/buzz/pull/9056.
- **Push-lease tombstones**: removing a community, and the durable
revocation outbox, open a one-off relay socket with no headers. A hosted
relay refuses the upgrade, so the lease is never revoked and the outbox
keeps retrying. Both now ask `HostedRelayAuth` for that community's own
assertion, because the community being removed may not be the active
session.
- **Notification avatars** (iOS notification service extension):
relay-hosted sender avatars were fetched with Blossom auth only. The
loader now takes the same `BuzzRelayAssertionIssuer` the resolver uses
for `/query`. Signed requests already refuse redirects.

The iOS Me-chat agent picker also fetched avatars with no headers, but
https://github.com/block/buzz/pull/9079 removed that native screen, so
this PR no longer touches it.

### Validation

- Added tests, each checked to fail when the code it guards is reverted:
- A queued push-lease revocation and a community-removal tombstone both
send `Nostr-Federated-Identity` on the WebSocket upgrade, as background
requests. Runs against a local relay socket that handles NIP-42 auth.
- A notification avatar on an organization relay is downloaded with the
issued assertion (BuzzPushKit).
- Issuing for a second community keeps the first one's cached assertion.
A background request records no failure, never backs up the key, and its
failure never reaches the active community's reconnect waiting on it.
- Background issuance never binds an unbound account to its key,
bypasses the shared assertion cache (each attempt gets a fresh
assertion, and foreground use never inherits one that skipped the key
backup), and its failure is never handed to a foreground caller waiting
on it. When it can't get an assertion it sends none, so a non-enforcing
relay still accepts the tombstone.
- A notification avatar whose organization session is replaced
mid-download is refused and dropped from the cache (BuzzPushKit).
- Voice notes and posterless video previews renew an expired assertion
before the native fetch, and fall back to cached headers if renewal
fails. A voice-note attachment journey binds the production provider.
- Cached reads never present a signed-out or replaced account's
assertion.
- `flutter analyze` is clean. `swift test` passes for the BuzzPushKit
avatar loader tests, and `flutter build ios --simulator --debug`
succeeds.
- Full mobile suite (`just mobile-check` and `just mobile-test`) passes
at 2c84940832.
- Automated full-app run on an iOS simulator (debug build of this PR
before it was rebuilt on main, signed in to the internal Builderlab
community with two more communities saved). Passed: launch and channel
list, image attachments and avatars in a channel, New chat sheet kept
open for about 80 seconds before the agent list was opened (all agent
avatars loaded, so headers are requested per download), dismiss and
reopen, background and resume, switching to a second community (its icon
and avatars loaded), and removing a community. The native New chat sheet
is unreachable in the normal flow on this branch (a new chat opens the
Flutter draft page; the native sheet only opens when resuming an
unfinished chat), so that check ran with a temporary local bypass that
was reverted. Limits: none of these communities requires the hosted
relay assertion in this build, so the run shows the Swift-to-Dart header
bridge, the per-relay cache and the tombstone path cause no regression,
not that the assertion is sent or accepted. No voice note, video preview
or deleted message was reachable. Screenshots are omitted because they
show internal channel content.
- Limitations: no end-to-end run against a hosted relay. Simulator
sign-in to a hosted relay needs SSO, so a hosted-relay run needs a
device build.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
2026-11-05 12:32:18 -08:00
Matt Dickenson
120d75d976 Expose strict code-entry pairing and live authorization bindings (#9061)
Native clients using Rust pairing can now receive the existing
`desktop-code-v1` flow: accept its challenge, submit a user-entered
six-digit code, handle correlated rejections, and require successful
code submission before accepting the source proof. A failed durable
import can explicitly acknowledge failure.

Adds a public, live-session channel binding (session ID, ordered
ephemeral peers and transcript commitment) so applications can authorize
the exact exchange using each device's independently authenticated
account session. The binding does not itself authorize secret export.
Legacy APIs and the wire format remain compatible.

Validation: 79 pairing unit tests and seven public API journeys pass,
including wrong-code retries, five-attempt lockout, replay/peer
rejection, failed import, and identical live bindings on both devices.
Focused `cargo clippy -p buzz-core --all-targets -- -D warnings` and
formatting pass. Repository formatting/mobile checks and desktop, web,
and Flutter build/test lanes pass after installing the pinned Flutter
dependencies. Three initial normal push-hook attempts stopped at
different unrelated ACP deadlines. The final normal push passed all
applicable hooks, including desktop Tauri Clippy/tests and the full Rust
unit lane, with existing hook lanes serialized and four Rust test
threads. No checks were disabled and no ACP source was changed.
Independent review found no blocker in this API delta. App and human
acceptance remain outstanding; this PR is a draft.

---------

Signed-off-by: Matt Dickenson <mdickenson@squareup.com>
2026-11-05 20:18:14 +00:00
Tom Brow
6793cbd25b fix(mobile): focus the search field when search opens (#9120)
Opening search from Home now focuses the search field, so the keyboard
comes up right away and you can start typing without tapping the field
first. This applies when `SearchPage` opens as its own route; the
embedded variant is unchanged. Closing with X and reopening works as
before (the previous query is kept and the field is focused again).

### Validation

- Extended the existing open-and-close journey in
`search_page_test.dart`: it now opens search without tapping the field
and asserts the field has focus and the keyboard is shown. The step
fails with the production change reverted.
- Full mobile test suite and checks passed in the pre-push hook.
- Automated run on an iOS 27 simulator (iPhone 17 Pro) signed in to a
real community: opened search from Home (keyboard up, caret in the
field), typed a query and got channel and message results, closed with
X, reopened (keyboard up again). The recording contains internal
community content, so it was shared internally rather than here.
- Human testing waived under the author's standing exception; the
automated simulator run above covers it.

buzz-review-completed

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

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:49:48 -08:00
Tom Brow
9aa70124ba perf(mobile): validate each verified profile once, not on every publish (#9101)
Opening or resuming a big channel spent about 210 ms of UI-thread CPU
validating verified profiles, measured in Dart CPU samples on an iPhone
12 mini (Profile build) across two separate channel-resume captures (213
ms and 211 ms in `VerifiedProfilesNotifier._snapshot`, nearly all of it
in `parseVerifiedProfile`).

Every publish rebuilt the profile map by validating every known head
again, and a channel with many members publishes once per subscription
batch as each lands, so the work grew with members times batches. Each
subject's parse is now kept while its head and the authority stand. Only
`checked` changes between publishes (rebatching unchecks and rechecks
subjects), so it is applied to the kept parse without validating again.

### Validation

- Added `a later batch reuses profiles already parsed`: after a second
subject rebatches the first, each head is validated exactly once. It
fails when the kept parse is ignored.
- `flutter analyze` is clean and the full mobile suite passes.
- Same phone and scenario after this change: `parseVerifiedProfile` no
longer appears in the resume CPU samples, and main-isolate message
handling over the 15 s after resume dropped from 604 ms to 491 ms (one
run each). Frame build and layout time on resume is unchanged; that is
separate work.
- Automated app run on the same phone with this change: Home, search,
the large channel and its resume after the reconnect grace period.

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

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
2026-11-05 11:31:38 -08:00
John Matthew Tennant
c848b6dcad fix(mobile): offer a new identity beside saved keys on unclaimed accounts (#9107)
## Summary

This fixes a dead end in mobile Builderlab sign-in. A new account
(unbound, with no backups) on a device that already had saved identities
could only pick one of those identities. Any saved key forced the
picker, and creating a key required the device to have none. If the
service then definitively rejected binding the chosen key, the app
showed "Let's try that again" with no way forward: the chosen key stayed
in the account's slot, so every retry and every launch re-sent the same
rejected bind.

For an unclaimed account, the picker now also offers **Create a new
identity**:

- Creation writes only this account's empty identity slot, so every
saved and device key is kept.
- Creation is offered only when every current device key was among the
choices shown, so a new identity never silently passes over a key the
user didn't see.
- Bound accounts, accounts with backups, pairing and pending import
never offer it.

When binding a chosen key is definitively rejected, the blocked screen
offers **Choose a different identity**:

- It appears only for a 400, 404, 409 or 422 from `/verify` in the
current attempt, for the current account and pubkey, and never during
pairing, whose transferred key is the only choice. Evidence from an
earlier attempt is cleared before each bind. A challenge rejection, a
403, a 503 or any other ambiguous failure offers nothing.
- Before releasing the slot, it rechecks the account: still unbound
(`/current` and status), no backups, the same subject, and a slot that
still holds that pubkey without a receipt.
- It releases the slot only when another durable copy of the same key
exists (the device inventory or another account slot), then verifies the
slot is empty. No key is deleted: only the account's pointer to the
rejected choice is cleared. A failed or refused release keeps the slot
and the choice, so it can be retried in place or after a restart.
- After release, the picker runs again and offers the saved keys plus
creation.

The recovery code lives in `hosted_flow_identity_recovery.dart`, a part
of `hosted_flow.dart`, to stay within the mobile file-size limit.

### Related issue

None found.

### Testing

**Physical iPhone, a signed release build of `1fb633d55` installed over
the existing app (keychain and saved identities kept):** a saved
identity was rejected, then Choose a different identity, the picker,
Create a new identity and Join your community. Joining then completed.
This head differs from `1fb633d55` only in the guards for leaving,
pairing and a failed or refused release listed below, which this journey
does not reach, and in no UI code. The status of that phone's original
rejection was not captured, so it is not confirmed to be one of the
rejections above. The 409 case is covered by tests.

**Tests (red on `main`, green here):**

- Identity service and store: creation beside offered keys; refusal for
unseen or partly offered device keys; release when another copy exists;
refusal for a sole copy, a binding, a backup, a receipt, another account
or an unusable recheck; failed delete and retry; a cancelled attempt.
- Onboarding journeys: create beside saved keys; select, rejected bind,
restart, choose a different identity, create and bind; ambiguous 503 and
403 failures; challenge rejection and stale evidence.
- Release boundaries: an account switch at `/current`, at status, or
between the recheck and the delete; cancelling between the recheck and
the delete; leaving the picker before a late creation; an unreadable
device inventory; leaving while a release is in flight; a failed delete
retried in place and after a restart; a release refused because the
inventory is unreadable, then retried in place; a rejected paired key.
Each keeps every key, leaves the new account's slot untouched and leaves
a cancelled flow idle, and a retry converges.
- Mutating the creation guard, the generation check, the binding
recheck, the store's receipt check, the account and cancellation checks
before the delete, or the device-inventory error handling fails a test.

#### Screenshots

Physical iPhone at `1fb633d55` (frames from a screen recording; saved
public keys redacted):

| Rejected bind | After Choose a different identity | After Create a new
identity |
| --- | --- | --- |
| ![Phone: rejected bind offers Choose a different
identity](https://github.com/user-attachments/assets/cfd81998-d860-4b2e-9963-e68ec322ef9b)
| ![Phone: picker with saved identities and Create a new
identity](https://github.com/user-attachments/assets/85235963-746c-489c-8134-a1c20f6c7cd1)
| ![Phone: Join your community after creating a new
identity](https://github.com/user-attachments/assets/bc7505c4-147d-4b03-8073-db4be0bd88f8)
|

Widget-test renders of the same screens (the real `HostedFlow` against a
mocked account service, with a fixture key; the square at the top left
is the test renderer's placeholder for an asset):

| Picker on an unclaimed account | Rejected bind |
| --- | --- |
| ![Widget render: picker with Create a new
identity](https://github.com/user-attachments/assets/3616eba2-f9dc-4e36-b6de-21cbad5fc8b6)
| ![Widget render: rejected bind with Choose a different
identity](https://github.com/user-attachments/assets/30bc6ef2-d63e-4f4b-8193-153ce5a3cafc)
|

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

---------

Signed-off-by: John Tennant <jtennant@squareup.com>
Co-authored-by: Xander <ff30c859b96bd5233ffaf5d071200458f1274bd0dab03589214cf1055198491d@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:23:59 -05:00
Brad Seiler
8a113ed99e fix(desktop): show upgrade guidance (BUZZ-517) (#9033)
## Summary

Old Buzz desktop clients now check a community’s public requirement
before each connection. An explicit `requires_nip_fi: true` shows an
upgrade screen with a fixed https://buzz.xyz download action and manual
fallback. Missing or false requirements, and failed discovery, preserve
normal connection behavior.

Retry, community navigation and address editing preserve keys and saved
communities. Changing an address cancels the old onboarding transaction
and fences pending profile checks. Native discovery sends no
credentials, follows no redirects, and limits requests to five seconds
and 256 KiB. Wire fixtures are exported from main `6b8a72e835` with
reproducible provenance.

### Related issue


[BUZZ-517](https://linear.app/squareup/issue/BUZZ-517/v0-clients-show-an-upgrade-screen-for-communities-requiring-nip-fi).
Related: [#8984](https://github.com/block/buzz/pull/8984) contains
mobile parser preparation;
[#8920](https://github.com/block/buzz/pull/8920) repairs relay rejection
messages.

### Rollout

There is no client flag. The upgrade screen appears only when the relay
advertises `requires_nip_fi: true`. Public auto-update delivery requires
a desktop release containing this change and promotion to the [OSS
auto-update
feed](aa749a862e/RELEASING.md (promote-an-oss-desktop-release-to-auto-update)).

In Shadow mode, requiring a community activates the screen; making it
optional restores normal connections. Global Enforce requires every
community regardless of that setting. Rollback must restore an effective
non-required policy. The optional and required outcomes are demonstrated
by Brad’s two manual cases below.

### Testing

At `71689b4708`, Honor’s isolated browser journeys passed 3/3 with zero
retries and verified served assets, including the fixed
`https://buzz.xyz` opener and manual fallback. McKeon independently
built and passed the same three journeys at the previous head
`8caa25260b` with the repository command: `CI=1 pnpm --dir desktop
build:e2e`, then `CI=1 pnpm --dir desktop exec playwright test
relay-entry-guidance.spec.ts --project=smoke`. Native IPC was mocked.

Brad [tested the running pre-rebase app
locally](https://github.com/block/buzz/pull/9033#issuecomment-6662960875):
an optional bb-public community in Shadow connected without a message;
required bb-block staging showed the upgrade message. These results
apply to that running app, not the rebased head.

Before:

![Earlier upgrade card with synthetic community
data](https://github.com/user-attachments/assets/63fbaa66-d551-4c20-ab04-c7fd966638e0)

After:

![Public upgrade card with Download latest Buzz action at
buzz.xyz](https://github.com/user-attachments/assets/f26a5d2a-47b4-4510-a86f-667d6f7c6d3d)

Generated with Codex

---------

Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
2026-11-05 19:22:01 +00:00
Tom Brow
dce37f74d7 fix(mobile): skip the read-state snapshot when nothing needs publishing (#9093)
Every mobile resume re-syncs read state and then schedules a read-state
publish, whether or not anything changed. Each publish starts by
downloading the full read-state snapshot (about 1 MB compressed on a
heavy account) and only then finds nothing to send. Together with the
re-sync's own snapshot, that is two full downloads per resume, and a
third when a publish from before backgrounding retries.

The publish now checks first, using the same packing it would publish,
and returns before the snapshot when nothing is pending and no slot
differs from what was last published. When something did change it
behaves exactly as before, snapshot included.

Today the snapshot fails before downloading on gzip-serving relays, so
this only pays off once [#9089 read-state gzip
fix](https://github.com/block/buzz/pull/9089) lands; without this PR,
[#9089 read-state gzip fix](https://github.com/block/buzz/pull/9089)
roughly doubles resume download on heavy accounts. It has no merge-order
dependency.

### Validation

- Added test: `a publish with nothing new skips the snapshot`. It
publishes a change (one snapshot), publishes again with nothing new (no
snapshot, no event), then publishes another change (snapshot and an
event carrying both marks). It fails without the early return.
- Existing read-state suite passes.
- iPhone 12 mini, Profile build, Home backgrounded 12 s and resumed, all
socket bytes received in the 15 s after resume:

| Build | Download per resume | Snapshot fetches |
| --- | --- | --- |
| main (snapshot broken) | 3.07 MB | 0 completed |
| [#9089 read-state gzip fix](https://github.com/block/buzz/pull/9089) |
6.85 MB | 3 |
| [#9089 read-state gzip fix](https://github.com/block/buzz/pull/9089) +
[#9090 no read-state replay](https://github.com/block/buzz/pull/9090) |
6.08 MB | 3 |
| [#9089 read-state gzip fix](https://github.com/block/buzz/pull/9089) +
[#9090 no read-state replay](https://github.com/block/buzz/pull/9090) +
this | 2.71 MB | 1 |

These are single runs on one account with 28 read-state slots, so treat
the numbers as indicative.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
2026-11-05 11:21:33 -08:00
Tom Brow
cf8fbbba55 fix(mobile): keep verified names on screen in channels with over 1,024 members (#9111)
In a channel with more members than the verified-name interest limit
(1,024), returning to the app made member names flip: a verified name
changed to a profile name with a collision suffix (for example "Ankit ·
ykn9") or to an npub, then back once a refetch landed. Seen on an iPhone
12 mini in a 2,004-member channel; instrumented logging showed the
verified-name map dropping from 956 entries to 0 on opening that
channel.

Tracking a large roster evicts the oldest-tracked subjects, and eviction
dropped their records too. On resume, member and profile preloads track
the evicted people again, which evicts the ones on screen, so their
verified names disappear until the next fetch. Evicted records now stay
as unchecked cache (up to 4,096, oldest untracked dropped first) and are
rechecked when tracked again. Only tracked subjects accept new records,
so the cache never grows from relay traffic.

### Validation

- Added `a subject evicted by a large roster keeps its last known name`:
it fails when eviction drops the record.
- `flutter analyze` is clean. The full mobile suite passes except 10
push and notification-toggle tests that fail identically on unchanged
main on this machine.
- Same phone, 2,004-member channel, after this change: the verified-name
map held steady (978 entries, growing to 1,828) through opening the
channel and two resumes after 12 s and 20 s in the background, and the
"ankit, lhoward" and "sfoo, Dani" rows never changed in the screen
recording. Before, both flipped (to "Ankit · ykn9" and an npub) and back
after each resume.
- Automated app run on the same phone with this change: Home, search,
the large channel, and its resume after the reconnect grace period.

This touches the same `_snapshot` line as
[#9101](https://github.com/block/buzz/pull/9101), so whichever lands
second needs a one-line rebase.

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

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
2026-11-05 11:15:41 -08:00
Tom Brow
244038b495 mobile: reuse channel message rows that did not change (#9115)
Opening the keyboard in a busy channel janked: the composer's bottom
inset changes on every keyboard animation frame, and each change rebuilt
the message list, which rebuilt every visible message row. On an iPhone
12 mini that came to 8 frames over 16 ms per keyboard open, with 290 to
360 ms spent in build. The same cascade runs on every page rebuild, such
as the dozen or so while a large channel settles after returning to the
app.

Flutter skips a rebuild only when it gets the identical widget object
back, so the message list now keeps each row it built and returns it
while nothing the row is built from has changed. Shared inputs (the
message list, membership, channel names, send animations, the focus
callback, today's date and so on) clear the cache when they change. Each
row also compares its neighbors, thread summary, typing and working
state, and send-animation and handoff flags. Rows still rebuild through
their own provider watches. The composer's focus callback is now stable
so the page's rebuilds don't clear the cache.

### Validation

- Added `a growing composer reuses the message rows on screen`: it types
a four-line message and checks that the visible rows are the same widget
objects. It fails without the cache.
- `flutter analyze` is clean. The full mobile suite passes except the 10
push and notification-toggle tests that fail identically on unchanged
main on this machine.
- iPhone 12 mini, Profile build, a 2,000-member channel, one matched run
each before and after (the after column is an earlier revision of this
change with the same mechanism):

| | Before | After |
| --- | --- | --- |
| Keyboard open, build time | 359 ms, 290 ms | 64 ms, 43 ms |
| Keyboard open, frames over 16 ms | 8, 4 | 1, 0 |
| Return to the app, build time | 953 ms | 881 ms |
| Return to the app, frames over 16 ms | 21 | 18 |

Most of what remains on return comes from rows' own watches (for example
the agent display-name map is a new object on every agent directory
reload), which this change doesn't address.
- Automated app run on the same phone with this exact branch: Home,
opening the large channel, opening the keyboard twice (58 and 69 ms of
build, one frame over 16 ms each), scrolling back through several days
of history, and returning to the app after 12 s in the background. Day
dividers, reply counts, reactions and system rows rendered correctly
throughout. Typing is covered by the widget test rather than the device
run, to avoid drafting in a real channel.

- Simulator QA of this branch (Debug build, a private test channel):
typing a four-line message, sending, scrolling to the top and back, a
reaction, an edit, a thread reply, a delete, sending again, and
backgrounding and returning. Every row rendered and updated correctly,
with no replayed send animations. The jump-to-latest button didn't
respond to synthetic simulator taps before or after resume; it's a
native iOS view this change doesn't touch.


https://github.com/user-attachments/assets/04909261-81fe-4514-98aa-8d7fdfdcace5

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
2026-11-05 11:15:32 -08:00
Tom Brow
3ff4154ca6 fix(mobile): back off push bootstrap retries (#9092)
When push setup fails, the app retried every 5 seconds forever. On an
iPhone 12 mini whose push gateway enrollment is refused (a CDN 403 on
the installation challenge), each retry refetched the relay's NIP-11
document over a new TLS connection and logged the whole HTML refusal.
That is about 720 attempts and roughly 14 MB of traffic an hour for as
long as the app stays open, with no way to succeed.

Push registration, lease publication and tombstone retries now back off:
each consecutive failure of the same attempt doubles the delay from 5
seconds up to 15 minutes, with jitter between half and all of it so
devices that failed together don't retry together. A success (including
a renewal), a different attempt, or a channel-grammar retry (which keeps
its 5-minute delay) ends the streak. The first retry still comes within
5 seconds, so a brief blip recovers as quickly as before.

### Validation

- Added tests: `repeated failures back off exponentially up to a cap`
checks every delay's bounds at both jitter extremes over 12 failures, `a
success ends the failure streak`, and `a different attempt starts its
own backoff`. The first fails when the gate goes back to a fixed delay.
- Existing push suite passes.
- On the 12 mini before this change: `Push lease bootstrap failed` with
the 403 page and a fresh NIP-11 fetch every 5 seconds for the whole
session.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
2026-11-05 11:15:22 -08:00
Tom Brow
e34acb0ac5 fix(mobile): keep reply counts on screen while a reconnect catches up (#9100)
Every time the app comes back from the background, the "N replies" links
under messages in an open channel vanish and reappear a moment later. On
an iPhone 12 mini in a busy channel this happened on nearly every
resume, since the session drops its socket five seconds into the
background and the channel reconnects.

A reconnect discards the channel window and refetches its newest page.
Until that page lands, thread summaries came only from replies already
loaded, which in a windowed channel is usually none. The discarded
window's summaries now stand, below every fresher source (live
summaries, thread queries, overflow floors), until the replacement
history lands, which then decides, including dropping a summary the
relay no longer reports.

### Validation

- Added `reply counts stay on screen while a reconnect catches up`,
which fails when the retained summaries are left out.
- `flutter analyze` is clean and the full mobile suite passes.
- iPhone 12 mini, Profile build, a 2,000-member channel backgrounded 12
s and resumed (same scenario, one run each). Before: the "6 replies"
link vanished for about a second and the timeline shifted up, then back.
After: the link and the scroll position stay put through the catch-up.
- Automated app run on the same phone with this change: Home, search,
the large channel and its resume after the reconnect grace period.

### Accepted gap

If you had scrolled back past the first page, those older rows still
lose their reply counts once the reconnect catch-up finishes, as they do
on main today. Their counts return when pagination refetches their page
or the channel is reopened.

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

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
2026-11-05 11:14:52 -08:00
Tom Brow
5e36d65c9e fix(mobile): inflate gzip read-state snapshot responses (#9089)
On relays behind a CDN that gzips responses (the Builderlab relay does),
mobile read state never synced. Every read-state snapshot fetch, and
therefore every read-mark publish, failed with `FormatException:
Unexpected extension byte`, so read marks made on the phone never
reached other devices and each reconnect retried and failed again.

The relay query transport deliberately leaves gzip bodies compressed so
they can be inflated off the UI isolate (see `RelayHttpQueryClient`),
but the snapshot path decoded the raw bytes with `utf8.decode`. It now
uses `relayResponseText`, like the other query callers, for both the
discovery and the snapshot responses.

Heads-up for reviewers: with the snapshot working, a resume also pays
for the full snapshot download on top of the existing read-state replay
on the live subscription. A follow-up PR removes that replay.

### Validation

- Added test: `inflates gzip discovery and snapshot bodies` serves both
responses gzipped. Without the fix it fails with the same `Unexpected
extension byte` error seen on the device.
- Full mobile suite: all pass except the known stale
`fullscreen_onboarding_test` case, which fails on main too.
- iPhone 12 mini (Profile build, Builderlab community): before, every
resume logged `snapshot failed` and `publish failed (journal retained)`.
After, the snapshot on resume and the snapshot inside a publish both
completed with no errors.
- Automated app run on the same phone with this change: Home
backgrounded past the reconnect grace period and resumed, then search
and a large channel opened. No read-state errors logged. Read marks were
not compared across devices.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
2026-11-05 11:14:39 -08:00
Luke Tornquist
50c10dc7b2 perf(relay): cache the community-active check on socket admission (#9109)
## What

Socket admission (root and audio) and the ephemeral write fence now
share one cache of "is this community active?". An entry is served for 1
s after its read checked out a pool connection, and concurrent misses
for one community share one read. Archive and deletion drop the entry
before they close the community's sockets.

## Why

Every new socket runs `is_community_active` before it can authenticate.
In a reconnect storm that means thousands of identical reads for one
community. A read that times out in the pool refuses the socket
(`community active check failed; refusing the socket`), and the client
then reconnects and adds more load. On a local cold-start reconnect rig,
this refusal count roughly tripled once #9012 freed pool capacity for
other reads. It is now one of the top causes of connect failures.

## How

- `buzz_db::Db::is_community_active_read_at` returns the answer together
with the instant its connection was checked out, following the #9012
`ReadAt` pattern. `is_community_active` now calls it, and the datastore
span stays under its existing name.
- `community_active::CommunityActiveCache` holds an entry per community
and a `SharedReads` gate. An entry is served for
`COMMUNITY_ACTIVE_MAX_AGE` (1 s) after its read. Both answers are
cached. A cached "inactive" fails safe and lasts at most 1 s after an
unarchive. Errors are not cached.
- **The archive sweep stays exact.** Admission registers a socket before
it checks, so the sweep closes every socket registered before it. A
socket that registers after the sweep must see the community as
inactive. `apply_community_disconnect` invalidates the entry before the
fence and sweep. Invalidation bumps an epoch, and a fill whose read
began before the bump is not published. A post-sweep socket therefore
reads afresh. It cannot take a pre-archive answer from the cache or from
a shared in-flight read. Host invalidations (archive and unarchive
writes, local and received) also drop the entry.
- **Bound when the notification is missed.** A pod that misses the Redis
disconnect admits sockets from a cached "active" for at most 1 s after
the archive commits. Lifecycle revalidation then closes them. This
matches the 1 s host-routing bound in `docs/community-auth-upgrades.md`
("archive and deletion … cached tenant binding can lag those changes by
up to one second"). Deletion keeps its own write fence
(`guard_community_write`).
- Lifecycle revalidation and persistent ingest still read uncached.

## What got simpler

The ephemeral fence's separate `serving_active_cache` is gone. It ran
the same SQL predicate through `is_serving_active`, used a 10 s TTL and
had no invalidation. The connect path, the WebSocket ephemeral path and
HTTP typing now share one cache with one 1 s bound and one invalidation
path. That leaves one fewer cache concept, and the ephemeral fence goes
from 10 s stale to at most 1 s.

## Tests

- `community_active::tests` (unit):
- 16 concurrent misses cost one read, and an entry past its max age is
re-read.
- An invalidation during an in-flight read fences that fill. A waiter
that joined the read reads afresh and gets "inactive".
  - Errors are not cached.
-
`connection::tests::bootstrap_admits_from_the_cached_community_answer`:
with the database unreachable, a cached "active" admits a socket through
the real `handle_connection`, and the socket gets its AUTH challenge.
- `postgres_tests::cache_misses_behind_a_held_pool_share_one_fresh_read`
now also covers this cache. With a one-connection pool held past every
max age, eight concurrent admission checks cost one DB read, and the
entry lands fresh.
-
`postgres_tests::archived_community_is_refused_after_invalidation_or_max_age`:
after an applied archive, the next check refuses at once. An archive
this pod never hears about refuses once the entry passes 1 s.
- The ephemeral and typing fence tests now seed the new cache.
- Mutation-checked on Blox. Each of these turns a test red: dropping the
epoch fence, the max-age filter, the single-flight gate, the connect
path's cached read, or the disconnect invalidation; stamping `read_at`
before the pool wait; and calling the uncached read on the ephemeral
path.

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

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:09:14 -05:00
Luke Tornquist
ff15c347c2 fix(db): auto-migrate prebuild-guarded indexes on small databases (#8802)
## Problem

Migrations `0076`, `0078` and `0081` raise an exception on any table
with at least one row unless its index was prebuilt with `CREATE INDEX
CONCURRENTLY`. A relay that runs migrations at startup
(`BUZZ_AUTO_MIGRATE`, on by default in the Helm chart) and has data in
those tables therefore crash-loops on upgrade:

```
migration error: while executing migration 81: error returned from database:
prebuild idx_nip_fi_profile_heads_names concurrently; see docs/profile-search.md
```

`0081` (#9019) hits every auto-migrating relay that has published NIP-FI
verified profiles. `0076`/`0078` (#8523) hit any relay with artifact
data or channel members. Each of these tables is small enough on such
relays that a plain index build takes milliseconds.

The root cause is `docs/database.md` Rule 6. It told authors that an
index on a populated table MUST be built concurrently by an operator
ahead of time, so migrations were written to refuse populated tables.
With auto-migration that refusal is a crash loop, not a safe stop.

## Fix

The migrations are already applied on some databases, so they can't be
edited: sqlx would reject the changed checksums. Instead,
`run_migrations_locked` builds their missing indexes first. For each
entry in `INLINE_INDEX_BUILDS` (`0076`, `0078`, `0081`), it runs
migrations through the version before it, then builds any missing index.

Each build is a plain `CREATE INDEX` in its own transaction, under the
same 1s `lock_timeout` and 5s `statement_timeout` the migrations use.
That is the bounded inline build `0049` already does:

- **Small populated databases** upgrade the same way empty ones do.
- **Large databases** still fail closed. Only a lock or statement
timeout (`55P03` / `57014`) gets the "prebuild concurrently" hint,
pointing at that migration's own doc. Other errors pass through
unchanged. The blocked migration is not recorded.
- **Prebuilt indexes** are found with `to_regclass` and skipped before
`CREATE INDEX`, so deployments that prebuilt concurrently never request
the writer-conflicting lock.
- **Invalid prebuilt indexes** are left for the migration to reject, as
before.

`CONCURRENTLY` is not used in this path. The runner holds the migration
advisory lock, and another replica blocked on that lock holds a snapshot
that a concurrent build would wait on.

### Rules

- `docs/database.md` Rule 6: with `BUZZ_AUTO_MIGRATE` on, every
migration must apply its own change on a populated database and must
never refuse one because it has rows. A new index is built inline under
the migration's budgets. Only deployments that apply migrations out of
band (auto-migration off) prebuild concurrently.
- Root `AGENTS.md` (step 4) and `migrations/AGENTS.md` repeat the
never-refuse rule.
- `docs/artifact-sync-deployment.md` and `docs/profile-search.md`
describe the startup behavior first and the concurrent prebuild as the
out-of-band path.

## Tests

- `populated_database_auto_migrates_through_every_migration` (Postgres)
migrates to `0075`, seeds artifact rows, and checks four phases:
1. **Lock held:** while a writer lock is held, the `0076` build fails
closed.
2. **Index prebuilt:** with the `0076` index prebuilt, the build is
skipped.
3. **Partial `0078`:** a blocked `0078` build keeps the builds before it
committed.
4. **Verified profile seeded:** with a verified profile head inserted
before `0081`, a final startup succeeds. It asserts that the applied
versions equal every embedded migration and that all six indexes are
valid, ready and live.
- `frozen_prebuild_guards_have_inline_builds` (no database) scans
`migrations/`. It fails if any migration refuses a populated table
(`RAISE EXCEPTION 'prebuild …`) without an inline build, and if a new
guard is added.
- `migration_execution_cannot_bypass_schema_destruction_lock` allows
exactly one production `run_to`, inside `run_migrations_locked`.

## What got simpler

- Auto-migrating relays no longer need a manual, operator-only prebuild
step for small deployments. That restores the chart README's contract
that `helm upgrade` is the whole upgrade procedure.
- Rule 6 no longer steers authors toward refusing populated tables, and
a test enforces that.
- The change adds one table-driven step for three frozen migrations and
no new configuration.

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

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:52:06 -05:00
Blake McAnally
ab399231c2 docs: mark desktop/ as the deprecated Buzz 0.X app (#8840)
## Summary

`desktop/` is the Buzz 0.X desktop app. Buzz 1.0 lives in
[block/buzz-app](https://github.com/block/buzz-app), and its README
already says so. Nothing in this repo says it, so contributors and
agents keep opening feature PRs against `desktop/`.

This PR adds the rule in three places:

- **`AGENTS.md`**: a notice at the top of "Desktop App" and a note in
the repo layout. Agents read this file first. The notice also says that
CLI PRs do not update the 0.X agent guidance in `nest_skill.md`.
- **`CONTRIBUTING.md`**: a new item in "PRs We're Unlikely to Merge".
- **`desktop/README.md`**: a notice at the top.

The rule: build desktop features in block/buzz-app, or as a Buzz plugin.
Change `desktop/` here only to fix a 0.X bug, or to keep it compiling
after a shared crate changes.

## Testing

- This PR changes only documentation. I checked the diff and the two
block/buzz-app links.

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

---------

Signed-off-by: Blake McAnally <bmcanally@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:11:22 -06:00
Luke Tornquist
bc99726f5c docs(agents): add Review-Proven Rule 9, design client paths for the whole fleet (#9106)
## What

Adds Review-Proven Rule 9 to `AGENTS.md`: **design every client-driven
path for the whole fleet running it at once.**

Before an author adds or changes a connect, reconnect, renewal, poll, or
catch-up path, they:
- count the relay reads it costs per client, and keep reconnect to a
delta from the client's cursor
- jitter its start and back off with a cap
- prefer an event-driven refresh with a long backstop over a short poll
- on the relay, check every cache on a connect or auth path at a 0% hit
rate, with single-flight misses that survive a saturated pool

## Why

Relay load tests keep finding the same class of defect. A path is cheap
for one client, but when a rollout drain, node eviction, relay restart,
or network blip makes every client run it in the same few seconds, it
collapses the relay. Each bullet cites a merged PR that fixed an
instance: #4542, #8846, #9012, block/buzz-app#2215 and #2308.

Unlike rules 1–8, this one comes from load tests rather than review
mining, and the rule text says so. The section intro and the Reviewing
bullet now say the rules list includes non-review sources.

## What got simpler

Nothing in code. This is one rule added to the existing list. It turns a
recurring load-test finding into a check made at planning and review
time, so it doesn't keep resurfacing as a late load-test result.

## Validation

Docs-only. I checked the diff, the Markdown rendering, and every cited
PR number and title.

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

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
mobile-v0.28.0-rc.3
2026-11-05 11:57:01 -05:00
Luke Tornquist
aa749a862e perf(db): serve single-root thread reply reads from thread_metadata (#9075)
## Summary

A plain thread-reply read, `{kinds:[9], #h:[channel], #e:[root],
limit:100}`, goes through the generic `query_events` path. That path
gathers every event tagging the root into a `MATERIALIZED` CTE, then
sorts it to keep one page. On a root with 500k replies, each page costs
about 514k buffer reads plus a temp-file spill.

This PR serves single-root reply reads from `thread_metadata` instead.
It walks `idx_thread_metadata_window` newest-first, fetches each reply
by primary key, and applies every filter predicate before `LIMIT`. Page
cost scales with the page size, not with the thread.

## What changed

- **Routing (`crates/buzz-db/src/store/event.rs`).** A `query_events`
read takes the new route when it has:
  - one channel,
  - one lowercase-hex `#e` root,
  - kinds limited to 9 and/or 40002,
- no `#p` filter (any number of values; the relay marks such filters
`exact_match`),
- no `authors`, `pubkey` or `ids` selector. The route tests those per
reply, so an author with no reply in a hot thread would walk the whole
thread; the generic read narrows by author first.
  
  Every other `#e` read stays on the fenced path from #7854.
- **Root check before routing.** `#e` must name a thread root. A cheap
probe looks up the target in `thread_metadata` first. If the target is
itself a reply, the read takes the generic path, which returns its
direct children the NIP-01 way. A reply counts if its row has a root, or
if it is a rootless legacy stub whose own NIP-10 markers resolve to a
reply.
- **COUNT unchanged.** COUNT post-filter fallbacks (`count.rs`,
`bridge.rs`) set the new `EventQuery::exact_match` flag through
`prepare_count_fallback`. Routing is skipped for them, so COUNT still
counts every event that matches under NIP-01.
- **Same predicates on both paths.** The predicate block moved out of
`build_query_events_sql` into `push_event_filters`, which both paths
call. Filters on the route therefore match the generic path, including
the `#e` tag match, `since`/`until`/`before_id` and deletion.
- **Unnamed statements.** Routed reads run as unnamed statements, so
Postgres plans each one with its root and never caches a generic plan.
The benchmark below shows the routed SQL is also fast under a forced
generic plan, so this is a second safeguard.
- **Narrowed semantics, documented.** Messages that name the root only
in a bare `e` tag, a `mention` tag or a lone `root` marker are top-level
under NIP-10. They have no `thread_metadata` row, so the routed read
omits them. Live subscriptions still deliver them. `docs/nips/NIP-CW.md`
gains a "Plain Thread-reply Filters" section describing this. A client
that needs those messages can add another kind to the filter, which
sends the read down the generic path.

## Benchmark (synthetic, Postgres 16, 500k-reply thread)

Median of 5, newest page of 100. "Rare" is a root with 3 replies.
"Generic" forces `plan_cache_mode = force_generic_plan`.

| | main | this PR |
|---|---|---|
| hot root, custom plan | 2802 ms (513k buffers, 46,750 temp blocks
written) | 1.6 ms (407 buffers) |
| hot root, generic plan | 1779 ms | 0.6 ms |
| rare root, custom plan | 1.2 ms | 0.8 ms |
| rare root, generic plan | 61 ms | 0.08 ms |

Author-filtered reads (`authors:[x], limit:1`) on the hot root: an
author with no reply there takes 1.4 ms on the generic path but 1.7 s on
the route, so author and id selectors stay generic.

On both roots, the PR's SQL returns the same rows as main.

## Tests

-
`e_tag_filter_shape_tests::thread_reply_route_admits_only_single_root_message_reads`
checks which reads take the route. Two roots, mixed kinds, no kinds, no
channel, an uppercase root, a `#p` join, an `exact_match` read, and
`authors`/`pubkey`/`ids` selectors all stay generic. The routed SQL is
checked for the `thread_metadata` walk, the `#e` match and the window
ordering, with no `MATERIALIZED`.
- `postgres_tests::thread_reply_reads_use_thread_metadata` (Postgres
lane) covers:
  - mixed 9/40002 replies, a nested reply and a soft-deleted reply,
- a bare-`e` message that the route omits and the generic read (kinds
`[9,40002,7]`) still returns,
  - a lone-`root` message, which the route omits too,
- an `exact_match` read, which returns the same rows as the generic
read,
  - a nested-parent target, which gets its direct child the generic way,
- root stubs and a legacy parent with no row of its own, checked through
the probe,
  - keyset paging,
  - no named prepared statement left behind after repeated runs.
-
`handlers::req::tests::postgres_tests::multi_p_thread_filter_keeps_nip01_results`
(Postgres lane) runs a two-value `#p` thread filter through the real
filter-to-query adapter and checks that a bare reference to the root
still comes back.
- `handlers::req::tests::count_fallback_fetches_one_extra_candidate`
checks that COUNT fallbacks set `exact_match`. It is DB-free and was
missing from the `buzz-relay --lib` allowlist, so this PR adds it to
`just test-unit`.
- Mutation-checked: the Postgres test fails if any of these is broken:
  - routing disabled,
  - persistent statements,
  - the nested-target probe dropped,
  - stubs treated as all replies or as no replies,
  - COUNT allowed to route,
  - the deleted filter dropped.
- Blox gate: `cargo fmt`, clippy (`-p buzz-db -p buzz-relay
--all-targets`), buzz-db lib (nextest and plain parallel `cargo test`),
buzz-relay lib, and the buzz-db and buzz-relay Postgres lanes. One
unrelated artifact-sync scale test timed out under load; it passes alone
and in CI.

## What got simpler

- One predicate builder replaces the inline block, so the two read
shapes cannot drift apart.
- One targeted route replaces the planned extra fence variant (a
keys-only `MATERIALIZED` CTE). That variant was dropped because it
reduced the spill but still scanned every reply.

## Identified debt (not in this PR)

- The bridge `page` extension (`crates/buzz-relay/src/api/bridge.rs`,
`extract_page_offset`, built for the kind:0 directory listing) still
maps to SQL `OFFSET`. The routed read honours it too, for parity. Deep
pages there should move to keyset (`until` + `before_id`).

buzz-review-completed

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

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:14:12 -05:00
Tom Brow
6b8a72e835 fix(mobile): send hosted relay auth with iOS custom emoji downloads (#9056)
On iOS, custom emoji in the native emoji picker often render as the
sparkle fallback icon instead of the image.

The native picker downloads custom emoji with `URLSession` and asks
Flutter for request headers over the `mediaHeaders` channel call. That
call returned only the media read `Authorization` header. Hosted relays
also require the `Nostr-Federated-Identity` assertion, which the Flutter
image path gets from `HostedRelayHttpClient`, so relay-hosted emoji get
a 401 and the picker falls back to sparkles. Emoji hosted elsewhere
still load, which is why only some are missing.

The `mediaHeaders` bridge now also includes the hosted relay assertion
via a new `HostedRelayAuth.headersForUrl`, scoped to the relay's own
origin like `HostedRelayHttpClient`. The native loader refuses redirects
for requests that carry the assertion, matching the Dart client, so the
token can't be forwarded to another host.

The iOS reactions sheet had the same gap: it passes avatar and custom
emoji URLs to native views with only the `Authorization` header. A new
`nativeMediaHeadersProvider` combines both headers for any native media
consumer, and the reactions sheet uses it.

### Validation

- Added tests: the native picker's `mediaHeaders` call returns the relay
assertion for relay media and no headers for an external emoji URL;
`headersForUrl` returns the assertion only for the relay origin (not
another host, subdomain or port). The reactions sheet passes the
combined headers for avatars and custom emoji, and
`nativeMediaHeadersProvider` merges both headers only for relay URLs.
- Focused existing validation: the emoji picker and organization relay
auth suites pass, and `flutter analyze` is clean. In the full mobile
suite, the only failures are 10 in `notification_toggle_test`,
`push_bootstrap_test` and `push_bridge_test`, and they fail the same way
on `main`.
- Automated full-app simulator run (iPhone 17 Pro, iOS 27, debug build
of this branch with hosted auth enabled, signed in to two hosted
communities through a cloned simulator keychain, driven with AXe).
Covered: launch signed in, channel and DM lists, opening a channel and a
DM, the native emoji picker (its Custom section shows the emoji as an
image, not the sparkle fallback), sending a custom emoji message and
seeing it render, reacting with the custom emoji (shown in the
quick-reaction row and as a reaction pill), opening a thread, switching
communities, avatars and community icons, background and resume, and a
cold relaunch. Result: no crashes or regressions seen. Screenshots are
omitted because they show real account and community names.
- Limitations: this run does not reproduce the original bug. The hosted
community's custom emoji also loaded in a temporary build with the relay
assertion removed from the `mediaHeaders` bridge, so its emoji URL
evidently doesn't need the assertion, and the run doesn't prove the fix.
The reactions detail sheet could not be reached because simulated long
presses on the pill opened the message menu, and inline image
attachments were not exercised. A device build against a community whose
emoji are relay-hosted is still needed to confirm the fix.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
2026-11-05 04:27:16 -08:00
Tom Brow
920b1e64bd perf(mobile): keep covered pages still while the keyboard moves (#8941)
## Summary

A keyboard raised by a route above (the Add members sheet, search, a
native picker) changes the app's `MediaQuery` on every animation frame.
Covered pages that read `MediaQuery` directly or through a `Scaffold`
rebuilt and relaid out all their content for keyboard motion they cannot
show. On #4things, typing in Add members rebuilt every visible message
of the channel page underneath for each keyboard frame.

`CoveredRouteKeyboardInsets` holds a page's `viewInsets` and `padding`
at their last values from when its route was current, while the route is
covered. Every other `MediaQueryData` field passes through. A covered
page still follows the keyboard down, so when a covering route takes
focus and the page's own keyboard closes, the page lowers behind the
scrim. A size change (rotation) drops the held values so safe-area
padding stays right. When the route becomes current again with the
keyboard still above the held value, the page keeps the held values
rather than jumping up and riding the keyboard back down. It switches to
live values once the keyboard reaches the held value, or as soon as
focus is inside the route (the page raised its own keyboard). Otherwise
the live values return. It always returns the same widget shape, so
covering a page never remounts it. Home, search, the channel page and
channel details use it.

## Measurements

iPhone 12 mini, Profile build, Builderlab #4things (1,361 members).
Flow: open the channel, open its details, tap Add members, tap the
search field, then type "chris" one character per second. The window is
the 10 s of typing, keyboard included. Both builds also contain the
fixes from [#8928](https://github.com/block/buzz/pull/8928),
[#8929](https://github.com/block/buzz/pull/8929) and
[#8931](https://github.com/block/buzz/pull/8931).

| | Before | After |
| --- | --- | --- |
| UI frame time, total | 3,344 ms | 383 ms |
| Frames over 16.7 ms | 101 of 232 | 2 of 297 |
| Median / p90 frame | 14.1 / 26.1 ms | 0.6 / 2.0 ms |
| UI isolate CPU samples (1 ms) | 2,793 | 868 |
| Samples in `MessageContent.build` | 621 | 0 |

Single runs per side, not repeated.

## Testing

- New widget tests: a covered page doesn't rebuild for keyboard frames
and gets the live insets back when it's current again. A current page
follows the keyboard. Covering a page keeps its state. A page uncovered
with the keyboard still up does not jump, and follows the keyboard once
it reaches the held value. A page whose own field takes focus after
being uncovered follows the keyboard immediately. A covered page follows
its own keyboard closing and takes new padding when rotated. The last
test fails (3 mounts instead of 1) against a version that returned a
different widget tree when covered.
- `just mobile-check mobile-test` passes.
- On the phone: Add members search, typing and results behave as before.
The channel page and search still move above the keyboard when they're
current.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost>
mobile-v0.28.0-rc.2
2026-11-04 20:27:59 -08:00
Logan Johnson
700e690193 feat(db): enforce unique listed channel names with an index (#8787)
## Summary

Stacked on https://github.com/block/buzz/pull/8782 (merged), which makes
the relay refuse a second open channel with a name that another channel
in the community already has. That check lives in relay code, so an
older relay, a direct database write or a missed code path can still
create a duplicate.

This PR adds the same rule to the database: a unique index on channel
name, ignoring case, for the channels that show in the channel list.
With the index in place, a duplicate fails at the write, whatever wrote
it. The relay's own name check from
https://github.com/block/buzz/pull/8782 is no longer needed, so this PR
removes it. Clients still get the same refusal, `invalid: an open
channel named "…" already exists`.

The migration builds the index itself, also on a populated `channels`
table, so an auto-migrating relay upgrades with no operator index step
(the rule from https://github.com/block/buzz/pull/8802). It still
refuses to run while duplicate names remain, because the index cannot
exist over them. `docs/channel-name-uniqueness-deployment.md` gives the
full rollout.

## Details

- **The index** (migration `0083_channels_listed_name_unique.sql`):
  ```sql
  CREATE UNIQUE INDEX idx_channels_listed_name
      ON public.channels (community_id, lower(name))
      WHERE visibility = 'open' AND NOT unlisted
        AND channel_type IN ('stream', 'forum') AND deleted_at IS NULL;
  ```
It covers open, not unlisted streams and forums that are not deleted,
archived ones included. Private channels, DMs and unlisted channels may
still repeat names.
- **The migration builds the index inline, as `0049` does.** When
`to_regclass` finds no index, it runs a plain `CREATE UNIQUE INDEX`
under `lock_timeout` 1s and `statement_timeout` 5s. On a busy or very
large table it fails on those budgets, records nothing, and the relay
retries at its next start. When the index already exists (a concurrent
prebuild), it skips `CREATE` so it never requests the write-blocking
lock. In both cases it then checks that the index is valid, ready and
has the exact expected definition.
- **One refusal stays: duplicate names.** The migration refuses to run
while listed duplicates remain, and its error names the rename script.
The renames must go through the relay so clients get the new kind:39000,
and a migration must not change rows (`docs/database.md` Rule 3). This
is the "fails closed until its repair has run" case in
`migrations/AGENTS.md`, so the writer release from
https://github.com/block/buzz/pull/8782 and the rename are a required
stop.
- **Relay.** A write that takes the name of a concurrent, uncommitted
write waits for it, and fails if that write commits. So the post-write
check and the per-name lock from https://github.com/block/buzz/pull/8782
are removed. The store already maps the index violation to the same
"name taken" error.
- **Desired-state schema.** `schema/tables/public/channels.sql` has the
same index, and the schema parity test compares its live shape between
the desired-state schema and the migrations.
- **Rollout** (`docs/channel-name-uniqueness-deployment.md`), in order:
1. Deploy https://github.com/block/buzz/pull/8782 and wait until no
older relay pod serves.
2. Rename the existing duplicates in each community with that PR's
script, while relays keep serving. The script needs `psql` and
`python3`, and the operator key must be a member of each community. The
doc covers archived communities and communities being deleted.
  3. Confirm that no duplicate remains.
4. Only for a deployment that migrates out of band and is too large for
the budgets: build the index with `CREATE UNIQUE INDEX CONCURRENTLY` and
verify it.
5. Deploy this release. With auto-migration on, the relay applies 0083
at startup. With it off, run `buzz-admin migrate` first, never the file
through `psql`.

Rollback goes to the https://github.com/block/buzz/pull/8782 release
only. Keep the index, and keep auto-migration off.
- **Test fixtures.** Tests that inserted two open channels with one name
now use distinct names. Tests that migrate a populated database through
0083 no longer prebuild the index.

### Related issue

None found. Follows https://github.com/block/buzz/pull/8782.

### Testing

- Migration tests (`listed_channel_name_postgres_tests.rs`) start from a
database migrated through 0082:
  - With duplicates, 0083 fails and changes nothing.
- After the rename plan is applied, a channel writer that holds its lock
makes the inline build fail on the 1s lock budget, with nothing
recorded.
- With no writer, the populated table migrates with no prebuild, and a
raw duplicate insert fails on the index.
- An index with a different definition, and an invalid one, are refused.
A valid prebuilt index is accepted, and not replaced, while another
transaction is writing to `channels`.
- The store and relay name tests from
https://github.com/block/buzz/pull/8782 pass with the relay check
removed: the index refuses the duplicate, and the client still gets
`invalid:`.
- The rename plan and script tests from
https://github.com/block/buzz/pull/8782 run on a database at 0082, the
state the script runs in.
- Local Postgres runs on `108737dc0`, one fresh database per test as the
nextest wrapper does: 38 of 40 buzz-db migration-lane tests pass (both
listed-name tests, the migration parity test, the thread-window and
invite-upgrade tests that migrate populated databases through 0083). The
2 `event_follow_up` contract tests fail locally the same way on main.
All 49 buzz-db channel and channel-member store tests pass.

### Database review checklist

- [x] Phase: contract. Before it:
https://github.com/block/buzz/pull/8782 (writer release, required stop)
and its rename script. Nothing after it.
- [x] No trigger, function, procedure, rule or foreign key.
- [x] No migration mutates rows. The renames happen before it, through
the relay, with the script in https://github.com/block/buzz/pull/8782.
- [x] Never refuses a table because it has rows. Fails closed only with
duplicates (repair not run) or with an invalid or different index; then
0083 raises and the relay does not start. The release notes must name
the release that contains https://github.com/block/buzz/pull/8782 as a
required stop.
- [x] Rule 4: `migrations/`, `schema/tables/public/channels.sql` and the
parity test. A live catalog comparison covers the index.
- [x] Deployed relay: the https://github.com/block/buzz/pull/8782 relay
answers `invalid:` beside the index (its tests pass with the index
present). A relay older than that still writes, but gets a database
error, not `invalid:`, for a duplicate. That is why
https://github.com/block/buzz/pull/8782 is a required stop.
- [x] Timeouts and index build: `lock_timeout` 1s, `statement_timeout`
5s. The migration builds the index inline (plain `CREATE UNIQUE INDEX`,
skipped when `to_regclass` finds a prebuilt one). Only out-of-band
deployments too large for the budgets prebuild it with `CREATE UNIQUE
INDEX CONCURRENTLY` (doc step 4).

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

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:47:23 -05:00
Brad Seiler
64d12e2975 BUZZ-511: Honor relay mode for community NIP-FI enforcement (#8988)
## 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](https://github.com/block/buzz/pull/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>
2026-11-05 03:17:19 +00:00
Luke Tornquist
506a588f88 refactor(relay): remove database usage-metric queries (#9040)
## Summary

The relay no longer runs database aggregate queries to emit usage
gauges. Fleet and per-community counts of users, channels, relay
members, workflows, git repos, communities, messages, and active
users/channels are no longer relay metrics, and neither are the snapshot
health gauges that came with them.

### Removed

- **Gauges:** `buzz_total_users`, `buzz_total_channels`,
`buzz_total_relay_members`, `buzz_total_workflows`,
`buzz_total_git_repos`, `buzz_total_active_users`,
`buzz_total_messages`, `buzz_total_active_channels`,
`buzz_communities_estimated` / `buzz_communities_total`, and every
DB-derived `buzz_community_*` series (users, channels, messages, relay
members, workflows, git repos, active users, active channels).
- **Per-community mode:** `BUZZ_USAGE_METRICS_PER_COMMUNITY=all` and
everything it fed: the per-community in-memory gauges,
`buzz_community_storage_bytes` / `buzz_community_storage_objects`,
`buzz_storage_unmapped_community_bytes`, the community → host map query,
and the emission-scope bookkeeping that cleared series for renamed or
removed communities. No deployment set it. If one still does, the relay
ignores it.
- **Health signals:** `buzz_usage_snapshot_available`,
`buzz_usage_snapshot_age_seconds`, `buzz_usage_query_skipped_total`, and
the `usage_fleet_*` paths of `buzz_db_route_decision`.
- **Code and config:**
  - `buzz-db/src/store/usage.rs`, including its aggregate queries.
- The relay's `FleetUsageSchedule` and its cadence/deadline/retry
machinery.
  - The replica-only telemetry route (`Db::route_usage_read`).
- `BUZZ_USAGE_METRICS_REPLICA_MAX_AGE_MS` and
`DbConfig::usage_metrics_replica_max_age_ms`. If a deployment still sets
the env var, the relay now ignores it; startup doesn't fail.
- **Reader maintenance pool operation:** `ReaderOperation::Maintenance`
and its `close_on_drop` release path existed only for those telemetry
reads, so they're removed too. The pool-acquire contract drops from
twelve to eleven valid pairs, which lowers the per-pod series ceiling
from 216 to 198.
- **Dead code:** `Db::active_community_hosts` had no callers.

### Kept

- **Leader:** the usage poller's leader lock, plus the leader's invite
reaping and fleet storage-snapshot gauges. The session advisory lock
(`SessionLeader`, `Db::try_lock_session_leader`) moves to
`buzz-db/src/store/leader_lock.rs`, since storage-usage delivery uses it
too and it has nothing to do with usage queries.
- **In-memory gauges:** pod-local connection, subscription, and
online-user gauges. They read process state, not the database.
- **Writer maintenance pool operation:** `WriterOperation::Maintenance`
stays. Dozens of writer-side maintenance paths across `buzz-db` use it
(reapers, retention, the leader lock). The reader variant only ever
served the telemetry reads.
- **Storage-usage delivery:** unchanged. It reads the storage snapshot
for billing, not for metrics.

`docs/usage-metrics.md`, `docs/storage-accounting.md`, `.env.example`,
and the Helm chart README are updated to match.

## What got simpler

This is net deletion (~3.6k lines). One telemetry subsystem is gone: two
collection families with independent cadences, availability and age
semantics, relay-side deadlines, a dedicated replica routing policy, two
config knobs, a per-community emission mode, and a pool-operation
variant. Leader election now does only leader work, and it lives in its
own module.

## Operational note

Dashboards and monitors that read the removed series will go to no data
once this rolls out.

## Testing

- `cargo fmt --check`, workspace `cargo clippy --all-targets -D
warnings`.
- `buzz-db` lib, `observability_source`, and doc tests.
- `buzz-relay` lib tests.
- The Postgres lane for `buzz-db` (lib + tests) and `buzz-relay` (lib).

All of these run on a remote workstation at the PR head.

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

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:00:30 -05:00
Tom Brow
34174214fc fix(mobile): restore keysForNsec after the native key derivation change (#9085)
## Summary

[#8965 native key derivation](https://github.com/block/buzz/pull/8965)
removed `AccountIdentityStore._keys`. `keysForNsec`, added by [#9029
nsec backup](https://github.com/block/buzz/pull/9029), which merged
first, still called it. The two PRs didn't conflict textually, so `main`
now fails `flutter analyze` (`_keys` isn't defined at
`account_identity_store.dart:356`) and mobile CI is red.

This builds the keys through `keysForHex(_nsecHex(nsec))`, the same path
`withKeys` uses since #8965. It applies the same bech32 decoding and
secp256k1 scalar validation that `_keys` did.

## Testing

- `just mobile-check`: no issues found.
- `flutter test` (full mobile suite): 4460 passed, 49 skipped. No UI
change, so no app run.

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

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:47:12 -08:00
Tom Brow
e558f15faa Mobile: one New chat screen (#9079)
Mobile had two New chat screens.
[#8884](https://github.com/block/buzz/pull/8884) made the Flutter draft
page (first send creates and names the chat) the New chat screen, but
kept the older create form (a native iOS sheet, with a Flutter bottom
sheet on Android) for one case: resuming a creation that an older build
had saved with a chat name and no first message. Nothing creates that
state anymore, so the old form only appeared for users with an
interrupted creation from before that change.

This removes the old form. Every New chat, including that resume case,
now opens the draft page:

- **New chat**: unchanged.
- **Resuming an interrupted creation**: after the existing Resume /
Discard setup / Later prompt, the draft page shows the saved chat with
its agent and workspace locked and a Retry button, the same choices the
old sheet offered when it locked its fields. A creation saved with a
first message resumes exactly as before. One saved without a message
finishes without one, because a saved creation can't change after its
first relay write.

The iOS editor's chat-only code (`presentChat`, `updateChatAssistants`,
the agent list, and its avatar loader) goes too. The same editor still
handles profile fields, channel details and workspace names. The native
test and CI step that covered only the removed chat form are removed.

### Screenshots

Resuming an interrupted creation saved without a first message:

| Before: old native sheet (removed) | After: draft page (kept) |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/5d0a502e92fc3dfeffbe4fa95d63ba8f73d1e612/pr-9079--01-before-resume-native-sheet.png"
width="300"> | <img
src="https://raw.githubusercontent.com/block/buzz/5d0a502e92fc3dfeffbe4fa95d63ba8f73d1e612/pr-9079--02-after-resume-draft-page.png"
width="300"> |

A brand new chat already opened the draft page and is unchanged:

<img
src="https://raw.githubusercontent.com/block/buzz/5d0a502e92fc3dfeffbe4fa95d63ba8f73d1e612/pr-9079--03-new-chat-draft-page.png"
width="300">

### Validation

- Changed tests: `me_page_test` checks that Resume and Discard setup
both open the draft page and that no native sheet is requested.
`solo_creation_tags_test` checks that recovering an interrupted named
creation through the draft page's Retry continues with member setup,
keeps the saved name and agent, and adds no first message.
- Full mobile Flutter suite (4,431 passed) and `flutter analyze`
(clean). iOS debug simulator build succeeds.
- Automated simulator run (iPhone 17 Pro, iOS 27, signed-in account):
New chat opened the draft page, the agent picker selected an agent, and
the first send created the chat, which then appeared under Solo. New
workspace still opened the native editor, enabled Create once named, and
showed the Discard changes prompt on cancel. The recording isn't
attached here because it shows internal account content.
- Resuming an interrupted
pre-[#8884](https://github.com/block/buzz/pull/8884) creation can't be
produced by the current build. For the screenshots above, a
capture-only, uncommitted patch to a `main` build seeded one; the branch
build installed over it resumed it on the draft page. The widget tests
cover finishing it.
- `solo_creation_tags_test` also covers a conflicting pending Solo
organization edit during that Retry: the draft offers the Conflicting
Solo changes dialog, keeps the saved creation, and the next Retry
finishes it.

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


buzz-review-completed

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:34:05 -08:00
Tom Brow
237d794acb perf(mobile): skip re-decrypting read-state blobs (#8966)
## Summary

Read-state blobs are NIP-44 encrypted, and the app decrypts them with
pure-Dart ChaCha20 and HMAC-SHA256 on the UI isolate. Most of those
decrypts repeat earlier work:

- Every live subscription (each launch, resume and reconnect) replays
all of the account's read-state slots, and `_handleIncomingEvent`
decrypts each one again. The measured account has 27 slots of about 55
KB ciphertext each.
- Before every publish, `_publishOnce` fetches the read-state snapshot
and decrypts it, and decodes our own slots a second time to check
they're readable.
- The relay echoes each publish back, and our own blob is decrypted
again.

`ReadStateCrypto` now keeps a bounded memo from ciphertext to plaintext,
one per conversation key. `decrypt` checks it first, and `encrypt`
records the ciphertext it produced. NIP-44 payloads carry a random nonce
and a MAC, so under one key the same ciphertext always decrypts to the
same plaintext. Failed decrypts aren't cached and still throw every
time.

The memo is least recently used: a hit moves the entry to the newest
end, so our own superseded blobs are evicted before live ones. It holds
at most 8,000,000 code units across keys and values (about 8 MB, about
80 full slots). The first version used oldest-first eviction with a 2M
bound, which saved nothing on the device: the 27 slots total about 2.6M,
and a replay in slot order evicted each entry just before its next use.

## Measurements

iPhone 12 mini, Profile/AOT, Tom's Block community. Each run resumes the
app twice from the home screen and samples UI-isolate CPU for 15 s from
each resume.

| Build | `ReadStateCrypto.decrypt` per resume |
| --- | --- |
| `main` 14037e3 | 73 ms, 78 ms |
| `main` + this PR + [#8965](https://github.com/block/buzz/pull/8965) |
9 ms, 4 ms |

On the older base 3d26ed7 the first version of this PR measured 74/70 ms
before and 73/71 ms after (no gain). An instrumented build showed 27
decrypts per resume with 0 memo hits, all on one `ReadStateCrypto`
instance. With the LRU fix, the same base measured 7/3 ms.

## Testing

- New tests: the same ciphertext twice runs the cipher once. A
ciphertext from `encrypt` runs it zero times. Failed decrypts throw
every time. The bound caps retained size.
- Regression tests for the device finding: replaying 27 full-size slots
twice decrypts 27 times, not 54, and a hit keeps a live entry ahead of
superseded blobs under a small bound. Both fail on the first version's
oldest-first memo.
- Manager-level test: after the first sync, three publishes plus the
echo and snapshot fetches add no decrypts (3 total, instead of 6 with
the memo bypassed).
- `just mobile-check mobile-test` passes (pre-push hook), after merging
current `main`.
- Automated device run in place of manual testing: the build above on
the 12 mini, launched and resumed twice into a signed-in Block
community, loading the channel list and syncing read state (snapshot
fetch and live replay of all 27 slots).

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:24:13 -08:00
Tom Brow
9c5032b63b perf(mobile): derive keys natively and hash NIP-98 bodies faster (#8965)
## Summary

Two pure-Dart crypto paths that run on the UI isolate during normal use:

- **NIP-98 body hashes.** Every relay HTTP query
(`RelaySessionNotifier._postForEvents`) hashes its request body for the
NIP-98 auth header with pointycastle's `SHA256Digest`. This switches
that hash, the media upload hash and the NIP-OA preimage hash to
`package:crypto`'s `sha256`, which is already a dependency. The output
is identical.
- **Public key derivation.** `AccountIdentityStore.publicKeyForNsec`
runs on every relay auth assertion and derived the key with the `nostr`
package's pure-Dart curve math. It now uses the native
`derivePublicKeyHex` (libsecp256k1) with the same validation. The
default-agent writer now signs with the native `signNostrEvent` instead
of `nostr.Event.from`.

Left as they were: `nostr.Keys` construction for signing keys (nostr
2.0.0 always derives in Dart, with no way to pass a known public key),
and `nostr.Keys.generate()` on the rare key-creation paths.

## Measurements

- **Device** (iPhone 12 mini, Profile build, #4things captures): NIP-98
hashing took 17 to 24 ms of UI CPU per 10 to 20 s capture, mostly from
channel notices queries. `publicKeyForNsec` took about 7 ms per relay
auth assertion.
- **Synthetic** (Mac, AOT, same output): SHA-256 of a 1 KB body takes
39.9 µs with pointycastle and 6.6 µs with `package:crypto`. 50 KB: 1,518
µs vs 293 µs. 1 MB: 29.6 ms vs 6.1 ms.

- **Device A/B after the change** (iPhone 12 mini, Profile/AOT, Tom's
Block community, two app resumes per build from the home screen, 15 s
windows; A is `origin/main` 3d26ed7, B is A plus this PR and
[#8966](https://github.com/block/buzz/pull/8966)): NIP-98 signing per
resume went from 42 / 50 ms to 9 / 13 ms of UI CPU, and all SHA-256 work
per resume from 74 / 73 ms to 33 / 33 ms. Dart public-key derivation
through `nostr.Keys` (left as is, see above) still takes 15 to 23 ms per
resume.
- **Same A/B on current `main`** (14037e3): pointycastle SHA-256 per
resume went from 39 / 45 ms to 7 / 0 ms, and NIP-98 header building from
4 / 5 ms to 2 / 0 ms. `nostr.Keys` derivation: 12 to 17 ms per resume on
both builds.

## Testing

- New known-vector tests for the NIP-98 header hash: an empty body,
`abc`, and the one-million-`a` FIPS vector. They also check the event's
kind, method, signature and pubkey.
- New `publicKeyForNsec` tests: the generator-point vector, matching the
pure-Dart derivation, and rejecting invalid keys (scalar 0 and n,
garbage, an npub).
- These are correctness tests only. A timing test would be flaky, so
none would fail if the old pure-Dart code came back.
- `just mobile-check mobile-test` passes (pre-push hook).
- Automated device run in place of manual testing: the B build above on
the 12 mini, launched and resumed twice into a signed-in Block
community, loading the channel list, channel notices and read state over
authenticated relay queries (NIP-98 headers and relay auth assertions on
every request). Not exercised on the device: media upload and the
default-agent writer.

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

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:23:47 -08:00
morgmart
5d44038ea5 mobile: invite new users to create their first agent with a bold card (#9036)
## Summary

New users had no cue that the slot at the bottom of Home is where they
make an agent. A one-time, deliberately loud "Create your first agent"
card now sits above the empty Bestie slot and points at it.

- The card spans the screen above the bottom bar, with agent pictures
(the same ones the empty slot cycles through) scattered around its edges
and the message centered as plain text. It's allowed to cover part of
the chat list.
- It waits three seconds on Home, then pops up from the agent slot with
the popover entrance (a quick fade, rise and scale). Leaving Home
restarts the wait, so it never pops over another screen. With reduced
motion it appears without animation after the same wait.
- Tapping the card starts agent creation. The close button dismisses it
for good, as does a screen reader's dismiss action, activating the slot,
or ending up with a Bestie. Dismissal is remembered per account and
community. Other taps on Home go through to the page and leave the card
up.
- It shows when remote agents are on, the Bestie preference has loaded
with none set, and the account owns no agents yet. The last check keeps
existing users who never picked a Bestie from being told to create their
"first" agent.
- It only shows while Home is the current route, so sheets and pushed
screens hide it and it comes back afterwards if it was never dismissed.

The card is a new `BuzzCallout.card` variant of the reusable
`BuzzCallout` in the mobile design system (the plain tip variant stays
available). It uses the inverse surface in light mode and the raised
dark surface in dark mode (matching toasts), with an arrow, existing
radius, spacing and popover elevation tokens, and stays phone-sized on
wider screens. It grows to fit large text below its close button, and it
has live-region semantics, so screen readers announce it.

## Screenshots

These come from the widget test harness with the real agent pictures, at
small (320), standard (393) and landscape phone sizes, plus a tablet.
The harness can't render the native iOS glass bar, so these use the
Flutter bar; the card is the same on both platforms. Pictures look a
little soft because the harness renders at 1x and scales up. In
landscape and on tablets the card stops at large-phone width so the
pictures don't spread too thin.

| Standard, light | Standard, dark | Small, light |
| --- | --- | --- |
| ![Standard
light](https://raw.githubusercontent.com/block/buzz/e56b850e4e481ff78ad5ab6d05a86ab588e35748/first-agent-dark-iphone-narrow-light.png)
| ![Standard
dark](https://raw.githubusercontent.com/block/buzz/e56b850e4e481ff78ad5ab6d05a86ab588e35748/first-agent-dark-iphone-narrow-dark.png)
| ![Small
light](https://raw.githubusercontent.com/block/buzz/e56b850e4e481ff78ad5ab6d05a86ab588e35748/first-agent-dark-small-light.png)
|

| Landscape, light | Landscape, dark |
| --- | --- |
| ![Landscape
light](https://raw.githubusercontent.com/block/buzz/e56b850e4e481ff78ad5ab6d05a86ab588e35748/first-agent-dark-landscape-light.png)
| ![Landscape
dark](https://raw.githubusercontent.com/block/buzz/e56b850e4e481ff78ad5ab6d05a86ab588e35748/first-agent-dark-landscape-dark.png)
|

| Tablet, dark |
| --- |
| ![Tablet
dark](https://raw.githubusercontent.com/block/buzz/e56b850e4e481ff78ad5ab6d05a86ab588e35748/first-agent-dark-ipad-wide-dark.png)
|

## Testing

- Launcher tests cover first show and placement, Home staying tappable
underneath without dismissing, the card tap starting creation, the close
button, slot activation without a touch, screen reader dismiss, a Bestie
appearing, no reshow after dismissal, a separate community getting its
own first run, staying hidden for people with agents or without remote
agents, hiding under a sheet, the three-second wait and entrance,
reduced motion, and leaving Home during the wait (each
mutation-checked).
- Component tests cover the tip beside an edge target (320 and 1024
wide, 3x text) and the card: full width at 320, capped on wide screens,
growing at 3x text so the message stays clear of the close button
(mutation-checked), and the close button staying its own screen reader
control.
- The full mobile suite passes locally with CI's dart-defines, and
pre-push mobile checks passed at e2fdb791c.
- Still needs a device check on a real iPhone with a fresh account.

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

---------

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Carl <c217fe6b9d958f41c3a5e030dccc7f626775a923089cb6491305eade75ea1f1b@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Morgarita <6e967af659416160ab4f2fd56d74c8c6f273791ee7bfc9468a1be37c387947be@buzz.block.builderlab.xyz>
2026-11-04 15:40:10 -08:00
Wes
af54258f19 fix(mobile): streamline identity setup and community selection (#9070)
Selecting a saved community now connects only that destination, and
**Find my communities** remains available through **Switcher → Add
Community**. Failed destinations are named; completing nested Add/Find
opens the selected destination without resuming the outer batch.

Fresh hosted account setup now creates and backs up an identity
automatically through email sign-in, invites, community links and
community creation when the resolver confirms there are no
account-bound, backed-up, saved or device identities. Welcome has one
email sign-in entry. Authenticated import, existing-key recovery and
selection retain their explicit paths; pending import survives restart
and attaching a destination. Backup retry retains the same identity.

Web binding retains explicit identity creation and its browser-return
behavior. Self-hosted setup is separate and explicit. New-key creation
still requires platform sealing support; passive account restoration
never creates, binds or backs up an identity.

**Account tradeoff:** setup cannot discover a key stored only elsewhere
and never associated with this account. Such an account receives a new
backed-up, bound identity automatically. Importing a different old key
afterward fails with a binding mismatch; this flow does not undo that
account binding. There is no separate pre-authentication import button.

Pending import now survives retained community/web-bind link decoding:
internal import metadata is stripped before strict public-link parsing.
Once an identity is saved and resolved, the import request is retired
before binding or backup, so restart resumes the existing key. Passive
restoration also repairs stale import intents written by earlier builds
without creating, binding or backing up a key.

Validation (`f114c0e228`):
- All 30 hosted onboarding journeys pass. Import coverage includes
invite-to-community-link retention, restart before import, failed-backup
restart, successful-backup restart, and an older stale import intent.
- Three isolated production mutations (decode filtering, import
completion persistence, passive stale-intent handling) each fail the
relevant journey; restored control passes. Earlier auto-creation and
nested-selection mutations also failed their relevant journeys.
- Carl reviewed both P2 fixes and the final commit with no blockers.
Carl and Pinky previously cleared the shared setup behavior and Android
parity.
- Wes confirmed Android sign-in and fresh account creation on the
preceding build `be5690c684`. Android/iOS builds and earlier manual
acceptance do not establish native acceptance of the new import/restart
fixes. Installed devices remain on that preceding build.

**Remaining acceptance:** current-head CI and native validation of the
import/restart edge cases. Required publication hooks passed, including
mobile analysis/tests, branch-skew and file-size checks. The existing
iOS 16 sealing limitation remains: it cannot create a fresh hosted
identity. No membership/ownership repair, denial-classification change
or floating sign-in-control fix is included.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
2026-11-04 14:42:24 -08:00
Tom Brow
ec21753e1b Make mobile New DM and Add members pick recipients like mention autocomplete (#9065)
The mobile New DM picker listed the first 50 relay profiles
alphabetically, mostly identities the viewer doesn't know. It ran its
own debounced directory search with no cache, and its rows disappeared
on every keystroke until the search returned. Mention autocomplete in
the composer already solves the same problem well, so New DM and Add
members now use its implementation. Add members was reusing the same New
DM directory search.

### What changes

- **Same candidates and order as mention autocomplete.** The chooser's
candidate building, ranking, row stability, 200 ms debounced search and
search cache now back both surfaces. New DM is treated like a stream
with no members yet: the viewer's own agents and agents shared with them
appear up front, people are found by name, and agents that couldn't
answer the viewer are left out. Typing a name shows the same people in
the same order as `@name` in a stream.
- **Results stay on screen while a search runs.** A query searched
before shows its people immediately, and a longer query starts from the
shorter query's results. A failed search keeps its rows and adds a retry
row below them.
- **Presentation matches the mention rows.** Rows show the name, and
agents show "agent · managed by …". Npub lines are gone.
- **Sheet chrome.** The title is "New DM", like the menu item that opens
it. Both pickers keep their grouped cards but drop the "People and
agents" and "Selected" section titles and the empty header bands above
them (selected recipients stay in their own card at the top with
checkmarks on iOS, as chips on Android). The native iOS picker now
focuses search once it appears, which iOS used to ignore. In New DM a
pick clears the query, as an inserted mention ends its query.
- **Add members keeps what is specific to it.** Existing members and
members just added are never offered, names are still compared with the
channel roster so a same-name candidate is told apart, the query stays
after a pick so several matches can be added in a row, and agents that
don't answer the viewer can still be added, since a channel can hold
them.

While the WebSocket reconnects, the relay agent directory and its owners
keep their last read in the community instead of going empty, so shared
agents already found stay eligible in both New DM and the composer
chooser.

The debug-only offline preview directory and the old directory listing
and search providers are removed, since no picker reads them any more.

### Validation

Added tests: the New DM widget journey now drives the real mention
search through a fake relay session. It covers the empty-query state,
the debounce, rows kept while a longer query searches, cached results
shown again at once, selection, chips and submit. The layout journey
covers the 50-row cap, and the native-bridge journey covers title, rows
without key detail lines, the selection limit and failure handling. The
full `mobile/test/features/channels` suite passes.

Automated app run (iPhone 17 Pro simulator, iOS 27, debug build, Remote
Agents Pilot community): opened New DM from the menu, confirmed the
keyboard comes up with only the viewer's own agents listed, searched for
people and an agent, selected both and confirmed the query cleared after
each pick. Also exercised the composer mention chooser (same results and
order for the same query) and the shared Add members sheet (plain style,
autofocus, search, query kept after a pick). Captures show real
community members, so they're shared internally rather than here. The DM
was not actually created, to avoid a stray conversation; submit is
covered by the widget tests.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:55:26 -08:00
Tom Brow
f09956179d fix(mobile): keep the composer focused through iOS inactive spells (#9055)
On iPhone, opening Control Center or the app switcher while the composer
is focused can leave the keyboard up over a collapsed composer. The
orphaned keyboard then stays put while you navigate, for example back
from a thread to its channel, and it clips the composer pill.

### Cause

iOS reports a zero keyboard inset while the app is inactive and restores
the keyboard natively when the app returns. The composer treated any
inset drop as a user dismissal: it collapsed, unfocused and removed its
text field. When the keyboard came back, it was attached to no Flutter
field, so nothing could dismiss it. Flutter's own report of the native
side is
[flutter/flutter#189051](https://github.com/flutter/flutter/issues/189051).

### Change

The composer's keyboard observer ignores metrics while the app is not
resumed. iOS reports `resumed` with the inset still at zero and brings
the keyboard back a few hundred milliseconds later, so when a keyboard
that was up hasn't returned at resume, the composer waits a one-second
grace before treating it as dismissed. Any nonzero inset in that window
cancels the wait. A restored keyboard leaves the composer expanded and
focused; a keyboard that really stayed down still collapses the composer
once the grace passes.

### Validation

- Extended the existing "returns to the compact capsule when the
keyboard drops" widget journey with both inactive cases in the order iOS
reports them: resumed at zero with the keyboard returning about 400 ms
later (composer stays focused), and resumed with the keyboard staying
down (composer collapses after the grace). It fails without the fix and
against the first revision of this PR.
- Full mobile check and test suite pass.
- iOS 27 simulator (iPhone 17 Pro, debug build of the first revision):
focused the composer, typed, switched to Settings and back, opened and
closed Control Center, sent a message, and left the channel. The
composer stayed focused through each interruption, sending kept focus,
and leaving dismissed the keyboard. Limitation: the simulator keeps the
keyboard inset through these interruptions, so it reproduces neither the
original bug nor the resume grace; the on-device inset drop is from the
reporter's screen recording and the Flutter issue.

Human app testing is waived under the mobile testing exception; the
simulator pass above is the automated full-app run.

buzz-review-completed

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:54:12 -08:00
Tom Brow
7157a55f5f fix(mobile): keep the iOS 27 header blur in long chats (#8971)
On iOS 27, the frosted header in a long conversation could flicker
between blurred and sharp, most visibly right after returning to the
app.

### Cause

The native header mirrors Flutter's scroll depth into a hidden 10,000 pt
`UIScrollView` so UIKit can draw its scroll-edge effect. For a reversed
chat that depth is the loaded history above the viewport
(`extentAfter`), which can exceed the mirror's range in a long channel.
Past the end of that content, UIKit finds nothing under the bar and
drops the blur and top fade. While the timeline reconnects and catches
up, the lazy list's depth estimate moves back and forth across that
point, so the header flips between blurred and sharp.

### Fix

Cap the depth sent to the mirror at 1,000 pt. Only the first few hundred
points change the header (scroll-under material alpha and large-title
collapse), so pages behave the same below the cap.

### Validation

- Reproduced in an iOS 27 simulator (iPhone 17 Pro) with a debug-only
patch that cycled the reported depth through 0, 9,500, 10,500 and 20,000
pt. Without the fix, the header dropped its blur and top fade at the
deep values, matching the device recording. With the fix, the blur
stayed steady across the same cycle.
- Backgrounded and resumed a channel in the simulator with the fixed
build. The header stayed blurred through the reconnect and catch-up.
- Not yet checked on a physical iOS 27 device; the report came from a
device recording.

Captures stay in Buzz because they show internal conversations.

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

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
2026-11-04 13:52:53 -08:00
Tom Brow
041956ea44 fix(ios): saturate the push dedupe cutoff so extreme timestamps can't crash (#9010)
## Context

Follow-up to [#8973](https://github.com/block/buzz/pull/8973), from
Codex's
[review](https://github.com/block/buzz/pull/8973#discussion_r4458876285)
of its final head. The push dedupe cutoff subtracts the 15 minute
reorder window from the newest shown time. A signed event can carry any
`created_at`. One within 900 seconds of `Int.min` made that subtraction
overflow and trap, crashing the Notification Service Extension. The
claim was never saved, so every later push crashed the same way while
the relay kept returning that event. The relay's own drift check
normally rejects such timestamps, but the extension shouldn't depend on
that.

## Change

The cutoff now uses saturating subtraction
(`BuzzNotificationCache.presentationCutoff(after:)`), in both the claim
check and retention pruning.

## Validation

- New test `testExtremeTimestampsDoNotCrashTheClaim` claims messages
dated `Int.min`, `Int.min + 1`, now, and `Int.max`. It checks that each
behaves like any other message and nothing crashes. With the plain
subtraction restored, it crashes with signal 5 (overflow trap).
- Full BuzzPushKit suite: 108 XCTest and 66 Swift Testing tests passed.
- Automated app test: not run. This is a two-line arithmetic change
inside the extension's cache. The live APNs run on
[#8973](https://github.com/block/buzz/pull/8973) covers the flow.

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

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:49:54 -08:00
ravarora2
7b3c2f95f2 fix(mobile): bound notices to loaded message history (#9062)
## Summary

Mobile reads system notices older than its loaded message window, even
though those notices cannot yet appear. Bound notice reads by the oldest
loaded message's timestamp to avoid fetching history outside the loaded
time range.

A short or empty bounded response finishes that time range, rather than
all notice history. Preserve the full boundary second, resume when older
message pages load, and keep notices before the first message available
at the start of history. Enforce the 2,000-notice cap before fetching
reactions. Reuse cached reactions and deletions when older pages reveal
their notices.

For channels with fewer than 500 notices, scrolling back can now require
one bounded notice read per older message page. Previously, one
unbounded read covered the channel.

### Related issue

Related: #8962, which changed how mobile loads and displays membership
notices. No duplicate notice-bound PR found in the open mobile-notice
title search.

### Testing

Mutation checks: removing the production `since` argument or restoring
the old unconditional history-end flag each made all four notice-range
cases fail. Command: `cd mobile && flutter test --no-pub
--dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example
test/features/channels/channel_notices_journey_test.dart --name 'notice
ranges'`.

Replacing the captured message cursor with a fabricated maximum ID also
fails the empty-range progress regression. Command: `cd mobile &&
flutter test --no-pub
--dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example
test/features/channels/channel_messages_provider_test.dart --name 'an
empty notice range'`.

A read-only prepared-query check on local Postgres 17.10 used a
representative kind-40099 query. It held 22 event-related relation locks
with `force_custom_plan` and 99 with `force_generic_plan`. The generic
plan pruned seven of eight partitions during execution but retained the
broader locks. These results do not establish lock reduction or
throughput improvement under the relay's actual cached-plan workload; a
relay load test remains pending.

Native app scrolling against a live relay and human confirmation remain
pending.

For the device check, run `just mobile-dev` on a host with an iOS
Simulator runtime. Connect to a local test relay and open a channel with
more than 150 messages and interleaved topic notices. Scroll back
through at least three message pages, then to the start. Check that
visible notices remain in order and appear once. Disconnect during a
page read, reconnect, and continue scrolling to check recovery.

Generated with Codex

---------

Signed-off-by: BuzzPRReviewAssistant1 <a581a7e8115222acdc32ffb14bfce55d48c24478d98e155f8eba126b69b83c7b@buzz.block.builderlab.xyz>
Co-authored-by: BuzzPRReviewAssistant1 <a581a7e8115222acdc32ffb14bfce55d48c24478d98e155f8eba126b69b83c7b@buzz.block.builderlab.xyz>
2026-11-04 16:26:35 -05:00
klopez4212
99873459e9 Keep sign-in toasts usable at large text sizes (#9057)
At large text sizes, organization sign-in prompts could clip their
message and push recovery actions off-screen. Stack actions below the
message above 1.3× text, use fixed card corners, and skip the Dynamic
Island morph for that layout.

Follow-up to #9050, addressing [Wes’s large-text
blocker](https://github.com/block/buzz/pull/9050#discussion_r4469530660).
Normal-size styling and recovery callbacks are unchanged.

Validation: extended existing sign-in/cancel/error journeys at 1×, 2×
and 3× using Inter on a 320×568 viewport; assert text lies inside the
painted clip and the card stays on-screen. Removing the fix fails all
six enlarged-text cases. Mobile checks and full tests run through the
push hook. Human device testing of this follow-up is pending.

Local Flutter previews at 3× text (320×568):

| Before | After |
| --- | --- |
| ![Before: clipped text and action extending off
screen](https://github.com/user-attachments/assets/89b62c46-173c-4a1e-960a-f402f6d0346a)
| ![After: readable message above reachable Sign in
action](https://github.com/user-attachments/assets/ae3de975-3505-48ee-b9f3-b60aa710600f)
|

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-04 20:33:08 +00:00
klopez4212
ce94656b0d fix(mobile): use join template for community creation (#9072)
Community creation now uses the same mobile template, borderless iOS
input, and keyboard-coordinated transition as joining, replacing the old
welcome screen and boxed field. Name validation, separate form drafts,
reduced motion, and accessible error announcements are preserved.

Validation: analysis and 4,407 Flutter tests pass (45 skipped, four
workers); independent review passed. Kenny approved the simulator
preview. Agent verification of the full native transition remains
pending; the after snapshot below is a Flutter widget render.

| Before | After |
| --- | --- |
| ![Previous create
screen](https://github.com/user-attachments/assets/353b3b41-57b4-4a3a-9c21-10ea1c0becf2)
| ![Create using the join
template](https://github.com/user-attachments/assets/5a0d9d8d-cace-44b1-a3c8-bbe65f04a2e0)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-04 20:32:55 +00:00
Tom Brow
90de03aa63 feat(mobile): back up nsec to org builderlab account (#9029)
The Block tenant links a member's existing key to their account the
first time it issues them a relay assertion
([#8700](https://github.com/block/buzz/pull/8700)). Mobile never backed
that key up. Desktop FI builds back up to the tenant, but on mobile a
Block key that lives on just one phone can't be recovered once Block
turns backups on.

Mobile now backs up the organization relay's key to the organization
account:

- After every organization assertion, it reads
`/v1/buzz/nsec-backups/status`. If the key is already listed, there's
nothing to do.
- If the bound key isn't listed, it enrolls, seals with HPKE and
uploads, using the existing `HostedBackupService`. That service now uses
its account client's own origin, so organization and hosted backups
never share a pending envelope. Hosted storage keys and receipts don't
change.
- If the upload's outcome is unknown, the saved envelope is replayed
exactly at the next assertion.
- A tenant without backups answers 404 (Block production today). That,
and other definitive refusals, stop attempts for that account and key
until the next launch. Any other failure is retried at a later
assertion, after a delay that doubles from 1 minute to at most 1 hour.
Assertions renew while the app runs, so retries continue until the
tenant lists the key.
- Concurrent assertions share one attempt.
- iOS 16 can't seal, so it sends nothing. Backup failures never affect
the relay connection.

There's no UI. As in mobile's hosted onboarding, the backup happens
automatically. Desktop asks for an explicit backup choice first, so
whether mobile should too is a product call.

### Validation

Added tests (`organization_key_backup_test.dart`), which run through
real `HostedRelayAuth` assertion issuance with only HTTP faked:

- An uncertain upload is replayed with the same envelope at the next
assertion, then nothing more is sent in that launch.
- A key that's already listed sends no upload and drops a leftover
envelope.
- A tenant without backups is asked once per launch.
- A device that can't seal sends nothing.
- A failed status read is retried after the delay, not at the immediate
re-assertion. A key the tenant bound to someone else is never uploaded,
and its checks keep going at growing intervals.
- Two concurrent assertions make one attempt.
- A failing seal-capability check sends nothing.

Removing the assertion hook fails 4 of the 6 tests. Removing the shared
attempt or the retry delay fails its test. Full `mobile-check` and
`mobile-test` pass.

Device (iPhone 12 mini, release build) against bb-block staging, where
backups are configured and the relay enforces NIP-FI, with an existing
Block SSO session:

- On launch the app asserts, reads backup status and finds this key
listed, so it uploads nothing. The relay connection and channel list are
unaffected.
- A test-only build that ignored the listed shortcut enrolled, sealed
with the staging tenant's real KMS-backed key and uploaded, with a
stored response in about 360 ms. That re-stored my own test key.
- The final build at this head was then reinstalled and relaunched with
no backup failure logged.

The device's staging community was added in an earlier run, because
adding an enforcing organization relay by link is still blocked
([#8791](https://github.com/block/buzz/issues/8791)).

An independent agent review ran; its should-fix items are addressed in
the second commit. Codex's retry finding is addressed in the third,
which changes only failure-path timing; the device-tested success path
is unchanged. Wes's optional review notes are addressed in the last
three: an exact-timing retry test, backup queues per account origin (a
stalled organization backup no longer delays hosted onboarding's backup,
with a test), and a subject check on each enrollment and upload request.
Full `mobile-check` and `mobile-test` pass at the head. Human testing is
waived under the mobile automated-testing policy; the device run above
covers the at-risk flows (organization assertion, relay connection,
backup status, enrollment, sealing and upload).

buzz-review-completed

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:11:31 -08:00
Brad Seiler
a1f79e3aba feat(relay): persist NIP-FI upgrades (BUZZ-510) (#8985)
## 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](https://github.com/block/buzz/pull/8988) and [session
activation](https://github.com/block/buzz/pull/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](https://github.com/block/buzz/pull/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>
2026-11-04 19:56:07 +00:00
Luke Tornquist
4813b8cd15 relay: time every WebSocket read and every stored EVENT outcome (#9052)
## Summary

Two gaps in relay telemetry: WebSocket reads (`REQ`, NIP-AR sync,
`COUNT`) have no latency or outcome metric, and
`buzz_event_processing_seconds` only measures stored EVENTs that
succeed.

**What this does**
- **`buzz_ws_read_seconds{type, outcome, reason}`** (new histogram).
It's recorded from `access::ReadLog::drop`. Every WebSocket read already
finishes through that one seam, whether it ends `served`, `denied`,
`error`, or `aborted` (future dropped mid-read), so this adds no new
exit paths and changes no handler. Duration runs from the moment the
handler takes the frame until `EOSE` is queued (`REQ`), the initial set
ends (sync), or the total is sent (`COUNT`). `reason` is the existing
fixed denial/error code (`query_timed_out`, `database_error`,
`not_channel_member`, …), or `none`. The histogram's `_count` doubles as
the outcome counter, so one metric gives both read latency and read
failure rate.
- It's recorded *before* the refusal budget check, because the budget
only exists to suppress log lines. When `refused_read` is over budget,
it records the sample without building the log line, so its cost under a
refusal flood stays the same.
- **`buzz_event_processing_seconds{outcome}`**. Before this change it
was recorded only when `ingest_event` returned `Ok`. A store that was
slow *and failing* (statement timeouts, pool exhaustion) produced no
samples, so latency looked better while the store was degrading. It's
now recorded on every persistent-ingest outcome: `accepted`,
`not_accepted` (for example a duplicate), `invalid`, `auth`, `error`.
Queries that sum across all outcomes still work.

**Cardinality.** Every label value is a `&'static str` from a
code-defined set: 3 types × 4 outcomes, with about 25 reasons that only
appear alongside `denied`/`error`. On purpose, there's no `kind` label:
`bounded_kind_label` still admits around 200 values, which is too many
to cross with histogram buckets.

**What got simpler:** `ReadOutcome::parts()` replaces the inline
outcome→label match in `Drop`, so the log line and the metric share one
mapping. There's no new instrumentation seam: the read metric reuses the
access log's lifecycle.

### Related issue

None found. This came out of a monitoring-gap review: without these
signals, alerts can't catch a slow or failing WebSocket read path, or a
failing EVENT store.

### Testing

-
`access::tests::every_read_is_measured_even_when_its_line_is_suppressed`
(unit). It drives the real `ReadLog` constructors and `Drop`: 20
admission refusals against a 16-line budget, a handler denial past the
budget, a `COUNT` error, a served sync and an aborted `REQ`. It asserts
the exact `(type, outcome, reason, samples)` rows. Two mutants each fail
it: removing the over-budget branch in `refused_read`, and moving the
measurement after the budget check in `Drop`.
-
`handlers::event::tests::postgres_tests::event_processing_is_timed_for_accepted_and_refused_ingest`
(Postgres lane). It sends one accepted text note, then a refused one
from a cached-ban sender, through `handle_event`, and asserts one sample
each under `accepted` and `auth` (a ban maps to the `auth` class).
Removing the `Err`-arm measurement fails it.
- Blox gate at `b314df43f2` (tree `4ca9ebc1`): `cargo fmt --check`
clean. The new Postgres test passes, and removing the `Err`-arm
measurement fails it. buzz-relay unit lane (`cargo nextest run -p
buzz-relay --lib --bins`): 1708/1708 passed.
- At `539796bd51` (the same code; only the second commit's test
assertion differs): workspace clippy (`--all-targets -D warnings`)
passed. The new unit test passed, and each of its two mutants failed it.
buzz-relay Postgres lane: 604/605 passed. The one failure was this
test's old `invalid` expectation, which `b314df43f2` corrects.
- CI at `b314df43f2`: both new tests appear in the CI logs as passing
(Rust / Unit Tests, PostgreSQL Domain / PostgreSQL Tests).

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

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:15:51 -05:00
Luke Tornquist
25abfc4117 fix(loadgen): bound desktop reads at stop, keep timely EOSEs, model presence and probes (#9016)
## Summary

Follow-up to #9005, addressing the review findings that merged
unaddressed. None of them affect a relay. `buzz-ws-client` gains one
method. Each one skews what the desktop client measures when the relay
is slow, which is the case the load generator exists for.

- **Reads stop at the stop.** A desktop HTTP read (including NIP-11) now
races the run's stop and is abandoned there without being recorded, as
EOSE waits already were. Before, a read that outlived the 15 s shutdown
grace got its client cancelled and failed an otherwise healthy run.
`Fetcher::finish` also keeps the read task's handle until the task ends,
so a client cancelled during the join still aborts the task instead of
detaching it.
- **Root lookups are bounded.** The desktop's seeded-root lookups, for
thread recovery and replies, now run under the desktop's 30 s read
timeout and stop, not reqwest's 60 s. `roots()` takes an optional bound,
and the basic human passes none. If the stop cuts a reply's lookup
short, the reply isn't published. That applies to the basic human too: a
reply whose root lookup finishes after the stop is dropped.
- **An EOSE that arrives in time stays in time.** While a publish waits
for its OK, `NostrWsConnection` buffers every other frame. The session
loop used to judge EOSE deadlines before reading that buffer, and read
one frame per pass. It now handles every frame already in hand (at most
256 per pass) before judging deadlines. Each frame is judged by when it
arrived, not when it is read. `NostrWsConnection` keeps each buffered
frame's arrival time, and the new `next_event_received` returns it. So
an EOSE that arrived after its deadline while an OK wait held it counts
as a timeout, and so does a frame that answers the probe late. Past that
cap, a pass defers due deadlines, for at most one timeout from the first
pass that defers them. While it is deferring, the pass only drains, so
another OK wait can't expire an EOSE that's already in hand, and
sustained traffic can't postpone a missing EOSE forever.
- **Presence and the silence probe.** Both modes now open the app's
live-only `presence` route
(`{"kinds":[20001],"authors":[…],"since":…,"limit":0}`, up to 256
co-members) alongside the profile and membership routes. As in the app,
presence takes no part in establishment: it isn't resumed or carried,
and if it fails it doesn't cut a renewal's carry for the other routes.
After a tick with no frame since the previous one, they send the app's
probe, `REQ probe-N {"kinds":[44100],"#p":[viewer],"limit":0}`, which
shows up as `desktop_probe` and is closed at its EOSE. Only frames
actually received count, including a publish's OK when it arrives before
the probe's deadline. A reaction with no target or a publish whose OK
timed out doesn't count. Expiry is judged after the drain against the
probe still outstanding. If no frame arrives within the EOSE deadline,
the session ends as failed and the client reconnects with backoff.
Before this, desktop runs left out presence fan-out and idle-client
probes, so they understated load.
- **Seed log.** "memberships added (N people)" now reads "memberships
ensured", since a rerun adds nobody.
- **Docs.** `perf/LOADGEN.md` notes that the six-read cap and flat 30 s
timeout are simplifications (the app has neither), and that `legacy` is
a lower bound. The pre-fix app also opened a per-channel route for each
open or non-member channel and re-sent every route each tick, and the
model leaves both out.

Not done: an explicit count of REQs abandoned at a renewal (report
polish; planned cancellations still aren't errors).

### What got simpler

Reads abandoned at the stop now go through one helper, `run::bounded`,
which the desktop reads and the root lookup share. Before, each read had
its own timeout and its own stop check. `global_reqs` returns the routes
the client actually opens, not a fixed pair. The probe and presence
routes add one `Route` variant, one op and about 40 lines of session
code. `Route::establishes` is now the one place that says which routes
take part in establishment, replacing the two `Replay` special cases.
"Heard" used to be inferred by comparing activity schedules before and
after each step. It now comes from frames only, so `publish` reports
whether the relay answered and that comparison is gone.

## Testing

On a Blox workstation, at the tree of this PR's head:
- `cargo fmt --all -- --check`, `cargo clippy -p buzz-test-client -p
buzz-ws-client -p buzz-cli --all-targets -- -D warnings`
- `cargo test -p buzz-test-client --lib` (parallel) and `cargo test -p
buzz-ws-client`, plus the `loadgen::desktop` tests five more times

New tests, each confirmed to fail when its fix is reverted:
- `a_read_in_flight_at_the_stop_is_abandoned_not_waited_for`: an 8 s
roster read at the stop. Without the stop race the client outlives
`drive`'s 5 s stop limit.
- `a_cancelled_finish_still_aborts_the_read_task`: aborting `finish()`
mid-join ends the read task. With the old `take()`, the task is
detached.
- `an_eose_in_time_is_not_expired_behind_a_slow_ok`: EOSEs at 1.2 s,
publish OKs at 3.5 s, deadline 2.5 s. Without the drain, all four routes
time out.
- `a_silent_tick_probes_and_an_unanswered_probe_reconnects`: the first
connection never answers its probe, so the client reconnects and its
next probe is answered.
- `a_probe_answered_behind_a_late_ok_does_not_reconnect`: the probe's
EOSE arrives in time, but it sits behind a heartbeat whose OK comes
after the probe's deadline. With expiry judged against a deadline
captured before the drain, the session fails and reconnects.
- `a_publish_ok_in_time_answers_an_outstanding_probe`: no probe EOSE
ever arrives, and heartbeat OKs answer each probe.
- `a_reaction_with_no_target_is_not_heard_as_a_frame`: targetless
reactions several times a second still leave the tick silent, so it
probes.
- `a_reply_whose_root_lookup_the_stop_cut_short_is_not_published`: the
stop cuts an 8 s root search short, and OKs are withheld. No `kind 9`
EVENT reaches the relay, and the client returns promptly.
- `a_backlog_defers_deadlines_for_at_most_one_timeout`: the deferral
bound and its reset.
- `a_long_backlog_behind_slow_oks_keeps_its_timely_eoses`: 600 replayed
events per REQ behind 3.5 s OKs, with a 1.5 s deadline and heartbeats
every second. The test fails if the deferral is timed from the REQ
instead of from the first pass that defers, and it fails if a second OK
wait can run between the deferred passes.
- `presence_is_a_live_only_global_only_while_someone_is_watched`: the
route's order and its live-only filter in both modes, and that it's
absent with nobody watched.
- `a_late_eose_is_not_answered_behind_a_slow_ok`: the timely case above
with EOSEs at 3 s instead, so all four routes time out, not succeed.
- `a_probe_answered_late_behind_a_slow_ok_reconnects`: a probe's EOSE
arrives 0.2 s after its deadline while a heartbeat waits for its OK, so
the session fails and reconnects. The test also checks that a heartbeat
followed that probe on the same connection, so the test can't pass on a
probe that simply went unanswered.
- `an_answered_probe_is_closed_at_its_eose`: `CLOSE probe-1` goes out
before any next probe.
- `routes_establish_once_and_a_renewal_holds_them_until_a_cut` gains a
case: a renewal where presence times out keeps the other routes carried
and establishes nothing.

Each new test fails when its fix is reverted (seven mutants in the first
round and six in the review round, run on Blox). The test harness now
has a tick override that can go below the scenario's 1 s floor, and
records EVENT frames by kind. The fake relay sends a REQ's replayed
events after the REQ's delay, not before, and the fake API's slow reads
sleep in `block_in_place`. No existing test changed behavior.

Existing expectations moved for the presence route (one more live EOSE
per client) and for reads now abandoned at the stop.

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

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:57:38 -05:00