Commit Graph

3348 Commits

Author SHA1 Message Date
Brad Seiler
2cd40badb8 Manage community NIP-FI policy with enable, disable and status (#9176)
## Summary

Operators can enable, disable and inspect a community's stored NIP-FI
requirement through signed POST/GET
`/operator/communities/nip-fi-policy` and `buzz-admin nip-fi-policy`.
This replaces the old enable-only endpoint and drops the
`nip-fi-upgrade` command added earlier in this PR, along with obsolete
compatibility code.

Each saved intent binds its desired state and observed generation/state.
Every accepted new intent advances the generation, including same-state
changes. Committed retries recover history without reapplying policy;
stale requests are refused. Current status exposes one pair of reusable
`expected_*` preconditions. Historical receipts remain readable after
policy changes or target deletion, including old rows with null
preconditions. Their UUIDs cannot be reused for new requests.

Migration 0085 stays additive; main’s default-channel recovery migration
retains version 0084. It preserves old receipt rows and INSERT
compatibility. Operator instructions cover use, recovery and client
migration: [usage
guide](33a1c0bb87/docs/operator-nip-fi-upgrade.md),
[client
contract](33a1c0bb87/docs/operator-nip-fi-policy-client.md).

### Rollout and containment

The old `/operator/communities/nip-fi-upgrade` route is removed; GET and
POST return 404 without its old auth handler. A pending sponsorship
client still targets that route. Migrate that client to the supported
API and deploy it with mutation dispatch paused before this relay
version reaches its fleet. Its complete CAS preconditions must be
durably saved before first dispatch and reused unchanged after a crash
or lost response. This is a pending integration dependency, not a claim
that the caller is deployed.

Close `BUZZ_NIP_FI_UPGRADES_ENABLED` on every old relay. Keep
`BUZZ_NIP_FI_CHANGES=false` on new relays. Apply schema first and finish
the whole-fleet rollout before opening the new gate and resuming
migrated clients. Before opening the new gate, verify the deployed
artifact SHA on every pod and probe each pod directly: the old route
must return 404, and a fresh, well-formed, signed policy request with a
new UUID must return 503 `upgrades_disabled`. A committed retry is not a
gate probe. Old pods with the old gate open can still accept unfenced
enables. Do not roll back to those writers after a policy operation. The
old variable is no longer read by this version; the new gate defaults
off and controls both directions.

Read accepted intent in `receipt`, current preconditions/effective
policy through status, and effective discovery through `/info`. Enable
stays `pending` because receipts cannot certify fleet-wide socket
closure. Under global `enforce`, stored disable leaves FI effectively
required. Closing the gate preserves receipt recovery; use a fresh CAS
disable or existing archive controls for containment. Preserve uncertain
requests and their UUIDs. This PR performs no live state change or
deployment.

### Related issue

N/A

### Testing

Published repair head: `33a1c0bb87a6fa10212eb4ec60788e24189627e2`,
incorporating main `39d5159caf`. The Git policy guards now apply only to
matched paths. Unknown GET/POST paths on relays without a web directory
return 404 instead of inheriting the internal endpoint’s 403. The
matched internal endpoint retains its localhost restriction and 1 MB
body limit, including its method fallback. The signed removed-route 404
assertion is unchanged.

Main’s migration 0084 is unchanged; the additive policy migration is now
0085 with identical SQL. Its populated-receipt upgrade test starts at
schema 84. Formatting and commit hooks pass; independent source review
has no findings. At this exact head, formatting, all-target Clippy (`-D
warnings`, relay/db/admin/pubsub), and test compilation (relay/db/admin,
`--lib --tests --no-run`) pass with exit 0. The full native PostgreSQL
lane passes: 1,373/1,373 tests, exit 0, including the unchanged signed
removed-route 404 assertion, fresh-database migration and populated
receipt upgrade from main schema 84 to 85. The first full package run
used an uninitialized private database and stopped with two
missing-table errors; that failed attempt is retained. After schema
initialization and verification, the corrected full package rerun
passes: 2,041/2,041 tests, exit 0 (1,396 skipped by unit selection). The
separate PostgreSQL lane skips 2,064 tests by its selection. Both full
runs use the exact published repair head. Hosted CI remains pending. The
new router test reproduced the old failure with the exact localhost-only
response body; the new production-router regression also passes in the
corrected package run. Four individual guard mutations are running
serially; their assertion-failure receipts and the restored final green
run remain pending.

Brad authorized one `git push --no-verify` of this repair conditional on
passing lint and compilation. Those gates passed; the push exited 0 and
both the remote branch and GitHub PR SHA were verified as
`33a1c0bb87a6fa10212eb4ec60788e24189627e2`. The normal publication
attempt was stopped before completion and its log is retained. This
exception leaves remaining tests and acceptance requirements
outstanding.

Historical evidence below applies to the earlier published head, not the
published repair.

Published head: `d31793ccbcdfd748361bcd559358915862b5ed57`. This
integrates main's missing `ChannelEvent.received_at` fixture correction
and corrects the newly integrated initial-policy test to use
`nip_fi_changes`, with matching documentation. Independent source review
has no open findings. At this exact head, all-target Clippy with `-D
warnings` passes for buzz-relay, buzz-db, buzz-admin and buzz-pubsub;
independent Clippy and formatting checks also passed.

The normal push hooks failed (push exit 1):
- Rust: `buzz-acp` recovery test hit its 15-second test deadline.
Nextest completed 844/1101 tests: 843 passed, 1 failed, 3 skipped; 257
were not run.
- Tauri: `buzz-terminal` login-shell test received no child output
within its 10-second test deadline; 26 passed, 1 failed.
- Mobile: gallery test `2 trailing images match carousel at 208.0, scale
1.0` expected no loading placeholders and found two; 4,524 passed, 60
skipped, 1 failed.

These are two test-level deadline failures and one assertion failure,
not compiler errors. The failing source paths are unchanged from main
`6c3ffd160b`; no baseline reproduction or flaky classification is
claimed. Desktop JS checks/tests/typecheck, branch-skew and file-size
checks passed. Full hooks are not reported as passing. The first hosted
PostgreSQL run at d31793cc failed the removed-route assertion (expected
404, got 403); the router repair above addresses that failure. That
hosted run’s Rust unit, Tauri and mobile jobs passed.

Historical d31793cc publication exception: Brad explicitly authorized a
single `git push --no-verify` for this head on 2026-11-06 after the
failed normal-hook run. That push exited 0 and its remote SHA was
verified. Failed-hook evidence is retained. The earlier `cf2c9ba599`
publication also used an explicit exception. Neither exception waives
outstanding validation, review or human acceptance before merge.

Historical evidence at `5b2eb6f09366b8a8f480a5fc61e8e5368f62302d`: 2,006
affected-package tests and 32 disposable-relay checks passed. These
predate the latest main merge and are not final-head evidence. Earlier
native PostgreSQL and separate `just ci` / `just test` attempts were
incomplete, interrupted or failed and are not passing results.

No staging run or human runtime confirmation is claimed. This PR is not
a draft and is ready for review; readiness does not attest human runtime
confirmation.

Generated with Codex

---------

Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
2026-11-06 22:07:31 +00:00
Helen Tian
39d5159caf [buzz] Recover interrupted default-channel auto-joins (#9045)
**Bug**

Default channels are channels that new community members should join
automatically. If the database could not list those channels—for
example, because its connection pool was busy—an invite claim or an
in-app admin add still admitted the member to the community. That member
could then remain outside the default channels permanently.

**Cause**

Community admission was saved before channel joins started. If listing
the channels failed, the relay logged the error and stopped. Retrying
the invite or admin add did not help: the member already existed, so the
relay skipped the joins.

**Fix**

Save a marker for unfinished channel joins in the same database
transaction as admission. If listing or joining channels fails, keep
that marker. Retry when the member next authenticates, claims the invite
again, or is added again by an admin. One channel's failure does not
stop attempts to join the others.

Retries preserve the existing membership rules:

- A member who leaves a channel during recovery stays out of it.
- Work from an earlier admission cannot add channels or clear pending
work after the member is removed and admitted again. Each admission has
a unique marker, checked under a database lock.
- A reconnect still checks whether the member allows the original admin
to add them to channels.
- Once recovery finishes, later connections do not enroll the member in
newly designated default channels. The operator command `buzz-admin
add-member` continues to add community membership only.

**Limits and rollout risks**

Recovery needs one of those later requests; there is no background retry
timer. This change does not repair members missed before it was
installed.

Channel announcements and notifications keep their existing best-effort
behavior. A failure after channel membership is saved can still lose an
announcement; this change does not replay it.

Migration 0084 adds an optional UUID column to `relay_members`. Apply it
before running the new code. Older relays ignore the column. The
migration has a five-second lock timeout, so it fails rather than
waiting indefinitely for its table lock. It adds no triggers, functions,
procedures, rules, foreign keys, or data backfill.

---------

Signed-off-by: Helen Tian <htian@squareup.com>
Co-authored-by: Flipper <1ef64189596befbc8b93f9021bff35ae5d02b155d9e05fc124bca7349927d597@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-06 16:27:39 -05:00
Luke Tornquist
38795ada87 fix(db): keep retained fence proofs when a probe fails (#9200)
## Summary

A failed replica fence probe no longer drops the proofs already
retained, so one failed probe no longer sends every read to the writer.

**Why.** `run_probe` called `fence.close()` on any probe error, which
cleared the ring. Until the next successful probe 500 ms later,
`fence.newest()` was `None` and every routed read went to the writer
with `reason="uninitialized"`. Under load this showed up as a steady
share of `bridge_query` reads labelled `uninitialized` on every pod,
while `heartbeat_age` stayed small. The module already documents that a
retained entry's proof never decays: a failed probe proves nothing new,
but it doesn't make an earlier proof wrong. So clearing the ring only
cost reader capacity.

**What changes.** A failed probe logs and records nothing. The retained
entries stand. A probe that keeps failing still closes the fence through
the existing 30 s staleness gate (`FENCE_STALENESS`), and
`heartbeat_age` remains the liveness signal. Regression handling is
unchanged: an epoch change or a same-epoch token regression still clears
the ring in `record()`. `close()` has no production caller left and
becomes a test hook.

**Likely trigger.** The masked-activity check
(`ProbeError::MaskedActivity`) counts a client backend whose state is
`active` with a `NULL` `xact_start` as masked. A backend that has just
started a statement can briefly show as `active` before its `xact_start`
is set, so busy writers hit this transiently. Under pgbench on Postgres
17, between 0.3% and 1.3% of scans saw such a backend. This PR doesn't
change that classification. See the debt below.

No protocol, schema, metric or client change.

### Testing

- `replica_fence::postgres_tests::failed_probe_keeps_retained_proofs`
(new, no database needed, so it runs in the unit lane like its
neighbours): records an entry, runs a probe tick against a closed writer
pool (the probe fails), then asserts `newest()` and `resolve()` still
return the entry, and that a rewound token recorded under the same epoch
after the outage is a `TokenRegression`. Mutation-checked on Blox:
putting `fence.close()` back on the error path fails it, including when
only the regression assertion is left.
- On Blox: the full `buzz-db` Postgres lane, the `buzz-db` unit tests,
`cargo fmt`, clippy for `buzz-db` and `buzz-relay`, and a `buzz-relay`
build.

### Rollout

There's no flag. Rollback is a revert. After deploy, watch:

- `buzz_db_route_decision{decision="writer",reason="uninitialized"}`
should drop to about 0 once a pod has booted, and the
`decision="reader"` share should rise.
- `buzz_db_replica_heartbeat_age_seconds` should stay small. Staleness
is now the only thing that closes the fence on probe failure, so this is
the liveness signal.
- `reason="stale"` may rise a little. `newest()` returns `None` for an
empty ring and for one older than the staleness gate, and both are
labelled `uninitialized`. Bounded reads can also miss the budget for one
tick when a probe beat is missed and fall back as `stale`. Some of
today's `uninitialized` share is expected to move to `stale` rather than
disappear.

## What got simpler

`ReplicaFence::close()` leaves the production API. A probe error now has
one effect (log and keep proving from what's retained) instead of a
second path that discards valid state. The probe loop is split into a
one-tick function that the test calls directly.

Identified, out of scope:
- `buzz_db` warnings, including this probe's failure warning, are
dropped by the relay's default log filter (`buzz_relay=info`), so
operators can't see why a probe fails.
- `MaskedActivity` misclassifies the transient
`active`-without-`xact_start` state. Treating it as not-open looks
sound, because such a backend's transaction starts after the scan, but
it touches the proof and deserves its own review.
- The probe's exclusive advisory lock waits behind channel writers'
shared locks, so a slow writer delays the probe.
- Probe failures have no metric. With retained proofs a failed probe no
longer shows up as `uninitialized` routes, and its warning is filtered
out of the logs (above), so intermittent failures are now invisible. A
probe-failure counter would fix that.
- An epoch rotation that fails still leaves an opening for a restore
that keeps the epoch. `TokenRegression` clears the ring, so if the
rotation `UPDATE` then errors, the next sample stores the rewound token
under the old epoch against an empty ring. This PR doesn't change that
path. A writer-side high-water token kept outside the ring would close
it.
- `newest()` gives the same `uninitialized` label to an empty ring and a
stale one, which hides a dead probe in route metrics.

🤖 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-06 16:06:11 -05:00
Tom Brow
597a8d1970 feat(mobile): rename Activity and add header filters (#9213)
The UI-only part of #9186 by @klopez4212, split out so it can land
without the Activity sync changes.

- The Inbox page becomes Activity, with header controls instead of
menus: a type menu (All, Threads, Mentions, DMs, Reminders, Drafts), a
sender menu (Anyone, People, Agents) and an Unread toggle. All of them
filter the feed the app already loads.
- Activity and Search get a top edge fade. Bulk mark-as-read and the
"New" divider are gone.
- DM thread replies group with their DM row, so the DMs filter includes
them.

Left out, for a separate sync PR: the Activity history cache,
subscriptions to every joined channel, the refresh coalescer,
thread-follow evidence, Reactions and Solo rows, and the read-state
changes. The Activity provider and read-state writes are unchanged from
main.

One fix on top: the iOS filter labels pass an unscaled font size, since
the native button already applies Dynamic Type. Before, large text was
scaled twice.

### Validation

- Kenny's added tests for the header controls (`tabs_journey`) and
`TopEdgeFade`, adjusted for the removed Reactions option. Added tests
for the DM filter, marking a DM row with replies under two thread roots
read, and sender filtering per event.
- Full mobile suite: see the latest CI run. Locally, 11 push and
notification-toggle tests fail identically on an unchanged checkout.
- Automated simulator run (iPhone 17 Pro, iOS 27) of a build combining
this PR with its sibling split: Home rows measure 44 pt, the bell opens
Activity and shows its dot after a row is marked unread,
type/sender/Unread filters each narrow the feed, a row opens its
conversation, Search results scroll under the fade, and filter labels
fit at the largest text size. Screenshots are in Buzz rather than here
because they show internal test data. Android was covered only by widget
tests.

### Before merge

- [x] Automated simulator run of Activity filters, row taps, read and
unread, and Search, with screenshots.

🤖 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: kenny lopez <klopez4212@gmail.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
mobile-v0.28.0-rc.5
2026-11-06 12:41:04 -08:00
Luke Tornquist
6c3ffd160b fix(relay): set received_at in the system-channel fan-out test helper (#9214)
## Summary

`buzz-relay` lib tests fail to compile on `main`: `error[E0063]: missing
field received_at in initializer of buzz_pubsub::ChannelEvent` at
`crates/buzz-relay/src/handlers/system_channel_postgres_tests.rs:1549`.

This is a semantic merge conflict. #9195 added
`ChannelEvent::received_at`, and #8519 merged a minute later with a test
helper that builds a `ChannelEvent` without it. Neither PR's CI saw the
other. This sets the field to `tokio::time::Instant::now()`, as every
other initializer does.

It breaks clippy (`Rust Lint`) and the backend-integration test archive
for every open PR, because CI builds the merge with `main`.

### Testing

- `cargo fmt --check` and `cargo clippy -p buzz-relay --all-targets -D
warnings` on a cloud workstation at this head.

## What got simpler

Nothing; a one-field compile fix.

🤖 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-06 20:18:50 +00:00
Tom Brow
beec055300 feat(mobile): move Activity to a Home bell and align Home spacing (#9212)
The UI-only part of #9193 by @klopez4212, split out so it can land
without the Activity sync changes.

- Activity moves from a row in the Home list to a bell button in the
Home header, with an unread dot driven by the existing Activity feed and
read state.
- Home filter capsules match native iOS and Android widths, with a
subtle outline on Android when the header collapses.
- Home section spacing is tighter.

Left out: the reconnect rewrite in #9193's last commit (live events
applied in place, no rebuild on reconnect, fatal fallback reads). The
Activity provider is unchanged from main. Channel and Solo rows keep
their bottom padding so they stay at the 44 pt minimum tap height.

### Validation

- Kenny's added tests: `inbox_unread_provider_test` (unread dot) and the
Home page bell and filter width tests.
- Full mobile suite: all pass except 11 push and notification-toggle
tests that fail identically on an unchanged checkout in this
environment.
- Automated simulator run (iPhone 17 Pro, iOS 27) of a build combining
this PR with its sibling split: Home rows measure 44 pt, the bell opens
Activity and shows its dot after a row is marked unread,
type/sender/Unread filters each narrow the feed, a row opens its
conversation, Search results scroll under the fade, and filter labels
fit at the largest text size. Screenshots are in Buzz rather than here
because they show internal test data. Android was covered only by widget
tests.

### Before merge

- [x] Automated simulator run of Home, the bell, Activity and channel
navigation, with screenshots.

🤖 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: kenny lopez <klopez4212@gmail.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:10:11 -08:00
Brad Seiler
446e9e0762 Allow operators to set initial NIP-FI policy on create (#9211)
Trusted operators can choose `initial_requires_nip_fi` on create-only
community provisioning. True commits required policy (generation 1),
validated issuer bindings and the owner with the new community in one
transaction. Omitted, null or false preserves optional creation; a
non-null field in legacy ensure mode returns coded 400. Missing
effective bindings refuses required insertion without leaving a row.

The response reports policy read from the stored row. Same-owner
create-only replay now accurately reports `existed` instead of
`created`; it stays successful and read-only, including after policy
disable or issuer-config changes. Different-owner conflict, legacy
ensure, bootstrap and the operator authorization gate retain their
behavior. This adds no schema changes or server-wide default flag.

Upgrade the whole serving relay fleet before callers request true. An
old writer may ignore the field and insert an optional row: response
checks detect it but cannot undo it, and later lookup remains read-only.
Turn caller targeting off before relay rollback. Mode off stores policy
but bypasses enforcement, independently of the upgrade gate.

### Related issue

Replaces closed #9172. The separate lifecycle API remains in #9176.

### Validation

Draft head: `bf16a1e0aba102c3be86f9621bd53d2cae98661c`. Independent
source review covers this head. Formatting, diff sanity, Clippy, build,
all 14 signed HTTP scenarios and full local `just ci` pass at this head.
GitHub reports 59 successful and 38 skipped checks, with none pending or
failing in the collected snapshot.

At parent `b9d9423a6058387f42c310f5a7e4dbe0c8d8b40f`, all 1,978 package
tests and 1,346 PostgreSQL tests passed (PostgreSQL concurrency 2). Four
deliberate mutations were detected and restored. Clippy then found seven
needless borrows in the new HTTP test; this head removes those borrows
only. Earlier validation passed 1,344/1,346 PostgreSQL tests at
concurrency 8; the replay assertion was corrected and the timeout
disappeared at concurrency 2. Failed-run evidence is retained.

At this head, the first mandatory push-hook attempt failed all nine
boot-lifecycle tests on child startup deadlines. A subsequent serial run
passed all nine (seven PostgreSQL-only cases skipped), with unchanged
deadlines and source. The first diagnostic was interrupted during
compilation without a result; all evidence is retained. A normal-hook
retry was stopped when the author explicitly authorized one push with
`--no-verify` for this exact commit. That exception skips local pre-push
hooks for publication only; failed hooks and remaining validation still
gate readiness.

Explicit human runtime confirmation remains a separate acceptance step;
agents have not attested it.

Generated with Codex


Validation update at the same draft head: Clippy, build and all 14
signed HTTP workflow scenarios passed, including atomic required
creation, stored-policy replay after disable without a current binding,
and refusal without leaving a row. The first live harness run stopped
after 12 checks because its SQL helper fetched rows after an UPDATE
without RETURNING; that harness query was corrected and the complete
workflow passed. Relay source stayed unchanged.

The full local `just ci` run failed in `mobile-check`: Dart formatting
reported 266 changes among 1,068 files, with unresolved `flutter_lints`
configuration warnings. The format check used `--output=none`; the
worktree remains clean. Repository validation is therefore not green.
That failure is retained as historical evidence; see the successful
setup-corrected rerun below. Explicit human runtime confirmation remains
separate.


Final local validation: after `just mobile-install`, `mobile-check`
passed without formatting edits, and full `just ci` completed
successfully at `bf16a1e0aba102c3be86f9621bd53d2cae98661c`. Tracked
files remained clean after installation and after the complete run. This
confirms the earlier mobile formatting failure resulted from missing
worktree dependencies. All failed runs remain preserved. GitHub's
current ready-for-review state is preserved; no agent added
`buzz-review-completed` or attested human runtime acceptance.

---------

Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@buzz.block.builderlab.xyz>
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
2026-11-06 15:07:08 -05:00
Luke Tornquist
e670b672b8 perf(loadgen): spread seed writes across channel creators (#9206)
## What

`buzz-loadgen seed` signed every channel create, member add, and thread
root with the community owner. In private channels without human
members, the owner also signed all the history. The relay limits HTTP
calls per pubkey (default 300 a minute), so a community with thousands
of channels can't be seeded against a relay that keeps its default
limits. The seeder doesn't retry a 429, so the seed fails partway, and a
torn-down `run_id` can't be reused.

Take one community with 20,000 private channels: the owner alone signs
more than 40,000 writes, which is over two hours of its quota. Human
authors had the same problem on a smaller scale. A human in hundreds of
consecutive private channels signed hundreds of history posts within
seconds.

Now the community owner creates its first 8 channels, and each further
block of 8 gets its own creator identity (`Role::Creator`, derived from
`run_id`). A creator signs its channels' kind:9007 and kind:9000 events
and their thread roots, and `run` looks roots up by the creator. That
keeps every creator well under one minute of quota, whatever the
community's channel count.

History and replies still rotate across humans: a private channel's own
human members, or its creator when it has none. Because only humans look
roots up, the creator signs nothing but roots in their channels. No
amount of history or replies can push a root out of `run`'s newest-100
lookup.

Human signers can still bunch up, for example a human in hundreds of
consecutive channels or a deep thread. To cover that, a seed write the
relay throttles now waits out the `retry in <n>s` window the relay names
and tries again, up to 10 times, before it counts as failed.

Compatibility: a community with up to 8 channels is seeded as before,
apart from the retries. An existing fixture with that shape keeps
working with `run`, including its root lookups.

## What got simpler

Nothing got simpler. This adds a creator role and a retry loop, which is
the smallest change that makes large fixtures seedable against default
limits.

## Validation

- `cargo fmt --check`, `cargo clippy -p buzz-test-client --all-targets
-D warnings`, and `cargo test -p buzz-test-client --lib loadgen`, run on
a Linux workstation at the head commit.
-
`no_channel_creator_signs_more_seed_writes_than_one_minute_of_its_quota`
seeds a private community of 400 channels against a fake relay. The
community has wide channels, a tier of humans in 280 channels each, and
runs at 2x. The test requires every creator to sign fewer than 300
writes. With the owner signing everything, it fails: the owner signs all
7,212 writes.
-
`throttled_writes_wait_out_the_quota_and_a_lasting_quota_fails_the_seed`:
when every write gets two 429s it is retried and the seed passes. When
the quota never clears, each write is tried 11 times and the seed fails
with the quota error.
- `private_channels_add_every_member_before_anything_is_posted` now uses
20 channels. It checks that every create, member add and root is signed
by its channel's creator, that the owner keeps channels 0–7, and that
every other post is by a human member.
- `root_lookups_search_the_channel_creators_posts` checks that `run`
searches for roots by the owner in a community's first block and by the
block's creator after it, across two communities.

🤖 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-06 19:48:24 +00:00
Luke Tornquist
a17243d846 feat(relay): runtime-tunable history-read and AUTH concurrency caps via feature flags (#9170)
## Goal

Give operators a way to relieve database pressure in seconds, without
restarting relay pods. Today every concurrency limit is an environment
variable, so changing one means a rollout. During an incident, that
rollout's restart sets off a reconnect storm of its own.

## What changes

**Two runtime-tunable caps.** Each is read from an integer feature flag
in a new process-wide evaluation context, and re-read every 5 s:

| Flag | Caps | Ceiling |
|---|---|---|
| `buzz.relay.concurrency.history-reads` | subscription-history reads
holding a reader connection (the #9132 gate) | the existing `pool −
pool/5` share, which keeps readers free for authorization |
| `buzz.relay.concurrency.auth` | verified NIP-42 AUTH frames running
their database checks | none (unbounded today) |

- **Flags can only tighten.** A positive value is clamped to the
ceiling. Zero, a negative value, no value, or a provider that never
initialized leaves the gate at its ceiling, which is today's behavior.
Once LaunchDarkly has delivered values, they stay in force through a
later outage, so a cap tightened during an incident survives a provider
blip.
- **Lowering never cancels admitted work.** A lowered gate admits
nothing new until the work in flight falls under the new limit.
- **Visibility:** `buzz_concurrency_limit{gate}` reports the applied
limit (`0` means no limit), and each change logs a line.

**One resizable gate.** `HistoryReadGate` becomes
`buzz_db::ConcurrencyGate`, a FIFO semaphore whose limit can move
anywhere in `1..=ceiling`:
- Lowering it forgets free permits and records any shortfall as debt.
- A permit released while there is debt is retired instead of returned.
- Raising it repays debt first, then adds permits.

All of this, and admission itself, settles against the debt under one
lock, so a newer limit always supersedes an older one; there is no
background resize task to race. Tokio hands a released permit to a
queued waiter before it runs; if that waiter is then cancelled, tokio
returns the permit to the semaphore directly, so admission retires any
permit it gets while debt is outstanding. The AUTH cap uses the same
type.

**AUTH shedding.** The permit is taken after signature verification, so
only real logins queue for the database checks. The wait is bounded at 2
s. A shed login gets a bare close with no `OK`, exactly like an auth
timeout. I chose this from what each client does on main today:
- **`OK false`:** the desktop app retries on an unjittered 60 s ladder.
Mobile parks the session until the app resumes. The agent harness fails
at startup.
- **Bare close:** the desktop app, mobile, the agent harness and the CLI
all reconnect on their jittered, capped backoff.

On the relay, the shed is visible as access line
`denied/concurrency_limit` and as
`buzz_auth_outcomes_total{outcome="concurrency_limit"}`.

**The feature-flag provider is chosen at startup.**
`BUZZ_FEATURE_FLAGS_PROVIDER` takes `static` (the default),
`environment` or `launchdarkly`, with `BUZZ_LAUNCHDARKLY_SDK_KEY` and an
optional `BUZZ_LAUNCHDARKLY_RELAY_PROXY_URL`:
- **Exactly one provider.** The value is parsed into a single provider,
so there is no stacking and no fallback, and the evaluator never changes
for the life of the process. The variable sits outside the
`BUZZ_FEATURE_FLAG_` namespace, so it can never be read as a flag.
- **Startup failures.** Local misconfiguration fails startup: an unknown
provider, a missing key, or a malformed proxy URL. LaunchDarkly being
unreachable, or rejecting the key, does not. The relay starts on
declared defaults, the SDK keeps connecting, and
`buzz_feature_flags_ready{provider}` shows whether the provider has
initialized. A revoked key must not crash-loop every pod over a tuning
knob.
- **Every build carries the LaunchDarkly adapter.** It stays inert
unless selected, so one published image serves every deployment and each
deployment's configuration pins its provider.

## Design-doc amendments (`crates/buzz-feature-flags/README.md`)

- **Provider selection.** The compile-time "exactly one provider"
contract becomes the startup selection above. The only-one guarantee now
holds per process instead of per build.
- **Startup.** Failing closed now covers local configuration only.
- **Guardrails.** "Flags may choose between equivalent implementations
only" gains one exception: a flag may set an operational limit as long
as it can only tighten it, and shedding under that limit stays
retryable.
- **Evaluation context.** `EvaluationContext::for_process()` adds a
process context, which LaunchDarkly sees as a single `relay`-kind
context keyed `relay`.

## What got simpler

- **Removed the compile-time provider contract:** the nested fixture
crate (with its own 2.8k-line `Cargo.lock`), its 229-line contract test,
and the exact-one `compile_error!` guards it required consumers to copy.
One startup parse plus a unit test replaces them.
- **One gate type serves both caps.** The history-read gate became that
generic type instead of gaining a sibling.
- **Net change:** about −2.3k lines.

## Review-Proven Rules applied

- **#1:** shedding is retryable (`PoolTimedOut` → CLOSED for history, a
bare close for AUTH) and never an empty success. Provider failures are
logged and surfaced through a gauge.
- **#2:** the gate settles every limit change and permit release under
one lock. The table test covers lowering while idle, lowering below the
count held, lowering then raising before holders leave, lowering then
raising past the count held, and restoring the ceiling.
- **#3:** the tests drive the production call sites:
`Db::get_channel_window_with_session` under a lowered and raised cap
(Postgres), and `handle_auth` under a full AUTH gate. The controller
test runs the real refresh loop.
- **#4:** values are clamped, waits are bounded, and the refresh runs on
a fixed interval with skipped missed ticks.
- **#9:** AUTH shedding sends clients down their jittered reconnect
backoff rather than an unjittered or terminal path (see above).

## Review rework (`92a975c8`, `3f09cb33`)

- **Keys renamed** to `buzz.relay.concurrency.*`: LaunchDarkly keys
cannot be renamed later, and the flag project may be shared with other
services.
- **Shutdown:** LaunchDarkly's final flush has no deadline of its own,
so `close()` now runs it on a detached thread and waits at most 2 s,
after the audit drain. (The blocking pool would not bound it: the
runtime waits for blocking tasks when it drops.)
- **Refresh loop:** a panicking refresh is caught, logged and counted
(`buzz_concurrency_limit_refresh_panics_total`), and the next tick
retries, so the caps cannot freeze silently.
- **Runbook:** `docs/operator-concurrency-caps.md`, covering how to
tighten a cap and the cases in which a tightened cap returns to its
ceiling (mistyped or deleted flag, pod started during a provider
outage).
- **AUTH test:** asserts the second login passes the gate and returns
its permit; the gate permit is renamed so it no longer shares a name
with the NIP-FI effect permit.

Gated on a Linux workstation at `92a975c8` (`3f09cb33` is rustfmt only;
`cargo fmt --check` passes there): clippy `-D warnings`,
`buzz-feature-flags` (50), `buzz-relay --lib --bins` (1740), the new
modules under parallel `cargo test`, and the Postgres AUTH tests (27).
Each new test fails under its mutant: the refresh without
`catch_unwind`, `close()` with no deadline (hangs until killed), and the
AUTH handler with the gate skipped.

## Testing

Re-run in full at `3c289134` after review rework, on a Linux
workstation:

- `cargo fmt --check`; `cargo clippy -p buzz-feature-flags -p buzz-db -p
buzz-relay --all-targets --all-features -D warnings`.
- `buzz-feature-flags` with and without `launchdarkly` (50 and 24
tests), `buzz-db --lib` (209), and `buzz-relay --lib --bins` (1738),
plus a plain parallel `cargo test` of the new relay modules.
- Postgres: the `buzz-db` routed-read and history-gate tests (55), and
the `buzz-relay` AUTH and connection tests (38).
- `a_cancelled_waiter_cannot_reopen_a_lowered_gate` fails with the debt
check in `admit` removed.
- The new infra-free relay tests are added to the CI unit lane
(`Justfile` and `scripts/run-tests.sh`).
- E2E against a local relay at `06c39d64`: cap gauges, sequential logins
and history under cap 1, a 20-login burst (19 shed with a bare close,
all reconnected), and LaunchDarkly misconfiguration and unreachability.


🤖 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-06 14:48:06 -05:00
Logan Johnson
01d9778606 fix(relay): route live REQ events by each filter's own channel scope (#8519)
🤖
## Summary

**The problem.** A Buzz client that watches one channel's details (its
name, topic, members) gets them once and then never hears about changes.
If someone renames the channel, the client keeps showing the old name
until it reloads. A related problem: when a client watches a channel's
messages and something community-wide in the same request, the channel's
new messages stop arriving.

**The fix.** The relay (the Buzz server) now decides where to send new
events separately for each part of a request, instead of once for the
whole request.

### Background: how a client watches for events

A client sends the relay a **subscription**: a request made of one or
more **filters**. Each filter describes some events, for example
"messages in channel X" or "the details of channel X". The relay answers
in two phases:

1. **History.** It sends the stored events that match, then a marker
that says "history is done".
2. **Live.** It keeps the subscription open and pushes each new matching
event as it arrives.

For the live phase, the relay must decide which new events a
subscription may receive. It sorts new events into **topics**: one topic
per channel, plus one community-wide topic for events that belong to no
channel. A subscription only hears the topics it signed up for. This is
the security boundary that keeps a private channel's events away from
people who cannot read it.

### Why it broke

The relay picked the topics **once for the whole subscription**, with
one rule: if every filter names its channels with an `#h` tag, listen to
those channels; otherwise, listen only to the community-wide topic.

That rule fails in two cases:

- **Channel details.** A channel's details are a special event (kind
39000) that the relay signs and stores under the channel itself. Clients
ask for it with a `#d` tag, not `#h`: `{kinds:[39000], "#d":[X]}`.
Because the filter has no `#h`, the rule chose the community-wide topic,
which never carries channel X's events. The client got the current
details in history, and then no updates. (NOSTR.md documented this gap
as a future enhancement.)
- **Mixed requests.** If a request had one filter for channel X's
messages and a second filter with no channel, the rule chose
community-wide for both. The channel filter silently lost its live
messages.

### What changes

Each filter now gets its own topics:

| Filter | Live events it receives |
|---|---|
| Names channels with `#h` | New events in the channels it names and may
read |
| Channel details (kinds 39000–39003) that name channels with `#d` |
Updates for the channels it names and may read, plus community-wide
events |
| Names no channel | Community-wide events, the same as before |

The relay then sends a new event only through a filter that **both**
matches the event **and** listens to that event's channel.

### Why this is safe

- **No filter can see more than it already could.** A filter's channels
come only from the channels it names, and each one must pass the same
read-permission check the history phase uses. A private channel's
outsider gets neither history nor updates (tested).
- **One filter cannot widen another.** This is the reason for per-filter
topics. The simple fix, "add the `#d` channels to the whole
subscription", would leak. Some channels, such as system channels and
future channel types, appear only to a filter that names them. With one
shared set of topics, a sibling filter such as "all channel details
created by person P" (`#P`) would start to receive live updates for such
a channel, even though that channel never appears in its history. #8469
closed this leak for the history phase. This PR keeps the live phase
consistent with it (tested).
- **Requests that worked before behave the same.** A request whose
filters all name `#h` channels got the union of their channels before.
Each filter's own `#h` condition already rejected the other filters'
channels, so per-filter topics deliver exactly the same events. A filter
with no channel keeps the community-wide topic, as before.
- **Recent joins are not missed.** Each server caches who may read which
channel for a few seconds, so a server can briefly miss a new member.
When a request names channels the cache does not list, the relay checks
them against the database. Before, it did this only when every filter
used `#h`. It now checks every channel any filter names, including `#d`.
This matters more now, because a live scope is fixed when the
subscription starts: a missed channel would stay missing until the
client subscribes again. The check keeps the same token restrictions
(tested). More than 2048 `#h` channels is still refused. More than 2048
named channels in total because of `#d` is not refused: the `#d` names
then skip this check and live routing, so the request gets history from
the cached access set and community-wide live updates, as before this
PR. The legacy desktop app sends one unbatched `#d` query for every
channel the user is in (tested).
- **Clean up stays exact.** A subscription signs up for each topic once,
even when several filters name the same channel. It releases each topic
once on close, on replacement with the same ID, or when access to a
channel is revoked. Revoking one channel removes only that channel; the
subscription keeps its other channels and its community-wide topic. The
tests check the counts on each path.

### Not in scope

A filter that reaches a channel without naming it still gets that
channel's events in history but not live. This is unchanged: before this
PR, such filters listened only to the community-wide topic too. Examples
are a channel-less filter, which history fills with every channel the
reader can see, and the "all system channels" opt-in (`#t:["system"]` or
`#P`).

### Related issue
Follow-up to #8469.

### Testing
- Two new Postgres tests drive the real request handler. (1) A
channel-details subscription receives updates after history, from this
server and through the handler that receives other servers' events from
Redis. (The test calls that handler directly. It does not cover
publishing or Redis transport.) A private channel's outsider receives
nothing. (2) In a mixed request, the channel filter and the channel-less
filter each keep their live events, and a sibling filter by creator
(`#P`) or system label (`#t`) does not receive a channel it did not
name.
- A Postgres test with a stale access cache: a just-joined channel still
reaches both a `#d` filter and an `#h` filter beside a channel-less one,
in history and live.
- New unit tests cover the per-filter topic rule and the subscription
registry.

---------

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-06 14:34:31 -05:00
Luke Tornquist
27ab6a085a fix(relay): deliver multi-node fan-out concurrently and never drop silently (#9195)
## Problem

Each relay node runs one consumer for events that other nodes publish to
Redis. It delivered them one at a time and awaited the access gate
(visibility and membership lookups) for each event. Under load it fell
more than 4,096 events behind the pub/sub broadcast. Each `Lagged(n)`
then skipped `n` events for good: nothing replayed them, and plain
subscribers were never told.

In a 15-node load test, about 50% of cross-node events were skipped at
10x load and about 75% at 13x. Throughput at 13x was also *lower* than
at 10x. Loadgen didn't show this, because it only times events that
arrive.

## Change

- **Concurrent delivery.** A dispatcher reads the broadcast and only
routes. It drops local echoes and queues each event on one of 16 workers
by `(community, topic)`. Each worker delivers its queue in order, so a
channel's events keep their order while different channels deliver
concurrently. `handlers/multinode_fanout.rs`
- **Known loss closes the affected connections.** An event is lost when
its worker's bounded queue (1,024 events) is full, or when it isn't
delivered within 2 s of this node reading it from Redis, whether it
waited in the broadcast, behind a backlog, or in a stalled delivery.
`ChannelEvent` now carries `received_at`, stamped by the pub/sub
subscriber. For a lost event, the relay sends `CLOSED "resync: live
delivery lagged"` to every sync the event would have reached (NIP-AR
Sync rule 5). It also closes every connection the event would have
reached, so those clients reconnect and catch up from their cursor.
Matching runs before the access gate, so a recipient that would have
been filtered out may be closed too. That costs a reconnect, never a
leak.
- **A lost ephemeral event is only counted.** Presence, typing and
observer frames are never stored, so no reconnect can replay them.
Losing one fences and closes nothing; it is still never delivered past
its deadline. Without this, one stale global presence event would close
nearly every mobile connection in the community, since every mobile
client subscribes to presence.
- **Unknown loss closes every subscribed connection.** If the dispatcher
itself lags the broadcast, it can't know which events it skipped. It
resyncs every sync (unchanged) and closes every connection that has a
subscription.
- **Immediate fence.** Each affected connection is fenced as soon as the
loss is detected. The live fan-out send path (`send_to_text_bytes`,
local and cross-node) delivers it nothing from then on. The fence is
written under the connection map's write guard, and the send path checks
it and queues its frame under one read guard, so no frame slips in
between the check and the send.
- **Why 2 s.** The mobile client resumes from the newest `created_at` it
received, minus 5 s of slack. The desktop app (block/buzz-app) resumes
960 s before its connection last heard the relay, which already covers
the full 900 s `created_at` drift. While an event waits, local fan-out
and other workers keep moving that cursor. The fence stops the cursor,
and the deadline means no withheld event is more than 2 s older than the
fence, so a reconnect replays it. Without the deadline, an event stuck
behind a long backlog could be withheld past the slack and never
replayed.
- **Paced closes.** Only the close is delayed. One closer task closes at
most 50 connections per 10 ms (5,000/s per node). A drop in a hot
channel therefore can't become a reconnect storm (Review-Proven Rule 9).
- `fan_out_pubsub_event` is now split into `take_local_echo` and
`deliver_pubsub_event`, so the dispatcher can check echoes before
queueing. Its behavior is unchanged.
- **Limits (mobile):** replay works while the 2 s deadline, plus the
writer's clock skew and its publish delay, stays under the client's 5 s
slack. The relay accepts `created_at` up to 15 min off, so a writer
clock more than about 3 s slow can still put an event behind the cursor.
An ordinary reconnect has the same limit today; closing it needs a
client rewind instruction, which is out of scope here. The desktop app's
960 s resume window has no such gap. A dispatcher that falls behind
without overflowing the broadcast still never delivers a late event, but
it detects the loss, and fences, late. Events skipped by dispatcher lag
are never seen. Neither case is bounded by the deadline, which is a
cutoff for attempting delivery, not a guaranteed fence time.
- **What a load-test rerun should watch:** under sustained overload the
deadline turns backlog into steady closes (up to 5,000/s per node), and
reconnecting clients replay from the same database pool. Watch
`buzz_multinode_fanout_dropped_total{reason="stale"}`,
`buzz_multinode_fanout_lag_closed_total` and the reconnect REQ rate next
to pool wait. On dispatcher lag, the dispatcher scans the subscription
registry and fences each subscribed connection synchronously, so also
watch how long it stays behind after a `Lagged`.

Metrics:
- `buzz_multinode_fanout_lag_total` (dispatcher lag) and
`buzz_multinode_fanout_total` (delivered) keep their meaning.
- `buzz_multinode_fanout_dropped_total{reason}` counts lost events:
`queue_full`, or `stale` for one not delivered by its deadline. A lost
ephemeral event counts as `ephemeral` instead and closes nothing.
- `buzz_multinode_fanout_lag_closed_total` counts close attempts for a
loss, one batch at a time.

## Rollout

- Staging first. The load-test rerun at 1x, 10x and 13x happens after
merge, with the staging load tests planned once the current relay
changes land. At 1x, expect zero `dropped_total` for stored kinds and
zero `lag_closed_total`. Report delivered %, `dropped_total{reason}`,
`lag_closed_total`, reconnect REQ rate and pool wait.
- Then one production cluster before the rest.
- **Rollback trigger:** `lag_closed_total` climbing steadily, or
reconnect REQs pushing pool wait up, at normal load. Rollback is a plain
revert and redeploy: there are no schema, config or wire changes.

## Ordering

Order is kept within a `(community, topic)`. Events on different topics,
for example a channel event and a global event, can now reach one
subscriber in either order. Before this change they arrived in Redis
arrival order. Nothing in the relay depends on order across topics: the
access gate re-reads membership and visibility per event. Clients order
by `created_at`.

## What got simpler / known debt

- The fan-out loop in `main.rs` shrinks to one `spawn` call, and the lag
handling lives next to the delivery code.
- No new config knobs. The worker count and queue size are constants
until a load test shows they need tuning.
- **Known debt, not in this PR:** a Redis pub/sub reconnect can still
lose events silently for plain subscribers. Syncs are already resynced
in that case (`artifact_sync::watch_link`), but ordinary subscriptions
are not.

## Tests

`handlers::multinode_fanout::tests`, each checked with a mutant on Blox:
- events reach their subscriber through the workers, in publish order
- a full worker queue closes exactly the dropped event's recipients
- a lost ephemeral event (full queue or stale) is counted as `ephemeral`
and neither fences nor closes, while a stored event lost on the same
queue still closes
- a local echo on a full queue closes nothing
- dispatcher lag closes every subscribed connection
- after a known loss or dispatcher lag, a recipient gets no newer live
event while it waits to be closed
- the fence also withholds relay-local fan-out
- an event that waited out its deadline before dispatch is lost, never
delivered
- an event behind a backlog is delivered inside its deadline, and past
it is lost (its recipient fenced and queued for close), never delivered
late (paused clock)
- a delivery that stalls on a database that never answers is cut off at
the deadline and handled as lost
- the closer cancels in paced batches (paused clock), and counts each
batch as it closes

🤖 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-06 14:33:16 -05:00
Luke Tornquist
0d5edb8bb3 perf(db): bound since-limited events reads with the month-floor literal (#9197)
## Summary

A newest-first `events` read whose own `since` is at or above the
recent-pass floor now runs as a single recent pass. Postgres prunes it
to the recent monthlies at plan time instead of locking every partition.
Results are unchanged.

**Why.** `split_for` skipped any read whose `since` was at or above the
floor (`since_prunes`), on the theory that its `since` already prunes.
It does, but only as a bind parameter. Once a prepared statement
switches to a cached generic plan, Postgres prunes bind-parameter bounds
at execution, after `AcquireExecutorLocks` has already locked every
`events` partition and its indexes. Live REQs with a recent `since` are
this shape, for example `#p` mention reads from reconnecting agents.
Under load they spill past the 16 fast-path lock slots and contend on
`LWLock:LockManager`. The ceiling PR (#9157) named this gap as
identified debt.

**What changes.** Such a read now gets the same `Split` as other recent
reads, so the recent pass carries the floor literal (plus the ceiling
literal for regular-kind reads) on `e.created_at` and on
`m.event_created_at` for `#p`. `Split` gains `older: bool`. It is false
when the read's own `since` is at or above the floor, because no row
below the floor can match, so no older pass follows a short recent pass.
A read whose `until` falls below the floor stays unsplit, as before.

No protocol, schema, index or client change.

### Related issue

Follow-up to #9157 (its identified debt: bind-parameter `since` reads).

### Testing

- `recent_pass::tests::split_eligibility`: the table now records not
split / ceiled / open and whether an older pass follows. New rows:
`since` at the floor, a recent `since` with regular, any and
addressable-only kinds, and a recent `since` with `until` below the
floor.
- `recent_pass::tests` in-memory exactness: adds a `since`-at-floor
split that runs exactly one pass and returns the rows at or above the
floor.
-
`postgres_tests::since_bounded_mention_read_locks_only_recent_partitions`
(new): runs partition maintenance, inserts `#p` rows at the floor, now,
one second below the ceiling and one day below the floor, then reads
with `since = floor`. The production path returns the three recent rows
through the mention join. Under `plan_cache_mode = force_generic_plan`
the recent pass locks only the partitions holding those rows. The
unsplit read locks every partition.
- The partition-lock helper from #9157's test is now shared by both lock
tests.
- Diagnostic on Postgres 17.11 with the 19-partition layout partition
maintenance keeps, for a `#p` read with `since` at the floor (not
committed):

| | `events` partitions locked | Relation locks | Non-fast-path locks |
  |---|---|---|---|
| Before (bind-only `since`, generic plan after five runs) | 19 | 26 |
10 |
  | After (floor and ceiling literals) | 2 | 9 | 0 |

`EXPLAIN (GENERIC_PLAN)` before appends all 19 partitions. After, it
appends only the previous and current monthlies.

Mutation-checked on Blox. Each of these fails the new Postgres test or
the unit tests: removing the `since_prunes` split, ignoring `older`, and
dropping the `e.` literal.

## What got simpler

Nothing got simpler. The change adds one flag to the existing `Split`
type and no new concept. The duplicated partition-lock reader in the
#9157 test became one shared helper.

🤖 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-06 14:29:00 -05:00
klopez4212
a25f3cd1d7 fix(mobile): match Agents sign-in to Home’s reconnect capsule (#9204)
Replaces Agents’ inline sign-in row with Home’s reconnect capsule. It
appears at the top of Agents, scrolls with the agent cards, and remains
available until leaving the page or signing in. Home is unchanged.

Validated with mobile checks, the full test suite, scrolling and
sign-in/re-entry regression journeys, and an approved iPhone Air visual
preview. The device preview forces the capsule visible while signed in;
that override is excluded from this PR.

| Before (widget render) | After (widget render) |
| --- | --- |
|
![Before](https://github.com/user-attachments/assets/3ab65502-8469-4c64-956b-40c9362175ce)
|
![After](https://github.com/user-attachments/assets/dbdcd3f3-80d1-4274-812e-90636378a3a3)
|

<details>
<summary>iPhone Air and large-text snapshots</summary>

Physical iPhone Air, with preview-only visibility override:

![iPhone Air
preview](https://github.com/user-attachments/assets/75d85f8e-704f-4e24-bfe4-08edfa27c229)

3x text, 320×568 widget render:

![Large
text](https://github.com/user-attachments/assets/79dd0223-5c64-4983-971e-1f20fb75c1b5)

</details>

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-06 11:20:53 -08:00
Will Pfleger
c8d736642e fix(cli): clearer desktop pairing errors, paired default relay, and auth desktop list (#9196)
Follow-ups to the CLI's Buzz desktop credential support, for a person
running the CLI with a desktop pairing.

- **The "no credentials" error names the relay.** It says which relay
the CLI tried and which relays this CLI is paired for, and tells you to
pass `--relay`. When a pairing exists for that relay but no running
desktop accepts it, it says so instead.
- **Default relay for a paired person.** With no `--relay`, no
`BUZZ_RELAY_URL`, no inherited agent credential and no explicit key, the
CLI uses the one relay that a running desktop still accepts this CLI's
pairing for, confirmed by `describe` on a same-user connection. Stored
pairings alone never count. With zero or several such relays, the
default stays `http://localhost:3000`. A desktop's refusal fails the
command with its message. When another paired desktop's check fails (its
same-user check times out or can't confirm the owner, or it stays rate
limited), the command fails rather than pick a relay, even if one other
relay was accepted, since that desktop may accept a relay of its own; a
timeout or exhausted throttle fails with a retryable error. `auth
desktop unpair` never runs discovery, and `auth desktop pair` never asks
other desktops for their pairings, so one desktop's refusal can't block
pairing another. Help text updated.
- **`buzz auth desktop list`.** Lists each pairing's instance, relays
and pubkey, with `accepted`, `not accepted` (revoked or no longer
bound), `not running`, `unconfirmed`, `rate limited` or `refused` (the
last three with an `error`), using the same check as the default relay.
Desktops are asked at once, without waiting out throttling. It never
prints tokens, needs no relay, prints `[]` with no pairings, and
`--format compact` drops the pubkey.
- **Retryable same-user timeout.** When a paired desktop's same-user
check times out and nothing else serves, the command now fails with a
retryable `unavailable` error (exit 2) instead of "no credentials" (exit
3).
- **Regression tests** for the NIP-42 AUTH event naming the relay with
its trailing slash, for skipping a paired desktop whose user can't be
confirmed, without sending it anything, and for relay selection never
asking desktops for pairings with an explicit key, `auth desktop pair`
or `auth desktop unpair`, or over an inherited agent relay.

---------

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-11-06 19:11:49 +00:00
Luke Tornquist
744f3946f6 perf(relay): serve restriction rows for 10 s with cross-pod invalidation (#9199)
## Summary

The restriction cache (`AppState::restriction_state_cached`) is the
relay's hottest auth read. With a 1 s max age, most per-request
ban/timeout checks miss and hit Postgres. This change serves cached rows
for **10 s** and makes every restriction write invalidate them on every
pod, so the longer age only bounds a pod that misses the notification.
That's the same bound the membership cache already uses.

### What changes

- **`RESTRICTION_CACHE_MAX_AGE` goes from 1 s to 10 s.**
- **Cross-pod invalidation for every restriction writer.**
`AppState::invalidate_restrictions(tenant)` drops the community's rows
locally and publishes a new `CacheInvalidation::Restrictions` over the
existing Redis cache-invalidation channel. The writers that call it:
- kind 9040 ban, 9041 unban, 9042 timeout, 9043 untimeout
(`moderation_commands.rs`)
- admin HTTP `DELETE /members/{pubkey}/ban` and `/timeout`, which
invalidated nothing before
- report-resolution and direct ban/timeout, at the enforcement
convergence point so crash recovery repeats it. Timeout invalidated
nothing before.
- recording an agent's owner (HTTP token/NIP-OA owner link and both
invite claims, through `disconnect_unowned_agent_clusterwide`). The
agent's cached row predates the link and lacks the owner's restriction;
the old disconnect-path drop used to cover this.

Before this change, unban, timeout and untimeout cleared only the
handling pod, and bans cleared other pods only through the disconnect
path.
- **Community-wide drop.** An agent's row also carries its owner's
restriction (`restriction_state` reads `users.agent_owner_pubkey`), and
the cache can't tell which agents a target owns. Dropping the
community's rows covers owner→agent without a lookup. Restriction writes
are rare next to the reads the cache absorbs.
- **Stale-refill fence.** Before, a read in flight when a ban landed
could refill the old "allowed" row. The 1 s age bounded that; at 10 s it
needs a fence. The restriction cache now uses the epoch-fenced fill the
community-active cache already had: a fill is published only if no
invalidation happened since its read began.

### What got simpler

- That shared-read, epoch-fence and max-age logic is now one generic
`FencedCache<K, V>` (`fenced_cache.rs`). `CommunityActiveCache` becomes
a type alias over it.
- The restriction cache drops its hand-rolled fill in `state.rs`, plus
`CachedRestriction` and the separate `restriction_reads` gate.
- `disconnect_pubkey_local` no longer touches the restriction cache, so
roster removal and self-leave stop flushing it.

Net +146 lines, tests included (new writer assertions plus the generic
cache tests, moved from `community_active.rs`).

### Accepted bounds

- A pod that misses the Redis notification (subscriber reconnect window,
or an older pod mid-rollout) serves a stale row for at most 10 s. Older
pods log and skip the unknown `op`. Bans still close sockets clusterwide
through conn-control, and socket admission and moderation-command actors
still read fresh.
- `banned` is computed at read time, so an expiring ban can outlast its
expiry by up to 10 s. That fails closed. Timeouts still lift on time,
because the cached row carries `muted_until`.
- An owner link drops the whole community's rows, not just the agent's,
because the cross-pod message is community-scoped. A link happens once
per agent, so the extra refills are negligible.

### Identified debt (not in this PR)

- `open_channels_cache` and `HostCache` hand-roll similar read_at/epoch
logic. `open_channels_cache` could move onto `FencedCache`.
- Concurrent waiters whose shared read *fails* each re-read on their own
(`SharedReads::turn`). Sharing the failure is a separate change.

## Test plan

- `fenced_cache` unit tests cover:
  - concurrent misses sharing one read
  - entries served inside the max age and expired past it
  - invalidation fencing an in-flight fill and its waiters
- scoped invalidation dropping only matching keys while also fencing
fills
  - errors not being cached
- `state::tests::restriction_invalidation_drops_the_community_rows`
checks that local and received drops remove the target's and an owned
agent's rows, and that another community's rows stay.
- `owner_link_drops_the_agents_cached_restriction_row`: a timed-out
owner's fresh agent, admitted over HTTP with that owner, is refused its
first write. Removing the owner-link invalidation fails it.
- Postgres tests now assert that the cached row is dropped by:
- kinds 9040–9043
(`restriction_commands_drop_the_cached_restriction_row`)
  - admin HTTP unban and untimeout, with other-community isolation
  - direct ban and timeout through the enforcement convergence point
- Mutants (epoch check, scoped epoch bump, `Restrictions` arm, admin
unban invalidate, convergence invalidate, 9040 invalidate) each fail
their test.
- `stored_writes_honor_cached_ban_until_it_ages_out` pins the 10 s bound
with literal ages: a 9 s old cached ban still refuses a write, and a 10
s old one is reread. Raising `RESTRICTION_CACHE_MAX_AGE` to 11 s fails
it.
- On a cloud workstation: fmt, file-size, clippy (`buzz-pubsub`,
`buzz-relay`), the nextest unit suites, and the full `buzz-relay`
Postgres lane. In the full lane, 622/626 passed. The other 4 are tests
this change doesn't touch, and each passes in isolation at both the base
commit and this head.
- Two relays sharing one Postgres and Redis: a timeout, untimeout and
admin-HTTP lift on one relay take effect on the other's next write, a
few ms after the first relay acknowledges.

🤖 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-06 13:13:38 -05:00
Luke Tornquist
abb2586f8c Pin sqlx to the upstream fix for a cancelled BEGIN (#9192)
## Problem

sqlx 0.9.0 counts a transaction only after Postgres acknowledges its
`BEGIN`. If the future is dropped while `BEGIN` is still in flight (a
cancelled REQ history read, a client that disconnects, a timeout), sqlx
doesn't queue a `ROLLBACK`. The pool's release ping then reads the late
`BEGIN` reply, and the writer pool hands the connection to the next
borrower while it is still inside that transaction. The reader pool
already closes such connections in its release check
(`Db::read_pool_options`). The writer pool has no release check.

Load tests showed what happens next:

- `there is already a transaction in progress` (25001) on the next event
read's `BEGIN`.
- Sometimes a burst of `current transaction is aborted` (25P02). The
leftover from the event read is `READ ONLY`, so the next write fails and
aborts it, and every later statement on that connection fails until the
connection is closed. During a burst, ban checks failed closed with 503
and window reads returned 500.
- A read-write leftover would also leave autocommit writes uncommitted,
and the next read's `ROLLBACK` would discard them.

## Change

- Pin every sqlx crate to transact-rs/sqlx#4394 (merged, not yet
released). That commit takes the transaction count before the `BEGIN`
round trip, so a cancelled begin queues its `ROLLBACK`. The crates share
types, so they have to move together. The pin also picks up the other 23
commits between v0.9.0 and that fix, including the libsqlite3-sys fix
`sqlx-sqlite` was already pinned for.
- Add a regression test,
`writer_pool_postgres_tests::begin_cancelled_in_flight_leaves_no_writer_transaction_open`.
It holds `BEGIN` in flight behind a paused proxy and times it out, for
both the event read's `BEGIN ... READ ONLY` and a plain `begin()`. It
then asserts that the next borrower's autocommit write commits and that
its backend is `idle`, not inside a transaction.

Other upstream changes in the pin that reach the Postgres path:
`TCP_NODELAY` on sockets (transact-rs/sqlx#4336), reuse of the TLS
connector across connection attempts (transact-rs/sqlx#4242), a fix for
a memory leak when the statement cache is disabled
(transact-rs/sqlx#4337), a describe-time generic-plan change used only
by the macros (transact-rs/sqlx#4286), and the `whoami` default-user fix
(transact-rs/sqlx#4303). The rest are SQLite, MySQL, CLI, or docs
changes.

Remove the pin once sqlx 0.9.1 ships.

## What got simpler

The lockfile loses its second `sqlx-core` copy, which the
sqlx-sqlite-only pin used to pull in. No runtime code changes. A
pool-level release check would have fixed the same bug, but it costs a
round trip on every writer release.

Comments that described the 0.9.0 behavior as current are refreshed: the
reader pool's release check (`Db::read_pool_options`) and the lease
heartbeat (`EntityDeletionStore::heartbeat`). The reader test
`proof_timeout_mid_begin_leaves_no_reader_transaction_open` asserted
that the release check closed the connection left mid-`BEGIN`. With the
pin, sqlx rolls that transaction back itself, so the test now asserts
the check finds nothing to close. That also catches a sqlx regression
the check would otherwise hide.

Identified debt (not in this PR): the release check's open-transaction
arm, and the reader-session `default_transaction_isolation` pin it
depends on, are now only a backstop against a sqlx regression. They
could be removed in a follow-up.

## Testing

- Against sqlx 0.9.0 (main's `Cargo.toml`/`Cargo.lock` at 962b3fdd), the
new writer test fails for each `BEGIN` form on its own. The event read
fails with `cannot execute INSERT in a read-only transaction`, and after
a plain `begin()` the next borrower's backend is not `idle`. With the
pin it passes 5/5.
- Single-machine load-test A/B against main (962b3fdd), with three main
runs and two pin runs. Main logged 25001 and 25P02 in every run (41/80,
10/9, 12/18); the pin logged none. Client error rate, latency, and
throughput matched within run-to-run noise.
- Full suite on the pin: every `just ci` recipe, the PostgreSQL lane,
and `just test`. The only failure from the pin was the reader test
above. Reds that don't involve sqlx: the desktop Tauri recipes
(`alsa-sys` needs ALSA headers on the build host),
`mobile-check`/`mobile-test` (a Flutter SDK `ColorScheme` type clash),
and two `buzz-acp` `git_bootstrap` tests (no git credentials on the
host). Two bridge search-timeout tests failed only under the full
parallel lane; run alone they pass on both the pin and 0.9.0.
- Two later commits change only tests, comments, and `Cargo.lock` (the
lock now moves only the sqlx crates). At those heads: the PostgreSQL
lane, the unit lane, and workspace clippy pass. Three tests failed once
under a contended full lane, and each passed 3/3 alone on both the pin
and 0.9.0. The two transaction tests fail on 0.9.0 and pass on the pin.

🤖 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-06 13:07:04 -05:00
Blake McAnally
e43d57c6ba feat(cli): manage sidebar sections with buzz sidebar sections (#9191)
Replaces #8759, which had its head on a fork. This branch also carries
the stars/mutes (#8781) and sort mode (#8784) work that merged into it.

## Summary

Sidebar sections were desktop-only. They live in the encrypted
kind:30078 `channel-sections` preference, and the CLI had no command for
it. An agent that mirrors a team's Slack channels into Buzz could create
the channels but not group them into sections.

This adds `buzz sidebar sections`:

| Command | What it does |
|---|---|
| `list` | `{sections: [{id, name, icon?, order}], assignments:
{channel_id: section_id}}` |
| `create --name <n> [--icon <emoji>]` | Adds a section at the end;
returns `section_id` |
| `update --section <s> [--name] [--icon \| --clear-icon]` | Renames a
section or changes its icon |
| `delete --section <s>` | Deletes a section; its channels go back to
the default list |
| `reorder --section <s>...` | Sets the section order; unlisted sections
keep their order after the listed ones |
| `move --channel <uuid>... --section <s>` | Moves one or more channels
into a section |
| `unassign --channel <uuid>...` | Returns channels to the default list
|

`--section` takes a section id, or a section name when the name is
unique. One `create` and one `move` per section set up a whole sidebar:

```bash
buzz sidebar sections create --name "Engineering" --icon "🛠"
buzz sidebar sections move --section "Engineering" --channel <uuid> --channel <uuid>
```

## Design

- **Wire compatibility.** `sidebar_doc.rs` ports desktop's per-field LWW
register document (`channelSectionsSync.ts`, `sidebarLwwMap.ts`,
`sidebarLaneStore.ts`): decode, shape validation, projection, encode,
and `setRegs` stamping (`v = max(now, newest + 1)`, one shared stamp per
edit, unchanged leaves not rewritten). CLI writes merge with desktop
edits field by field. The CLI also imports the flat (meta-less) format
that mobile writes.
- **One user action is one publish.** Each command reads the head once
and publishes once. A multi-channel `move` therefore lands as one event.
A command that changes nothing publishes nothing and prints `"message":
"unchanged"`.
- **Never overwrite what it cannot read.** If the head fails decryption,
signature verification, or decoding (for example, an unknown `meta.v`),
`list` and every write fail. They do not treat the head as empty. A
write that the relay reports as dominated exits 5 (`Conflict`).
- **Same limits as desktop.** 100 sections, 1,000 assignments, and the
65,535-byte ciphertext cap. Channel IDs are stored in lowercase
hyphenated form, which is the form desktop matches.
- **No relay change.** The relay already stores `channel-sections` as a
generic replaceable kind:30078 event.

## Scope notes

- The commands always edit the **signing identity's own** sidebar,
because the document is NIP-44 encrypted to self. A local agent that
runs with the user's key (for example, `BUZZ_PRIVATE_KEY` set to the
user's nsec) sets up that user's sidebar. A managed agent with its own
key edits only its own sidebar.
- The request also mentioned "an equivalent agent/MCP tool". Agents use
`buzz-cli` for agent-facing operations, so the CLI help and
`crates/buzz-cli/README.md` document the `list` output contract. This PR
adds no separate MCP tool. This PR does not change `desktop/`
(deprecated; see #8840).
- `create` does not dedupe by name, which matches desktop. If a setup
script runs twice, it creates duplicate sections, and later `--section
<name>` lookups then fail as ambiguous. They do not pick one silently.

## Testing

- `cargo test -p buzz-cli`: 513 pass. The sidebar tests are:
- A journey through the production command functions against a fake
relay. The fake relay checks the head filter (`kinds`, `authors`, `#d`,
`limit`). The journey covers list, create, a multi-channel move as one
publish, a no-op repeat, invalid input with no publish, reorder, update,
unassign, delete, and the decrypted event's kind, tags, and document
shape.
- Rows for head selection (newest `created_at`, lower id on a tie,
another author's event ignored), `created_at = head + 1` after a
future-dated head, a mobile flat head, an unreadable head, a tampered
head, the count limit, the ciphertext cap, the NIP-44 plaintext cap, an
empty `--icon`, and a dominated write (exit 5).
- Each guard was mutation-checked: removing it makes at least one test
fail.
- **Desktop interop:** a CLI-written document was fed to desktop's own
`decodeDoc` and `projectSections` (from
`desktop/src/features/sidebar/lib`, run with node). Desktop decoded it
as a meta document, its projection matched the CLI's exactly, and
`canonical(json) === canonical(encodeDoc(tree))`. So desktop treats the
head as settled and does not republish it.
- **Live read:** `buzz sidebar sections list` against a real relay
correctly decrypted and projected a desktop-written sidebar (2 sections,
2 assignments). I ran no live writes.
- `cargo clippy -p buzz-cli --all-targets -D warnings` and `cargo fmt
--check` are clean.
- An agent review ran. It reported no blockers, and its optional
findings were fixed: tests now bind the relay filter and head selection,
a silently dropped head is an error, a no-op `reorder` writes nothing,
the `safe_int` overflow is fixed, channel IDs are canonical, and an
empty `--icon` is rejected.

### Manual test steps

1. `cargo build -p buzz-cli`, then set `BUZZ_PRIVATE_KEY` (your nsec)
and `BUZZ_RELAY_URL`.
2. `buzz sidebar sections list`: you see your current desktop sections.
3. `buzz sidebar sections create --name "CLI test" --icon "🧪"`: within a
few seconds, a "CLI test" section appears at the bottom of the desktop
sidebar.
4. `buzz sidebar sections move --section "CLI test" --channel <uuid>
--channel <uuid>`: both channels move under "CLI test" in desktop.
5. In desktop, rename the section. Then run `buzz sidebar sections
list`, which shows the new name.
6. `buzz sidebar sections delete --section <id>`: the section
disappears, and its channels return to the Channels list.

The author ran these steps and confirmed the result on an earlier head.

**Live re-test on this head** (rebased on `main` at 7fe3e6f), with the
Buzz Desktop credential provider against the production relay:

- `list` read the existing desktop-written sidebar (2 sections, 9
assignments).
- `create` (with an icon), `move`, a repeated `move` (published nothing;
`"unchanged"`), `update --name`, `list`, `unassign`, and `delete` were
all accepted.
- After cleanup, `list` output was byte-for-byte equal to the snapshot
taken before the test.

**`buzz-app` Desktop interop:** Buzz Desktop now ships from
`block/buzz-app`, so I checked the CLI document against its code at
`main` as well. `projectSidebarRecord` and `projectSidebarPreferences`
(`src/features/relay/`) project the CLI document to the same sections,
and the projected record equals the CLI document, so Desktop does not
republish it. The document also satisfies the Desktop signer rules in
`src-tauri/src/identity.rs` (`version: 1`, meta keys `v`/`s`/`a`,
section fields `name`/`icon`/`live`/`order`, 16-hex device ids).

Note: Desktop loads `channel-sections` by fetch, not by live
subscription, so a CLI edit shows in an open Desktop on its next sidebar
refresh (for example, a restart), not instantly. This is the same for
edits from another device.


Requested in Buzz:
`buzz://message?channel=e62570dd-33ad-42c5-b92b-75f2689f9694&id=2f3f2d152dc8a2cf2e75d803e7822faea28c24c163158feb97edfc57e2106c0e`

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

---------

Signed-off-by: Blake McAnally <bmcanally@squareup.com>
Signed-off-by: bmcanally <bmcanally@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 17:36:04 +00:00
klopez4212
0a4d209046 Show mobile floating dates while scrolling (#9185)
Floating dates in conversations and threads now appear during reader
scrolling, then fade to zero over 250 ms after one second idle.
Automatic positioning stays quiet, and renewed scrolling reveals the
date immediately. Reduced-motion behavior is preserved.

Validation: mobile analysis, scroll/timing widget tests, and iPhone Air
preview. Full mobile tests run through the push gate.

Home and Inbox styling has moved to
[#9187](https://github.com/block/buzz/pull/9187); this diff only
contains scrolling-date behavior and its supporting tests/helper
extraction.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-06 09:35:52 -08:00
John Matthew Tennant
1bb2ad443d fix(relay): regenerate missing image thumbnails on request (#9174)
## Summary

Clients ask for an image's small `.thumb.webp` thumbnail first and fall
back to `.thumb.jpg` on a 404. Images uploaded before
[#9003](https://github.com/block/buzz/pull/9003) only have the JPEG, so
every client pays an extra 404 for them and transparent avatars keep
showing on a black background. The same thing happens when a thumbnail
write failed at upload time.

When a thumbnail variant is missing but its image original exists, the
relay now regenerates the thumbnails on that request and serves them
with 200. It uses the same generator and storage writes as upload, which
are now one shared `store_image_thumbnails`. Later requests are ordinary
hits.

- GET (full and range) and HEAD both regenerate, so a HEAD-then-GET
client sees the same result.
- Regeneration runs only after the existing tenant, auth and sidecar
checks for that thumbnail.
- It is bounded. At most two regenerations run per relay process
(`try_acquire`, no waiting), and originals larger than `max_image_bytes`
are skipped. In both cases, and for non-images, missing originals or any
decode or storage error, the response is the 404 it is today. Errors are
logged at warn.

### Related issue

Follow-up to [#9003](https://github.com/block/buzz/pull/9003) and
[#9105](https://github.com/block/buzz/pull/9105), which made relays
write WebP thumbnails and clients prefer them.

### Testing

Two router-level tests against Postgres and MinIO upload a PNG through
the real route, then delete its thumbnails:

- `missing_thumbnail_is_regenerated_from_image_original`: GET and HEAD
of `.thumb.webp` and `.thumb.jpg` return 200 with the right content
type, and both objects are stored afterward. With regeneration disabled
it fails with `GET webp: 404, expected 200`.
- `missing_thumbnail_is_not_regenerated_when_unsafe`: a non-image blob,
a missing original, an oversize original and a busy relay (no free
permit) each return 404 and store nothing. Once the permits are free,
the same request returns 200. Removing the size cap or the permit guard
fails it.

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

Signed-off-by: John Tennant <jtennant@squareup.com>
Co-authored-by: Simone <0828be588d5c9c4d092c5b1cf01761d5aef63d6b104807fd8e8284d517dd1a99@buzz.block.builderlab.xyz>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 11:46:27 -05:00
Luke Tornquist
3fdd8c6ff1 feat(loadgen): model agent turns and sustained holds (#9182)
## Why

The loadgen agent polled HTTP `/query` every few seconds. A real agent
harness (`buzz-acp`) never polls when idle. It works in bursts around
each prompt, and those bursts are the load a fleet of active agents puts
on the relay. To answer "how well does the relay handle N active users
plus their agents over a sustained period", the loadgen needs to model
those bursts and hold them for an hour or more.

## What

- **`[agent.turn]`**: an idle agent starts a turn on a Poisson schedule
and runs one turn at a time. A turn lasts 0.5–1.5 × `mean_secs`. The
steps follow `buzz-acp`:
- **Start**: a `limit:1` `/query` finds the message to answer
(`http_query`), which stands in for the harness's subscription. Then the
harness's own reads (`turn_context` op): for a threaded message, its
thread context in one `/query` (root by id, newest 13 replies, the
agent's own newest reply) plus a `/count` when the window is full. Then
kind:39000 metadata, project home (kind:30621, kind:30617) and the
author's profile. Then two kind:7 reactions on the message.
- **Work**: typing every `typing_secs` and observer frames of
`frame_bytes` every `frame_secs` over the socket, plus `reads` tool
reads (`/query`, limit 50).
- **End**: a threaded reply, under the root that NIP-10 `resolve()`
gives. Then reaction removal: query own reactions, then a kind:5
deletion for each.
- One HTTP action at a time runs next to the socket. Socket publishes
(presence, typing, frames) don't wait for their OK: each OK is matched
as it arrives, one missing after 30 s is an error, and at most 128 wait
at once. So the socket and HTTP sides stay on schedule while either is
slow, as in the harness. A 2xx response that doesn't decode counts as an
error. No agent request starts after the stop.
- The request mix approximates sustained turns: every turn reads like a
first prompt. A harness already holding a thread reads 25 replies (not
13), batches profile lookups and caches project-home lookups for 30 s,
so this reads profiles and project homes more often than a busy harness.
- `agent.channel_subscriptions`: one live mentions REQ per channel, like
the harness. `agent.presence_interval_secs`: presence heartbeats.
- `agent.query_interval_secs = 0` turns polling off.
- **Sustained holds**: ramp steps may now repeat a level. A 60-minute
hold can be six 600 s steps, each one reported and judged, so drift
shows up as a trend.
- New op `turn_context`. New scenario
`perf/scenarios/local-agent-turns.toml`. `perf/LOADGEN.md` documents the
model.

All new fields are off by default (`turn.per_hour = 0`, presence 0, no
channel subscriptions), so existing scenarios run unchanged.

## What got simpler

- The agent loop's per-action branches now go through one `sent` result.
The observer frame build, the HTTP submit and the `/query` body read are
shared helpers. The old inline frame code is gone.
- "Hold for an hour, judge every 10 minutes" is now a ramp of repeated
levels. No new abort-window concept was needed.
- Identified debt, not touched here: abort is still judged only at step
end, so a collapsed relay is not detected until the step closes.
Repeated short steps reduce that gap.

## Testing

- On a Blox workstation at 6bb151d1: `cargo fmt --check`, `cargo clippy
-p buzz-test-client --all-targets -D warnings`, and `cargo test -p
buzz-test-client --lib` (103 passed). The five turn tests passed 10/10
repeated runs. Three mutants (awaiting the OK again, dropping the stop
guard in `submit`, dropping it in `query`) each fail a test.
- `local-agent-turns` against a local relay at 6bb151d1: both steps 0
errors, 0 clients cancelled.
- `agent_turns_read_react_stream_reply_and_remove_their_reactions` runs
an agent against a fake socket and a fake HTTP relay. It asserts the
exact thread-context filters and count, the metadata, project and
profile reads, the reactions, typing, frames and presence, and that
typing or frames arrive while a tool read is held. It also asserts the
threaded reply under the right root and the deletion of exactly the
reactions the agent added.
- `agent_turns_keep_moving_while_socket_oks_lag`: the same journey with
every socket OK 1.5 s late, past the 1 s heartbeat.
- `a_turn_stopped_while_adding_a_reaction_sends_nothing_more` and
`a_turn_stopped_while_reading_its_reactions_deletes_none`: the stop
lands while a reaction POST or removal query is held, and no request
follows it.
- `agent_turn_reads_that_do_not_decode_are_errors`: 200 responses that
are not events count as errors, and no turn reacts.
- `a_timed_out_route_is_recovered_again_after_a_renewal` (desktop) fails
2 times in 10 on unmodified main as well. That flake is pre-existing and
unrelated to this change.
- Live: a single-relay run with 10k humans and 20k turn-taking agents
(held at 1x for 60 min, then 1.5x and 2x). Results will be added here.

🤖 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-06 16:33:28 +00:00
Will Pfleger
b329e05c5b fix(cli): list DMs, attach any file, validate kinds, default the relay (#9168)
Fixes five defects in the `buzz` CLI.

- **`dms list` returned `[]`.** It queried kind 41001, which the relay
no longer publishes. It now reads the viewer's DM group metadata (kind
39000, `t=dm`, `p=viewer`) and drops every DM listed in the viewer's
newest relay-signed visibility snapshot (kind 30622), the same hidden
set the desktop uses. It keeps paging until it has the requested number
of visible DMs or the history runs out, so hidden DMs never crowd out
visible ones. Hidden DMs stay hidden; reopening one brings it back.
- **Uploads accepted only images and MP4.** The CLI now leaves type
acceptance to the relay, as the desktop does: unsniffable files go up as
`application/octet-stream`, and the relay's own allow and deny rules
apply. Local size caps match the relay's defaults (50 MB images, 500 MB
MP4, 100 MB other files). `messages send --file` adds an imeta
`filename` (the basename, with control characters and both path
separators replaced by spaces) and writes non-media files as a plain
link (`[name](<url>)`) instead of `![image](…)`; inline media still
embeds.
- **`messages get --kinds` silently ignored bad values.** Any
non-numeric or out-of-range (above 65535) kind now fails with a
bad-input error, exit 1.
- **Upload errors lost their category.** A missing or unreadable file is
bad input (exit 1), and `messages send --file` returns the upload's own
error instead of wrapping every failure as `Other` (exit 4). The "auth
tag may be stale" hint on every 403 is gone: the relay answers 403 for
ordinary permission denials, such as a non-moderator reading moderation
data, so the hint misled.
- **Agents had to pass the relay by hand.** On macOS and Linux, with no
`--relay` and no `BUZZ_RELAY_URL`, the CLI uses the relay an agent's
inherited `BUZZ_CREDENTIAL_PROVIDER` token acts on, from its `describe`
answer. If that lists several relays, it fails with exit 1 and asks for
`--relay`. An explicit flag or env var still wins and is still checked
against the credential. Paired tokens never choose a relay, `auth
desktop unpair` never asks the desktop, and otherwise the default stays
`http://localhost:3000`. Help text says so.

---------

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: wpfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-11-06 11:20:47 -05:00
klopez4212
962b3fdd88 Add avatar color backdrop to mobile agent creation (#9184)
Adds a blurred, saturated backdrop to mobile’s **Choose avatar** screen
that crossfades with the selected avatar or emoji color. Enlarged
artwork keeps the effect abstract; the upper fade leaves the bottom
controls clear. Supports reduced motion and high contrast.

Validated with analysis, the full mobile suite (4,518 passed),
light/dark widget previews, and an iPhone Air install with the existing
account preserved.

### Snapshots

Flutter widget captures:

| | Before | After |
|---|---|---|
| Light | ![Light
before](https://github.com/user-attachments/assets/2293d06f-8744-4949-bb6c-10e3d9975c4b)
| ![Light
after](https://github.com/user-attachments/assets/cfe75783-d2fe-4672-a2bb-c4426ed59b4f)
|
| Dark | ![Dark
before](https://github.com/user-attachments/assets/1b09e33a-1cdd-4054-8def-dd1bac0bc691)
| ![Dark
after](https://github.com/user-attachments/assets/6c903577-5cfd-4a87-9974-cbe8c9aeae60)
|

buzz-review-completed

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-06 07:21:28 -08:00
ravarora2
32997e2e1b Cache channel memberships once per user (#9060)
## Summary

A cold client in 280 channels can issue 280 separate membership reads,
each checking out a database connection. Cache the user's complete
active-membership set per community so those channel checks share one
read.

Keep the existing membership rules and 10-second freshness bound,
measured from connection checkout. Moka now accounts for snapshot size
with a 64 MiB weighted budget per relay. Each snapshot holds at most
16,384 UUIDs. The database pool controls query concurrency; there is no
separate 16-load limiter. Channel discovery keeps its separate filtering
rules. No schema change or migration.

### Request flow

1. Look up the community/user entry in Moka. A fresh complete snapshot
answers channel checks by binary search.
2. A fresh `PointChecks` marker sends each check to the database; it
stores no permission decision.
3. On a miss, create a lightweight, weighted loading entry. Requests for
that user share its lock.
4. Recheck after acquiring the lock. If waiting for that user’s lock
times out, use a point check.
5. Fetch at most 16,385 membership IDs. Above 16,384, retain a
`PointChecks` marker instead of a partial list.
6. Otherwise, sort the complete list into a compact immutable UUID
array.
7. Replace the loading entry through Moka. Its weigher now includes the
completed payload plus estimated bookkeeping.
8. Let Moka manage admission, eviction, and 10-second expiry. Membership
changes remove the relevant user or community entries.

Publication and invalidation share a short synchronous lock. A loader
publishes only while its original loading lock still identifies the
current entry. An old load can finish its original request, but cannot
repopulate an invalidated entry. Database failures propagate;
cancellation releases the user lock. Both database-query futures
allocate only when used, which keeps nested handler futures small. Cache
hits allocate neither query.

The budget covers estimated retained cache weight. Moka enforces it on a
best-effort basis; query buffers and snapshots held by active requests
can add memory. This is not a strict process-memory ceiling. Empty
entries and fallback markers have nonzero weight. There is no separate
memory-reservation or eviction implementation.

### Operational notes

- A newly granted membership can be absent from another relay's cached
snapshot until the Redis invalidation arrives, or until the 10-second
freshness bound expires if it is lost. Cached write checks can briefly
deny access; explicit channel subscriptions already confirm missing
membership against the database.
- `buzz_membership_cache_hits_total` counts channel checks served from a
fresh snapshot. `buzz_membership_cache_misses_total` counts bounded
full-set load attempts. Timeouts now use point checks, recorded by
`buzz_membership_cache_fallbacks_total{reason="wait_timeout"}`. The
other fallback reason is `oversize`; the `load_limit` path has been
removed. Before/after hit ratios are not directly comparable.
- Keep `channel_id` in membership invalidation messages for
compatibility with older relay instances during rolling deployments,
even though the new local cache invalidates by user.

### Related issue

Follow-up to #9012.

### Testing

**Current head: `a79a40cc5a6e4c7c611ed1b6bcbdd8b8c0490ef2`, including
main `f82944e3180e28502f5ea599ac9973aa6398ddb3`.** Commit `393b32583`
fixes the large-membership test fixture by refreshing planner statistics
after bulk seeding. Before that fix, the boundary test failed locally
and was the sole PostgreSQL test failure in GitHub CI (1,409/1,410
passed); the repaired test passes in 3.54 seconds including setup and
all checks. An isolated copy of the production schema/query at 16,384
memberships selected a quadratic join without statistics and a hash join
after `ANALYZE`; the latter executed in 10.86 ms. The cap and freshness
rules are unchanged.

At this exact head, the full regular buzz-db/buzz-relay suite passed
1,963/1,963 and the full PostgreSQL suite passed 1,310/1,310. This
includes both previously failing Git-auth cases and the repaired
membership boundary test (2.302 seconds including setup and checks).
Rebuilt signed live checks also passed. Full local `just ci` passed
(exit 0). GitHub CI is green: 79 successful checks and 31 skipped, with
none pending or failed at verification. The [verified matched A/B at
this
head](https://github.com/block/buzz/pull/9060#issuecomment-6673813172)
against main `f82944e31` is complete: four 6,600-client arms in both
orders show 90.98–91.24% fewer membership-cache queries and 73.76–74.23%
fewer authorization checkouts. Auth error rates improved in both orders;
auth p99 and overall operation errors were mixed. Average relay RSS
increased 1.6–4.3%, with flat to slightly lower sampled peaks;
PostgreSQL average PSS decreased 1.7–5.2%, with mixed peaks. The
16,384-row production query executed in 24.953 ms after fixture ANALYZE.
Signed pool-4 and 4,097/16,384/16,385-membership probes passed. See the
report for raw counts, memory, provenance and limitations. The
[completed A/B at the earlier `6f0358875`
head](https://github.com/block/buzz/pull/9060#issuecomment-6672419096)
is reported separately; it does not establish current-head performance.

Users above 16,384 memberships still use uncached authoritative point
checks. The higher cap reduces how many users hit that fallback; it does
not eliminate the cost. Each maximum-size list contains 256 KiB of UUIDs
plus overhead within the unchanged 64 MiB estimated cache budget.
Temporary allocations and retained readers can add memory outside that
budget.

Agent-run signed WebSocket test at
`a79a40cc5a6e4c7c611ed1b6bcbdd8b8c0490ef2`, using the rebuilt relay and
an isolated database: non-members cannot post; grants allow both private
channels; removal blocks writes and delivery from A while B continues;
re-add and resubscription restore A. Delivery checks completed in 1.776
s after removal and 0.466 s after re-add. A user grown above 5,000
memberships used snapshots with no oversize fallback. After growing
beyond 16,384, authoritative point checks still enforced removal and
re-add; four oversize fallbacks were recorded. The relay stopped and all
three test ports closed. Binary SHA-256:
`5b69a5aed07e9beef6bd6d2c5661c07fbdf1901dedb1729d27ab5e86a6b8d6a3`.

Build this head with `cargo build -p buzz-relay --bin buzz-relay` and
start an isolated local relay as described in
[TESTING.md](6f03588757/TESTING.md).
Reproduce with two accounts and two private channels: check denial
before membership, grant both, remove one, then re-add. Subscribe to
both channels during the test. At the current head, verify a user with
5,000 memberships still uses snapshots, then give the second account
more than 16,384 memberships and repeat the permission checks through
the fallback path. This was agent verification; human confirmation is
still pending.

#### Historical load test, before the memory-bound refactor

These measurements apply to `519509451`, before weighted eviction, the
per-user cap, and bounded loads. The refactored version was subsequently
measured at `6f0358875`; see the linked report above. The current-head
comparison is complete and reported separately
[above](https://github.com/block/buzz/pull/9060#issuecomment-6673813172).

Compared base `f9ef721e688847ed0c1512081160b6e42cdf92fd` with head
`519509451c10d94074f597124d19f2254f0b96d1` on a dedicated Linux
workstation. Ran base → fix, then fix → base. Each arm used 6,600
clients, 100 connects/second, six generator shards, and a 300-second
hold. Both versions used pool size 50, the same notices index, CPU
allocation, restored fixture, and load generator
(`4813b8cd15411355f05599e263ab15dd04e7afcc`). All four arms and 24 shard
reports completed; recorded binary hashes, fixture manifests, index DDL,
and shard configurations were verified.

| Metric | First pair: base | First pair: fix | Reverse pair: base |
Reverse pair: fix |
|---|---:|---:|---:|---:|
| Per-channel membership query calls | 1,319,382 | 7,205 | 1,367,525 |
7,465 |
| All membership-related query calls | 1,419,429 | 216,639 | 1,457,453 |
220,253 |
| Membership query execution time | 19.55 s | 10.55 s | 18.89 s | 10.74
s |
| Authorization pool checkouts | 1,667,944 | 457,649 | 1,690,624 |
453,910 |
| Average relay CPU, cores | 3.43 | 2.32 | 2.29 | 2.00 |
| Server authentication timeouts | 5,613 | 3,832 | 3,152 | 3,120 |
| Client authentication p99, highest shard | 8.378 s | 7.010 s | 6.349 s
| 6.606 s |

The cache reduced per-channel membership reads by 99.45%, all
membership-related calls by 84.7–84.9%, and authorization checkouts by
72.6–73.2%. Relay CPU was 12.7–32.4% lower. These runs support an
efficiency improvement, but do not establish a repeatable
authentication-latency or timeout improvement.

The pool still saturated. Mean authorization checkout wait increased
from 176.6 to 486.2 ms in the first pair and 118.6 to 442.6 ms in
reverse order. Removing cheap checkouts changes that average's
denominator; it does not isolate latency changes for equivalent
requests. Both versions retained client errors. Memory usage did not
consistently improve.

Each arm restored the fixture and started with a cold relay cache, but
Linux page cache was not dropped. The first baseline arm performed
materially worse than later arms. Operation counts differed with
reconnects and failures. Reported p99 is the maximum shard p99, not a
merged percentile. No perf profile was collected.

Reproduction with the reviewed harness (build exact commits above as
`base` and `fix`; optional delayed profiler disabled and complete
query/metric collection enabled):

```bash
RUN=pr9060_ab EXTRA_ENVS="BUZZ_DB_POOL_SIZE=50" \
ARMS="current:scen=bbcold.toml:rate=100:relay=base:sql=nidx.sql current:scen=bbcold.toml:rate=100:relay=fix:sql=nidx.sql" \
bash ~/lt-src/bb.sh

RUN=pr9060_ba EXTRA_ENVS="BUZZ_DB_POOL_SIZE=50" \
ARMS="current:scen=bbcold.toml:rate=100:relay=fix:sql=nidx.sql current:scen=bbcold.toml:rate=100:relay=base:sql=nidx.sql" \
bash ~/lt-src/bb.sh
```

Generated with Codex

---------

Signed-off-by: Ravneet Arora <rarora@squareup.com>
Co-authored-by: maincourse <37ce551b493d4a998caf030929c47bce0b6a0e43f1178b3a63c4151d7c16b76e@buzz.block.builderlab.xyz>
2026-11-06 08:10:52 -05:00
morgmart
9a6cb3ffdf mobile: make the first-agent card stand out in dark mode (#9171)
Follow-up to #9036. In dark mode the "Create your first agent" card used
the raised dark surface (#1A1A1A), which barely separates from the black
Home page, and dark mode shows no shadow — feedback was that it's hard
to see.

The card now uses the inverse surface in both themes (light: dark card
on white; dark: light gray card with black text), so it always contrasts
the page. No new tokens.

Tests: `flutter analyze` on the widget; `flutter test
test/shared/widgets/buzz_callout_test.dart test/features/channels/` all
pass.

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

| | Before | After |
|---|---|---|
| Dark | ![Dark
before](https://github.com/user-attachments/assets/c5f217aa-df2b-4ce9-9f27-655f2a946d76)
| ![Dark
after](https://github.com/user-attachments/assets/72c6e018-cd6b-4877-85e8-7df8d250db13)
|
| Light (unchanged) | ![Light
before](https://github.com/user-attachments/assets/0cca03fc-ab52-4132-b977-abcc300c104f)
| ![Light
after](https://github.com/user-attachments/assets/12bcf97e-9d11-4445-8406-5d3e196969f0)
|

_Rendered from the widget at phone width with the real fonts and
artwork._

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Morgarita <6e967af659416160ab4f2fd56d74c8c6f273791ee7bfc9468a1be37c387947be@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:23:34 -08:00
Brad Seiler
4e28176f13 fix(relay): close legacy sockets after community FI upgrades (#8991)
## Summary

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

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

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

### Related issue

[BUZZ-512](https://linear.app/squareup/issue/BUZZ-512), under the
revised eventual-closure scope. Builds on merged #8985 and #8988. The
replacement starts from main and adds no activation schema or migration.

### Rollout and limits

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

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

### Testing

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

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

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

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

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

Generated with Codex

---------

Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
2026-11-05 22:01:09 -05:00
John Matthew Tennant
b894fb02ff fix(mobile): show transparent avatars without a black background (#9105)
## Summary

Transparent PNG and GIF avatars render on a black background in mobile
avatars and iOS notification avatars. Both load the relay's small
thumbnail, which was always a JPEG with no alpha channel.

[block/buzz#9003](https://github.com/block/buzz/pull/9003) makes relays
write a `{sha}.thumb.webp` thumbnail that keeps transparency, alongside
the `{sha}.thumb.jpg` that every relay writes. This PR loads the WebP
first.

- `MediaImage` with `preferThumbnail` tries `.thumb.webp`, then
`.thumb.jpg`, then the original. Only a 404 moves on to the next source;
any other failure still shows the error widget. Missing thumbnails are
remembered as before, so later builds skip straight to the source that
exists.
- `BuzzNotificationAvatarLoader` follows the same order for notification
avatars.

Relays without 9003, and images uploaded before it, keep rendering from
the JPEG as they do today.

### Screenshots

A transparent PNG avatar rendered by `MediaImage` on the same relay,
before and after (widget harness on an iOS simulator).

| Before | After |
| --- | --- |
| ![Before: black square behind the
avatar](https://github.com/user-attachments/assets/5da0a704-df15-4917-9d5c-9745bc83224d)
| ![After: transparent
corners](https://github.com/user-attachments/assets/3d31f4d4-cf65-4923-b367-4fd8cf3af679)
|

### Related issue

AIDA-2308 (internal). The desktop counterpart is
[block/buzz-app#2477](https://github.com/block/buzz-app/pull/2477).

### Testing

- `missing thumbnails fall back to the JPEG, then the original`
(`avatar_image_test.dart`): when the relay 404s every thumbnail, the
avatar requests the WebP, then the JPEG, then the original. When the
WebP fails with a 500, the avatar shows its fallback and requests
neither the JPEG nor the original.
- The relay thumbnail journey tests run against a relay that 404s
`.thumb.webp`, so they cover the older-relay path end to end.
- `BuzzNotificationAvatarLoaderTests` checks the WebP-then-JPEG
thumbnail order for relay media, and that non-relay media gets no
thumbnails.

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

Signed-off-by: John Tennant <jtennant@squareup.com>
Co-authored-by: Simone <0828be588d5c9c4d092c5b1cf01761d5aef63d6b104807fd8e8284d517dd1a99@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:32:58 -08:00
Tom Brow
a1efbf63bd fix(mobile): delete composer chips as one unit (#9133)
In the mobile composer, Buzz links (channel, message, repo, PR, issue)
and selected agent `@mentions` render as chips, with the source text
kept as invisible characters behind the chip. Backspace right after a
chip removed one of those hidden characters, so the chip turned into a
broken raw URL or partial name with no visible change for most of the
presses.

This PR gives chips one consistent editing model:

- **A link is a chip only once something follows it.** Its pattern runs
to whitespace or the end of the text, so in practice that is any time
the caret isn't at its end. Pasting exactly one link adds a space after
it (unless whitespace already follows), so it becomes a chip straight
away.
- **A link shows its raw URL while the caret is at its end.**
Backspacing the space after a chip, or moving the caret back past it,
reveals the URL for editing, one character at a time. Moving the caret
away turns it back into a chip. A caret at the link's start leaves it a
chip.
- **Agent `@mention` chips stay atomic.** Backspace right after one
removes the whole mention.
- **Chips are atomic for selection and forward delete.** A caret placed
inside a chip moves to its nearer edge, arrow keys step over it, and a
selection that covers part of a chip grows to cover all of it. Forward
delete just before a chip removes the whole chip. Typing, pasting,
deleting or cutting over a selection replaces exactly the (snapped)
selection.

The controller records each chip's source range while it builds the
spans, so only what is actually drawn as a chip behaves as a unit. Text
in code spans and IME compositions are never rewritten. Undo restores
the previous text: edits at the end of a revealed link are plain text
edits, so undo no longer looks like a backspace into a chip.

Human `@mentions` aren't chips today and are unchanged.

After this change, in the simulator: a link becomes a chip once a space
follows it, backspace reveals the raw URL, the next backspace removes
one character, and the arrow keys reveal the link and turn it back into
a chip.


https://github.com/user-attachments/assets/1d051dd7-92fa-47c9-b569-a7ef5e00ca6f

<img
src="https://github.com/user-attachments/assets/9980aa01-bcf2-4a1b-9c25-4349ce999e26"
width="300" alt="Composer with a channel link chip after a space"> <img
src="https://github.com/user-attachments/assets/5dfbaa44-a0f4-4917-b7ca-eeeb63e47dae"
width="300" alt="After one backspace the raw URL shows"> <img
src="https://github.com/user-attachments/assets/1a675d56-7310-4b13-9dd9-caa26ff99283"
width="300" alt="The next backspace removes one character">

### Validation

- Added tests: the composer permalink-chip test now covers the boundary
space and reveal (soft and hardware backspace, arrow keys), caret and
selection snapping, forward delete, paste adding a space, IME
composition, and undo after typing at the end of a link or after
completing a link. The agent-chip test covers whole-mention backspace,
selection snapping over a mention and a mention in a code span. Breaking
each rule (reveal, snapping, paste space, whole-chip delete, widening
only inside one chip) fails a test.
- Focused existing validation: the full mobile suite (`just mobile-check
mobile-test`) passes.
- Automated app run: iPhone 17 Pro simulator, iOS 27, debug build of
this branch, in a private test channel. Typed a Buzz channel link and a
space (chip), backspaced twice (raw URL, then one character removed),
retyped the character and space (chip again), then moved the caret left
past the space (raw URL) and right again (chip). Nothing was sent. The
run covered the composer only; the change touches nothing outside 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: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:29:17 -08:00
Tom Brow
fbfbf3d64f test(mobile): drop the video preview step removed with native frame loading (#9164)
Fixes red main. https://github.com/block/buzz/pull/9144 added a step to
the expired-assertion journey in `native_media_headers_test` that loads
a posterless video preview through `videoPreviewFrameLoaderProvider`.
https://github.com/block/buzz/pull/9138 merged around the same time and
replaced native first-frame loading with a static placeholder, removing
that provider, so `flutter analyze` now fails on main with `Undefined
name 'videoPreviewFrameLoaderProvider'`.

Posterless previews no longer fetch anything natively, so the step has
nothing left to guard. This deletes it along with its fake video player,
restoring the test file to its state before #9144.

### Validation

- `just mobile-check` and `just mobile-test` pass.

🤖 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-06 01:23:59 +00:00
Luke Tornquist
1bb44ae9ef fix(relay): close a WS search whose hit hydration fails instead of sending EOSE (#9148) 2026-11-05 20:23:46 -05:00
Luke Tornquist
8ae35c5c92 perf(db): stop the events recent pass at a ceiling for regular-kind reads (#9157) 2026-11-05 20:22:57 -05:00
Brad Seiler
bb3c5f7175 feat(relay): project compact viewer memberships (BUZZ-615) (#9118)
## Summary

Cold login downloads other people's full rosters just to find the
viewer's channels. This adds a compact projection from canonical
memberships through the existing Nostr `/query` bridge. One primary SQL
statement reads the viewer's rows and channel state, then the relay
signs that exact result.

Merged spec PR #9117 owns both relay-only kind registrations. Self mode
(`membership_snapshot`, kind 39008) requires the viewer's own NIP-98
key. Public mode (`membership_public`, kind 39009) lets an admitted
requester look up any subject's listed, open forum/stream/workflow
memberships. It excludes private/unlisted channels, DMs, system channels
and deleted or removed memberships, including shared private channels.
Both modes require payload-bound NIP-98 and return signed request-bound
envelopes; overflow or backend errors fail the whole request. Legacy
kind-39002 reads and signatures stay intact. No schema migration or
persisted per-user list is added.

### Related issue

[BUZZ-615](https://linear.app/squareup/issue/BUZZ-615). Implements the
compact discovery contract merged in #9117. Related:
[#8898](https://github.com/block/buzz/pull/8898),
[#9060](https://github.com/block/buzz/pull/9060). Client lazy-loading
follows separately; this does not include
[buzz-app#2627](https://github.com/block/buzz-app/pull/2627).

### Testing

Current head `5c0c35400a` rebases the five implementation patches
unchanged onto main `c39ee507`, then repairs the removed Redis test API.
McKeon independently verified the rebase and reviewed that repair. Full
core/database/relay package tests, PostgreSQL membership tests (9/9),
Clippy and every mandatory publication hook pass at this head. Old-head
CI run 42132655155 failed six jobs on that same removed API; four
summary checks propagated those failures. New-head hosted CI is pending.
Main advanced to `e3a0f3d59d` during publication; a local merge-tree
check found no conflicts. Historical runtime evidence below remains
attributed to its tested heads.

Prior self-mode validation at `6d2ffceeee` (historical evidence, not
validation of the public-mode change):

- `just ci`, pre-push hooks and all 2,221 core/DB/relay package tests
passed.
- All six new Postgres protocol tests passed. All five guard mutations
failed assertions: mandatory auth, flag gate, single-statement cut,
overflow probe and tenant predicate. A/B are older runs against
production files unchanged from `9784df51`; C/D/E were rerun on final
SQL/fixtures. The earlier surviving tenant mutation is superseded by the
final duplicate-ID witness.
- The full isolated Postgres run finished 1,288/1,294 green; six
existing push/media/FI/startup cases failed. All six passed when rerun
serially after the concurrent local gates stopped. The failed full run
is retained and is not represented as a clean full-suite pass. The
preceding correction `1c47dfc9` had passed all 1,294 tests.
- `just test` passed its unit steps but integration setup was blocked by
Docker Desktop organization sign-in enforcement. The waiting wrapper was
stopped with SIGTERM; this is not a complete pass.
- A real local relay rebuilt at this head returned 67,896 bytes for 468
memberships in 33 ms unthrottled. Signatures, request/viewer/tenant
binding, content identity, wrong-viewer refusal, removal and role
changes verified.

The scale fixture adds five large rosters and 20,000 unrelated channels.
Compact bytes stay unchanged as other members grow; EXPLAIN checks 468
indexed viewer rows and bounded channel-ID lookups. A same-UUID
cross-tenant fixture verifies fields and duplicate rejection
independently of row order.

Durable local evidence: `WORK_LOGS/BUZZ_615_20261105/MANIFEST.md` and
`MANIFEST.json` map commands/results to commits or verified mutant-file
hashes and index the exported logs. Concrete human-test steps and the
signed-request helper are included. Human testing remains pending; ready
state follows the author’s explicit request. Native/browser 140/205 KB/s
acceptance belongs to the client PRs.

Public-mode validation at `c6e269170e`: McKeon independently ran all
nine isolated PostgreSQL membership tests and five mutations. Every
mutation failed its expected assertion: public selection predicate,
subject tag, public routing, identity domain and self serialization
order. The full core/DB/relay package run, all nine isolated PostgreSQL
membership tests and the relay build passed at c6. A real signed HTTP
workflow verified signatures, bindings, identity hashes, all visibility
exclusions, archived guest roles, requester-independent content identity
and malformed-request refusals. After a channel became private, the
public list shrank from three entries to two; a role change appeared as
admin. The local test relay was stopped afterward.

The restacked child `8c56f97d6409` has the same code, tests and
configuration as c6; only the spec differs. A new full core/DB/relay
package run passed at that exact child head. The parent `17a481a89716`
separately passed the full core package check. The first child attempt
used the wrong local database and failed five existing tests on a
missing table; its log is retained separately from the successful
configured run. These checks do not attest human testing.

### Rollout and rollback

This PR does not deploy or enable the feature. Both
`BUZZ_MEMBERSHIP_SNAPSHOT_ENABLED` and `BUZZ_MEMBERSHIP_PUBLIC_ENABLED`
default to false and independently gate advertisement and execution. An
authorized operator sets them in the relay Helm environment managed by
`squareup/block-coder-tf-stacks`. First deploy the image to every
staging pod with both flags off; then enable each capability separately
for staging validation. Enabling either mode does not require or enable
the other. Repeat that dark-then-enable order for bb-block after staging
passes. Opt-in client rollout follows the relay contract and the
throttled desktop/browser workflow.

Watch `buzz_membership_snapshot_query_seconds`,
`buzz_membership_snapshot_encode_sign_seconds`,
`buzz_membership_snapshot_response_bytes`, split by
`mode=membership_snapshot` or `mode=membership_public`, plus `/query`
413/503 responses and primary-pool wait time for the Authorization
operation. Confirm zero unexpected full-roster discovery downloads on
supported clients and correct roles/membership against the canonical
cut. Abort enablement for any binding/signature mismatch, access
leakage, incorrect complete result, stale-response regrant, unexpected
overflow for the 468-channel account, sustained 503s or a material
regression in primary-pool wait/foreground reads. Turn the affected flag
off to roll back. Self-mode clients recheck support and start a new
legacy authority generation; public-mode clients recheck support and
back off without treating public absence as membership loss. Keep cached
display/user data.

The first named usable channel target of five seconds at 140 KB/s
remains a client acceptance target, not a result established by this
relay PR. The 119-event approximately 5 MB read-state contention and
native/browser throttled tests are required before broad client rollout.

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 20:14:08 -05:00
Tom Brow
fd0ec10505 perf(mobile): rank search channels once per query (#9136)
Search re-ranked every channel on each live channel event, even while it
sat covered in the back stack, and could move a result while you were
reaching for it. On an iPhone 12 mini in a large community, each re-rank
cost 4 to 20 ms at about 2.4 channel events per second: 454 ms of 2,575
ms UI CPU in a 25 s scroll of a big channel with search behind it, and
343 ms in the 15 s after a resume.

Channel results are now ranked when the query runs and when the set of
channels changes: the first load and a directory install (including one
another caller started). Live channel events no longer reorder shown
results; they only update the shown rows' content (membership badge,
member count, name) in place. The next query ranks the current list.

### Accepted gap

A channel joined, left or renamed while results are shown updates its
row but keeps its position until the next query.

### Validation

- Added test: `live channel events never reorder shown results` adds a
channel and checks it is ranked in, moves it to the top of the list
through activity and checks the shown rows don't move, lets a live event
land between another caller's directory status turning loaded and its
install and checks the open channel shows, then joins that open channel
and checks its Open badge goes without the row moving. It fails when
activity re-ranks again, when a change to the channel set is not ranked,
and when shown rows keep stale content.
- Search suite and full mobile suite pass except the known
notification/push failures that also fail on main. Analyze clean.
- Automated simulator run of this branch (debug build, Builderlab
community): searched "studio", channel and people results listed, opened
a channel result, went back with the results still shown.
- Device numbers are from a wired Profile build of main 120d75d976
(pre-change); the change removes the call path, so there's no
post-change device capture.

🤖 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 17:04:31 -08:00
Wes
1a30e51689 mobile: start new agents on the server-recommended model (#9151)
## Summary

Builderlab's model catalog can now recommend a model and thinking effort
for new agents (`recommended_selection` on
`/v3/beekeeper/get-model-catalog`). The mobile create-agent screen now
starts on that recommendation, so the default can change on the server
without an app release.

New agents start on the first of these that applies:

1. The model an agent link suggests, when the catalog offers it
(unchanged).
2. The server's recommended model and effort.
3. The first Sonnet model at medium effort, as added in #9034. Used
while a deployment sends no recommendation.
4. Blank.

A setup resumed after a failure still keeps its saved choice. A
recommendation that isn't in the returned catalog, or that is malformed,
is ignored. Existing agents' saved models are never changed.

The server only sends a recommendation when the deployment's catalog
config sets `recommended_selection`. Until then, the app keeps falling
back to Sonnet.

## Testing

- Unit tests cover the recommendation beating the Sonnet fallback, a
draft model beating the recommendation, and recommendations that should
be ignored (unsupported effort, unknown model, missing effort,
malformed, absent).
- A new widget test checks that the form opens on the recommended model
and effort instead of Sonnet.
- `just mobile-check` and the full mobile suite (`flutter test
--dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example`) pass locally.

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

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:00:58 -07:00
Tom Brow
5d094cd769 release(mobile): push candidate tags as the operator, not the App (#9084)
Mobile release candidates (`mobile-vX.Y.Z-rc.N`) are currently created
by the `mobile-release-candidate.yml` workflow, using the
`buzz-release-bot` App key that also serves the desktop, relay and chart
lanes. This PR takes mobile off that key. After it lands, the only way
to create a mobile candidate tag is for a person on the mobile release
team to push it.

### What changes

- `scripts/mobile-release.sh candidate` keeps all its existing checks:
version, source branch, clean tree, canonical origin, ancestry and next
rc number. It no longer dispatches a workflow. It now:
- verifies the mobile tag ruleset 26588677 (active, the expected rules,
scoped to `refs/tags/mobile-*` or `refs/tags/mobile-v*`, and bypassable
by the current user), and that the App-only general tag ruleset 14378754
excludes `refs/tags/mobile-*`;
- requires origin's effective push URLs to be exactly its fetch URL, and
fetches a pinned `--commit` missing from a shallow clone;
- creates the annotated tag locally and pushes exactly that ref
(`--no-follow-tags`, explicit refspec);
- confirms through the GitHub API that `block/buzz` holds that exact tag
object, then verifies the remote tag as before.

A rejected push removes the local tag only when origin lacks it, so a
retry starts clean. When the push reports failure but origin has the
tag, the script keeps the local tag and still runs the canonical GitHub
check, reporting success only if it passes. When GitHub can't confirm
the tag, the local tag is kept and the message says how to reconcile it.
- `scripts/release-rulesets.sh`: `require_mobile_release_tag_ruleset`
replaces the App-oriented check. `MOBILE_RELEASE_TAG_RULESET_ID` is
`26588677`.
- Removed `.github/workflows/mobile-release-candidate.yml`,
`scripts/publish-mobile-release-candidate.sh` and its test, and their CI
references. Updated `RELEASING.md` and the auto-tag workflow header
comment.
- The desktop, relay and chart auto-tagging is unchanged.

### Rollout

Tag ruleset 26588677 ("Restrict Mobile Tags") is active with the two
mobile release managers as bypass actors. The App (Integration 4349119)
is temporarily a bypass actor of 26588677 and 19321162 so the current
`main` workflow keeps working; it is removed from both right before this
merges. General tag ruleset 14378754 now excludes `refs/tags/mobile-*`;
without that, rulesets stack and it would reject release managers'
mobile tag pushes. The branch ruleset 19321162 now has the same bypass
actors, so `require_mobile_release_branch_ruleset` no longer requires
"no bypass"; it still checks the review and status check policy. Release
managers must be bypass actors of both rulesets.

### Before merge

- [x] Remove Integration 4349119 (`buzz-release-bot`) from the bypass
actors of rulesets 26588677 and 19321162.

### Validation

- `shellcheck -x scripts/release-rulesets.sh scripts/mobile-release.sh
scripts/mobile-release-source.sh
scripts/test-mobile-release-contract.sh`: clean.
- `scripts/test-mobile-release-contract.sh`: passes. It runs against a
local bare remote with a stubbed `gh` and covers:
  - a successful annotated push at the pinned SHA;
- ruleset failures (not bypassable, inactive, wrong rules, wrong scope,
excludes, non-numeric ID), with `refs/tags/mobile-v*` also accepted as
the include, each failing before any tag is created;
- a push rejected by a pre-receive hook, which leaves no local tag and
allows a clean retry;
- `push.followTags=true` with a stray local annotated mobile tag
(exactly one ref published), a push that lands but exits non-zero (tag
kept), and a GitHub API outage after the push (tag kept, rerun publishes
the next rc);
- origin push URLs pointing at a fork (pushurl, a second pushurl,
pushInsteadOf, insteadOf), a shallow clone pinning a historical commit,
and 14378754 lacking the mobile exclusion;
  - rc numbering, source branch rules and `--commit` pinning.
- `scripts/test-release-ref-contract.sh` and
`scripts/test-desktop-release-candidate.sh`: pass.
- Live read-only check against real GitHub as a bypass actor: sourced
`scripts/release-rulesets.sh` and `scripts/mobile-release-source.sh`,
then ran `require_mobile_release_tag_ruleset` (including the 14378754
exclusion check) and `require_mobile_release_branch_ruleset
mobile-v0.27`. Both returned success. No tag was created.
- No mobile app test: this is a script and workflow change only.

🤖 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@localhost>
mobile-v0.28.0-rc.4
2026-11-06 00:56:27 +00:00
Tom Brow
fc85b0d154 test(mobile): check posterless video previews renew the relay assertion (#9144)
Follow-up to https://github.com/block/buzz/pull/9134, which made
posterless video previews and voice notes renew an expired relay
assertion before handing headers to the native player. The voice-note
path got a test there, but nothing failed if the video preview went back
to the cached-only headers.

This extends the expired-assertion journey in
`native_media_headers_test` with one step: the real
`videoPreviewFrameLoaderProvider` loads a relay video through a fake
native video player, which records the headers it is created with. The
player must receive both the media `Authorization` proof and the renewed
`Nostr-Federated-Identity`.

### Validation

- Added tests: the video-preview step above. Switching the loader back
to the synchronous cached headers fails it.
- `flutter analyze` is clean and the full mobile suite passes (4508
tests).
- Test-only 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>
2026-11-05 16:51:20 -08:00
Will Pfleger
fba8a7a94f feat(relay): log WebSocket EVENT writes on the access target (#9154)
Logs every WebSocket `EVENT` on the access target
(`buzz_relay::access`). Before this, accepted writes logged only `Event
ingested` on the handler target, and refused or ephemeral writes logged
nothing.

### What's logged
Each `EVENT` frame writes one `event=write` line when its handler
finishes. The line has `conn_id`, `community`, `pubkey` (the
authenticated login, if any), `kind`, `event_id`, and
`outcome`/`reason`, taken from the OK the relay sent:
- `accepted`
- `rejected` (INFO), with `reason` set to the OK's NIP-01 prefix
(`invalid`, `restricted`, `auth-required`, `duplicate`, `rate-limited`,
…). Any other message is logged as `other`.
- `error` (WARN), when the OK starts with `error`
- `aborted`, when the handler ended before deciding an OK

`outcome` is always the relay's decision. If the OK couldn't be queued
because the outbound channel was full or closed, the line adds
`ok_queued=false`; the field is left off otherwise.

`EVENT`s refused before the handler runs are logged too, where their OK
is sent: admission rate limits, with admission's reason, and a full
handler pool, as `rate-limited`.

The OK text after the prefix, the event's content and its tag values are
never logged. Lines join to `auth`/`close` lines (and so to `peer_addr`)
through `conn_id`, the same way `req` lines do.

Rejected writes are capped per connection by the same refusal-budget
mechanism as refused reads, on their own counter: the first 16
rejections are logged and the rest are only counted, in a new
`writes_suppressed` field on the `close` line. Accepted writes are never
capped.

### How
`access::WriteLog` sends the OK and records it in one call
(`write.ok(...)`). That call replaces every
`conn.send(RelayMessage::ok(...))` in `handle_event` and the
observer-frame handler, so the line always shows what the client was
told.

### Tests
- `access::tests`: the reason comes from the OK prefix only; each
outcome logs at the right level and without content; rejected lines are
capped by the budget and accepted ones are not (draining the channel so
every OK is queued); an OK refused by a full or a closed outbound
channel keeps its outcome and adds `ok_queued=false`.
- `handlers::event::tests::fanout_access`: an unauthenticated `EVENT`
going through `handle_event` writes one `rejected`/`auth-required` line,
with no content or tag values.

---------

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-11-06 00:46:24 +00:00
Luke Tornquist
bd1b17dee8 perf(db): plan multi-value #d reads with their values (#9131)
## Summary

A REQ with several `#d` values for a NIP-33 kind runs as a named, cached
statement. A client resolving channel metadata for up to 128 channels
sends this kind of REQ. After a few runs on a connection, Postgres can
switch the statement to a generic plan, which ignores the bound values:

- The `d` match stops being an index condition on
`idx_events_parameterized`.
- The generic plan instead walks the author's rows in `created_at` order
and filters `d_tag` row by row.
- The `LIMIT` is one row per requested value, so the walk never stops
early. It reads every stored version of the kind, not one probe per
value.

This change makes three fixes:

- Multi-value `#d` reads now run as unnamed statements, as tag-value
matches already do (`plans_with_bound_values`), so Postgres plans every
call with its values. This covers both the page `SELECT` and the
`COUNT`.
- The values are bound as one array (`d_tag = ANY($n)`) instead of one
bind per value. The SQL text no longer changes with the number of
values, which matches how channel scope is bound.
- The page `SELECT` and the `COUNT` now share one `push_d_tag_filter`
helper instead of two copies of the `d` predicate.

**What got simpler:**
- One helper replaces the duplicated `d` predicate blocks.
- A `#d` read no longer produces a different statement for every value
count.

### Related issue

N/A. None found.

### Testing

- New unit test `d_tag_sql_does_not_depend_on_value_count`: the SQL text
is identical for 2 and 128 values, for both the page query and the count
query.
- Postgres test
`value_matched_queries_are_not_cached_as_prepared_statements`, extended
from the tag-value test. It runs each `#d` page and count query 6 times
and asserts no `d_tag = ANY(` statement is left in
`pg_prepared_statements`. It also checks the page and count results.
- Mutants, each caught by the matching test:
- dropping `d_tags` from `plans_with_bound_values` fails the Postgres
test;
  - going back to a per-value `IN (...)` list fails the unit test.
- Gates run at 5f71e7143b8bc4cfbecb6b4cf886be3827d54943:
  - `cargo fmt --check`
  - `cargo clippy -p buzz-db -p buzz-relay --all-targets -D warnings`
  - `buzz-db` unit tests: 215 passed
- `scripts/postgres-test-run.sh -p buzz-relay -p buzz-db --lib --tests`:
1291 passed
  - `scripts/test-postgres-test-discovery.sh`
- Local `EXPLAIN ANALYZE` on synthetic data (50k versions of one
author's kind 39000 rows, 128 `#d` values):
- With `force_custom_plan`, `d_tag = ANY(...)` is in the Index Cond on
every partition.
- With `force_generic_plan`, the partition holding the rows drops
`d_tag` to a row filter.
- On larger data the generic plan can switch to an ordered `created_at`
walk.

🤖 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 19:41:03 -05:00
Diem Nguyen
133c883b8c fix(mobile): confirm before starting a Huddle from the header (#9155)
Tapping the headphones button in a channel header used to start a Huddle
straight away, which turned on the microphone and posted "Huddle
started" to the channel. The button is easy to hit by accident. Now,
when no Huddle is running there, the button asks "Start a Huddle?"
first, with Cancel and Start Huddle.

- `showConfirmation` in `shared/widgets/confirmation_dialog.dart` adds a
non-destructive mode to the shared confirm helper.
`showDestructiveConfirmation` now calls it with `destructive: true`, so
existing callers behave the same. On iOS the native UIKit alert reads a
new `destructive` argument and uses `.default` style instead of
`.destructive` when it's false. On other platforms the confirm button
keeps the default `FilledButton` style instead of the error color.
- `_huddleNavigationAction` asks before calling `start`. That covers
both the Material header button and the iOS native navigation action.
Opening or joining a Huddle that's already running stays one tap, and so
do the explicit "Start new" buttons inside the Huddle sheets.

Companion to block/buzz-app#2687, which adds the same confirmation on
desktop. buzz-app#2660 also moves the desktop button away from the
members button, but there's no equivalent move on mobile: the headphones
is the only action in the header.

### Screenshots

Rendered by a scratch widget test from the real `ChannelDetailPage`
(Android/Material path, Inter loaded) at 390×844. The scratch test has
been removed. On iOS, the same confirmation shows as a native UIKit
alert, which isn't captured here because this machine has no iOS
Simulator.

Before: tapping the headphones goes straight into a call.

<img
src="https://github.com/user-attachments/assets/adced280-2abd-44ad-a079-1c83749892a0"
width="300" alt="Before">

After, light and dark, cropped to the new dialog:

| Light | Dark |
| --- | --- |
| ![After,
light](https://github.com/user-attachments/assets/2e7c5bc3-ebd9-451b-9b20-5d916947722c)
| ![After,
dark](https://github.com/user-attachments/assets/999c1798-9d91-4b83-b8c2-eb8490da6bf0)
|

### Validation

- The existing journey "community transition cancels and awaits an
in-flight Huddle start" now taps the button and checks that the dialog
appears, that Cancel closes it without publishing anything, and that
tapping again and choosing Start Huddle starts the call. With the
confirm removed, this test fails.
- `channel_member_profile_actions_test` checks that destructive
confirmations still send `destructive: true` to the iOS bridge.
- Full `flutter test`: 4,508 passed. `flutter analyze` and `dart format`
are clean.
- Not yet checked: the iOS native alert on a device or simulator, and a
human trying it.

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

Signed-off-by: Diem Nguyen <diem@squareup.com>
Co-authored-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:33:07 -08:00
Luke Tornquist
e3a0f3d59d feat(cli): send NIP-FI evidence via credential chain and Buzz desktop (#8999)
## Summary

Relays that enforce federated identity (NIP-FI) reject every protected
`buzz` CLI request with `401`. The CLI never sends
`Nostr-Federated-Identity`. This PR gives the CLI a credential chain,
lets it present NIP-FI evidence, and lets it take its identity from Buzz
desktop. Relays without NIP-FI behave exactly as before.

**Credential chain** (`crates/buzz-cli/src/credentials.rs`)
- Raw `Keys` are replaced by `Credentials`, which provide the public
key, signing through nostr's `NostrSigner`, NIP-44, and the NIP-OA auth
tag. NIP-FI evidence comes through `evidence(relay)`.
- The explicit key (`--private-key` / `BUZZ_PRIVATE_KEY`) is the first
link. A later link can add a signer that holds no local key (a desktop
provider, for example) without reshaping callers.
- Operations that need key material rather than a signature are
purpose-bound methods instead of a key accessor:
  - `engram_d_tag` (the NIP-AE HMAC over the conversation key)
- the relay-scoped GIF customer id (random and fixed for the process
when there is no local key)
- `buzz mem` now encrypts, decrypts and derives the `d` tag through the
provider. Invalid ciphertext skips an entry; a signer failure aborts, so
an unavailable credential never reads as empty memory.
`buzz_core::engram` gains the composable steps behind it: `encode_body`,
`event_builder`, `validate_envelope` and `check_d_tag`. `build_event`
and `validate_and_decrypt` are unchanged wrappers, so `buzz-acp` and
desktop callers don't move.
- Agent-management drafts (`agent_management.rs`) encrypt and sign
through the provider.

**Evidence on every protected request**
(`crates/buzz-cli/src/client.rs`)
- A single `authorize` helper gets the NIP-FI assertion first, then
signs the proof just before sending, so a slow mint cannot age it past
NIP-98's 60 s window. It sets these on every protected route:
`Authorization` (NIP-98 or Blossom kind:24242), `x-auth-tag`, and
`Nostr-Federated-Identity: Bearer …`. The routes are `/query`, `/count`,
`/events` (stored and moderation), `/upload`, `/media/upload`,
`/media/*`, `get_authed`, `post_json_authed` and
`post_json_once_authed`.
- Exempt routes (`get_public`) are unchanged.
- The WebSocket publish sends the header on the upgrade via
`connect_authenticated_with_headers`. NIP-42 AUTH signs through the
signer (`buzz-ws-client` now takes `NostrSigner`; `&Keys` callers
compile unchanged).
- An upgrade denial is an HTTP response under NIP-FI.
`WsClientError::upgrade_rejection` returns its status and body, which
map like any other relay error, so a `403` still says whether evidence
or authorization was refused. A denial after NIP-42 (such as a key
mismatch) is a 1008 close; `WsClientError::PolicyClose` keeps its reason
and the CLI reports it as an `auth_error`.
- The WebSocket publish caps each network phase as a whole, writes
included: connect, AUTH challenge (with any Pongs), AUTH send plus OK,
EVENT send plus OK, and a best-effort close. NIP-42 signing between
phases is bounded by the signer. The relay drops a socket not signed in
within 5 s, so a sign-in that is throttled or comes back after the
socket closed reconnects and signs the new challenge, at most 3
connections. Each connection fetches current NIP-FI evidence for its
upgrade, and a throttled sign-in is waited out between connections, so a
dead challenge is never signed. EVENT is signed before connecting and
sent once, after AUTH succeeds. Running out is `unavailable` (exit 2,
`retryable:true`).
- Relay traffic follows no redirects: reqwest strips `Authorization`
across origins but not `x-auth-tag` or `Nostr-Federated-Identity`, so a
3xx is returned as a relay error. Credential header values are marked
sensitive.

**Headless evidence source**
(`crates/buzz-cli/src/federated_identity.rs`)
- Set `BUZZ_FEDERATED_IDENTITY_TOKEN` (your own NIP-FA sign-in session:
a human session for your key, not an agent grant) and
`BUZZ_FEDERATED_IDENTITY_ADAPTER` (the adapter base) to enable it. Both
are env only: command lines are visible to other local users.
- The CLI requests an assertion and the adapter mints it, through `POST
{adapter}/v1/identity/assertions` with a NIP-98 proof in
`Nostr-Authorization` (NIP-FA §Relay assertion).
- It checks the returned pubkey and expiry, parses the assertion as a
compact JWS (three base64url segments, JSON header with `alg`, JSON
payload), and requires its `exp` to equal `expires_at`. It caches one
assertion per process, bound to the relay when credentials are resolved,
and re-mints at the earlier of half-life and 30 s before expiry; the
desktop link caches and refreshes its assertion by the same rule.
Concurrent callers share one mint. An HTTP proof is signed again, after
current evidence, when the assertion changed while it was signed or the
evidence check aged it 30 s or more, up to 3 rounds, then fails as
`unavailable` (retryable).
- Compared with the separate rules before: a 300 s assertion now
refreshes at 150 s (was 270 s) on both links, a 40 s adapter assertion
at 10 s (was 20 s), and a 20 s desktop assertion is kept for 10 s
instead of fetched on every lookup. Desktop assertions now need a
readable JWS header with `alg` and a non-empty signature segment. An
unsupported headless relay URL now fails when credentials are resolved.
- The token goes only to the adapter origin (https, or http on
loopback), never follows a redirect, and never appears in errors or
`Debug`.
- A mint retries network errors, `429` and any `5xx` up to three
attempts with jittered backoff (or the adapter's `Retry-After` on
`429`/`503`, as delta-seconds or any HTTP-date form, capped at 30 s), on
every path (reads, post-once, moderation, stored events, WebSocket). It
then fails as `CliError::Adapter` (`adapter_error`, exit 2,
`retryable:true`). Relay retry loops never repeat it. A stored write
keeps `delivery_unknown` when any earlier attempt may have been
delivered, so a later adapter failure never invites a re-sign. Relay
mutation retries are otherwise unchanged.
- An explicit port 80 relay URL (read numerically from the authority, on
any scheme) is a usage error: `wss://host` means port 443, and NIP-FA
has no relay on port 80.
- A refusal is never retried: a non-transient status with an unreadable
or truncated body is still a refusal.
- Each NIP-FA denial code maps to an `auth_error` with a specific
message.
- These settings belong to the explicit key: without one they are not
read, and when the desktop link wins a warning names them.
- If an enforcing relay answers `401` and no assertion was sent, the CLI
returns an `auth_error` that names both settings, or, for a desktop
user, says to sign in to the community in Buzz. A desktop user's `403`
hint no longer mentions `BUZZ_AUTH_TAG`.

**What got simpler**
- About ten request sites built `Authorization` and `x-auth-tag`
themselves. Five copies of the 401/403 error-mapping block are now one
`relay_error`.
- The `sign_nip98` / `sign_blossom_*` helpers are gone. Proofs are a
three-case `Proof` enum consumed by `authorize`.
- `BuzzClient::keys()` (raw secret access from every command) is
removed. Commands use `public_key()`, `sign_event` or a purpose-bound
credentials method.

**Desktop credential provider**
(`crates/buzz-cli/src/desktop_provider.rs`,
`docs/local-credential-provider.md`)

Buzz desktop is a credential provider in the chain, after an explicit
key:

1. An inherited `BUZZ_CREDENTIAL_PROVIDER` token (set by the desktop for
the agents it launches). It decides the identity: when it is revoked,
not bound to the relay or unreachable, the command fails. A paired
token, which acts as the human, is never tried. That keeps a
well-behaved CLI from acting as the owner by accident; it is not a
boundary against an agent that can run its own commands as the owner's
OS user, and the contract says so with the mitigations.
2. Otherwise, a paired token stored in the OS keychain (`keyring`; macOS
native keychain, Linux Secret Service; no file fallback). A desktop that
answers `denied` or `unavailable` fails the command; only an unreachable
or unconfirmed advertisement is skipped. Revoked entries are forgotten.

New commands: `buzz auth desktop pair` and `buzz auth desktop unpair`.
The help no longer marks `BUZZ_PRIVATE_KEY` as required.

The CLI talks to the desktop over the loopback contract in
`docs/local-credential-provider.md` (NIP-46-shaped envelopes,
`Buzz-Credential-Provider` version header, same-user check on the
listening socket for paired and inherited tokens, via `/usr/sbin/lsof`
on macOS, given 5 s; a check that runs out of time is a retryable
`unavailable`, a wrong owner is refused). Every response's `id` must
match its request, error envelopes included. Paired tokens live only in
the keychain, survive desktop restarts, and are harness-neutral for
agents.

- **Relay binding:** tokens bind the whole relay URL (`http`/`https`
read as `ws`/`wss`, lowercase host, no default port, `/` for an empty
path; path and query count). The CLI sends that URL in `pair` and
`federated_identity`; NIP-FA audience canonicalization is the desktop's.
- **Rate limits:** a `rate_limited` answer refuses before acting, so the
CLI re-sends after `retry_after` (capped at 30 s), up to four times,
except a NIP-42 sign-in, whose wait goes to the WebSocket reconnect. A
re-sent NIP-42, NIP-98 or Blossom proof is re-dated (new id, Blossom
`expiration` moved with it); content events are re-sent exactly. If it
is still throttled, the command fails as `rate_limited` (exit 2,
`retryable:true`), not `auth_error`, whether the throttled call was
NIP-98, event signing, NIP-42 or encryption. Transport failures are
never re-sent, and relay commands are never replayed. A `buzz mem` list
that is throttled partway through completes.
- **Memory:** a `nip44_decrypt` answered with `invalid_request` is
invalid ciphertext, so `buzz mem` skips that entry (NIP-AE filtering);
any other provider failure aborts.
- **Keychain changes:** each change reloads the map under a short
`flock` on `~/.buzz-foundation/cli-pairings.lock` (directory forced to
`0700`, lock opened with `O_NOFOLLOW`) and changes only its own
instance; the lock is never held across the approval wait. An unreadable
entry is refused, never overwritten; `unpair` with no instance clears
it.

### Related

Related:
[block/buzz-app#2260](https://github.com/block/buzz-app/pull/2260) (the
desktop side of the provider contract)

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Will Pfleger <wpfleger@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-11-06 00:26:22 +00:00
Logan Johnson
c39ee507b2 fix(mobile): find verified people who have no profile (#9086)
## What changes

Mobile now finds a person who has a relay-verified profile (NIP-FI, kind
30382) but never published a kind:0 profile. This happens, for example,
when someone skips profile setup. The relay's `POST /profiles/search`
already finds such people by their verified name. Each one comes back as
a kind:30382 head with no kind:0. Mobile kept only kind:0 results, so
these people did not appear.

- `RelaySessionNotifier.searchProfiles` keeps kind:30382 heads from the
endpoint.
- `VerifiedProfilesNotifier.searched` turns a search page into people:
- If the relay authority is not known yet, it first waits for NIP-11
discovery. Otherwise, a search that finished first would drop these
people for that query.
- It checks each head's signature with the directory's off-isolate proof
checker.
- Proven heads go into the verified directory, so the rows show verified
names right away.
- `searchedPeople` (in `verified_profile.dart`) builds one row per
person, in the relay's order:
  - If the person has a kind:0, the newest kind:0 is used, as before.
- If not, it uses the proven head that the authority signed, provided it
parses as an active verified profile.
- These cases add no row:
  - a head signed by another key;
  - a head that names the relay key but has a bad signature;
  - a retraction;
  - a malformed subject.
- **Behavior change for all mobile mentions:** every verified person now
matches in mention ranking by their verified full name and username, as
well as by display name, including people who have a kind:0. The relay's
search matches these same three names. Without this, a person whose
display name is their username (`glilienfeld`, full name "Gabi
Lilienfeld") came back from the relay for "gabi", and then the local
filter dropped them.
- Callers:
  - Mention search, which also serves Add members and New DM.
  - The user rows in global search, through `directoryUsersFromSearch`.

The desktop app has the same fix for Manage members:
https://github.com/block/buzz-app/pull/2554

## Screenshots

These come from the real Add members sheet on an Android emulator. Only
the relay session, verified-profile discovery and the member actions are
faked. The names are made up. Jordan Park has a kind:0. jrivera has only
a verified profile, with the full name "Jordan Rivera". The search is
"jordan". The row shows the verified display name, which here is the
same as the username.

| Search finds jrivera | After tapping + on jrivera |
| --- | --- |
| <img width="300" alt="jrivera is found next to Jordan Park"
src="https://github.com/user-attachments/assets/d93b77b1-1bc7-4d09-afc1-f359616a192a"
/> | <img width="300" alt="jrivera is selected to add"
src="https://github.com/user-attachments/assets/ce6c7a71-b098-434e-8b0e-ed0de8d1a5a2"
/> |

## Tests

- `add_members_sheet_names_test.dart`: a journey in which Gabi has
`display_name` = `glilienfeld` and `full_name` = "Gabi Lilienfeld", and
NIP-11 discovery is slow.
  - While discovery is pending, typing "gabi" shows nothing yet.
  - When discovery finishes, Gabi appears with no further keystroke.
- A head from another key does not appear, and neither does a head that
names the relay key but has a bad signature.
  - **Add member** adds Gabi's key.
- I removed each fix in turn: the full-name alias, the signature proof,
and the wait for discovery. The journey fails each time, for the
expected reason.
- `verified_profile_test.dart`: a table of `searchedPeople` cases.
  - A kind:0 wins over a head, and the newest kind:0 counts.
- These find nobody: another signer, a retraction, an uppercase subject,
a non-profile kind, and any head when the authority is unknown.
- `relay_session_test.dart` and `channel_management_provider_test.dart`:
rows for the kept heads and the directory row.
- `flutter analyze` is clean, and the full `flutter test` passes on
6efef79cc: 4,462 tests.

**Note:** mobile on `main` does not compile at e558f15fa.
`account_identity_store.dart` calls the removed `_keys`. That fix is
https://github.com/block/buzz/pull/9085. The local runs above had that
one-line fix applied but not committed. Until it lands, mobile CI on
this PR will fail.

## Not covered

- **Search during a reconnect:** discovery starts only when the relay
session is connected. If an HTTP search succeeds while the session is
reconnecting, that search leaves these people out.
- **Discovery failure:** if NIP-11 discovery fails, the search also
leaves these people out.
- In both cases the mention cache keeps that partial page for the query,
and the next search tries discovery again. People who have a kind:0 show
as before. Showing the left-out people when the authority arrives later
would need the cached results to be rebuilt; that is a possible
follow-up.

🤖 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-05 18:53:40 -05:00
Brad Seiler
66c3d6917d feat(protocol): define compact membership discovery (BUZZ-615) (#9117)
## Summary

Cold login currently downloads full channel rosters to learn the
viewer's memberships. This defines compact discovery in NIP-CL and
reserves relay-only kind 39008 for a signed, request-bound response.
Full kind-39002 rosters remain compatible.

The contract binds a complete snapshot to the community, viewer, NIP-98
request and requested channel types. It defines a primary database cut,
overflow errors, lazy-roster precedence, reconnect repair and safe
fallback across mixed relay versions. The next PR implements both query
modes. This PR does not enable either capability.

A separate `membership_public` capability uses relay-only kind 39009 for
lookups of any subject’s listed-open forum, stream and workflow
memberships. It preserves roles and archived state while excluding
private, unlisted, DM and system channels. Signed answers bind
requester, subject and public scope; they cannot grant membership or
sending permissions. The self-mode contract is unchanged.

### Related issue

[BUZZ-615](https://linear.app/squareup/issue/BUZZ-615). No duplicate
compact-membership implementation found. Related work:
[#8898](https://github.com/block/buzz/pull/8898) protects full-roster
signing; [#9060](https://github.com/block/buzz/pull/9060) caches
membership lookups.

### Testing

McKeon independently reviewed the contract and the fixes to request
binding, per-channel race fencing and mixed-version fallback. Content
checks parse every JSON example and resolve relative links. Paul’s
review fixes remove implementation details from the normative text and
move the existing 39009 registration and regression test into this PR.
Protocol behavior is unchanged. Runtime validation belongs to the
implementation PR. This description does not attest human testing.

### Implementation and operator notes

Both kinds are registered in `crates/buzz-core/src/kind.rs`, included in
`ALL_KINDS`, and rejected as client-submitted events by the relay-only
guard. The dependent implementation uses one primary SQL statement’s
MVCC snapshot over active `channel_members` joined to nondeleted
channels, supported by the partial `(community_id, pubkey) WHERE
removed_at IS NULL` index. Public requests share the existing
community/requester `ApiCalls` quota configured by
`human_api_calls_per_min`.

`BUZZ_MEMBERSHIP_SNAPSHOT_ENABLED` and `BUZZ_MEMBERSHIP_PUBLIC_ENABLED`
independently default to false. Deploy support across the fleet with
both flags off, then enable capabilities independently. Disable the
affected capability to roll back; client fallback and back-off behavior
remain specified in NIP-CL. Client opt-in and production-shaped
throttled validation follow separately.

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 23:52:07 +00:00
Jemiah Westerman
cec47c20a3 perf(db): hydrate search hits with exact event timestamps (#9126)
## Summary

Keep each search hit's existing signed `created_at` alongside its event
ID during canonical hydration. WebSocket search, HTTP search, and
profile-name search now use a bounded exact-pair database lookup so
PostgreSQL can prune unrelated event months. Also constrain
channel-create replay detection by the timestamp of the
already-available signed event.

## Scope / compatibility

- Combined-tree verification also passed: repository-wide `just ci`, 220
unit/source-policy tests, and the full 722-test buzz-db/search
PostgreSQL suite. All three temporary timestamp-constraint mutations
were killed by functional/plan assertions and restored.
- Independent of companion post-insert push and deletion PRs; based
directly on `main`.
- Deploy/code-only change. No schema/migrations/backfills, persistent or
serialized formats, indexes, triggers, functions, procedures, rules, or
foreign keys added.
- Existing ID-only event APIs remain available for callers without
timestamps.
- Maximum 500 input pairs, validated before checkout; duplicate inputs
do not multiply results; missing/deleted/wrong-community/wrong-timestamp
hits are omitted.
- Caller ordering, authorization, observability, bounded-only replica
routing, proved connection use, non-cancel fallback, and cancellation
propagation remain intact. A timestamp is not treated as a completeness
proof.
- Existing/new relays coexist against unchanged schema; no
rollout/prebuild dependency.

## Verification

- Compiled `buzz-db`, `buzz-search`, and `buzz-relay` library/tests.
- 220 infrastructure-free unit/source-policy tests passed.
- 35 focused PostgreSQL tests passed, including cross-month exact pairs,
tenant/deleted/missing scope, same-second IDs,
duplicate/empty/invalid/oversized/full batches,
routing/fallback/cancellation, channel replay, and existing search
regressions.
- `EXPLAIN ANALYZE BUFFERS` of production hydration SQL verifies only
requested event months execute under forced custom **and generic**
plans, including the full **500-distinct-pair** shape. A wrong-timestamp
decoy detects cross-product matching.
- Targeted clippy with `-D warnings`, formatting, diff checks,
PostgreSQL discovery, and differential file-size checks passed.
- Live isolated relay smoke: exact channel-create replay, HTTP NIP-50
search hydration, profile-name search hydration, and authenticated
WebSocket NIP-50 results/EOSE. All three hydration route labels observed
in local metrics.
- Independent review completed; optional maximum-batch plan coverage
added and independently re-reviewed. Compile fixes for SQLx 0.9 API
compatibility verified.

No production latency improvement or workload-percentage claim is made.

---------

Signed-off-by: Jemiah Westerman <jemiah@squareup.com>
Co-authored-by: Son of Anton <8e0ef2ab02d69a7ac089ef5eca90d1a3fbbdc546f6322dc34d0c4f3deecea7f4@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:43:28 -08:00
Jemiah Westerman
997ce3a2c9 perf(db): prune post-commit push enqueue by signed event timestamp (#9128)
## Summary

Carry each inserted event's signed `created_at` through the existing
in-memory, post-commit push enqueue path. The worker now looks up the
source event by community, ID **and exact timestamp**, allowing
PostgreSQL to prune unrelated monthly partitions.

All seven direct insert-followup hooks forward the timestamp already
bound into their INSERT. No receipt-time or causal-order inference is
used.

## Scope / compatibility

- Combined-tree verification also passed: repository-wide `just ci`, 220
unit/source-policy tests, and the full 722-test buzz-db/search
PostgreSQL suite. All three temporary timestamp-constraint mutations
were killed by functional/plan assertions and restored.
- Independent of the companion search-hydration and deletion PRs; based
directly on `main`.
- Deploy/code-only change. No migrations, backfills, schema changes, new
indexes, triggers, functions, rules, procedures, or foreign keys.
- No persistent/serialized payload changes: `push_match_queue` and
`push_wake_outbox` remain unchanged. Their later ID-only lookups are
outside this PR.
- Admission/gate ordering, commit-only submission, savepoint rollback
behavior, duplicate suppression, bounded queues, best-effort drops, and
existing lock/statement/client deadlines are retained.
- Existing and new relay versions can coexist against the unchanged
schema; no rollout ordering or operator prebuild is required.

## Verification

- Compiled `buzz-db`, `buzz-search`, and `buzz-relay` library/tests.
- 218 infrastructure-free unit/source-policy tests passed.
- 12 focused PostgreSQL tests passed, including new timestamp
propagation and savepoint tests plus existing follow-up/admission/gate
regressions.
- `EXPLAIN ANALYZE` of the exact production enqueue INSERT verifies a
single event month executes under forced custom and generic prepared
plans.
- Targeted clippy with `-D warnings`, formatting, diff checks,
PostgreSQL discovery, and differential file-size checks passed.
- Live isolated relay smoke: channel creation/replay, signed message
ingestion/readback, and completed post-commit enqueue worker metric.
Test lease had empty subscriptions; no push gateway was contacted.
- Independent local review completed: no remaining blockers.

No production latency improvement or workload percentage is claimed;
this establishes the pruning mechanism and behavior preservation.

---------

Signed-off-by: Jemiah Westerman <jemiah@squareup.com>
Co-authored-by: Son of Anton <8e0ef2ab02d69a7ac089ef5eca90d1a3fbbdc546f6322dc34d0c4f3deecea7f4@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:43:02 -08:00
Jemiah Westerman
0cfa50ac70 perf(db): use exact event timestamps for transactional deletion (#9130)
## Summary

Preserve the target event's already-known signed timestamp through
deletion planning and use `(community_id, id, created_at)` for the
canonical transactional read, soft delete, and retention-free purge.
Known-target operations can prune unrelated monthly partitions instead
of repeatedly probing by ID alone.

Public ID-only deletion callers retain their discovery step and reuse
the discovered timestamp for subsequent mutations. Operator deletion
similarly reuses its existing discovery result. Canonical kind/channel
classification and locking remain database-owned inside the transaction.

## Scope / compatibility

- Combined-tree verification also passed: repository-wide `just ci`, 220
unit/source-policy tests, and the full 722-test buzz-db/search
PostgreSQL suite. All three temporary timestamp-constraint mutations
were killed by functional/plan assertions and restored.
- Independent of companion search-hydration and push-enqueue PRs; based
directly on `main`.
- Deploy/code-only change. No schema, migrations, backfills,
persistent/serialized data formats, indexes, triggers, functions, rules,
procedures, or foreign keys added.
- Runtime `DeletionTarget` now carries the target timestamp; no wire
payload changes.
- Community admission, canvas replacement locking, replay repair,
atomicity, retention-free purge/mention cleanup, artifact restrictions,
and once-only thread accounting remain intact.
- Extract deletion helpers into a focused private module, preserving
existing public reexports.
- Existing/new relays coexist against the unchanged schema; no operator
prebuild or rollout dependency.

## Verification

- Compiled `buzz-db`, `buzz-search`, and `buzz-relay` library/tests.
- 218 infrastructure-free unit/source-policy tests passed.
- 23 focused PostgreSQL tests passed: cross-month/exact-key/tenant
isolation, missing/wrong/deleted targets, rollback and concurrent
replay/counters, retention-free cleanup in desired and migration schema
modes, canvas locking, and existing deletion/admin regressions.
- Actual production prepared SELECT/UPDATE/purge SQL prunes to one event
month under custom and generic plans.
- Targeted clippy with `-D warnings`, formatting, diff checks,
PostgreSQL discovery, and differential file-size checks passed.
- Live isolated relay smoke: signed message and reply ingestion, atomic
deletion, deleted payload absent from queries, thread counters return to
zero, and safe deletion replay.
- Independent local review completed: no remaining blockers.

No production latency/workload benchmark is claimed.

---------

Signed-off-by: Jemiah Westerman <jemiah@squareup.com>
Co-authored-by: Son of Anton <8e0ef2ab02d69a7ac089ef5eca90d1a3fbbdc546f6322dc34d0c4f3deecea7f4@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:42:41 -08:00
Luke Tornquist
b6d4403149 perf(redis): index mesh ready records and share one Redis connection (#9113) 2026-11-05 18:42:02 -05:00
Matt Wilcox
4aae6c22aa fix(mobile): delay iOS message actions until a deliberate hold (#9049)
## Summary
Increase the iOS message and reaction hold delay from 200 ms to 500 ms,
giving users more time to start scrolling without opening a menu.
Movement detection and other platforms remain unchanged.

Apple documents a [0.5-second default for
UILongPressGestureRecognizer](https://developer.apple.com/documentation/uikit/uilongpressgesturerecognizer/minimumpressduration).

### Related issue
Fixes #8500.

### Testing
- Regression checks fail with the old timeout and pass with the change:
no recognition at 499 ms, recognition by 501 ms, and scrolling after a
300 ms pause cancels the hold.
- Device testing pending; the local iOS simulator SDK is unavailable.
Before review, verify deliberate holds, slow scroll starts, and reaction
holds on an iPhone.

🤖 Generated with Goose

Signed-off-by: Matt Wilcox <mwilcox@squareup.com>
Co-authored-by: Gen <8a28d770b293a0b223e7c671e5985a35fd9bc1a3c7e3fa92ff715bd2fe61b253@squarewebplatform.communities.buzz.xyz>
2026-11-05 15:34:10 -08:00
Blake McAnally
b0f68a06ad feat(cli): user status expiry and status read (#8777)
> Replaces #8768, which was opened from a fork.

## Summary

This change adds two things to `buzz-cli`.

`buzz users set-status` now accepts `--expires`. The status then clears
itself at that time.

`buzz users status` is a new command. It reads the visible status of one
or more users.

## Design

`--expires` takes a unix timestamp or a duration. A duration is a number
with a unit: `s`, `m`, `h`, `d`, or `w`. A bare number is a unix
timestamp. The CLI had no duration parser to reuse. The only one is
private to `buzz-workflow`. A small parser now lives in `users.rs`.

The CLI rejects an expiry that is not in the future. It does this before
it signs anything. The error text says that a bare number is a
timestamp.

`--expires` cannot combine with `--clear`. A status with no text and no
emoji cannot have an expiry. The SDK rejects that shape.

`buzz-sdk` has a new builder, `build_user_status_expiring`. It adds
`["expiration", ts]` after the emoji tag. This is the tag order desktop
uses. `build_user_status` now calls the new builder with no expiry. Its
output is unchanged. Existing callers need no change.

`users status` queries kind 30315 with `#d: general`. It picks the
newest event for each author. A higher `created_at` wins. On a tie, the
lower event id wins. Desktop uses the same rule.

The CLI then drops a user when the newest event has no text and no
emoji. It also drops a user when the status has expired. An expired
status means `expiration <= now`. An older status never shows behind a
newer cleared or expired one.

The expiration tag is read like desktop's `parseInt`. A value such as
`90abc` reads as 90. A value such as `soon` reads as no expiry.

The output is an array: `[{pubkey, text, emoji, expires_at,
updated_at}]`. `emoji` is an empty string when there is none.
`expires_at` is `null` when the status does not expire. A user with no
visible status has no row.

`--pubkey` takes hex, npub, or an exact display name. It reuses the name
resolver of `messages`. It is repeatable. The limit is 100, because each
name costs one profile search. With no `--pubkey`, the command reads
your own status.

A relay response that is not valid JSON is an error. It is never an
empty result.

## Testing

What ran:

- `cargo test -p buzz-cli -p buzz-sdk` passes.
- `cargo clippy -p buzz-cli -p buzz-sdk --all-targets -- -D warnings`
passes.
- `cargo fmt --all --check` passes.
- `just file-size-check` passes.

The CLI tests use an axum fake relay. They call the production functions
`publish_status` and `read_statuses`. These are the same functions that
the commands call. The tests cover these cases:

- One publish carries the right tags.
- Bad expiries cause no publish.
- Expired, cleared, and malformed statuses.
- Newest-event choice and tie-break.
- Hex, npub, and name input.
- The 100-pubkey limit.
- An invalid relay response.
- A publish followed by a read.

Interop evidence: I ran the real `buzz` binary against a local fake
relay. No live relay was used. I fed the published event to desktop's
own `parseUserStatusEvent` and `fetchUserStatusLookup` with node.
Desktop showed the same text, emoji, and `expiresAt` that `buzz users
status` printed. The check printed `INTEROP OK`.

Mutation checks: I broke each guard once. Each break made a test fail.
The guards were:

- the expiry comparison, `<=` changed to `<`
- the future-expiry check
- the `d=general` filter
- the newest-first order
- the tie-break
- the 100-pubkey limit
- the author de-duplication
- the invalid-response error
- the hide-cleared-status check
- the duration overflow check
- the SDK expiration tag
- the SDK empty-status check

I restored each guard after the check.

One note on process: I wrote the code and the tests in the same pass. I
did not write the tests first. The mutation checks cover the gap.

## Manual test steps

Use a test identity on a relay that you may write to. Set
`BUZZ_RELAY_URL` and `BUZZ_PRIVATE_KEY` first.

1. Set a status that expires in two minutes.

   ```bash
buzz users set-status --text "testing expiry" --emoji "🧪" --expires 2m
   ```

   Success: the output has `"accepted":true`.

2. Read the status.

   ```bash
   buzz users status
   ```

Success: the output is one row. `text` is `testing expiry`. `emoji` is
`🧪`. `expires_at` is about two minutes from now.

3. Open the desktop app as the same user. Success: your status shows on
your profile.

4. Wait for the expiry. Read again.

   ```bash
   buzz users status
   ```

   Success: the output is `[]`. Desktop also hides the status.

5. Try a bad expiry.

   ```bash
   buzz users set-status --text x --expires 3600
   ```

Success: exit code 1. The error says that the value must be in the
future. Nothing is published.

6. Try the clear conflict.

   ```bash
   buzz users set-status --clear --expires 1h
   ```

Success: exit code 1. The error says `--clear` cannot be used with
`--expires`.

7. Read another user.

   ```bash
   buzz users status --pubkey <hex|npub|name>
   ```

Success: you get that user's visible status. You get `[]` if they have
none.

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

Signed-off-by: bmcanally <bmcanally@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:28:24 -06:00
Blake McAnally
303157f53d feat(cli): send a thread message to the channel (#8778)
> Replaces #8769, which was opened from a fork.

## Summary

This change adds `buzz messages send --from-thread <root-event-id>`.
The flag is the CLI form of desktop "Send to channel".
It publishes a new top-level message in the channel.
The message carries the tag `["buzz:sent-from-thread", <root-id>,
<excerpt>]`.
The message has no NIP-10 reply tags.

## Design

Desktop puts the thread root id in the tag. It does not use the selected
message id.
Desktop publishes kind 9. The CLI does the same.
The CLI checks the root event before it publishes anything.
The root must exist. The root must have the same `h` tag as `--channel`.
The root must be a thread root. If the id belongs to a reply, the CLI
fails and prints the real root id.
A missing root exits with a not-found error. Other failures exit with a
usage error.
No event is published after a failed check.
The excerpt comes from the root content.
It is a Rust port of `summarizeThreadRoot` in `sentFromThread.ts`.
The port clips by code point, as desktop does. It breaks words by UTF-16
unit, as desktop does.
`--from-thread` conflicts with `--reply-to` and `--broadcast`. It works
only with kind 9.
The relay does not validate this tag. Desktop's Tauri code validates it
(`message_tags.rs`).
The CLI output follows those rules: 64 hex characters, an excerpt of at
most 64 characters, and no control characters.
The change adds no dependency and no SDK change. The CLI appends the tag
to the SDK `build_message` builder.
The README describes the flag. This PR does not change `desktop/`
(deprecated; see #8840).

## Testing

What ran:
- `cargo test -p buzz-cli`: 508 passed.
- `cargo clippy -p buzz-cli --all-targets -- -D warnings`: clean.
- `cargo fmt --all --check`: clean.
- `just file-size-check`: passed.

Test coverage:
- One test calls `cmd_send_message` against a fake relay. It covers
success, a reply id, another channel, a missing root, an invalid id, and
a non-kind-9 send. The failure cases publish nothing. The success case
publishes one event with the tag and without `e` or `broadcast` tags.
- One test checks the clap conflicts.
- Unit tests cover the excerpt boundaries (63, 64, 65 characters, the
word-break threshold, and multi-byte and emoji text).
- One test compares the Rust excerpt with 639 vectors. Desktop's own
`summarizeThreadRoot` generated the vectors. The first run found an
ordering bug (desktop replaces each pattern over the whole text in
turn). The code now does the same.

Interop evidence:
- A node script runs the real `buzz` binary against a local fake relay.
It is not a live relay.
- The script passes the published tags to desktop's
`getSentFromThreadReference` and `getSentFromThreadRootId`.
- Desktop accepts the tag, returns the same root id, and returns the
same excerpt as `summarizeThreadRoot`.
- Output: `["buzz:sent-from-thread","abab…ab","Launch plan for the doc 😀
word word word word word word word…"]` then `INTEROP OK`.

Mutation checks. Each guard was broken once. A test failed each time.
Then the guard was restored.
- Channel check removed: success test fails.
- Reply-root check removed: success test fails.
- Kind check removed: success test fails.
- `--broadcast` conflict removed: clap test fails.
- Word-break threshold `>` changed to `>=`: three excerpt tests fail.
- UTF-16 break changed to code-point break: two excerpt tests fail.
- Tag not attached: success test fails.

## Manual test steps

Use a test channel. You need `BUZZ_RELAY_URL` and `BUZZ_PRIVATE_KEY`
set. Build with `cargo build -p buzz-cli`.

1. Post a root message. Copy its event id as `ROOT`.
`buzz messages send --channel <CHANNEL> --content "Launch plan:
[doc](https://example.com)"`
2. Send it to the channel.
`buzz messages send --channel <CHANNEL> --content "Decision: ship it"
--from-thread $ROOT`
Success: the output has `"accepted":true`. A new top-level message
appears in the channel.
3. Read it back.
   `buzz --format json messages get --channel <CHANNEL> --limit 1`
Success: the tags include `["buzz:sent-from-thread","<ROOT>","Launch
plan: doc"]`. There are no `e` tags.
4. Open the channel in desktop.
   Success: the message shows the "sent from thread" line for that root.
5. Try a reply id.
`buzz messages send --channel <CHANNEL> --content x --from-thread
<REPLY_ID>`
Success: exit code 1. The error names the root id. Nothing is published.
6. Try a root from another channel, and try `--reply-to` together with
`--from-thread`.
   Success: both fail and publish nothing.

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

Signed-off-by: bmcanally <bmcanally@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:28:19 -06:00
Blake McAnally
5c91e0d6fe feat(relay): add kind 30180 custom emoji entry (#8783)
## Summary

This PR adds kind 30180, `KIND_EMOJI_ENTRY`. One event holds one custom
emoji.
The relay accepts, validates, stores, and replaces these events.
The SDK gets `build_emoji_entry`.
`docs/nips/NIP-CE.md` documents the kind.

This is the relay part of the custom-emoji scale-up (Option C).
It does not change kind 30030 sets, message `emoji` tags, or reaction
`emoji` tags.
It adds no migration and no schema change.

**Read this before you deploy.** Prefix search for kind 30180 works only
on a database whose `events.search_tsv` indexes that kind. See "Search
index" below.

## Design

- `crates/buzz-core/src/kind.rs`: `KIND_EMOJI_ENTRY = 30180`. It is in
`ALL_KINDS`. A compile-time check proves it is parameterized
replaceable. 30180 was free on `origin/main`.
- `crates/buzz-relay/src/handlers/ingest.rs`:
  - The kind needs `UsersWrite`.
  - The kind is global-only. It is never channel-scoped.
- `validate_emoji_entry` runs before storage. It requires exactly one
`d` tag and exactly one `emoji` tag.
  - It rejects any `h` tag.
- It requires `d` to equal the shortcode in the `emoji` tag, and both to
equal the normalized shortcode.
- It requires an `http://` or `https://` URL of at most 2048 bytes. It
uses the SDK check `check_custom_emoji_url`, which is now public.
- It caps `content` at 256 bytes (`MAX_EMOJI_ENTRY_CONTENT_LEN`). A
shortcode is at most 64 bytes, so the cap leaves room.
  - Every rejection starts with `invalid:`.
- `crates/buzz-sdk/src/builders.rs`: `build_emoji_entry(shortcode, url)`
and `emoji_entry_search_text`. The content is the shortcode with `_` and
`-` replaced by spaces.
- Replace semantics use the existing parameterized-replaceable path. A
second publish with the same `(pubkey, d)` leaves one live event. Two
authors with the same `d` keep two events. The NIP tells clients to
prefer the newest event and report the conflict.

### Decisions

- **WebSocket `search_mode`: not changed.** Prefix search works through
`POST /query`, which already reads `search_mode`. This is the smallest
correct change. The `REQ` search path still uses full-word matching. The
NIP says so. A client that needs typeahead uses `POST /query`.
- **Search text.** Postgres `simple` already splits `party_parrot` at
the underscore. It keeps `sad-parrot` as one token and also indexes
`sad` and `parrot`. So the space replacement is not needed for matching
today. It is kept because it is harmless and does not depend on parser
details.
- **Content cap of 256 bytes.** The cap is documented in the NIP and the
code doc comment.

### Search index (important)

The design doc says kind 30030 is not excluded from the full-text index.
That is true for `schema/` (the desired state): it indexes every kind
except a deny list. It is not true for a database that was created by
`migrations/`:

- Migration 0008 gives a fresh install a positive allowlist: kinds 0, 9,
40002, 45001, 45003.
- `scripts/maintenance/nip_rs_search_allowlist.sql` moves a populated
database to the same allowlist.

On an allowlist database, kind 30180 is **stored and resolvable by `#d`,
but not searchable**.
I did not change this. A change to `search_tsv` rewrites the partitioned
`events` table. That needs the reviewed out-of-band procedure in
`docs/database.md` (rules 6 and 5), and this task forbids migrations and
schema files.

Before clients depend on search, a human must check the deployed
expression:

```sql
SELECT pg_get_expr(adbin, adrelid) FROM pg_attrdef d JOIN pg_attribute a
  ON a.attrelid = d.adrelid AND a.attnum = d.adnum
WHERE a.attname = 'search_tsv' AND d.adrelid = 'events'::regclass;
```

If the result lists `kind IN (0, 9, ...)`, add 30180 to the allowlist
through the out-of-band procedure first.

## Spike

I ran this on a throwaway local Postgres 17 (Docker), desired-state
schema from `pgschema`.
The loader inserted 100,000 kind 30180 events from 200 authors through
`Db::replace_parameterized_event`. It used 16 parallel tasks, and it did
not use the network.
The loader was a scratch example. It is not in this PR.
Rows landed in `events_p_future` because the local partitions end at
2026-06. Real deployments have monthly partitions, so scans split across
partitions.

**Insert rate.** 100,000 inserts took 33.9 s (about 2,900 per second) at
the DB layer. The relay adds rate limits on top (see the design doc,
section 2.5).

**`#d` batch of 50 names** (actual relay SQL shape, warm cache, `LIMIT
100`):

| Case | Plan | Execution time |
|------|------|----------------|
| No `authors` | Index scan on `idx_events_parameterized`. It uses
`community_id` and `kind` only. `d_tag` is not a leading key, so it
scans the kind range. | 12 ms warm, 28 ms cold |
| 50 names, all their authors (50 distinct) | Index scan with `pubkey`
and `d_tag` in the index condition | 1.5 ms |
| 50 names, one author | Same index, tight condition | 0.13 ms |

Result: with `authors`, the lookup is fast. Without `authors`, the cost
grows with the number of entries of the kind. At 100,000 rows it is
still tens of milliseconds. No index change is needed now. Re-measure at
1,000,000 rows.

**Prefix search**, `kinds=[30180]`, relevance order (the default),
`LIMIT 50`:

| Query | Matching rows | Execution time (warm) |
|-------|---------------|-----------------------|
| `parr` | 8,169 | 115 ms (124 ms cold) |
| `p` | 15,990 | 219 ms |
| `party par` | 8,169 | 266 ms (cold) |
| `zap blob 999` (rare) | 1 | 0.4 ms |
| `parr` with `search_sort: "newest"` | 8,169 | 0.8 ms |

Result: the GIN index finds matches fast. Relevance order then scores
every matching row (`ts_rank_cd`) and sorts them. The cost grows with
the number of matches, not with `limit`. My synthetic words are few
(24), so each prefix matches many rows. Real names are more varied, but
a one- or two-letter query will still match many rows. The `search_sort:
"newest"` option avoids the scoring and was 150 times faster here.
Clients should use `search_sort: "newest"` for typeahead, or require at
least 3 characters. This is a recommendation for the client work. It
needs no relay change.

**Side effects of bulk insert** (from reading `ingest.rs` and
`handlers/event.rs`):

- Audit log: each accepted event queues one `EventCreated` audit entry.
The audit worker writes them one at a time under an advisory lock. The
queue holds 1000 entries and uses `send().await`, so a bulk run slows
the writer down instead of dropping entries. 96,000 events add 96,000
audit rows.
- Live fan-out: each event is published to the global topic and matched
against open subscriptions in `sub_registry`. A client with an open
filter for kind 30180 receives every event.
- Workflow triggers: each event spawns a task that calls
`workflow_engine.on_event`. That is one trigger lookup per event.
- Push: the DB push enqueue only matches kinds 9, 40002, 45001, and
45003 (`PUSH_MATCH_KINDS`). Kind 30180 does not wake push subscribers.
- No `channel_id`, so the `events_created_at_floor_guard` trigger does
not apply.

## Testing

What ran (all on the final tree):

- `cargo test -p buzz-core -p buzz-sdk -p buzz-relay -p buzz-search`:
pass.
- `cargo clippy -p buzz-core -p buzz-sdk -p buzz-relay -p buzz-search
--all-targets -- -D warnings`: pass.
- `cargo fmt --all --check`: pass.
- `just file-size-check`: pass.
- Postgres lane, desired-state schema (`BUZZ_TEST_SCHEMA_MODE=desired`)
with a local Redis: `cargo test -p buzz-relay --lib handlers::ingest --
--include-ignored`: 205 passed, including the four new tests.

New tests:

- `emoji_entry_validation_table` (no infra): one row for each rule.
- `emoji_entry_is_a_global_users_write_kind` (no infra).
- `emoji_entry_ingest_validates_and_replaces` (Postgres): goes through
`ingest_event`. It rejects a bad shortcode, `d` not equal to shortcode,
an `h` tag, and a non-http URL. It checks that nothing is stored. It
then publishes twice with the same `d` and checks that one live event
remains and that it is the newer one.
- `emoji_entry_prefix_search_finds_by_word_prefix` (Postgres): `parr`
finds `party parrot` and `sad parrot`. `party par` finds one. `joy`
finds `bufo joy`. It fails with a clear message on an allowlist
database.
- SDK: `emoji_entry_happy_path` and `emoji_entry_rejects_bad_input`.

Not run:

- `just test` was not run in full. I ran the relevant Postgres tests
directly.
- `cargo test -p buzz-search --test postgres_fts_integration` fails 23
of 23 on my shared local database. Those fixtures use fixed hostnames
and need the fresh-database lane (`scripts/postgres-test-run.sh`). I did
not change that crate. This is an environment limit, not a result of
this change.
- The new search test fails on a database built by `buzz-admin migrate`
(allowlist). That is the finding above. It passes on the desired-state
schema that the CI lane uses (`postgres-test-run.sh`).
- I ran no live relay and no end-to-end client.

Interop evidence: none for this item. The desktop and mobile constants
are out of scope here. `/home/bloxer/ref/interop.mjs` was not needed
because no TypeScript changed.

Mutation checks (break the guard, confirm a test fails, restore):

| Mutation | Result |
|----------|--------|
| Remove the `validate_emoji_entry` call |
`emoji_entry_ingest_validates_and_replaces` fails |
| Remove the `h` tag check | table test and ingest test fail |
| Remove the `d == shortcode` check | table test and ingest test fail |
| Remove the URL check | table test and ingest test fail |
| Remove the content cap | table test fails |
| Allow more than one `d` tag | table test fails |
| Allow more than one `emoji` tag | table test fails |
| Remove the kind from `required_scope_for_kind` | scope test, ingest
test, and search test fail |
| Remove the kind from `is_global_only_kind` | global test and ingest
test fail |
| SDK: skip shortcode normalization | both SDK tests fail |
| SDK: skip the URL check | `emoji_entry_rejects_bad_input` fails |
| SDK: skip the space replacement | `emoji_entry_happy_path` fails |

One guard has no relay-level test: the space replacement does not change
search results, because Postgres already splits `_`. The SDK test covers
it.

## Manual test steps

You need Docker and a local checkout. Use a local relay only.

1. Start the services and apply the desired-state schema, so that search
indexes kind 30180:

   ```bash
   cd ~/buzz && . ./bin/activate-hermit
docker run -d --name buzz-pg -e POSTGRES_USER=buzz -e
POSTGRES_PASSWORD=buzz_dev -e POSTGRES_DB=buzz -p 127.0.0.1:5432:5432
postgres:17-alpine
   redis-server --daemonize yes --save ""
PGHOST=localhost PGPORT=5432 PGUSER=buzz PGPASSWORD=buzz_dev
PGDATABASE=buzz ./bin/pgschema apply --file schema/schema.sql
--auto-approve
docker exec -i buzz-pg psql -U buzz -d buzz -v ON_ERROR_STOP=1 <
scripts/reconcile-schema-after-pgschema.sql
   ```

   Success: `Changes applied successfully!`.

2. Run the new tests:

   ```bash
export
BUZZ_TEST_DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz
BUZZ_TEST_SCHEMA_MODE=desired
   cargo test -p buzz-relay --lib emoji_entry -- --include-ignored
   cargo test -p buzz-sdk --lib emoji_entry
   ```

   Success: 4 relay tests and 2 SDK tests pass.

3. Optional, with a local relay (`just relay`) and a local key. Send
these through `POST /events` with the SDK or a small script that calls
`build_emoji_entry("party_parrot", "https://example.com/p.gif")`.
Success: the relay returns `accepted: true`.
- Publish a second entry with the same shortcode. Query `POST /query`
with `{"kinds":[30180],"#d":["party_parrot"]}`. Success: one event, the
newer one.
- Query
`{"kinds":[30180],"search":"parr","search_mode":"prefix","limit":50}`.
Success: the entry is in the result.
- Publish an entry that has an `h` tag or an `ftp://` URL. Success: the
relay returns `invalid:`.

4. Check your target database before you rely on search (the SQL in
"Search index"). Success: the expression does not list an allowlist such
as `kind IN (0, 9, 40002, 45001, 45003)`. If it does, search does not
find kind 30180 yet.

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

Signed-off-by: bmcanally <bmcanally@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:28:05 -06:00
Blake McAnally
171100f308 feat(cli): read-only unread state (NIP-RS) (#8789)
## Summary

This change adds `buzz channels unread`. The command is read-only. It
shows how many unread messages each channel has. It uses the read state
that desktop and mobile already sync (NIP-RS). It never publishes an
event. It never marks anything read.

Output: `[{channel_id, name, last_read_at, unread_count, capped}]`. The
most unread channel is first.

## Design

The command has two parts.

**Part 1: read state** (`commands/read_state.rs`)

- The command queries your own `kind:30078` events with the `read-state`
tag.
- It verifies each signature. It rejects events from other authors.
- It keeps the newest version of each `d` tag.
- It decrypts each slot with NIP-44 to yourself. It applies desktop's
validation rules.
- It merges all slots with `max`.
- It ignores `ov_*` keys. Desktop and mobile do not read them either.

**Part 2: counting** (`commands/channel_unread.rs`)

The rules follow desktop's `communityUnreadObserver.ts`.

- Without `--channel`, the command uses your non-archived channel
memberships.
- It reads message kinds 9, 40002, 45001, and 45003. For a DM it also
reads huddle starts (48100).
- It reads only events after the channel marker (`since = marker + 1`).
- Your own messages do not count.
- A message is read when its time is at or below the greatest of three
markers: the channel marker, its `msg:<id>` marker, and its
`thread:<root>` marker. A broadcast reply ignores the thread marker.

**Bounds and signals**

- At most 200 channels per run. `--limit` sets the number. The maximum
is 200.
- At most 1000 events per channel. A channel at that cap shows
`"capped": true`. Its count is a lower bound.
- If you have more channels than `--limit`, the extra channels are not
counted. Stderr shows
`{"warning":"channels_truncated","counted":N,"dropped":M}`.
- More than `--limit` `--channel` values is an error. The command never
drops a named channel.
- An unknown named channel exits with code 1.

**Failure rules**

- A slot that cannot be verified, decrypted, or decoded fails the
command with exit 4. Desktop skips such a slot. A skip would make the
count too high, with no sign of it.
- A slot with a newer `v` is skipped. Stderr shows
`read_state_slots_skipped`.

**Deviations from desktop**

- The command has no 7-day fetch horizon. It reads all slots. This can
only add markers.
- It does not apply desktop's notification gate. That gate uses
desktop-local data (followed threads, muted channels). So a count can be
higher than the desktop badge.
- It does not read mobile's `activity:` markers. Desktop does not read
them.

The README describes the output and the gotchas. The stable-inventory
test lists the new subcommand. This PR does not change `desktop/`
(deprecated; see #8840).

## Testing

**What ran**

- `cargo test -p buzz-cli`: 510 passed.
- `cargo clippy -p buzz-cli --all-targets -- -D warnings`: clean.
- `cargo fmt --all --check`: clean.
- `just file-size-check`: passed.

**Unit tests**

The tests run the production functions against a fake axum relay. The
fake relay applies the real filters (`#h`, `#d`, `#p`, `#t`, `since`,
`limit`).

- Merge: max across slots, newest version wins, bad values dropped,
`ov_*` ignored.
- Failures: bad JSON, wrong shape, bad `client_id`, wrong author, bad
signature, and undecryptable content all fail.
- Counting: own messages, `msg:` markers, `thread:` markers, broadcast
replies, archived channels, DM kinds, and sort order.
- Bounds: channel limit, event cap, `capped`, and explicit-channel
limits.
- Query shape: each channel query has `#h`, a bounded `limit`, and
`since = marker + 1`.
- A bad slot stops the command before any channel query.

**Interop evidence**

A node script runs the real `buzz` binary against a fake relay. Events
are made and encrypted by `nostr-tools`. This is an independent NIP-44
code base. The script compares the CLI with desktop's own TypeScript:

- `mergeReadStateEvents` gives the same frontier as `last_read_at` for
six channels.
- `fetchCommunityUnread` gives the same read/unread result as
`unread_count > 0` for each channel.

The six channels cover: read, unread, never read, own message only, two
slots with max-merge, and a `msg:` marker. The script prints `INTEROP
OK`. The script is not part of the repository.

**Mutation checks**

I broke each guard once and ran the tests. Each break made a test fail:

- Own-message exclusion.
- `msg:` marker, `thread:` marker, and broadcast rule.
- `since = marker + 1`.
- `capped` signal and channel limit.
- Explicit channels over the limit.
- Archived skip and DM kinds.
- Max-merge, newest version wins, and the `t` tag requirement.
- Signature check, author check, unknown `v`, and the 256-byte key
limit.

A mutation of the decrypt error path did not fail a test. The JSON step
fails the same way, so the break changes nothing. I removed that check
from the list.

## Manual test steps

You need a relay and an identity that has used desktop or mobile on that
relay.

1. Build the CLI:

   ```bash
   . ./bin/activate-hermit && cargo build -p buzz-cli
   ```

2. Set the identity and the relay:

   ```bash
   export BUZZ_RELAY_URL=<relay-url>
   export BUZZ_PRIVATE_KEY=<your-key>
   ```

3. Run the command:

   ```bash
   ./target/debug/buzz channels unread | jq .
   ```

Success: you see one object per channel. Each object has `channel_id`,
`name`, `last_read_at`, `unread_count`, and `capped`.

4. Compare with the app. Open desktop or mobile on the same identity. A
channel with a badge must show `unread_count` above 0. A channel with no
badge must show 0. Counts can be higher than the badge for muted
channels and followed threads. This is expected.

5. Read a channel in the app. Wait a few seconds. Run the command again.
Success: the count for that channel is 0 and `last_read_at` is higher.

6. Test the channel limit:

   ```bash
   ./target/debug/buzz channels unread --limit 1 | jq length
   ```

Success: the command prints `1`. If you have more than one channel,
stderr shows `channels_truncated`.

7. Test an unknown channel:

   ```bash
./target/debug/buzz channels unread --channel
00000000-0000-4000-8000-000000000000; echo $?
   ```

Success: the command prints a not-found error and the exit code is `1`.

8. Check that nothing was written. The command sends no events. The app
badges stay the same after the run.

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

Signed-off-by: bmcanally <bmcanally@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:27:47 -06:00