Commit Graph

3379 Commits

Author SHA1 Message Date
klopez4212
838976d6e4 Add invite preview video and fallback poster (#9282)
Adds a 16-second Buzz invite-preview video and wordmark-only poster,
matching onboarding colors and fuzzy texture. Includes a local
preview/poster picker and reproducible media export. This prepares
assets; live invite Open Graph metadata and relay deployment are
follow-up work.

Validated: preview browser checks and independent review pass. Full
`just ci` stopped during Rust compilation because the disk filled.
Native iMessage playback is not yet tested.

Video:


https://github.com/user-attachments/assets/8977f990-fb79-4217-bec1-fa8d2b93ece0

Fallback poster:

![Wordmark-only fallback
poster](https://github.com/user-attachments/assets/b1c5754a-2bb7-4540-b797-102bc57c5e15)

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
2026-11-09 10:35:12 -08:00
kazemek21
3071279cd8 NEXUS-4336: Wait for desktop-provider socket ownership proof (#9270)
## TL;DR

Retry missing desktop-provider socket ownership proof on the same open
connection, within the existing five-second limit. Credentials and
provider requests remain withheld until the exact server-side socket
belongs to the caller's UID; a wrong UID or lookup error still fails
immediately.

The ownership code moves into its own module to keep the existing
oversized file within the repository's file-size ratchet. The macOS
lookup now bounds output and checks the child exit status while
retaining cancellation cleanup.

### Related issue

Fixes NEXUS-4336.

### Testing

42 provider tests passed on Linux, including synthetic transport tests
for delayed/absent proof, wrong UID, lookup failure, deadline expiry,
zero early request bytes, and child cleanup after timeout/cancellation.
Targeted Clippy, formatting, and file-size checks passed. Removing the
retry or bypassing proof caused the production-path regression to fail.

Native macOS execution, installed/source parity, and the cause of any
live authentication failure remain unverified. This source change does
not establish installed or live recovery. No human-test attestation is
claimed.

Generated with Codex. Posted by Codex (gpt-6.1-sol) on behalf of kylez.

Signed-off-by: Kyle Zemek <kylez@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-09 10:11:26 -08:00
Salman Mohammed
658253009c fix: unblock image audit with patched MCP SDK (#9287)
## Summary

The latest main image builds succeed on both architectures, but
publication fails because the same-SHA CI security audit rejects newly
reported advisories.

Update rmcp 3.4.1 to 3.9.0 to fix its HTTP client, server-cache, and
OAuth advisories. Add documented exceptions for rmcp 1.8's disabled HTTP
transports and SQLite used only by the examples-only MeshLLM smoke
harness's private log store. Dependency and feature bans constrain those
exceptions. This does not patch the remaining SQLite version or migrate
buzz-agent's subprocess client.

### Related issue

None found. Failing runs:
[CI](https://github.com/block/buzz/actions/runs/42653097086), [image
publication](https://github.com/block/buzz/actions/runs/42653096928).

### Testing

No manual runtime testing. Local validation was limited to the
dependency audit, buzz-dev-mcp compilation, SQLx pin verification, and
lightweight Git hooks. Broad local Rust and Tauri hooks were skipped at
the author's request. The image qualification gate remains intact.

Generated with Codex

Signed-off-by: Salman Mohammed <smohammed@squareup.com>
2026-11-09 12:44:17 -05:00
Alex Rosenzweig
34bab2f64b feat(ci): allow admin dispatch of desktop candidate validation (#9288)
## Summary

Desktop Release Candidate only supports PR events today. Add a manual
dispatch with a required version input that validates the existing
`version-bump/<version>` branch. Require the workflow from `main` and
current repository admin permission for both the original actor and any
rerunner before checkout; API failures deny the run. Existing PR
validation and read-only token permissions are preserved.

GitHub can still accept a dispatch from a writer; the workflow rejects
that run before candidate checkout. This validates an existing candidate
and does not create or publish a release. Document the command in
`RELEASING.md`.

### Related issue

None found in open issue/PR title searches for "desktop release
candidate".

### Testing

- `actionlint .github/workflows/desktop-release-candidate.yml` passed.
- `bash scripts/test-desktop-release-candidate.sh` passed, including the
new authorization tests executed from the production workflow shell with
only the GitHub API mocked. Covers admin access, all lesser roles,
missing permission responses, API failures, rerunners, and non-main
dispatches.
- Mutation checks confirmed that removing the admin check, rerunner
check, or main-ref restriction fails the new tests.
- Self-review completed with no blocking findings.
- `just ci` is running locally; results will be updated here.
- Hosted manual dispatch has not been exercised: the new guard requires
dispatch from `main`, where this change is not merged. Human
verification is pending, so this PR remains a draft under `AGENTS.md`.

Once available on `main`, an admin can test with `gh workflow run
desktop-release-candidate.yml --repo block/buzz --ref main -f
version=<existing-candidate-version>`. Expect permission checks to pass
and the immutable candidate validator to run against that candidate. A
writer or a dispatch selecting another workflow branch should fail
before checkout. Manual runs do not replace the candidate PR's required
checks.

---------

Signed-off-by: Alex Rosenzweig <arosenzweig@squareup.com>
Co-authored-by: BigBoss <19a7c9f8477c0dd12d428c2b2faa352556cd13b3ec3d717498b60f85a57a90a9@buzz.block.builderlab.xyz>
2026-11-09 12:36:32 -05:00
Matthew Boston
c502e53e63 feat(mobile): tag the reacted-to message author on reactions (#9141)
## Why

NIP-25 says a reaction should carry a `p` tag naming the author of the
reacted-to event. buzz-app's Activity → Reactions tab
(block/buzz-app#2638, merged) finds reactions to your messages with a
`#p` read. Reactions sent from mobile have no `p` tag, so they never
appear there. block/buzz#9121 makes the same change for `buzz reactions
add`.

## What

- Every reaction sent from mobile now carries `["p", <message author>]`.
This covers the reaction pill, the quick-reaction row, the emoji picker,
and reactions on system rows.
- Removing a reaction is unchanged.
- Agent status reactions (buzz-acp) and the desktop app are unchanged by
decision. Agent status reactions stay out of Activity.

## Testing

- New unit test: `ChannelActions.addReaction` publishes exactly `["e",
target]` and `["p", author]`. It fails without the change (actual tags:
`[['e', 'target-event']]`).
- The message-actions widget test now checks that a quick reaction
passes the message's author.
- `just mobile-check`: format and `flutter analyze` are clean (no
issues).
- `just mobile-test`: 4,507 passed, plus the push-unconfigured and
secp256k1 suites. All pass.
- No screenshots: the change is one tag on the published event, and
nothing on screen changes.
- Not yet: a run on a device, and the human test.

## How a human can test

1. On mobile, as user B, react 👍 to a message from user A.
2. As user A, open Activity → Reactions in a current buzz-app build.
3. It works if a "B reacted 👍" row appears. Before this change, no row
appears.

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

Signed-off-by: Matthew Boston <mboston@squareup.com>
Co-authored-by: mboston Implementer <932d3b6ebea6cc83e091551ae46d1c7e7f8f7d0fa8e9cef5e6facc8544af413b@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
mobile-v0.28.0-rc.10
2026-11-09 09:16:29 -07:00
Tom Brow
5f59940544 fix(mobile): steady New DM picker rows and animate picks (#9249)
On iOS, the New DM (and Add Members) picker is a native UIKit sheet that
Flutter drives by pushing the picker's whole state on every rebuild.
Each push called `reloadData`, which rebuilt every visible row in a
fresh, unreused cell showing the initials fallback, then fetched each
avatar again over the platform channel. So whenever anything refreshed
(several refreshes land right after foregrounding), every avatar blinked
to its initial for a frame or two. Picks also jumped instantly from the
results to the chosen section.

Now the picker:

- ignores a pushed state identical to the one it shows;
- applies the rest as a diffable snapshot, so unchanged rows keep their
cells and changed rows are redrawn in place, in reused cells;
- keeps loaded avatars by avatar key for the life of the sheet, so a
redrawn row shows its avatar at once;
- animates a member's row moving between the chosen and result sections
when it's picked or removed (no animation with Reduce Motion).

Flutter's toggle handlers in both pickers now read the current selection
rather than the one captured at build time. A fast second tap could
otherwise add the same person twice, which the diffed table would
reject.

### Validation

- Native member picker test (`scripts/test-mobile-member-picker.sh`),
extended: after an avatar loads, a changed row is redrawn with that
avatar and requests nothing more. The avatar check fails on `main` and
passes here; the existing identity and accessibility checks still pass
with the diffable table.
- Added a New DM test: two toggles before Flutter rebuilds never list a
person twice. It fails without the toggle fix.
- Full mobile Flutter suite and analyzer pass in CI on the current head,
which includes `main`'s fix for the right-drag link tests.
- Simulator runs of the app (iPhone 17 Pro, iOS 27, signed in to a test
community): opened New DM, backgrounded and foregrounded the app,
searched, picked from the list and from a search, and removed a pick.
Without this change, every avatar flashed to its initial after a
foreground. With it, no avatar flashed at any point, and picks and
removals animate between sections. Recordings show real community
members, so they're shared in Buzz rather than here.

#### Full-app comparison, `main` vs this PR

The same scripted New DM sequence on a clone of the same signed-in
simulator (iPhone 17 Pro, iOS 27), first on `main` (65e864301d) and then
on this PR (330805cfc4): pick george, then glm; search "ju", pick judge,
clear the search; remove george; background and reopen the app. The list
shows only agents, no people.

Both builds end in the same state: each pick moves into the selected
section with its check mark, search narrows to one result and clearing
it restores the full list, the removed pick returns to the list, and
every avatar keeps rendering after the app reopens. Sampled at 6 frames
per second around the reopen, neither build fell back to initials in
this run. The visible difference is motion: on `main` a picked row jumps
into the selected section, and here it animates there.

Before (`main`):


https://github.com/user-attachments/assets/12d88f47-0c10-4e78-a12c-18235e068f88

After (this PR):


https://github.com/user-attachments/assets/b3d8edb1-94ad-44dd-b6b3-3db22d8311b0

### Before merge

- [x] Update onto `main` once main's link swipe tests are fixed (they
were fixed by [#9255 Solo chat
settings](https://github.com/block/buzz/pull/9255)), so Mobile Checks
can pass.

buzz-review-completed

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
2026-11-09 07:34:02 -08:00
Tom Brow
52cb6818fa perf(mobile): keep remote images on disk and show small ones as thumbnails (#9158)
## Summary

The mobile app now saves remote images on disk. After a relaunch,
avatars, custom emoji, message attachments and agent artwork show
immediately. The app does not download them again.

Small images on a relay now load only the relay's thumbnail. The app
never downloads the full-size upload to show a small image.

## Why

Before this change, the app kept remote images only in memory. Each cold
launch downloaded every image again. On a slow connection, attachments
showed a "Loading image" placeholder for a long time.

Also, when a relay had no thumbnail for an image, the app downloaded the
full-size upload. That upload can be up to 20 MiB, for an avatar that is
a few dozen points wide.

## What changes

### Images on disk

- `MediaImageProvider` reads and writes a `MediaDiskCache`. Each account
in each community has its own cache, at `<root>/communities/<community
id>/media/<signer pubkey>`. The cache is built on
`flutter_cache_manager`, with a JSON index.
- On iOS, the root is the app group's `Library/Caches` (from a new
`buzz/app_group` method channel). App extensions can then read the same
cache. On other platforms, the root is the app cache directory.
- Each cache holds a maximum of 250 MB. When it is full, the least
recently used images go first. `MediaCacheIndex` measures each file when
it writes it, because `flutter_cache_manager` limits only the number of
files. Every disk read marks the file used, and every write schedules
eviction, including a refresh that replaces bytes the package already
holds in memory (by default the package does neither for those).
- The cache does not save an image that is larger than 5 MiB. The app
still shows that image from memory. Thus, one large image cannot push
out all the others.
- Relay media URLs contain a content hash, so their bytes never change.
The cache keeps them until it evicts them. For other URLs, the cache
shows the saved image, and then gets a new copy in the background after
one week. A maximum of two of these refreshes run at the same time. The
app decodes a refreshed image before it replaces the saved copy.
- When you remove a community, the app deletes its cache. When a
community gets a new signing key, the app deletes the media of the old
account. At launch, the app also deletes caches of communities and
accounts that are no longer present.
- Startup does not wait for the cache. The cache directory resolves in
the background. Image reads wait for it for a maximum of 3 seconds.

### Thumbnails for small images

- A small relay image loads the relay's WebP thumbnail. If the relay has
no WebP thumbnail, the image loads the JPEG thumbnail. If the relay has
neither, the widget shows its placeholder (initials for an avatar, an
empty tile for artwork).
- Native avatars drawn by UIKit (the navigation bar, the community icon
on the create button, and the New DM and Add Members pickers) use the
same choice, through a new `smallMediaImageProviders`. They try the same
thumbnails, fall back the same way, and show initials when neither
thumbnail exists. Before, they downloaded the full-size upload and only
decoded it small.
- Larger layouts and the full-screen viewer load the full-size upload,
as before.

### Agent artwork

- Preset agent artwork (the tray, flight, video placeholder, avatar hero
and the avatar editor's blurred backdrop) now loads through
`MediaImage`, so it uses the disk cache too.
- The create-agent and empty-bestie pages warm the same
`MediaImageProvider` that their widgets read, through a new
`mediaImageProviderFor(ref, url)`.
- No code in `mobile/lib` uses `Image.network` or `NetworkImage` now.

## Demo

A cold relaunch (the app is stopped, then started) into a synthetic "QA
demo" Solo chat that holds a few generated images. Shown at half speed.

Before: the attachment shows a "Loading image" placeholder while it
downloads.


https://github.com/user-attachments/assets/107f43a3-63d0-40c3-bd80-4ea9d7897a97

After: the attachment shows with the chat, from disk.


https://github.com/user-attachments/assets/d3985bb2-838c-4efa-93cb-44cef51f677d

## Tests

- `test/shared/relay/media_disk_cache_test.dart` follows one journey
through the real provider and a real cache in a temporary directory. The
first launch downloads and saves. An offline relaunch shows the
full-size image, its thumbnail and an external image from disk, and the
oversized image goes back to the network. Eight days later, only the
external image refreshes. Removing the community deletes its directory.
- A second test checks the order of eviction when the cache is full,
that a file read from disk is evicted after files that were not, and
that replacing a file's bytes after eviction last ran evicts again when
the cache goes over budget.
- `community_provider_test` checks that launch deletes orphan media
directories, that a new signing key deletes only the old account's
media, and that removing a community deletes its media.
- `avatar_image_test` checks that a missing WebP thumbnail falls back to
the JPEG thumbnail, and that an avatar with no thumbnail shows its
initials.
- `native_avatar_image_test` checks the same for native avatars: a WebP
404 paints the JPEG thumbnail, the full-size upload is never requested,
a relay with no thumbnail paints initials and is not asked again, and
any other failure stops at the initials.
- `flutter analyze` and the full mobile test suite pass.
- Automated app runs on an iPhone 17 Pro simulator (iOS 27): the channel
list, a DM, Home, the Agents grid and an agent details sheet show their
images. After a relaunch, the images came from disk and the app wrote no
new files. In the "QA demo" chat, a new image from the app showed
immediately.
- Automated run of the native-thumbnail fix on an iPhone 17 Pro
simulator (iOS 27): the native New DM picker shows photo avatars, and
the cache gained only relay `.thumb.webp` entries for them, no full-size
uploads. After a cold relaunch, the picker showed the same avatars
within about 1.5 seconds, and the cache directory gained no files. The
avatar editor backdrop was not exercised in the app. The test community
has no remote agents, and the backdrop tests cover only emoji and
picked-image selections.

## Before merge

- [x] Automated simulator run of this head: avatars and agent artwork
show their thumbnails, and a cold relaunch shows images from disk.

🤖 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>
2026-11-09 07:21:21 -08:00
Luke Tornquist
15174bc2b2 fix(relay): deliver slow cross-node events late instead of closing their recipients (#9235) 2026-11-09 10:10:54 -05:00
Tom Brow
f38285562c fix(mobile): read only shown events from filtered Activity rows (#9215)
## Summary

Follow-up to [#9213 Activity filters
UI](https://github.com/block/buzz/pull/9213), from its [last Codex
finding](https://github.com/block/buzz/pull/9213#discussion_r4497322772).

With Threads, Mentions or a People/Agents filter active, an Activity row
holds only part of its conversation. Marking it read advanced the
`thread:<root>` (or channel) marker through the row's latest event,
which also read replies the filter hid. For example, reading a person's
reply in People also read an older agent reply in the same thread.

Filtered rows now advance the synced thread or channel marker through
every event that is read, stopping just before the first unread event
the filter hides, and write local `msg:` marks only for shown events
past that point. Once the hidden event is read too, the marker catches
up and syncs both. This also fixes filtered DM rows from #9213, whose
`msg:`-only reads never synced to other devices. Unfiltered rows are
unchanged.

## Testing

- New widget test: "reading a thread row under People leaves hidden
agent replies unread". It fails without the fix, and checks the synced
marker stops short of a hidden unread reply, then advances fully once
nothing hidden is unread.
- `flutter test test/features/activity/` (136 pass) and `flutter
analyze` on the activity feature.

## Before merge

- [x] Automated simulator run (iPhone 17 Pro sim, iOS 27, re-run on
d17f79a; the catch-up change in 28b927e is covered by the widget test):
with Agents selected, marked a channel mention row unread then read; it
stays read under Anyone and after relaunch. Screenshots are in Buzz
(internal data). The test account has no thread mixing agent and human
replies, so the hidden-reply case is covered by the widget test.
- [x] Main vs PR simulator comparison of a filtered thread read with a
hidden unread reply (iPhone 17 Pro sim, iOS 27; main at
[65e8643](https://github.com/block/buzz/commit/65e864301d), PR at
[b2414cf](b2414cf6d8)).
Neither test account has a real thread with both a person and an agent
mention, so this runs the Activity page on the simulator with seeded
events through the widget-test harness (`flutter run` of the test). The
thread has an older agent mention and a newer person mention, both
unread. The steps: choose People, swipe the person row to read it,
switch to Agents, then Anyone. Left is main, right is this PR.
  - After the read, the person row is read under People on both.
- Under Agents, main shows the older agent reply as read (the thread
marker moved to the person reply's timestamp). The PR keeps it unread
(marker stops one second before the agent reply).


![cmp-4-agents-after](https://raw.githubusercontent.com/block/buzz/aa95e2b22a6ac7815ba1ba36453c0c6c3a994bec/pr-9215--cmp-4-agents-after.png)
- Under Anyone, the thread row is read on main and still unread on the
PR, because its agent reply is unread.


![cmp-5-anyone-after](https://raw.githubusercontent.com/block/buzz/aa95e2b22a6ac7815ba1ba36453c0c6c3a994bec/pr-9215--cmp-5-anyone-after.png)

🤖 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-09 06:59:18 -08:00
Tom Brow
58696ff577 fix(mobile): show the channel name as the notification subtitle (#9173)
On Apple Watch, a Buzz notification showed the community name ("buzz")
under the sender, taking a line of the small display without saying
where the message came from. iPhone never showed it, because iOS draws
the banner from the communication intent and drops `content.subtitle`.
The Watch renders the plain fields.

The subtitle now carries the conversation name, the same one the intent
uses for its group name:

- Team channels and forums: `#channel`
- Your own Solo chats: the bare name, no `#`
- DMs, and channels not yet in the presentation cache: no subtitle

The name logic moves into one helper so the subtitle and the iPhone
group name can't drift apart. A channel that's known but isn't eligible
for the communication style (for example, an unverified roster) now gets
`#channel` as its plain-banner subtitle in place of the community name.
Losing the community name means the Watch no longer tells communities
apart. That seems like the right trade for most people, who are in one
community.

### Validation

- BuzzPushKit `swift test`: all 110 XCTest and 69 Swift Testing cases
pass. Updated the resolver assertions: `#General` for a cached stream,
both with and without communication styling, and no subtitle for DMs and
uncached channels.
- Not yet exercised on a device or Watch.

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

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 06:59:13 -08:00
Tom Brow
e1e1cd50ab fix(mobile): keep Home's header clean under sheets (#9152)
On iOS, opening a sheet over Home (for example a channel's long-press
actions) left a hard horizontal edge and a light glow band under the
filter chips. Home's header keeps a native UIKit soft-edge view behind
its Flutter controls, and the sheet's dim barrier doesn't composite
cleanly over it.

While another route covers Home, the header now switches to its Flutter
backdrop through the existing `FrostedAppBar.nativeViewSuppressed` hook,
the same mechanism other headers use while a backdrop covers them. Home
already hides its native bottom controls under the same condition.

| Before | After |
| --- | --- |
| <img alt="before"
src="https://github.com/user-attachments/assets/a5bf01da-4f88-4055-809d-e347b35408a9"
width="300"> | <img alt="after"
src="https://github.com/user-attachments/assets/6813bb27-9f76-45d5-81c3-b46d8e0df647"
width="300"> |

### Validation

- iPhone 17 Pro simulator (iOS 27), Builderlab test community:
reproduced the artifact on `main` with Home scrolled and the channel
actions sheet open, then confirmed it is gone with this change. Also
opened and dismissed the sheet repeatedly and checked the header before,
during and after.
- Transition timing, frame-stepped from simulator recordings of `main`
(top row of each strip) and this PR (bottom row) on the same Home state:
- The header and the bottom navigation controls switch on the same
frame. A modal sheet doesn't drive Home's secondary animation
(`MaterialPageRoute.canTransitionTo` is false for a sheet route), so the
bottom controls' `secondaryAnimation == 0` check reduces to the same
`isCurrent` gate the header uses.
- Known limitation: while the native header view is swapped out, the
header blanks for about 2 display frames as the sheet opens and about 3
as it closes (about 30 to 50 ms, seen as the missing chips below).
`main` has no blank frames but shows the hard edge.

  Opening:

<img alt="sheet open, main above PR"
src="https://github.com/user-attachments/assets/df8f04f2-da72-4889-b9c8-5c47596fe7e3"
width="900">

  Closing:

<img alt="sheet close, main above PR"
src="https://github.com/user-attachments/assets/aa234a82-773d-4c9f-bba4-469afd158de8"
width="900">
- Extended the iOS Home widget journey: the native header view is
removed while the channel actions sheet is open and restored after it is
dismissed. Without the fix, the journey fails.
- Full mobile Flutter suite passes.

buzz-review-completed

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

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 06:59:08 -08:00
Tom Brow
ac2c5831f3 fix(mobile): show already-loaded custom emoji in the iOS reactions sheet (#9150)
On iOS, the reactions sheet (long-press a reaction pill) could show a
custom emoji as truncated `:shortcode:` text, for example `:eye…`, even
though the same emoji rendered as an image on the message pill a moment
before.

The sheet is a native UIKit view that downloaded each custom emoji again
with its own `URLSession` request and showed the shortcode label until
that download finished, and for good when it failed. A shortcode is
illegible in a chip that size.

Now:

- Flutter keeps the encoded bytes of custom emoji it has downloaded (a
bounded LRU: 512 KiB per emoji, 8 MiB total, rebuilt when the media auth
scope changes so accounts never share bytes) and hands them to the
sheet, which decodes them without a fetch. Emoji the timeline never
loaded still download natively as before.
- A custom emoji in the sheet never shows its shortcode: it stays blank
while loading and shows the sparkles icon, matching the native emoji
picker, if the image cannot load. Reaction pills in Flutter get the same
sparkles fallback. Inline emoji in message text keep the shortcode
fallback, where it reads as text. Avatars in the sheet keep their
initial.

### Before / after

Reactions sheet header and chips right after opening, on a message with
two custom-emoji reactions (synthetic test channel, captured from a 30
fps screen recording).

| Before (main) | After (this branch) |
| --- | --- |
| <img width="300" alt="Before: custom emoji chips show truncated
:qa-... shortcodes"
src="https://github.com/user-attachments/assets/593e555b-9c82-4eec-9049-3d909f987c76">
| <img width="300" alt="After: custom emoji chips show images"
src="https://github.com/user-attachments/assets/a6739997-b39f-4062-9e5c-d22a8a38b368">
|

### Validation

- Added tests: the native sheet's payload carries the bytes the pill
already downloaded; the combined reaction tray shows sparkles, not the
shortcode, when a custom emoji fails to load (the existing alignment
test previously asserted the shortcode).
- Full mobile suite and `just mobile-check` pass; the iOS simulator
build compiles.
- Automated simulator run (iPhone 17 Pro, iOS 27, main vs this branch,
signed in to a test community): channel list, opening a channel, the
message action sheet, the emoji picker, and the reactions sheet on a
message carrying two custom emoji I added plus a thumbs-up. On main the
sheet's custom-emoji chips show truncated `:qa-...` shortcodes for a few
frames before the images arrive; on this branch they render as images
from the first frame (see Before / after). Limitations: the emoji images
load fine over the network in this setup, so the failed-download
sparkles fallback was covered by tests only, not seen in the app; a long
press on the combined reaction tray opened the message menu in the
simulator, a tap opened the sheet.

🤖 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-09 06:59:03 -08:00
Tom Brow
40cfa72cd4 mobile: add follow and remind actions to inbox row menu (#9142)
Long-pressing an Inbox row opened a sheet with only **Mark as
read/unread** and **Open conversation**. To follow a thread or set a
reminder, you had to open the conversation and long-press the message
itself.

The row sheet now also offers:

- **Follow thread / Unfollow thread**: acts on the conversation's thread
root, or on the message itself for a top-level row (the same rule the
message actions sheet uses). It's hidden for Solo chats, which have no
threads, and for grouped DM conversations, which have no single thread
to follow.
- **Remind me**: opens the existing "Remind me later" sheet for the
row's latest message and publishes the same kind-30300 reminder. If that
message already has a pending reminder, the new time reschedules it: the
event reuses its `d` tag (an addressable replace) and keeps its note, so
you don't get a second reminder. It loads the reminder list first,
because otherwise the list loads only with the Reminders filter. It's
hidden when signed out, like the message sheet.

The sheet is now scrollable so the four rows fit on short screens and at
large text sizes (without it, the new test overflowed at the default
test surface).

### Demo

Long-press a channel mention row in the Inbox, follow the thread, reopen
the sheet (now **Unfollow thread**), then set an "In 1 hour" reminder.
The rows are agent test chatter from a pilot community.


https://github.com/user-attachments/assets/ad94e5f2-1048-4585-9663-145e814892e3

### Validation

- Added tests: one Activity page journey that follows and unfollows a
thread row, follows a top-level row, then sets a reminder through the
sheet and checks the published target (event, channel, preview). Further
tests cover rescheduling an existing pending reminder (same `d` tag,
note kept, "Reminder updated"), hiding Follow in a Solo chat, and hiding
Remind me when signed out. The `test/features/activity`,
`test/shared/reminders` and message-actions suites pass.
- Re-run on the branch head (99b2418) in an iPhone 17 Pro simulator (iOS
27) debug build, same community: long-pressed a thread-capable channel
mention row, tapped **Follow thread**, reopened the sheet and saw
**Unfollow thread**, tapped **Remind me** and then "In 1 hour", and saw
the "Reminder set" toast (this is the video above). Long-pressing a Solo
chat row showed no **Follow thread** row, as intended. Not covered on
the simulator: rescheduling an existing reminder, signed-out state,
large text sizes. Those are covered by the widget tests.
- Automated app run on an iPhone 17 Pro simulator (iOS 27) signed in to
the Remote Agents Pilot community: opened Inbox, long-pressed a channel
mention row, followed the thread, reopened the sheet and saw **Unfollow
thread**, then set an "In 1 hour" reminder and saw the "Reminder set"
toast. That run predates the reschedule and Solo changes, which are
covered by the widget tests above. I didn't re-run unrelated Inbox flows
(swipe to mark read, filters) on the simulator. Those are covered by the
existing widget tests.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 06:58:58 -08:00
Tom Brow
ad5699d05a fix(mobile): start no threads in Solo chats (#9123)
Agents in a Solo chat always answer at the chat's top level. On mobile
you can still start a thread in Solo (Reply action, swipe-to-reply,
media reply) or reply inside an existing one, so the agent's answer
lands outside the thread you wrote in. Desktop already avoids this: Solo
shows existing threads but starts none and has no thread composer.

This matches desktop on mobile:

- No Reply quick action, swipe-to-reply or media reply in Solo chats.
- Existing Solo threads still open from their reply summary, read-only
(no composer).
- Every other channel is unchanged.

### Demo

Synthetic Solo chat "QA demo", iPhone 17 Pro simulator. Each clip: right
swipe on an agent message, then long-press for the actions.

Before (main): the swipe shows the reply circle and opens a thread with
a composer; Reply is in the actions.


https://github.com/user-attachments/assets/cc5025a3-b7ba-4ab2-bebb-06c7b54e7906

After (this branch): the swipe does nothing; the actions have no Reply.


https://github.com/user-attachments/assets/b31f9983-8c52-45c2-ae88-1661aa71210e

### Validation

Added tests: a Solo chat's message neither swipes to reply nor offers
Reply in its actions, and a Solo thread opens without a composer.
Existing swipe-reply and Solo channel tests pass, along with the full
mobile suite and analyzer.

Simulator run on an iPhone 17 Pro, signed in to a real community: in an
existing Solo chat, a right swipe on an agent message no longer moves or
opens a thread, and the long-press actions show no Reply (Copy link,
Remind me and the rest are unchanged). Team-channel Reply is covered by
the existing swipe-reply tests, not this run.

A second simulator run on an iPhone 17 Pro, in a synthetic Solo chat,
compared main and this branch: on main the swipe opens a thread with a
composer and the actions list Reply; on this branch neither does (see
Demo). The Demo's "Follow thread" action still appears in a Solo chat's
actions on both builds; this PR does not change it.

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

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 06:58:53 -08:00
Tom Brow
733d164e80 mobile: persist user profiles so names and avatars render on launch (#9116)
On a slow connection, mobile shows npubs and blank avatars on every cold
launch and community switch, because user profiles live only in memory
and are refetched from the relay each time.

This persists the verified profiles the app has already seen, per
identity and relay, so names and avatar URLs render on launch before the
relay answers.

### Behavior

- Profiles are stored as a disposable cache in the same shape as the
existing channel list snapshot: one SharedPreferences entry per identity
and relay, versioned, capped at 2000 profiles and 2 MB (oversized inline
avatars are skipped), deleted on sign-out and by orphan cleanup.
- On launch the cache restores the snapshot synchronously. Each restored
profile is still revalidated against the relay on its first lookup that
session. `get` and `preload` return the restored data right away instead
of waiting on the network, and a failed revalidation retries on the next
lookup.
- Identity-sensitive callers keep their guarantees: `resolve()` and
`refresh()` treat restored profiles as unconfirmed and query the relay,
including while a background revalidation is still in flight.
- Writes are debounced (2 s) and flushed when the community scope
changes.

Not in this PR: avatar image bytes still use only Flutter's in-memory
image cache, so avatar images still download after a cold launch (names
and URLs no longer wait). The NSE keeps its own app-group profile cache,
which this doesn't share yet.

### Demo

Cold relaunch into a synthetic Solo chat ("QA demo"), iPhone 17 Pro
simulator, same clip timing on both builds. To make the gap visible on a
fast connection, both builds had a local-only 5 s delay on profile
fetches (not part of this PR). The channel's system lines show the
member name once its profile is known and a shortened npub until then.

Before (main): "npub1...created this channel" for about 5 s until the
relay answers, and the agent avatar starts as a letter placeholder.


https://github.com/user-attachments/assets/de558521-a210-4ac6-b216-d77260036985

After (this branch): "tomb created this channel" and the agent avatar
render from the first frame.


https://github.com/user-attachments/assets/093b30fc-ec24-4f26-8535-7cc1791d2e44

### Validation

- Added tests: a journey across three simulated launches (relay
reachable, offline, reachable with a rename) covering restore,
non-blocking revalidation, retry after failure, a concurrent `resolve()`
during revalidation, and sign-out removal; plus storage scoping, bounds,
writer retirement and corrupt-data handling. The revalidation and
in-flight guards were each removed once to confirm the journey fails.
- Full mobile check and test suite pass locally.
- iOS simulator (iPhone 17 Pro, Remote Agents Pilot as Tom's Builderlab
account): after browsing, the snapshot held 16 verified profiles (6.4
KB), and cold relaunch rendered normally. Offline cold launch was not
exercised on the simulator.
- iOS simulator, synthetic Solo chat, main vs this branch (see Demo):
with profile fetches artificially delayed 5 s, main showed shortened
npubs in the channel's system lines until the relay answered, and this
branch showed the restored names on the first frame.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 06:58:48 -08:00
Tom Brow
439d7bcc12 fix(mobile): keep a sent thread reply above the composer (#9026)
Fixes two thread symptoms that come from the same state: after sending a
reply, the composer clears but the new reply sits hidden under the
composer (or out of view entirely) until you leave and return, and the
**Latest** button stays visible even though the list looks like it is at
the bottom.

### Cause

1. A sent reply grows into place over its 500 ms entrance transition
(`LocalSendRow`). The thread's one-shot tail correction can finish while
the row is still growing. Once it has finished, nothing moves the list
as the reply keeps growing, so the list stops short of the real end
(about 40 pt on an iPhone 17 Pro simulator) and the reply ends up behind
the composer. Because the list isn't actually at its end, **Latest**
shows. It is visible even with the composer focused when an earlier drag
detached tail-follow.
2. A thread opened at a specific reply (from Inbox, a push notification
or a message link) skipped the new-reply follow logic for its whole
lifetime, not just for the replies it opened with. If the reader
scrolled at all before replying, their own sent reply was never brought
into view: it landed below the viewport, and a quick swipe couldn't
reach it before the next layout.

### Fix

- While the thread follows its tail, a `ScrollMetricsNotification`
listener keeps a position that was at its previous end on the new end
whenever the extent grows. It only acts when the last reply is laid out,
the reader isn't dragging or opted out, and no navigation to the tail is
in progress, so programmatic jumps away from the tail are left alone.
This also covers other growth at the tail, such as media or typing rows.
- A targeted entry now absorbs the replies it hydrates with until its
target jump has revealed the list. Hydration can come in two steps (the
route's snapshot of the target, then the relay's full thread). Absorbing
only the first let the second, when it held the reader's own earlier
replies, schedule a tail correction that cancelled the one-shot target
jump and left the list hidden behind its loading placeholder. Later
arrivals use the ordinary rules: the reader's own reply comes into view,
and other people's replies still don't move a reader who is away from
the tail.

### Before and after

Same flow on an iPhone 17 Pro simulator, in a synthetic QA channel
thread: scroll the list away from the bottom, focus the composer, send a
long reply that wraps onto many lines. Before (main) the sent reply ends
with its bottom edge under the composer; after (this branch) it ends
clear above it.

Before (main):


https://github.com/user-attachments/assets/5101b63d-e2df-46d1-bfd1-49038330ddf5

After (this branch):


https://github.com/user-attachments/assets/1df81ad5-bb6b-4df6-b4e3-5c37fd8aab67

### Validation

- Test `a sent reply stays above the composer as it grows in` now runs
for a plain entry, a targeted entry (`initialMessageId`), and a targeted
entry whose relay hydration is delayed and includes the reader's own
replies. The targeted row opens at an older reply, checks it is visible,
drags a little, focuses the composer with the keyboard up, sends a
multi-line reply, and checks that the reply ends above the composer and
**Latest** is hidden. Each row fails without its fix (growth: reply
bottom at 504, composer top at 402; targeted: the reply is never
followed; delayed hydration: the target never becomes hit-testable
because the list stays hidden).
- Full `flutter analyze` and mobile test suite pass.
- Before/after capture on an iPhone 17 Pro simulator (iOS 27, debug
builds of this branch head and of current main), in a synthetic QA
channel thread with enough replies to scroll. Flow: scroll away from the
bottom, focus the composer, send a long wrapping reply. Over 7 runs on
main, the reply's bottom ended 3 to 25 pt under the composer in 5; over
6 runs on this branch, it ended 17 pt above the composer every time.
Limitation: debug simulator builds only, and the shortfall on main
varies run to run.
- Automated full-app run on an iPhone 17 Pro simulator (iOS 27) against
the Remote Agents Pilot community. In a test DM thread, I sent short and
wrapping replies with no prior scroll, after dragging up and returning,
and after the follow-up narrowing. Each reply ended fully above the
composer, and **Latest** stayed hidden at the bottom. I also dragged up
with the keyboard dismissed and confirmed the list stays at the tail
with no stray **Latest**. For the targeted entry, I opened a thread from
a message link, scrolled, and sent replies: without the targeted-entry
fix they stayed out of view under the keyboard; with it hot-reloaded
they were followed (the combined build is covered by the widget test
rows above). I then reopened that link right after an app restart, which
hydrates in two steps: before the hydration fix the thread showed only
its root with the replies hidden, and with it the list revealed at the
target and a sent reply came into view. Human testing is waived per the
owner's standing exception. Limitation: this run did not cover physical
devices or media replies.

The simulator recording was taken in an internal community, so it is
shared internally rather than embedded here.

🤖 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@localhost>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
2026-11-09 06:58:43 -08:00
Helen Tian
22d96d94ef [buzz] Recover database pool capacity after stalled connections (#9267)
> **The pin is a temporary workaround for this week's public release.
The SQLx fix is still unmerged.**
>
> 1. We considered a maintained fork, but repository permissions blocked
it.
> 2. We will move to an official SQLx release once it includes the
required fixes and is verified with Buzz. Revisit the fork if needed.

## Bug


https://linear.app/squareup/issue/BUZZ-656/relay-writer-pool-connections-stranded-after-a-burst-bb-public-2026-11

A short database interruption can leave the relay unable to handle
database requests after traffic returns to normal. All connection slots
can remain occupied even though no request is using them and PostgreSQL
is idle. New requests then time out waiting for a slot.

## Cause

SQLx, our database library, returns connections to the pool in a
background task. If a socket stays open but stops answering, the final
health check has no timeout. That task keeps its slot, and
idle-connection cleanup cannot reclaim it
([#4349](https://github.com/transact-rs/sqlx/issues/4349)).

## Fix

Pin SQLx to
[`c3735abf`](c3735abf03)
from [PR #4407](https://github.com/transact-rs/sqlx/pull/4407). After
five seconds, it stops a stalled return operation, discards the
connection, and frees the slot so new requests can open a replacement.

Preserve the existing [transaction rollback
(#4394)](https://github.com/transact-rs/sqlx/pull/4394) and [SQLite
compatibility (#4402)](https://github.com/transact-rs/sqlx/pull/4402)
fixes. Give the reader's own check four seconds so it can record its
timeout before the outer deadline.

## Critical risk

**This fix depends on a SQLx patch that has not been merged.** Its
five-second limit affects every pool using this revision, so it can also
discard slow but healthy connections and cause more reconnections. The
local reproduction proves recovery from the injected failure; it does
not establish production behavior.

**Prevent accidental replacement:** require SQLx `=0.9.0`, disable
Renovate updates for all seven crates, and fail required CI if the
approved revision, lockfile source, or update restriction changes. These
guards prevent version drift; they do not remove the runtime risk of the
patch. An upgrade must explicitly update the policy after verifying all
required fixes.

## Before and after evidence

With real PostgreSQL 17.6 on macOS arm64, a proxy silenced 100 existing
connections while allowing new connections. All borrowers dropped their
connections; PostgreSQL showed 100 idle sessions.

| Observation | Before | After |
| --- | --- | --- |
| Pool slots still occupied | 100 after 10 seconds | 0 after 5.008
seconds |
| New work | Next checkout timed out | All 100 replacement connections
served `SELECT 1` |

<details>
<summary>Captured before and after output</summary>

**Before**

```text
REPRO burst: 100 dropped borrowers, all release pings held: size=100 idle=0 active=100
REPRO burst DB sessions: [("idle", "ClientRead", 100)]
REPRO burst after 10.000680583s: size=100 idle=0 active=100
REPRO burst: later checkout timed out despite PostgreSQL being healthy
```

**After**

```text
REPRO burst: 100 dropped borrowers, all release pings held: size=100 idle=0 active=100
REPRO burst DB sessions: [("idle", "ClientRead", 100)]
REPRO burst after 5.008456917s: size=0 idle=0 active=0
REPRO burst: all 100 replacement slots serve SELECT 1 while old sessions stay silent
```

Before evidence used Buzz `96af488d968a1ae9d961c2132bea27822735f8da`
with SQLx `f1e94ec3`. After evidence used Buzz
`4bfe60197e8c390a8701053228cd50c5391f37d3` with the SQLx pin committed
in `f253760085e24358728fa4f4fef059ccf892157f`. These are local
fault-injection results: they establish the stranded-slot mechanism and
recovery, not what originally interrupted production database traffic.

</details>

---------

Signed-off-by: Helen Tian <htian@squareup.com>
Co-authored-by: OpenAI Codex <noreply@openai.com>
2026-11-09 17:40:31 +11:00
Will Pfleger
65e864301d feat: send a User-Agent on relay WebSocket upgrades (#9246)
Relay WebSocket clients in this repo now send a `User-Agent` of the form
`<name>/<version>` on the upgrade request. The version comes from the
build.

| Client | User-Agent |
|---|---|
| `buzz-acp` | `buzz-acp/<crate version>` |
| `buzz-cli` | `buzz-cli/<crate version>` |
| `buzz-ws-client` (default) | `buzz-ws-client/<crate version>` |
| `buzz-pairing-cli` | `buzz-pairing-cli/<crate version>` |
| `buzz-audio-client` (default) | `buzz-audio-client/<crate version>` |
| mobile (relay and huddle sockets) | `Buzz-Mobile/<app version>` |

- **`buzz-ws-client`**: every connect path sends the default. A
`User-Agent` in the caller's `connect_with_headers` headers replaces it,
so other consumers can identify themselves without any new API.
`buzz-cli` uses this to send its own name.
- **`buzz-audio-client`**: keeps a `User-Agent` already on the
host-supplied request, and adds its default otherwise.
- **Mobile**: `openRelayWebSocket` sets the HTTP client's `userAgent`,
which also covers the huddle transport. Startup waits up to 500 ms for
the app version from `package_info_plus` before the app runs, so the
first socket carries it; if the lookup fails or times out, the value is
`Buzz-Mobile`.

Each client has a test asserting the header on the upgrade request.

---------

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-11-08 20:43:55 -05:00
Will Pfleger
31fa53174d Record why relay connections close and quiet close warnings (#9245)
Since bb-public prod moved to `RUST_LOG=error,buzz_relay=info`, two
connection-close warnings send about 1.9M lines a day to Datadog. This
moves that information to the access log and a metric.

- **`close_reason` on the access `close` line.** The first reason
recorded wins. When the server has already decided to close (shutdown, a
policy disconnect, or a cancellation), the reader defers to that
decision, so a peer's reply to the server's Close is never logged as the
cause.
- Values: `auth_timeout`, `auth_failed` (an AUTH rejected and closed
immediately, or a timeout after a rejected AUTH), `missed_pongs`,
`client_closed`, `socket_error`, `frame_too_large`,
`control_channel_full`, `shutdown`, `server_closed`, `refused` for
connections refused before they were served, and `handler_dropped` for a
handler dropped before normal cleanup.
- A community disconnect logs its own value: `community_archived`,
`community_deleted`, `auth_policy_upgraded`, `authorization_denied`,
`access_revoked` or `removed`.
- **The "NIP-42 auth timeout" and "3 missed pongs" warnings are now
`debug!`**, with the same text and fields.
- **New counter `buzz_ws_missed_pong_disconnects_total`**, next to
`buzz_ws_auth_timeouts_total`. A heartbeat tick that races a close
already decided is not counted.

Timeouts, ping cadence, the frames sent and when connections close are
unchanged. The new infra-free tests are added to the `test-unit`
selection and `scripts/run-tests.sh`.

---------

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-11-08 20:03:40 -05:00
Matt Toohey
4bfe60197e fix(relay): say "channel owner or admin" in the call_webhook save refusal (#9169)
## Summary

The SEC-006 save gate in `handle_workflow_def` refuses a `call_webhook`
workflow unless the saver is an owner or admin. It looks up the role
with `get_member_role(community, channel_id, …)`, so this is the saver's
**channel** role. The refusal text said only "require the owner or admin
role", which reads as the community owner or admin.

Clients now show the relay's refusal text when a save is refused
(block/buzz-app is about to pass it through as written), so a member
would be sent after the wrong role. The refusal now says:

> forbidden: workflows with call_webhook actions require the **channel**
owner or admin role

- The role check is unchanged.
- The `forbidden:` prefix is unchanged, so clients still treat it as a
permission refusal.
- This now matches the desktop helper copy under the Webhook URL field
("Webhook steps require a channel owner or admin").

No test, fixture, doc or client copy in this repo quoted the old text,
so this is a one-line change.

### Related issue

None found for the save-path wording. #5135 (open) rewrites the SEC-006
refusals on the **trigger** path (`handle_workflow_trigger`) but leaves
this save-path string alone, so the two don't overlap.

### Testing

Not verified locally: no tests, builds, clippy or rustfmt were run, and
the commit was pushed with `--no-verify`. CI is the first check. The
change only edits a string literal.

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

Signed-off-by: mtoohey <mtoohey@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 15:09:20 -08:00
klopez4212
8cce12f591 feat(mobile): consolidate Solo chat settings in one sheet (#9255)
## Summary
Replace the Solo chat header controls with an ellipsis that opens Chat
settings, showing the selected agent’s avatar and workspace. Agent and
grouped Workspace pickers share one backdrop; closing either returns to
settings without flickering. The sheet smoothly resizes to fit each page
and respects Reduce Motion.

### Related issue
No duplicate found.

### Testing
Full pre-push checks pass without exclusions: analysis, formatting,
file-size checks, all 4547 mobile tests (67 skipped), push-configuration
tests, and secp256k1 tests. Navigation, backdrop continuity, smooth
resizing, and Reduce Motion are covered. Updated four stale link-swipe
tests to use the leftward reply gesture introduced in #9127. Installed
on iPhone Air with saved state preserved.

Flutter snapshots with sample data (attached only, not committed):

| Chat settings | Workspace |
| --- | --- |
| ![Chat
settings](https://github.com/user-attachments/assets/624e9db5-c017-4c3d-b6e1-0455b744f5c1)
| ![Workspace
picker](https://github.com/user-attachments/assets/e51748cd-3f08-47ef-9de1-3856838a30fb)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-08 12:06:02 -08:00
Brad Seiler
8130cd9fc7 fix(cli): preserve attachments across successive edits (#9226)
## Summary

Editing attachment messages through `buzz messages edit` can remove
their files from the effective message. Preserve the latest live imeta
set across successive edits and add `--clear-attachments` for explicit
removal. Add an SDK builder for complete replacement attachment sets
while preserving the existing API.

The relay provides a versioned effective-edit lookup on `/query` for
kinds 9/40002/40008/45001/45003; media reads fold the same set. It uses
the existing normalized-target index, fresh channel authorization, and a
primary snapshot. Generic `#e` matching is unchanged. Mixed-case
historical edits cannot restore removed files, including when another
reference would allow republication.

### Related issue

Fixes #3022. Follows #9077. Related open attempts: #3062 and #3065.

### Testing

Published head: `c630c4ebf0b20a9618f4844629eec1e666699cbe`, through all
mandatory push hooks. The governed-five snapshot passed all four
PostgreSQL/HTTP acceptance tests with `RUST_MIN_STACK` unset and 3,333
affected-package tests. The committed test-only array correction passed
clippy and the preflight rerun.

Independent mutations detected dropped read/lookup kinds, the CLI
two-kind split, and preflight ordering, channel and live-edit errors.
The published follow-up adds forum preview retention/suppression and a
deleted-original preflight fixture; both surviving mutations now fail as
intended. Independent review verified the fixes. All four acceptance
tests pass; the full DB/relay run had two NIP-FI failures that passed in
isolation. The actual standalone MCP workflow passed against this relay
and clean local app `5b583b8c69596353bc37e9f3938eca1aabcd081e`: 15 MCP
edits, 10 direct publications and 60 member GET/HEAD checks across five
isolated blob pairs. Both tested heads are now published. Relay hosted
checks report 80 successful and 31 skipped, with none pending or failed;
app hosted checks and human upload → edit → clear → edit → GET/HEAD
confirmation remain pending. No human acceptance is claimed.

### Rollout

The lookup, CLI preservation/clear and media reads use kinds
9/40002/40008/45001/45003. Deploy the relay before releasing the CLI and
paired [MCP producer](https://github.com/block/buzz-app/pull/2937). New
clients abort without publishing against an unsupported relay. Old CLI
edits emit empty imeta and now revoke diff/forum attachments that
previously remained readable. Other channel-tagged kinds retain the
historical plain-edit path. No additional schema change is needed beyond
migration 0086.

Readiness remains blocked by mobile edits that omit imeta, the app UI
fold that can restore older files, unaudited SDK/direct callers and
unmeasured historical exposure. Run
`docs/sql/private-media-edit-preflight.sql` for each community; producer
updates do not repair signed historical edits. No enforce activation is
authorized. `off` skips policy checks, `shadow` changes policy metrics
while allowing policy failures, and `enforce` denies inaccessible
references.

Observe
`buzz_media_publication_access_total{mode="shadow",reason="denied"}`.
Prefer rolling back the client release while retaining the additive
lookup. A relay rollback makes new CLI/MCP edits fail closed;
already-published imeta remains valid under the older reference rules.
Concurrent edits still use `(created_at,id)` without compare-and-swap.

Generated with Codex

---------

Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
2026-11-08 10:55:55 -08:00
Tom Brow
d839d3731a fix(mobile): swipe left to reply so swipe-back keeps rightward drags (#9127)
Swipe-to-reply used a rightward drag, the same direction as iOS
swipe-back. A rightward drag that started on a message armed a reply
instead of going back, and the reply recognizer claimed horizontal drags
in either direction once they started on a bubble.

Now a message slides **left** to reveal Reply on its trailing side, and
the reply recognizer only wins the gesture arena for leftward drags.
Rightward drags that start on a message are left to back navigation. The
threshold, haptic, rubber-band resistance and thread destination are
unchanged. On bubbles that reach the row's trailing edge (your own
messages), the Reply feedback is kept on screen beneath the bubble, so
the slide uncovers it.

### Demo

Before (main): a rightward drag that starts on a message arms Reply and
slides the bubble, instead of being left to back navigation.


https://github.com/user-attachments/assets/f149cd11-c326-466c-a17b-3e4698708af8

After (this PR): a rightward drag on a bubble does nothing, a leftward
swipe reveals Reply and opens the thread, and edge swipe-back returns to
the channel.


https://github.com/user-attachments/assets/539a5682-1032-452e-a57b-6f27abfe2d64

### Validation

- Focused existing validation: the swipe-reply widget tests were
mirrored to the new direction for channel and thread rows, own and
incoming messages. They now also check that a rightward drag starting on
a bubble reaches an outer horizontal drag handler (a stand-in for
swipe-back) without moving the row or opening a reply. The Bestie DM
test's reply drag was flipped too.
- The full mobile suite (`just mobile-check` and `just mobile-test`)
passes.
- Automated simulator run (iPhone 17 Pro, debug build): swiped left on
outgoing and incoming bubbles (feedback shows on the trailing side,
thread opens), edge swipe-back from a thread and channel, and a
rightward drag starting on a bubble (no reply). The demo above was
recorded on a synthetic private channel (own messages only;
incoming-message rows were covered in the earlier run and by the widget
tests). Human testing is waived under the owner's standing exception.

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

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 15:40:22 -08:00
Tom Brow
a7a534d476 mobile: long-press a message link for open, copy and share (#9135)
Holding a link in a message used to open the whole-message actions
sheet, so there was no way to copy or share just the link. Now a hold on
a link opens a small **Link** sheet showing the URL with **Open link**,
**Copy link** and **Share link**. Web links share as URLs so the share
sheet can preview them; other links (such as `buzz://`) share as text.
Open link appears only for destinations the app can open (web and
`buzz://` links), so a `mailto:` link offers Copy and Share. Repo, PR
and issue chips, which have no mobile destination, also offer Copy and
Share on hold. While a finger is down, the link gets a light tint behind
it, the same kind of press highlight the message row paints. A hold
anywhere else in the message still opens the message actions, and a tap
on a link still opens it directly.

The link gets its own long-press recognizer with the same hold deadline
as the message row. Pointer events reach the deeper recognizer first, so
at equal deadlines the link wins the arena before the message row, the
same mechanism reaction pills already use to claim their own hold.
Rendering is otherwise unchanged: the link widget tree only gains a
`RawGestureDetector`, a `Semantics` wrapper and a `DecoratedBox` for the
tint, press state lives in the link widget so only the link rebuilds,
and the sheet is built only when opened. Open reuses the existing tap
path (external browser, authenticated relay-media download, or Buzz deep
link).

### Validation

Added tests: a journey in `message_content_test.dart`, run with both iOS
(200 ms) and Android (default) hold timing, that holds surrounding text
(message menu opens, link sheet doesn't), holds the link (link sheet,
not the message menu), copies it (clipboard gets the URL), opens it from
the sheet and by a plain tap, and checks the press tint appears on touch
and clears when the finger moves away. Removing the link recognizer or
the tint makes it fail. Another test holds a `mailto:` link and checks
the sheet offers Copy and Share but not Open. Swipe-to-reply tests in
`swipe_reply_tests.dart` drag a message starting on its link and beside
it, with both hold timings, and both open the reply.

Full `just mobile-check mobile-test` passes (after rebasing onto main,
two `hosted_backup_service_test.dart` tests failed once under load and
pass on rerun; this PR doesn't touch that code).

Automated app run on an iPhone 17 Pro simulator (iOS 27) signed in to a
Builderlab community, in a private QA channel: held message text
(message actions sheet, dismissed and composer focus restored as
before), held a link (Link sheet), Copy link (toast, simulator
pasteboard holds the URL), held again and Share link (system share sheet
with the page preview). The simulator's share sheet then stopped
responding to dismissal, a known simulator share-extension quirk
unrelated to this change. After adding the tint, a second simulator run
held the link and captured the pressed state (below). Not covered:
Android device run, VoiceOver.


https://github.com/user-attachments/assets/6f5bda14-14af-450e-b021-f98ebc072f9b

| Held | Link sheet | Copied |
| --- | --- | --- |
| <img
src="https://github.com/user-attachments/assets/86262d85-b745-41b4-8c40-96372d0cec61"
width="300" alt="Link held, tinted"> | <img
src="https://github.com/user-attachments/assets/7c9fe05a-17a0-47c4-851e-e89456bc4520"
width="300" alt="Link sheet"> | <img
src="https://github.com/user-attachments/assets/d97c417a-a79b-4dd2-a43e-35ace7ea5552"
width="300" alt="Link copied"> |

🤖 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>
mobile-v0.28.0-rc.9
2026-11-07 15:40:16 -08:00
Tom Brow
e86d0e23a3 feat(mobile): long-press a Solo chat to open its details sheet (#9163)
Long-pressing a team channel in the Messages list opens a bottom sheet
of channel actions. Long-pressing a Solo chat did nothing, so its
settings (star, mute, members, move, copy, leave/archive/delete) were
only reachable from inside the chat.

Now a long-press on a Solo chat row opens that chat's details screen as
a sheet, with the same presentation as the team channel sheet: drag
handle, close button, 640 pt max width, 70% max height. The content is
the existing channel details page rendered without its app bar
(`ChannelDetailsPage(inSheet: true)`), so it stays in sync with the full
page. Tapping a chat still opens it as before.

Solo chats are placed in Solo workspaces, not channel sections, so their
details (sheet and full page) no longer show "Move to section…". That
row wrote channel sections, which left the chat where it was. Moving a
Solo chat between workspaces stays on the chat header's workspace
control.

### Demo

Long-pressing a Solo chat in the Messages list (Solo filter) opens its
details sheet; Star toggles, the sheet scrolls to the lifecycle actions,
and the close button dismisses it. Synthetic chat named "QA demo".


https://github.com/user-attachments/assets/e8b8a799-be1f-4d4b-b47d-f2209e2b1920

### Validation

- Added test: long-pressing a Solo chat on the Me page opens the details
sheet with the chat's name, the sheet scrolls to its lifecycle actions,
and the channel-section move is absent.
- The full mobile test suite and `flutter analyze` pass.
- Full automated simulator run on the final code (iPhone 17 Pro, debug
build, signed-in Builderlab account, light and dark mode). In the Solo
long-press sheet: Star and Mute toggle and revert; Edit, a member's
profile and Add members open and close back to the sheet; Copy channel
name and Copy channel ID copy the right values and show a toast; the
sheet scrolls; the Leave, Archive and Delete confirmations appear and
were cancelled; the close button, scrim tap and drag down all dismiss
it; "Move to section…" is absent. Tapping the chat still opens it, and
its in-chat details page also omits the section move. Regression check:
the team channel long-press sheet and team details page are unchanged,
including a working "Move to section…". The recording is in Buzz rather
than here because it shows internal channel names. Limitation:
destructive actions were cancelled, not confirmed.

🤖 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-07 15:40:11 -08:00
Tom Brow
1a0dae77bf fix(mobile): shimmer loading photos in the iOS carousel (#9167)
On iOS, a message with several photos renders them in a native UIKit
carousel. While a thumbnail downloads, each cell showed the words
"Loading image". Single images and the Flutter carousel already show a
shimmering skeleton, so the native carousel stood out.

Native carousel cells now show the same shimmer as Flutter's
`SkeletonShimmer`: a fill in the theme's primary color at 10% opacity,
with a lighter band sweeping across every two seconds. Flutter passes
the primary color to the native view along with the brightness. With
Reduce Motion on, the fill stays still. Once the thumbnail arrives the
shimmer hides. If the thumbnail fails, the cell shows its existing
"Image unavailable" message on the plain surface.

### Demo

A synthetic private chat with a four-photo message, reopened on a cold
launch. To make the loading state easy to see, both builds were patched
locally to delay thumbnail delivery by 4 seconds (the patch is not part
of this PR).

Before (main): each cell shows the words "Loading image" until the
thumbnail arrives.


https://github.com/user-attachments/assets/3724873d-4f33-4db7-af16-634dce3371ac

After (this PR): each cell shimmers in the theme's primary color until
the thumbnail arrives.


https://github.com/user-attachments/assets/20f65789-de3f-4e13-892f-3b2d9a0461cc

### Validation

- `just mobile-check` and the full `just mobile-test` suite pass.
- Automated simulator run (iPhone 17 Pro, debug build): posted four
photos to a synthetic private chat from the app's photo picker,
relaunched, and opened the chat on both main and this branch. Before
shows "Loading image"; after shows the shimmer, which gives way to the
photo when it arrives. Limitations: light mode only, and the 4 second
thumbnail delay was a local patch to make the state observable. Dark
mode, Reduce Motion and the failed-thumbnail state were not exercised.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 15:40:05 -08:00
klopez4212
f3b127e3af fix(mobile): keep Home bell tappable while scrolled (#9241)
## Summary
Keep the mobile Home bell tappable while scrolling. Reserve both header
buttons’ space as soon as the filters overlap the toolbar, and clip
filter overflow.

### Related issue
No matching open issue or PR found.

### Testing
- `just mobile-check` and `just mobile-test` passed.
- Android/iOS regression coverage checks the bell at five scroll
positions and Activity navigation. iOS uses platform-view hit testing
plus its callback; no physical-device verification.
- Pre-push checks passed. The additional `just ci` run passed workspace
Rust lint and desktop source checks; stopped during desktop native
dependency compilation, so full local CI is incomplete.

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-07 08:30:56 -08:00
Tom Brow
12f3d51001 feat(mobile): one Builderlab per build, internal and public distributions (#9224)
The mobile app can now be built as a public build or an internal build,
set explicitly with `BUZZ_DISTRIBUTION=internal|public`. Each build
trusts exactly one Builderlab (`BUZZ_BUILDERLAB_URL`), and sends that
Builderlab's relay assertions only to the relays in
`BUZZ_BUILDERLAB_RELAYS`. The three settings are set together or not at
all; with none set, the app is a Nostr-only development build. The
decision is recorded in
`docs/adr/0001-internal-and-public-mobile-builds.md`, and the terms are
defined in `CONTEXT.md`.

Before this change, one build supported a public Builderlab and an
organization Builderlab at the same time, with two sessions. An
organization's members could reach public communities from the
organization's app.

### Behavior

- **Public build:** QR pairing, email sign-in and multiple communities
are unchanged. It has no knowledge of any organization. An
organization's relay already rejects clients that can't present its
Builderlab's assertion, and the public build relies on that.
- **Internal build:** the first screen has a single **Sign in** button
that goes straight to the organization's single sign-on and then joins
the organization's community. There is no QR pairing, community link or
community creation, and the community switcher is hidden. Only the exact
relay hosts listed in `BUZZ_BUILDERLAB_RELAYS` are reachable.
- **Identity (internal):** a member whose account has no Nostr identity
yet gets a new key on first sign-in; the first relay assertion binds it,
and the app backs it up to the organization automatically. A member
whose account is already bound uses that key from this device, or
restores it from the organization's backup. If neither is possible, Sign
in says it couldn't restore the identity and saves nothing.
- **Sign-out (internal):** a destructive **Sign out** row at the bottom
of Settings, the build's only way out. It removes the community, closes
Settings and returns to Sign in. The Builderlab session is cleared from
storage immediately and can never be reused to sign back in. The relay
push-stop keeps the retired credential in memory only, for up to 10
minutes, and uses it only to get an assertion for a key that is already
bound. It is a best-effort request.
- **Upgrade to an internal build:** a saved public Builderlab session is
cleared. Communities on other relays are hidden, not deleted, and their
keys stay on the device.
- `BUZZ_HOSTED_RELAY_AUTH` is unchanged here.

### Screenshots (internal build)

| Sign in | Community menu | Settings |
| --- | --- | --- |
| ![Internal build landing with a single Sign in
button](https://github.com/user-attachments/assets/05f076a2-1552-4095-8322-626754a8765b)
| ![Community menu with Agents, Appearance and Settings, without Switch
community](https://github.com/user-attachments/assets/0c0cf4e0-3fa9-458e-817f-b4665374505b)
| ![Settings ending with a red Sign out
row](https://github.com/user-attachments/assets/f3d60dc1-8170-45e3-a16d-0a1156fdae1a)
|

Gateway-side push revocation, which needs no Builderlab session, is
planned as a separate PR.

### Validation

Added tests:
- An internal-build journey: one Sign in creates and binds a key, and a
retry after a cancelled join reuses it. The app uploads that key's
backup itself. Sign out from Settings closes it and returns to the
landing, which ends the session, and Sign in then restores the same
identity from the backup. Communities on other relays stay stored but
hidden.
- Internal Sign in for a bound account: the key on this device, a key
restored from backup, and no backup (clear error, nothing saved). Each
guard was shown to fail when removed.
- Organization relay assertions: assertions go only to exact listed
hosts, not subdomains. After sign-out, only a push stop for a bound key
gets an assertion, and nothing can sign back in. Proven to fail without
the fix.
- Builderlab build setting parsing.

CI runs the internal and public journeys in their own lanes with those
settings. The full suite passes in the default configuration. The full
suite isn't meant to run under the internal configuration, because most
fixtures use relays that an internal build hides.

Automated full-app run on an iPhone 17 Pro simulator at the current
head:
- Public build, upgraded in place over a signed-in install: home loads,
a channel opens and a message sends.
- Internal build: a fresh install shows only Sign in. With a saved
organization community, the community menu has no Switch community, and
Settings → Sign out → confirm returns to Sign in.

Real single sign-on can't run in the simulator.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost>
mobile-v0.28.0-rc.8 mobile-v0.28.0-rc.7
2026-11-06 21:27:14 -08:00
Tom Brow
794ee64183 fix(ios): mint hosted NIP-FI assertions in the notification extension (#9228)
Share the hosted Builderlab session with the iOS notification extension so it can mint hosted NIP-FI assertions. The extension queries a hosted relay without an assertion first and mints only after a NIP-FI challenge, for a key the account is already bound to. Credential headers never follow redirects, and flag-off builds clear a previously shared session.

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mobile-v0.28.0-rc.6
2026-11-06 18:59:52 -08:00
Tom Brow
b58ee08404 fix(mobile): request hosted relay assertions only when NIP-FI is required (#9225)
Port the desktop NIP-FI requirement rule to mobile: hosted relay connections request an account assertion only when the relay's NIP-11 document requires NIP-FI, so communities that have not enabled it keep connecting with BUZZ_HOSTED_RELAY_AUTH on.

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 18:57:05 -08:00
Brad Seiler
96af488d96 fix(media): revoke private channel attachment reads (#9077)
## Summary

Removed private-channel members can currently download attachments if
they know the URL. With `BUZZ_MEDIA_READ_MODE=enforce`, fresh GET, HEAD
and Range requests check current membership and readable references on
the primary database. Removed members receive 404; remaining members
retain access, and rejoining restores access. Thumbnail aliases use the
parent attachment’s permissions.

Publication checks also prevent copied private URLs from granting new
access. They cover attachments, link previews, profile pictures,
banners, group pictures and emoji. Persona creation and changed-avatar
edits, plus team and individual snapshot imports, transfer animated
posters and animations under the agent’s signer before profile
publication. Failed persona transfers preserve existing local records;
import failures appear in the results.

Authorization uses bounded queries, a two-second statement deadline and
transaction-local JIT suppression. An index on edit targets avoids broad
scans. Publications have a 512-reference limit, and capacity waits up to
500 ms before refusal.

Protection defaults off. Existing downloaded bytes cannot be recalled.
All modes stop creating immutable HTTP cache entries.

### Related issue

Related approaches: #7565, #5185, #8451 and #8444. This change evaluates
live stored references directly.

### Testing

Published head: `3d1aa7431cc495a001a0f13e95e2e422c5a77ab1`, rebased on
main `2cd40badb85de059a6916206e133da7a0a88fb2e`. Push hooks were
**SKIPPED** with explicit author approval. Current-head HTTP, full
package, PostgreSQL migration/media and profile-filter mutation checks
are pending; CI is pending. The earlier complete HTTP journey passed at
`f20b449262` (identical source tree), with its evidence attributed
separately. Earlier failing preview/report-fixture runs are preserved;
they do not count as passes.

The HTTP journey covers upload/publication, fresh GET/HEAD/Range
revocation and rejoin, emoji replacement, edited previews, persona
visibility and non-reference decoys. The migration regression forces an
index timeout, verifies no 0086 history or media tables, then proves a
migrator through the base's 0085 can restart.

Human attachment removal/rejoin testing passed on the pinned
`1f08a8e0fd2edf19dc94321f7258518149fe1bf6` trial. Native animated
persona edits and team/individual import acceptance remain incomplete.
That old-snapshot database stays intact and cannot run the reconciled
binary; a new trial needs a separate fresh database.

### Rollout prerequisites

- Build and verify the reference index before shadow mode. Small
databases can auto-migrate through one atomic 0086 migration within the
configured timeouts. It builds the index before creating the media
tables, so failure records no new migration version. Large deployments
must disable auto-migration and prebuild indexes concurrently.
- **Enforce remains blocked unless the operator can rule out SDK/CLI
attachment edits and the unverified agent-harness edit producer.**
SDK/CLI edits still omit attachment metadata. That behavior is deferred,
not fixed. Removing this blocker requires latest-effective attachment
metadata across successive edits, no restoration of removed files, and
an upload → successive edits → GET/HEAD regression.
- Validate client signer compatibility and split emoji publications
above 512 references before enforce mode.
- Complete the human workflows above in an isolated community with
enforce enabled.
- Follow the [index deployment
guide](https://github.com/block/buzz/blob/honor/private-channel-media/docs/private-media-index-deployment.md)
and [operator
guidance](https://github.com/block/buzz/blob/honor/private-channel-media/docs/operator-private-media.md)
for rollout and rollback.

The [prebuild timeout
finding](https://github.com/block/buzz/pull/9077#discussion_r4486221478)
and [avatar-transfer
finding](https://github.com/block/buzz/pull/9077#discussion_r4486221458)
are implemented and independently reviewed.

### Schema change checklist

- [x] Additive expand and code deployment; no backfill or contract
phase. Prebuild/verify before shadow under either schema deployment
path.
- [x] No SQL function, trigger, procedure, rule or foreign key added.
Signed events are untouched.
- [x] No migration mutates existing rows.
- [x] Migration and desired-state schema match. Catalog parity, target
parsing, future partition inheritance and upgrade refusal are covered.
- [x] Old/new binaries coexist for serving. Suspend community deletion
during expansion and while rolled back to a binary with the old deletion
catalog.
- [x] Keep auto-migration disabled on old binaries after schema
expansion; sqlx rejects unknown versions.
- [x] Migration lock timeout 1 s; statement timeout 5 s. Small populated
databases build inline within those budgets; large deployments use
concurrent child-index prebuild/attachment with auto-migration off.
Missing, invalid or wrong indexes fail verification.

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:35:00 +00:00
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