Commit Graph

2956 Commits

Author SHA1 Message Date
Tom Brow
24bfa648c8 Page past excluded authored-thread replies in push resolution
Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-18 17:48:08 -07:00
Tom Brow
6540fbdeb4 fix(push): preserve baseline policies at lease capacity
Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-18 17:22:30 -07:00
Tom Brow
63dfcc6821 feat(push): notify authors about replies in their threads
Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-18 16:58:15 -07:00
Tom Brow
8f2c7710e9 fix(mobile): default community push notifications to on (#8425)
New mobile communities currently require a manual Settings opt-in before
push can start. Default that local preference to on for new communities
and stored records without a preference. Preserve every saved `true` or
`false`, including values written by older clients. Older default-off
values are indistinguishable from explicit opt-outs, so this does not
migrate them to on.

Push still requires a configured gateway, authenticated connectivity,
and a valid relay capability. iOS continues to control display
authorization through its existing permission prompt. Notification
eligibility and subscriptions are unchanged. Settings retains its
existing opt-out control for enabled communities even when capability is
unavailable.

Drafted from the 0.24 baseline; release-branch inclusion is a separate
decision.

### Related issue

Closest related issue: #3238 (synced defaults). This change is limited
to the mobile device-local community preference; no duplicate found.

### Validation

- Added or updated coverage for new and missing preferences, saved
true/false persistence, opt-out after restart, capability-gated
registration, and Settings without a configured gateway.
- Full configured mobile suite: 3,499 passed, 4 skipped. Separate
no-gateway suite: 4 passed. Flutter analysis passed.
- Existing permission-denied and unsupported-relay tests passed as part
of the mobile suite. No physical-device or end-to-end APNs validation
performed.
- Full local repository gate passed (formatting, lint, unit tests,
desktop/web builds, Tauri checks/tests, and mobile tests).



- Follow-up review edits: restored the original VISION.md wording and
shared the default in one private Community constant. All 43 focused
community/bootstrap tests passed.

<!-- buzz-review-completed -->

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-18 16:10:01 -07:00
Tom Brow
305fb7b5cd fix(push-gateway): request default notification sound (#8431)
Push banners arrive silently because the gateway's fixed APNs body never
requests a sound, even though the iOS app requests sound permission. Add
`aps.sound: "default"` to that constant so ordinary alerts request the
system sound even if the notification extension is not invoked. Preserve
the existing alert text, mutable-content flag, and class-blind,
content-free delivery contract; synchronize its normative specification
and executable models.

Companion client workaround:
[#8429](https://github.com/block/buzz/pull/8429). This gateway change
also covers the original-payload fallback and requires no mobile source
change. Sound remains subject to iOS settings and authorization. It does
not bypass Focus or Silent Mode, select critical alerts, or guarantee
vibration. Every public-profile wake requests the same sound, including
generic/restricted fallback banners.

### Validation

- Extended the real HTTP transport test: reproduced missing sound
(`null` versus `default`) before the fix, then passed with the fix. All
79 runnable gateway library tests pass; 17 infrastructure/manual tests
remain ignored.
- Added a guard against drift between the emitted bytes, NIP-PL, and
both formal models. Fixed-payload and stateful-delivery models and
mutation checks pass.
- Inspected the 0.24.0 (7) iOS source: sound permission is requested;
ordinary enrichment and restricted fallback copy incoming content. A
macOS UserNotifications probe confirms copy retention, but does not test
iOS Intents or audible device delivery.
- Focused existing native resolver/handoff tests pass (35 tests on
macOS). These cover resolution and fallback behavior, not iOS sound
playback.
- Deployed exact head
[b8a0eb6](b8a0eb6b78)
to the personal dev push gateway (Railway deployment
`b3e52fc8-d1d3-48d7-ae21-1a83c5db2e99`). Verified the dev relay already
routes to that gateway. On an iPhone 12 mini running the existing Buzz
Dev 0.22 (10140), without the client workaround, automation captured the
test banner and Tom confirmed audible sound on October 18.
- Broad local validation reached an unrelated ACP startup-deadline
failure in
`signals_cancel_startup_turn_and_pending_stdin_without_hanging`. No ACP
source or dependency changed, and ACP does not depend on the gateway.
The full local gate is not reported as passing.
- Local desktop pre-push tests passed 3,420 cases but failed two
unchanged agent-discovery checks (a 300 ms subprocess deadline and a
login-shell PATH comparison). Desktop does not depend on the gateway.
Retried publication with only the Rust-suite and desktop-Tauri lanes
excluded under the standing unrelated-failure exception; other hooks
remain enabled.


### Before merge

- [x] Verify a real push banner and audible sound through the dev relay
and candidate dev gateway on an iOS device without the client
workaround.

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-18 15:59:19 -07:00
klopez4212
bbb5183f33 feat(mobile): add swipe-to-reply message gesture (#8436)
## Summary
Swipe right on channel and thread messages to open the existing reply
destination. Adds a centred accent progress ring, Reply label, threshold
haptic/pulse, drag resistance, cancellation, and reduced-motion support.
Retreating below readiness re-arms feedback.

Includes a separate formatting-only fix required by main’s updated Dart
formatter.

### Related issue
Related message styling: #8281. No matching swipe-to-reply issue found.

### Testing
- Full pre-push checks passed, including mobile analysis/tests and
file-size checks.
- Widget coverage: routing, repeated threshold crossings, cancellation,
resistance, alignment, and centred scaling.
- Release preview installed on iPhone with the existing
profile/community; Kenny tested and confirmed the interaction.

Draft: native preview verification predates the rebase onto main’s
Flutter upgrade. No native gesture recording attached.

---------

Signed-off-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-18 18:19:21 -04:00
Will Pfleger
7c1a8967e0 chore(deny): check buzz-mesh-smoke in the root graph again (#8424)
🤖 Removes the whole-crate `[graph] exclude = ["buzz-mesh-smoke"]` from
`deny.toml`, so the root `cargo-deny` check covers `buzz-mesh-smoke` and
its `mesh-llm` dependency again. The advisories that crate brings in are
ignored one ID at a time instead, each naming its dependency path and
when to remove it:

- `RUSTSEC-2026-0367` / `0368` / `0369` — `zip` 8.6.0 extraction bugs
via `buzz-mesh-smoke` → `mesh-llm` v0.76.2. `mesh-llm` copies entries
manually and never calls `ZipArchive::extract`. Remove on the `mesh-llm`
release that ships Mesh-LLM/mesh-llm#2471.
- `RUSTSEC-2026-0370` / `0371` / `0372` — unmaintained (not vulnerable)
`zvariant`, `zbus_names`, `zvariant_derive` via `mesh-llm` → `keyring` 4
→ `secret-service` 5 → `zbus` 5. Remove when that stack leaves `zbus` 5.

Any new advisory under `mesh-llm` now fails CI instead of being silently
excluded. No workflow or lockfile changes; the old beta desktop crate in
`desktop/` is not checked.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-18 13:16:52 -07:00
Tom Brow
819dd7924f fix(mobile): bump Flutter 3.47, resolving memory leak (#8419)
With accessibility semantics active, opening and leaving channels
retained disposed composer overlay states through Flutter’s semantics
traversal map. Those states kept departed route and message trees alive.
This upgrades the pinned Flutter SDK from 3.41.7 to 3.47.7, which
includes [the upstream
fix](3c96406cc4).

Includes SDK compatibility and formatter changes, preserves CocoaPods
integration, and gives create-channel radio lists a Material ink surface
required by the newer framework. Widget regressions verify both the
framework portal and the production channel/composer lifecycle: departed
routes, composer elements, and portal states are collectible while their
application’s semantics owner remains alive.

Related issue: no matching open issue or PR found.

### Validation

- [Current-head CI
passed](https://github.com/block/buzz/actions/runs/39085320351): all
mobile test shards, checks, Android APK, iOS release build, and native
simulator tests.

- Framework regression fails on Flutter 3.41.7 with three retained
states and passes on 3.47.7.
- Signed native Debug iOS Simulator build and paired-community
navigation replay: after 18 channel visits and forced GC, retained
disposed portal keys fell from 18 to 0 and route instances from 19 to 1.
- Review follow-up: all 365 focused channel/overlay tests, analyzer, and
complete mobile pre-push checks passed.
- Full local CI passed before the test-only review follow-up, including
Rust and desktop validation, frontend builds, analyzer checks, 3,499
mobile tests (four skipped), and the separate unconfigured-push test.

The replay used iOS 26.5 Simulator with accessibility semantics enabled,
CPU profiling disabled, and Debug/JIT builds. The before app was a
preinstalled emoji candidate; the after app uses this branch. Object
retention and the focused regression establish the fix; absolute heap
differences are not a controlled release-memory measurement. Android
native execution was not tested.

### Create-channel compatibility

The radio groups retain their layout after switching their container to
Material.

| Before (3.41.7) | After (3.47.7) |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/42525e1bd9d9f264d0142c34ec61dbb6937ef758/pr-8419--01-before.png"
width="280" alt="Original create-channel radio groups"> | <img
src="https://raw.githubusercontent.com/block/buzz/42525e1bd9d9f264d0142c34ec61dbb6937ef758/pr-8419--02-after.png"
width="280" alt="Updated create-channel radio groups"> |

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-18 12:44:43 -07:00
Tom Brow
e048f5ac78 fix(mobile): bound search result rendering work (#8418)
Global search eagerly mounted every result and parsed entire messages
even for two-line previews. Large markdown hits could block the UI while
changing categories, updating results, or reopening search.

Build result rows lazily and bound long message previews to a literal
prefix of at most 512 UTF-16 units without splitting surrogate pairs.
Short messages retain rich formatting and mentions. Long snippets show
markdown punctuation literally; opening a result still uses the original
message. Truncated markdown is never passed to the rich renderer.

### Validation

- Added regressions for lazy message/channel/people rows, scrolling to
the final result, and a >100 KB markdown hit with a surrogate pair at
the preview boundary. All 23 focused search tests pass; full mobile
analysis is clean. The original large-hit test consumed a CPU core for
over 30 seconds before being terminated.
- Native iOS simulator build passed. Diagnostic Debug/JIT captures
attributed a 1,033 ms search frame and a separate 12,972 ms
result-update callback to widget/markdown work. A verified
populated-results run of the final candidate completed category
switching, scrolling, and closing, with a longest UI callback of 70 ms.
Separate instrumented Debug/JIT runs have uncontrolled host load,
warm-up, and live data; they are not release-performance benchmarks.
- Full local gate passed through desktop builds/checks, then failed two
unchanged native subprocess PID/timeout tests; both passed in isolation.
The remaining web build and mobile suite passed.

- [Codex
review](https://github.com/block/buzz/pull/8418#issuecomment-6243734066)
completed on the current head with no findings.
[CI](https://github.com/block/buzz/actions/runs/39082758977) passed,
including mobile checks, all three test shards, Android, the iOS release
build, and native simulator build/tests.

### Preview examples

Synthetic widget captures with app fonts, without real community data or
native iOS glass controls.

| Long message before | Bounded literal preview after |
| --- | --- |
|
![Before](https://raw.githubusercontent.com/block/buzz/5840caf6cc3e35ce0ecdba68cfbcb10506f3eb23/pr-8418--search-long-before.png)
|
![After](https://raw.githubusercontent.com/block/buzz/5840caf6cc3e35ce0ecdba68cfbcb10506f3eb23/pr-8418--search-long-preview.png)
|

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-18 12:41:06 -07:00
klopez4212
27b9c827fc Polish mobile message layout and navigation controls (#8427)
Align outgoing messages and galleries to the right, reclaim the outgoing
avatar gutter, and match desktop calendar dividers with balanced
spacing. Standardize navigation and sheet icons at 24 points, restore
native Edit/Done styling, and align section menus with trailing channel
indicators.

Validation: clean analysis; 3,513 mobile tests and push-configuration
checks passed; iPhone Release build installed and tested by the owner.
Includes regression coverage for message placement, date spacing,
gallery direction, and section alignment.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: fin <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-18 15:17:25 -04:00
Bradley Axen
3ffbfa693c feat(relay): start a channel with its members in one create (#8421)
## What

A kind:9007 can now name the channel's first members with `p` tags.
Those members are added in the same database transaction that creates
the channel. Resending a create whose OK was lost is now accepted
instead of refused.

## Why

Starting a new Solo chat today takes about 6–8 sequential relay round
trips before the first message goes out: create, read, add each agent
(kind:9000), read the roster, then send. With members in the create, a
client can start a chat with its agents as two writes: the create, then
the message as soon as the create is OK.

The resend fix makes the create safe for an outbox to retry. Today an
identical resend after a lost OK is refused with `duplicate: channel
already exists`, so the client can't tell success from a real UUID
collision.

## How

- **buzz-db:** `Db::create_channel_with_event` writes the channel, its
owner, its members (role `member`, `invited_by` = creator) and the 9007
event itself in one transaction. A channel row therefore never exists
without its create event. `create_channel_with_id` is unchanged for its
other callers.
- **Validation** (`side_effects::initial_members` and the
`validate_admin_event` 9007 branch):
  - Each `p` must be a 32-byte hex pubkey.
  - Duplicates and the creator are dropped.
  - At most 32 members.
  - An `h` tag is required when there are members.
- Each member goes through the same `decide_channel_add_policy` check a
kind:9000 uses, so an agent that refuses third-party adds keeps the
whole create out.
- **Side effects:** each initial member gets the same `member_joined`
system message and member-added notification a kind:9000 would post. The
fallback in `handle_create_group` creates a bare channel when the
pre-created row is missing; it announces no members, because it added
none.
- **Resend:** when the UUID is already taken, the same transaction
checks whether this exact event is the stored create.
- If it is, the create is OK'd as `duplicate:` (it is the same signed
create).
- Anything else, including a changed create from the same author, is
refused and writes nothing, so it can never re-run side effects or
announce members.
- A resend that arrives while the first create is still uncommitted
waits on its channel insert. It is confirmed once the first commits, or
creates the channel itself if the first rolled back. Before this, a
resend in that window was refused, and the app then withdrew a chat that
existed.
- **No compensation:** ingest no longer soft-deletes a pre-created
channel when the event insert fails. The rollback covers it.
- **Announcements:** a failed `member_joined` notice for one initial
member is logged at `error` and the loop continues, so the members after
it still get theirs. A resend writes nothing, so nothing would retry
them.

## Compatibility

A 9007 with malformed `p` tags, or more than 32 of them, used to be
accepted with the tags ignored. It is now rejected with `invalid:`. The
relay's own SDK and CLI builders send no `p` tags on 9007.

## Out of scope

- The kind:9000 OK still doesn't prove the membership change committed:
side-effect failures are logged and OK'd, and a duplicate replay skips
side effects. The new path doesn't depend on kind:9000.
- Events in separate HTTP requests are still unordered. Clients send the
first message after the create's OK.

## Tests

`store::channel::postgres_tests` (buzz-db):
- `a_resend_beside_an_open_create_confirms_it_once_committed`: holds a
create open, checks that the resend waits, commits, and expects `Exists
{ resent: true }` with one owner and one member.
- `a_resend_beside_a_failed_create_creates_the_channel`: the same, but
the first create rolls back.
- `a_create_whose_event_fails_leaves_no_channel`: an event the insert
refuses leaves no channel row, and a retry creates the channel.
- `another_create_for_a_taken_channel_writes_nothing`.

Two mutations, each caught: committing the channel before its event
fails the "leaves no channel" test, and looking up the event before the
channel insert waits fails the "confirmed once committed" test.

`handlers/create_with_members_postgres_tests.rs`, all through the real
ingest path:
- `create_adds_its_p_tags_as_members` checks:
  - the roles and the 39002 roster
  - exactly one `member_joined` notice
  - that the agent can post with no separate add
- `create_respects_the_members_add_policy`: an agent with policy
`nobody` gets an `invalid:` rejection, and no channel is created.
- `resending_a_create_confirms_it`:
  - an identical resend is accepted
  - a changed create (from the creator or from someone else) is refused
  - the roster is unchanged and nobody new is announced
- Unit tests for `initial_members`: dedupe, creator excluded, bad hex,
the 32-member cap, and the missing `h` tag.

Each new test was mutation-checked against the change it covers: no
members, no policy check, refuse every resend, and let a changed creator
create through. Each mutation turns its test red.

Agent exercise on a local relay, over `POST /events` the way the app
sends:
- The 9007 with `p` = agent was accepted in 54–97ms (debug build).
- The kind 9 was accepted 12–15ms after the create's OK.
- The agent was in the 39002 roster, and the operator listener delivered
the mention.
- An identical resend came back `duplicate:` (accepted), and a changed
create was refused.

Local suite runs at 10adcf6dd (the transaction fix), on PG14:
- `postgres-test-run.sh -p buzz-relay -p buzz-db`: 28 failures. That is
the same 27 as below, plus `personal_read_frontier_is_monotonic…`, which
hit a statement timeout under load and passes when run alone.
- `cargo test -p buzz-db -p buzz-relay`: the same 6 `api::media`
failures and the flaky `mesh_demo` test.

Earlier local suite runs:
- New tests pass and clippy is clean.
- `postgres-test-run.sh -p buzz-relay -p buzz-db`: 27 failures
(partition maintenance, a huddle links test, a redis boot test), and the
same 27 fail on a96ace4e5.
- `cargo test -p buzz-relay`: 6 `api::media` failures (they need a DB at
the default URL) also fail on base.
- One `api::mesh_demo` test is flaky: it passed 2 of 3 branch runs and
failed once on a timeout.

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

---------

Signed-off-by: Bradley Axen <baxen@squareup.com>
Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: claude00 <fb8b76a01257932b8ca35060862c29bd09b2137638573731c2d5074fc02c5385@buzz.block.builderlab.xyz>
Co-authored-by: ART <ea39c1c8acf57180161ef4ba4ed68abf8589f5291956b909a5aa991fc66664ca@buzz.block.builderlab.xyz>
2026-10-18 11:32:09 -07:00
Will Pfleger
6f67dcdac4 fix(desktop): bump zip to 9 for RUSTSEC-2026-0367/0368/0369 (#8412)
🤖 Bumps the desktop app's direct `zip` dependency from 8 to 9 to clear
RUSTSEC-2026-0367, RUSTSEC-2026-0368 and RUSTSEC-2026-0369 on the
managed Node.js archive extraction path.

zip 9 makes `ZipArchive::name_for_index` and `ZipFile::name` fallible.
Managed Node.js zip validation now rejects an entry whose name can't be
decoded, and the "unsafe path" error prints the raw entry name lossily.
The absolute-path, drive-prefix, traversal and `enclosed_name()` checks
are unchanged.

The lockfile update is scoped to `zip`; it pulls in newer `zstd`,
`lzma-rust2` and `ppmd-rust` that zip 9 requires.

**`zip` 8 is still in release builds.** With `--features mesh-llm`,
`mesh-llm` v0.76.2 (`mesh-llm-system`, `mesh-llm-plugin-manager`,
`mesh-llm-host-runtime`) still depends on `zip` 8.6.0. That goes away
when the `mesh-llm` pin moves to a release that includes
Mesh-LLM/mesh-llm#2471.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-18 10:41:21 -07:00
Will Pfleger
bc995041cd fix(deps): bump negentropy to 0.5.2 for RUSTSEC-2026-0365 (#8409)
🤖 Bumps `negentropy` (pulled in via `nostr-sdk`) to 0.5.2 to clear
RUSTSEC-2026-0365. The `zip` advisories (RUSTSEC-2026-0367/0368/0369)
are not covered here because `mesh-llm` still requires `zip` 8.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-18 17:30:50 +00:00
Will Pfleger
cf1bb8dd13 chore(deny): exclude buzz-mesh-smoke from cargo-deny graph (#8413)
🤖 Excludes the test-only `buzz-mesh-smoke` crate from the `cargo-deny`
graph via `[graph] exclude` in `deny.toml`.

`buzz-mesh-smoke` holds hardware smoke-test examples; nothing from it
ships. It is the only root-workspace crate that depends on `mesh-llm`,
which brings in `zip` 8.6 (RUSTSEC-2026-0367/-0368/-0369), the `zbus` 5
unmaintained notices (RUSTSEC-2026-0370/-0371/-0372), and `negentropy`
via `mesh-llm-client` → `nostr-sdk` (RUSTSEC-2026-0365). None has a
usable upstream fix for the pinned `mesh-llm` v0.76.2. The `zip` bump
has merged upstream in
[mesh-llm#2471](https://github.com/Mesh-LLM/mesh-llm/pull/2471), but no
`mesh-llm` release contains it yet (latest is v0.78.1).

CI's Security job runs `cargo-deny check` from the repo root, which
reads this `deny.toml`, so the exclusion applies there. These advisories
remain fatal for any shipped crate that pulls in the same dependencies.
No existing advisory ignores were changed.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-18 10:06:27 -07:00
klopez4212
3a45e87b25 fix(mobile): polish Inbox controls and system notices (#8423)
Use Tabler `filter-2` in Inbox and keep the iOS filter button rounded
when its menu closes. Remove shaded containers from chat system notices
while preserving spacing, profile links, and reactions.

Validation: mobile formatting, analysis, full test suite, and iOS
Release build; independent review found no blockers. Kenny verified the
icon and menu dismissal on his iPhone.

---------

Signed-off-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-18 10:31:21 -04:00
Tom Brow
a96ace4e55 fix(mobile perf): avoid eager emoji palette scans for prose (#8416)
Ordinary mobile messages eagerly built a set of every community emoji
shortcode and segmented their entire body just to decide whether to use
enlarged emoji rendering. Stop at the first non-emoji grapheme and build
the shortcode set only when a complete shortcode is encountered.
Emoji-only sizing and shortcode matching stay unchanged.

### Validation

- Full `just ci` passed.
- 125 focused detector and message-rendering tests pass; targeted
analysis is clean. The palette-access regression fails on the original
implementation (10,320 unnecessary reads) and passes with the fix. It
also checks that consecutive shortcodes share one palette scan.
- **Full Buzz app, real community data, debug/JIT iOS simulator, not a
rendering fixture:** two paired actual-input captures measured 125–127
µs per original detector call versus under 1 µs with the fix. All 5,200
executions per version agreed across 260 observed prose calls and a
2,580-entry palette. Instrumentation repeated each detector call, so
these captures are excluded from frame comparisons.
- Separate clean-code scrolling runs do **not** establish a reliable
smoothness improvement: quiet original UI-callback p95 was 10.1–12.6 ms
versus 10.1–12.9 ms fixed. A busy fixed run still reached 90.8 ms.
Normal app services stayed enabled; only scrolling was exercised. Live
background refresh, debug mode, host scheduling, and profiler overhead
limit interpretation. No physical-device/release-AOT or end-to-end
interaction-latency claim.

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-17 16:53:48 -07:00
Will Pfleger
6286a54321 feat(relay): store per-community NIP-FI issuer bindings (#8372)
Stack: this PR → #8377 → #8399 → #8401

bb-public creates communities all the time, so a hand-edited
`BUZZ_NIP_FI_COMMUNITIES` map can't list them. This PR stores an
explicit NIP-FI issuer allowlist for each community in the database and
writes it when the community is created. Login checks don't read the
table yet; that comes in #8377 together with the database-backed
resolver. Operator steps are in
[`docs/operator-nip-fi-bindings.md`](docs/operator-nip-fi-bindings.md).

**Storage.** Migration `0066_community_nip_fi_issuers` adds
`community_nip_fi_issuers (community_id, issuer, created_at)`, with
primary key `(community_id, issuer)` and the community write fence
attached. The assertion audience is `https://<communities.host>`, so the
table has no `aud` column. Zero rows means no binding. The table is
included in whole-community deletion and purge.

**One bindable rule.** `BUZZ_NIP_FI_PROVISION_BINDING` (`{"host_suffix":
..., "authorized_issuers": [...]}`) is parsed in `buzz-auth` with
`BUZZ_NIP_FI_ISSUERS`, whatever `BUZZ_NIP_FI_MODE` is set to, and both
the relay and `buzz-admin` use the same parser. Each authorized issuer
must be a configured issuer (1 to 2048 bytes), and the suffix must not
cover any host declared in `BUZZ_NIP_FI_COMMUNITIES`; an unreadable
declaration refuses startup even in mode `off`.
`ProvisionBindingPolicy::is_bindable(host)` is true for a host exactly
one label under the suffix that is not reserved. It decides
creation-time binding, explicit `bind`, and what the lookup route
reports.

**Binding on creation.** Rows are written in the same transaction as the
community insert on all three creation paths (`POST
/operator/communities` `create_only`, its legacy convergence branch, and
startup bootstrap), and only when the call actually created the
community, so existing rows are never rewritten.

**Reserved host.** A host whose `https://<host>` equals a configured
`command_audiences` value never holds a binding. Operator provisioning
of it is rejected with a 400 and the `reserved_host` error code,
bootstrap creates it unbound, and the availability route reports it
unavailable with an error code. `main.rs` calls
`verify_reserved_hosts_unbound` at startup; if a reserved host has rows
the relay refuses to start and names the fix (`buzz-admin nip-fi unbind
--host <h> --issuer <i>`, or revert the command audience). A database
failure during that check is a separate `Check` error.

**Operator commands.**
- `buzz-admin nip-fi backfill` pages unbound communities by host (1000
per page) and binds each page with one `INSERT ... SELECT` limited to
active communities. It skips communities being deleted, always prints
`{rows_bound, unbound, unbound_hosts, truncated}`, and exits non-zero
while any bindable community is still unbound. Archived communities are
bound on purpose. Both bind paths take the shared deletion lock and then
lock the community row (`FOR NO KEY UPDATE`, in id order for a batch),
the same order deletion uses, and the backfill insert also requires the
community to still have no binding, so an operator `bind` that lands
after the backfill listed a community wins and the default issuer is not
added beside it.
- `buzz-admin nip-fi bind --host --issuer` refuses a reserved host, a
host outside the suffix, or an unconfigured issuer.
- `buzz-admin nip-fi unbind --host --issuer` refuses to remove a
community's last issuer (archive or delete the community instead), with
one exception: when a valid provisioning policy shows that backfill
can't bind the host (it's reserved, or outside the suffix), the last
issuer can be removed. That is the reserved-host recovery path in the
runbook. A missing or malformed policy doesn't block `unbind`; it just
means the exception never applies.

**Lookup route.** `GET /operator/communities/nip-fi-binding?host=`
(NIP-98, `RELAY_OPERATOR_PUBKEYS`) reads the database and returns
`{host, active, issuers[]}`. `host` is the stored host. It returns 404
for an unknown host, 200 with `active:false` for an archived or
quiescing community, and `issuers:[]` when no provisioning policy is
set. It lists only issuers that are configured and on a bindable host.
kgoose depends on this contract; it is also documented in the runbook.

**NIP-FA.** The `relay_url` bullet now also says: "An adapter MAY obtain
its exact relay list from a configured, authenticated source; it MUST
still compare `relay_url` exactly."

**Deployment dependency.** The kgoose config side is already handled by
[cash-server#137671](https://github.com/squareup/cash-server/pull/137671).
In public-community mode it requires the command audience not to be a
community audience.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-17 13:13:49 -04:00
Tom Brow
bf8b356f3b Fix iOS channel crash from unbounded scroll depth (#8407)
### Summary

Opening a channel on iOS 27.0.1 could terminate Buzz when a reversed,
lazily loaded timeline reported an unbounded scroll extent. Flutter
forwarded `Infinity` to the native navigation bar, and UIKit threw
`CALayerInvalidGeometry` while setting the scroll view offset.

Use the finite viewport depth for that unbounded case, preserving the
scrolled-under appearance. Reject non-finite offsets at the native
platform boundary as a final safety check.

### Validation

- Added tests: a Flutter widget regression for an unbounded reversed
timeline, and an iOS native regression for a non-finite platform-channel
offset.
- Reproduced the original crash by tapping `#general` on an iPhone SE
running iOS 27.0.1. With this change, the channel rendered and survived
repeated back-and-reopen cycles. Native logging confirmed the channel
now sends finite offsets where it previously sent `Infinity`.
- The native regression passed on iOS 26.5 and 27.0 simulators with
Xcode 27.0. Flutter widget tests and `flutter analyze` passed.
- A full channel-flow simulator run was not available: the isolated
simulator Profile build could not resolve `BuzzPushKit`. The iOS 27.0.1
physical run exercised the full channel flow.
- The full Flutter suite had eight notification and push test failures,
all reproduced on the unchanged 0.24.0 source.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
mobile-v0.24.0-rc.2 mobile-v0.25.0-rc.1
2026-10-16 16:25:41 -07:00
Luke Tornquist
97e1f6a082 fix(db): validate channel roster snapshots in the app writer (#8316)
## Summary

Phase 1 of retiring the channel roster snapshot trigger
(`trg_events_guard_channel_roster_snapshot`, migration 0032).

Every admitted writer that can store a kind 39002 roster snapshot now
runs the same check the trigger runs, on the same transaction and under
the same lock:

- `LockedMemberSnapshot::replace_member_event` (relay's live roster
publisher) validates the snapshot it already holds the membership lock
for, before soft-deleting the old head.
- `insert_event_on` and `insert_event_with_thread_metadata_tx` (generic
event writes).
- `replace_parameterized_event_in_transaction_impl` (inside its existing
savepoint) and `Db::replace_addressable_event` (admin reconciliation
path), after dominance checks and before the old head is deleted.

The validator (`validate_channel_roster_snapshot`) is a direct port of
`guard_channel_roster_snapshot()`: four-field `p` tags, 64-char hex
pubkey, empty relay field, valid role, and an exact match against active
`channel_members` rows. It raises the same `23514` messages, so callers
still see `DbError::Sqlx(sqlx::Error::Database(..))` with the same code
and text.

**Lock order:** replacement key, then channel-membership key, then the
canonical read, then the old-head `UPDATE` and the write. Legacy writers
took the membership key later, at the trigger's INSERT, after the
old-head row locks. Taking it before the `UPDATE` is not the same order,
but it is strictly safer: it removes the row-lock/membership-key cycle.
The membership writers in `channel_members.rs` take the same membership
key before they change rows. Not every membership writer does yet:
`execute_kick_with_marker` (reached from report enforcement) and
`deploy_kick_member` in `relay_admin_actions.rs` set `removed_at`
without the key, so a kick can commit after a roster passes validation
and before that roster commits, leaving the removed member listed. The
retained trigger has the same gap, so this PR neither causes nor closes
it. When the trigger also runs it re-takes a key this transaction
already holds; transaction advisory locks are re-entrant, so this adds
no wait.

The validator is one verdict query on the happy path. On failure it
raises the trigger's exact error via a `DO` block, so mixed-version
behavior is identical; phase 2 can switch to a typed `DbError`.

**Unchanged on purpose:** the trigger, its function, migration 0032, and
the startup probe `verify_channel_roster_fence_behavior`. Pods still on
the old version depend on them. Phase 2 drops them once this version is
running everywhere.

## Tests

New PG tests in `channel_members.rs` `postgres_tests`, each with only
the roster trigger disabled (the helper asserts the catalog check then
fails):

- `app_roster_guard_matches_trigger_for_roster_input_table`: 19 shape
and membership cases (3/5-field tags, short and non-hex pubkeys, relay
field, unknown role, duplicate, role mismatch, missing/extra member,
uppercase hex, non-`p` tags, empty rosters, all roles). For each one,
the app outcome through `Db::replace_addressable_event` must equal the
trigger's outcome on a raw INSERT, including SQLSTATE and message.
-
`app_roster_guard_locked_writer_rejects_stale_and_accepts_current_without_trigger`
-
`app_roster_guard_covers_generic_and_parameterized_writers_without_trigger`
-
`app_roster_guard_rechecks_after_concurrent_membership_commit_without_trigger`:
the 0032 race. A newer membership commits while an older snapshot waits
on the membership key. The barrier is `pg_locks` with a bound, not
sleeps.

`stale_legacy_roster_cannot_replace_new_locked_snapshot`
(`replaceable.rs`) and `desired_schema_rejects_stale_legacy_roster_role`
(`channel_members.rs`) now simulate the old binary inline (replacement
key, old-head `UPDATE`, raw INSERT) instead of calling the app API, so
they test the retained trigger again. The replaceable.rs one keeps its
one-connection pool, so it still catches self-deadlock.

`observability_source.rs` gains
`every_production_event_insert_runs_the_roster_guard`, which requires a
real guard call in every production `INSERT INTO events` function, with
explicit exceptions (NIP-43 membership and push lease rows, both
`channel_id` NULL, and the two rolled-back startup probes), plus a
self-test.

Falsified: removing one guard call fails the source policy; dropping the
role clause or `lower()` fails the parity table; disabling the trigger
fails both legacy tests. The PG tests are picked up by the `postgres-ci`
nextest filter (`test(/postgres_tests::/)`); the source policy runs in
`just test-unit`.

### Coverage limits (review follow-up)

- `every_production_event_insert_runs_the_roster_guard` is a presence
check. It rejects missing, commented, and quoted calls, but it does not
prove a call is awaited, reachable, or passed the event's real kind and
channel. Behavior is covered by the PG tests above. The test now says
this in its doc comment.
- Exemptions match by function header. A kind 39002 insert added inside
one of the four exempt functions would pass the policy. This is
documented next to the exemption list.
- The 19-case parity table and the race test drive
`Db::replace_addressable_event` only. The generic, parameterized, and
locked writers get the mismatch case only, and the parameterized path's
membership-key-before-delete ordering is not exercised concurrently. All
five writers call the same validator, so this PR doesn't widen these
tests.
- `disable_roster_trigger` commits `DISABLE TRIGGER` on the test
database. That is safe under the per-test database harness the
`postgres-ci` profile uses, but not against a shared database. Its doc
comment now says so.

## Validation

Run on a Blox workstation (private PG 16 and Redis) against a tree
byte-identical to this commit:

- `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets
-- -D warnings`, `cargo nextest run -p buzz-db --lib`: pass.
- `cargo nextest run -p buzz-db --test observability_source`: 19 passed.
- `scripts/postgres-test-run.sh -p <pkg>` for buzz-admin, buzz-audit,
buzz-deletion, buzz-push-gateway, buzz-relay, buzz-search,
buzz-workflow: pass.
- buzz-db: the full run passed 513 and was stopped at a 240 s deadline
on `writer_pool_rejects_non_read_committed_database_default`, which
hangs the same way on the unmodified base. A second run excluding the
three host-flaky runtime tests already reproduced on the base passed all
511 others.

## Simpler?

Not yet. While the trigger is still in place, the check runs twice.
Phase 2 deletes the trigger, its function, the startup catalog and
behavior probes, the migration.rs equivalence asserts, and the pgschema
reconcile entry, which leaves one check in Rust.

## Phase 2 (separate PR, after fleet rollout)

**Prerequisite before claiming complete roster serialization (phase 2 or
anywhere else):** make both kick paths take the channel membership key
before they change rows, and add a concurrent kick-vs-roster-publication
test. The retained trigger does not prove this; it has the same gap.


A new migration drops the trigger from `events` and its partitions, then
drops `guard_channel_roster_snapshot()`. It also removes the schema
files and includes, `verify_channel_roster_fence*` and the relay startup
call, the migration.rs asserts, and the probe tests, and updates
`scripts/reconcile-schema-after-pgschema.sql`.

---------

Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-10-16 17:07:25 -04:00
Tom Brow
d79446316b fix(mobile): support DeviceHub in mobile-dev (#8404)
`just mobile-dev` fails on Xcode 27 machines that have DeviceHub instead
of Simulator. Resolve the simulator frontend inside the selected Xcode
installation: DeviceHub for Xcode 27, or Simulator for Xcode 26. Launch
by full path so an older installed or running app cannot determine the
choice.

This extracts the still-needed DeviceHub portion of Wes's
[#8330](https://github.com/block/buzz/pull/8330). The CocoaPods
deployment-target work already landed in
[#8379](https://github.com/block/buzz/pull/8379); this PR does not
change Podfile or its lockfile.

CI and pre-push mobile selection include the shared Justfile and mobile
recipe regression script, so edits to either run the regression in the
mobile gate.

### Validation

- Nine production-recipe checks pass, covering side-by-side Xcode 26/27
selection with spaces in paths, missing apps, launch failure, and
gateway argument forwarding. Mutations reject unqualified Launch
Services lookup and removal of DeviceHub selection.
- All 39 tests of the pinned paths-filter action pass, including the
Justfile and recipe-test selection cases. Mobile formatting, analysis,
and the full mobile suite pass.
- Native macOS smoke checks exercised launch from the selected Xcode
installation and reached the Flutter boundary. Flutter and identity
setup were stubbed, so no app was built or installed.
- The initial full local CI gate stopped on an unchanged ACP
signal-cancellation test; that exact test passed in isolation. The
initial pre-push run also hit missing dependencies and two unchanged
desktop agent-discovery tests. Desktop lint and TypeScript checks passed
after installing pinned dependencies. Publishing used the authorized
local-hook exception.
- Current-head
[CI](https://github.com/block/buzz/actions/runs/38963831198) passed,
including all nine recipe cases and native Swift validation. Owner
approval and Codex code/security reviews are complete with no unresolved
findings.


buzz-review-completed

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-16 14:06:53 -07:00
Luke Tornquist
60de1db0b8 fix(db): admit fleet sweeps per tenant instead of in-SQL fence predicate (#8318)
## What

Removes every production call to the SQL fence predicate
`community_write_allowed()` from application SQL, so no application
mutation depends on the boolean predicate anymore. This is one
prerequisite for BUZZ-315 part B; #8322 (tolerating absent fence
objects) is a separate one. The triggers and SQL functions stay in this
PR because mixed-version pods still depend on them.

The predicate isn't inlined. It's a VOLATILE plpgsql function that takes
the deletion lock and *then* reads lifecycle state on a fresh snapshot,
and an inline expression would read state from the statement's start
snapshot. A separate earlier check would race deletion. So each sweep
now:

1. reads its candidate community IDs with a plain read, bounded the same
way the original statement was,
2. opens one `AdmittedTx` per community
(`begin_community_sweep_transaction`, a thin wrapper over
`begin_community_write_transaction` that turns an admission refusal into
"skip this tenant"; the four periodic sweeps go through
`sweep_admitted_communities`, which also owns their error handling),
3. runs the original mutation scoped to `community_id = $1` and commits.

| Site | Change |
| --- | --- |
| `runtime/push_enqueue.rs` | Deleted the redundant predicate. This
transaction already calls `guard_transaction`. |
| `store/relay_invite.rs` `reap_expired_relay_invites` | Keeps the
global expiry order and the 1,000-row ceiling. Candidates are
pre-filtered to active tenants so fenced invites can't fill the batch.
Admission is still what authorizes the write. |
| `store/workflow.rs` `prune_scheduled_workflow_fires_before` | Prunes
per admitted community and sums the row counts. |
| `store/channel.rs` `reap_expired_ephemeral_channels` | Archives per
admitted community and returns the committed rows, so the relay still
runs their system and discovery follow-ups. The candidate read has no
lifecycle pre-filter (it has no `LIMIT`, so admission alone decides). |
| `store/event.rs` `backfill_d_tags` | Backfills per admitted community
at startup. |
| `store/push.rs` `claim_due_match_batch_with_loader` | Picks the
earliest due active community, admits it, and claims under the same `FOR
UPDATE OF q SKIP LOCKED` and batch limit. It skips at most 16 tenants
that fence between the read and admission. |
| `store/push.rs` `reap_exhausted_matches` | Reaps per admitted
community. |

**Tenant failures.** The four periodic sweeps (invite reap, workflow
fire prune, channel reap, exhausted match reap) log a warning and
continue when one tenant fails. That tenant's rows are left unmodified
and stay eligible, so the next tick retries them. A tenant error doesn't
abort processing of the other tenants already in the selected candidate
page. Invite cleanup keeps its oldest-first 1,000-row selection, so if
one tenant owns that entire page and its delete keeps failing, tenants
outside the page wait. No persistent tenant-local failure for that
delete is known in the current schema, and base aborted the whole page
on any error. The returned count or rows cover the tenants that
committed. Claim, enqueue and the startup backfill still return the
error to the caller.

**Database outages.** A connection-level error (pool acquire timeout,
closed pool, socket I/O; `DbError::is_connection_unavailable`) isn't
tenant-local, so it stops the periodic sweep at that tenant instead of
moving on. Without this, an outage that began after the candidate read
cost one writer acquire timeout and one warning per remaining tenant,
and invite cleanup runs inside the usage-metrics leader tick. Rows
already committed are still returned, so reaped channels keep their
follow-ups. Remaining tenants retry next tick.

**Known limit (pre-existing, tracked separately):**
- *Claim at a locked head.* `claim_due_match_batch_with_loader` returns
`None` once an admitted tenant claims zero rows. If every due row in the
oldest active tenant is row-locked, younger tenants aren't tried on that
call. Base behaved the same way. This PR doesn't change it.

## Tests

All tests use a one-session fixture (`test_support::admission_fixture`)
with `session_replication_role = replica`, so the trigger backstop is
off and only application admission protects the fenced tenants. The
fixture shadows `community_write_allowed` with a function that raises,
in a per-call `admission_test_<uuid>` schema.

- One test per site: a quiescing tenant's rows are untouched while an
active tenant in the same sweep still progresses.
- Race tests for invite reap, channel reap and claim. These are the
sites where the candidate read already filters out fenced tenants. Each
test holds the tenant's deletion lock, waits until the sweep is blocked
in admission, fences the tenant in that transaction, and asserts its
rows are untouched. The claim test also asserts that a second active
tenant is claimed in the same call.
- `channel_reap_returns_committed_rows_after_earlier_tenant_error` and
`periodic_reaps_continue_after_tenant_error_and_retry_retained_rows`
inject a per-tenant failure. They check that later tenants commit, that
the failed tenant's rows are retained, and that the next call processes
them.
- `fleet_sweep_stops_at_first_unavailable_connection` runs the sweep
helper against a closed pool with three candidates and asserts only the
first is attempted.
`fleet_sweep_returns_committed_results_when_database_drops_mid_page`
(PostgreSQL) commits the first tenant, closes the pool, and asserts the
first tenant's result is returned and the sweep stops at the second of
three candidates.
`connection_unavailable_covers_only_pool_and_io_failures` pins the
classification; the per-tenant failure tests above confirm a
database-raised error still continues.

Falsification, each with the tests failing as expected:

| Mutation | Result |
| --- | --- |
| Original SQL restored at invite sweep, push claim, d-tag backfill |
0/3 pass (`legacy SQL fence predicate called`) |
| `guard_community_write` returns `Ok(())` after taking the lock | race
tests + channel reap test 0/4 pass |
| Per-tenant errors abort the sweep again | 0/2 pass |
| Sweep helper `continue`s instead of stopping on a connection-level
error | `fleet_sweep_stops_at_first_unavailable_connection` and
`fleet_sweep_returns_committed_results_when_database_drops_mid_page`
fail |
| Sweep helper drops already-committed results when it stops (`return
Vec::new()`) |
`fleet_sweep_returns_committed_results_when_database_drops_mid_page`
fails |
| Sweep helper removed from the source-policy chokepoint check |
`serving_table_writes_expose_syntactic_chokepoint_or_guarded_tx_routes`
fails |

The PostgreSQL tests are under `postgres_tests` modules with `#[ignore =
"requires Postgres"]`, which puts them in the `.config/nextest.toml`
PostgreSQL filter. CI evidence is under Verification.

## Verification

CI at head `5d37fbda` ([run
38925996275](https://github.com/block/buzz/actions/runs/38925996275)) is
green. The PostgreSQL job ([job
117062895683](https://github.com/block/buzz/actions/runs/38925996275/job/117062895683))
passed 1017/1017. That includes named PASS lines for all 30
`admission_postgres_tests`,
`fleet_sweep_returns_committed_results_when_database_drops_mid_page`,
and both writer-pool tests listed below. Unit Tests ([job
117058136482](https://github.com/block/buzz/actions/runs/38925996275/job/117058136482))
has PASS lines for
`connection_unavailable_covers_only_pool_and_io_failures` and
`fleet_sweep_stops_at_first_unavailable_connection`.

Local gates ran on a Linux cloud workstation against private PostgreSQL
16 and Redis. The tree was `735170df`, which is this head rebased on
`main` `c0ab789`, so the gates built the merge result.

| Gate | Result |
| --- | --- |
| `cargo fmt --all -- --check` | pass |
| `cargo clippy --workspace --all-targets -- -D warnings` | pass |
| `cargo nextest run -p buzz-db --lib --tests` | 185 passed |
| `scripts/test-postgres-test-discovery.sh` | pass |
| PostgreSQL lane for buzz-db (excluding the two writer-pool tests
below) | 530 passed |
| PostgreSQL lanes for buzz-admin, buzz-audit, buzz-deletion,
buzz-push-gateway, buzz-search, buzz-workflow | all pass |
| PostgreSQL lane for buzz-relay | 413/426. The 13
`group_state_postgres_tests` fail with `PoolTimedOut` on unmodified
`main` `c0ab789` too (see below) |
| The three mutations in the falsification table above (`continue` ×2,
`return Vec::new()`) | each fails its target test |

These failures happen only on the workstation, and each one passes in
CI's PostgreSQL job at this head:
- `writer_pool_metrics_stop_after_session_timeout_setup_failure` stalls.
It's an unchanged writer-pool configuration test, and it stalled the
same way at earlier heads.
- The 13 relay `group_state_postgres_tests` fail at the same point,
`ensure community: Sqlx(PoolTimedOut)`, on unmodified `main` `c0ab789`.
This PR doesn't touch `buzz-relay`.

## What got simpler

- No application mutation calls the boolean predicate
`community_write_allowed()` anymore. Dropping the fence objects still
has other dependencies: serving-write lease acquisition calls
`assert_community_write_allowed()` (`store/deletion.rs:3068`), catalog
validation requires both functions (`store/deletion.rs:1195-1198`), and
the triggers and compatibility SQL still use them. Those belong to #8322
and part B, not this PR.
- The claim's `target` CTE is gone, and every sweep uses the same
admission-and-skip helper.
- The four periodic sweeps share one loop (`sweep_admitted_communities`)
for admission, skip, continue-on-tenant-error and stop-on-outage,
instead of four copies of it.
- The source policy treats that helper as a tenant-local chokepoint, so
`backfill_d_tags` no longer needs a reviewed exception.
- The per-tenant loops add code, because each community now gets its own
admitted transaction.

---------

Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
mobile-v0.24.0-rc.1
2026-10-16 15:46:37 -04:00
Luke Tornquist
2745a419a8 buzz-db: tolerate absent universal write-fence objects (expand) (#8322)
## What

This is the expand step for retiring the 32 universal
`community_write_fence_*` triggers and their SQL functions (BUZZ-315).
Relay pods auto-migrate at boot (`BUZZ_AUTO_MIGRATE`). The drop
migration therefore runs while the previous release is still serving,
and that release must not depend on the objects it drops. Today the app
depends on them in four places, and this PR removes all four from its
own code paths. Seven in-SQL `community_write_allowed` predicates in
fleet sweeps and push (`push_enqueue.rs`, `channel.rs`, `event.rs`,
`push.rs`, `relay_invite.rs`, `workflow.rs`) are #8318's scope. The
release is independent of the fence functions only with both this PR and
#8318. **It changes no schema.** The objects stay and keep working until
the follow-up drop PR.

| Old dependency | Now |
|---|---|
| Startup and readiness (`validate_serving_catalog_on`) require
`community_write_allowed`, `assert_community_write_allowed`,
`enforce_community_write_fence` and all 32 triggers | No longer
required. Still required: the tenant tables,
`community_deletion_lock_key` and the enabled community tombstone
trigger. |
| Serving-lease admission runs `SELECT
assert_community_write_allowed($1)` | Uses `guard_community_write`, the
same shared lock and state check as ordinary serving writes, in the same
transaction as the lease `INSERT`. Fenced or missing communities still
map to `AccessDenied`. |
| Live scoped-table discovery calls
`community_write_fence_excluded_table()` | Uses a Rust
`EXCLUDED_SCOPED_TABLES` list. A unit test pins it equal to the SQL list
while both exist. |
| The deletion executor compares the live `fenced_tables` against the
frozen inventory | Only tenant-table equality is compared. Inventories
**still record** live fences, so an older executor that validates an
inventory frozen by this release still matches. The digest still covers
the full frozen manifest. |

`lock_community_deletion_shared` now refuses transactions that are not
READ COMMITTED, so every lock-then-read admission gets the check:
`guard_community_write`, serving-lease finalization
(`guard_transaction_with_serving_lease`), lease renewal/verification,
owner preparation and owner-mutation admission. The old SQL assertion
did this check for the lease path, and the trigger backed up the rest.
The isolation level comes back from the existing
`pg_advisory_xact_lock_shared` statement, so the hot path makes no extra
round trip. The refusal comes before any lifecycle-state read.

## Mixed versions

| Combination | Why it is safe |
|---|---|
| Previous release, this DB | No schema change, so all old checks still
see the 32 fences. |
| This release, DB with fence objects | Extra objects are tolerated, and
the trigger backstop still applies. |
| This release **plus #8318**, DB without fence objects | Serving,
readiness, lease admission and deletion proven by
`migration_schema_fence_objects_are_optional_for_serving_and_deletion`,
which drops them inline. Without #8318, serving and deletion still work,
but the push matcher, exhausted-job reaper, invite retention, ephemeral
reaper and startup d_tag backfill fail with `42883
community_write_allowed(uuid) does not exist` (seen in deploy-stack
E2E). |
| Older executor, inventory frozen by this release | `fenced_tables` is
still populated from the live catalog (32). |

This PR does not depend on #8318 to merge and can ship alongside it, but
it is not drop-safe alone. The drop PR (#8323) needs #8219, #8318 and
this PR deployed on every pod, and every rollback image after the drop
must include #8318.

## Tests

-
`store::deletion::postgres_tests::migration_schema_fence_objects_are_optional_for_serving_and_deletion`
migrates to 0060. It then runs the following with the fence objects
present, then again after dropping them inline:
  - serving startup
  - readiness
  - the deletion catalog check
  - `inventory_schema`
  - lease acquire/release
- `AccessDenied` for quiescing, fenced, tombstoned and missing
communities
-
`store::deletion::postgres_tests::deletion_lock_admission_rejects_pinned_snapshot_isolation`
checks that REPEATABLE READ and SERIALIZABLE are refused by both
`guard_community_write` and `guard_transaction_with_serving_lease`. The
lease case uses a never-acquired lease, so if the isolation check is
skipped it fails as stale instead.
- `buzz-deletion`
`postgres_tests::approved_legacy_fence_inventory_accepts_current_catalog`
checks that a legacy digest with 32 fences validates against a live
catalog with none, and that an unknown tenant table is still rejected.
-
`store::deletion::tests::scoped_table_exclusions_match_sql_during_transition`
checks exclusion-list parity.
-
`store::deletion::postgres_tests::serving_lease_acquire_waits_for_fence_lock_then_refuses`
holds the exclusive deletion lock and checks that lease acquisition
stays pending. It then commits `fenced` and requires `AccessDenied` with
no lease row persisted. Removing the lease path's
`guard_community_write` call fails this test, while the other four
lease/admission tests above still pass.
- `buzz-deletion`
`serving_guard_heartbeats_long_operation_through_quiescing` now requires
`AccessDenied` for a lease acquired during quiescing, rather than any
error.

Each test was confirmed to fail when its fix is removed (rc 100, 0/1):
the isolation refusal, the three validators restored to the base
version, the fence-list comparison restored, and one name dropped from
the Rust list.

## What got simpler

The lease path loses its special SQL assertion and its error-code
translation and reuses the ordinary app guard. The serving and deletion
catalog checks each lose one requirement. No new abstraction. The
`fenced_tables` reader stays only for mixed-version executors; the drop
PR deletes it.

**Identified debt (pre-existing, outside this diff):**
`runtime::postgres_tests::writer_pool_rejects_non_read_committed_database_default`
hangs on clean `177551dda` too.

## Verification (Blox, tree identical to this commit)

| Gate | Result |
|---|---|
| `cargo fmt --all -- --check` | pass |
| `cargo clippy --workspace --all-targets -- -D warnings` | pass |
| `cargo nextest run -p buzz-db --lib` | 167/167 |
| `scripts/test-postgres-test-discovery.sh` | pass |
| `scripts/postgres-test-run.sh -p` buzz-admin / audit / deletion /
push-gateway / relay / search / workflow | all rc 0 (4, 7, 12, 11, 402,
20, 2) |
| `scripts/postgres-test-run.sh -p buzz-db` | 510 pass, 1 fail: the
pre-existing hang above, interrupted |
| Round 2 (this head): `-p buzz-db` / `-p buzz-deletion` | 511 pass +
the same pre-existing hang interrupted / 12/12 |

The new PG tests are selected by the `postgres-ci` filter
`(test(/postgres_tests::/) or binary(/^postgres_/)) and not
test(/(^|::)external_infra[^:]*::/)`.

buzz-review-completed

---------

Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-10-16 15:28:54 -04:00
Brad Seiler
2d0f78c6e1 feat(auth): support explicit mesh JWKS transport (#8382)
## Summary

Allow an issuer to opt into mesh HTTP for its JSON Web Key Set (JWKS).
Public HTTPS remains the default. This supports deployments whose
service mesh authenticates and protects the key endpoint.

The sealed source contract selects transport and contributes to policy
identity. Mesh mode requires a DNS hostname and checks every resolved
address against an explicit allow-list before pinning one address. Both
modes disable proxies, reject redirects, cap responses, and enforce a
whole-fetch deadline. The relay cannot verify mesh security: operators
must enforce mutual TLS, endpoint authorization, and egress
restrictions.

### Related issue

No duplicate mesh-JWKS issue or PR found. Existing NIP-FI relay
enforcement: #7224. No deployment change is included.

### Testing

- At `eccb8cb19420abda44cf6e5e62b1361e2843bd1d`: full auth package and
auth/relay all-target clippy passed. Full relay package passed: 1,457
library tests, 37 binary tests, and 9 boot tests; 446 tests were
ignored.
- At prior head `113939838cd81d5e8d0b0f8783c42ffaae44a4d3`: the full
relay library suite failed the demo echo test (1,455 passed, 1 failed,
440 ignored). Independent checks reproduced that timeout at base
`d61fe2071d2b3d8e56dd931947f16bfe0d6173fe`; the receive-cancellation
defect is outside this change. A later pass does not establish that
defect is fixed.
- Three falsifying mutations at `eccb8cb194` were caught: mixed DNS
answers, proxy bypass, and whole-fetch deadline. The full locked auth
package passed after restoring the exact source.
- At `eccb8cb194`, `cargo build --locked --release -p buzz-relay -p
buzz-cli -p buzz-admin` passed with command-local
`CARGO_PROFILE_RELEASE_STRIP=none`. This avoids a macOS loader error
without a source, lockfile, or project-profile change.
- Earlier hooks failed the ACP signal test and Tauri login-shell PATH
test. The ACP failure also reproduced on unmodified main `93ffc37c`.
Main merged without conflict. The full Tauri package then passed with
`RUST_TEST_THREADS=1`: 3,422 library tests and 3 integration tests, 19
ignored. All pre-push hooks passed with that same command-local setting;
no hook or test was skipped.
- At `eccb8cb194`, isolated `just test` passed all 107 runner steps. The
monolithic `just ci` run timed out after 1,120 seconds; all recipes
passed across that run and separate `just desktop-tauri-test`, `just
web-build`, and `just mobile-test` invocations. No monolithic pass is
claimed.
- Exact-head release live-local checks passed: loopback HTTP mesh JWKS
warm; valid assertion 101; expired/wrong issuer/wrong audience 403;
valid signed disconnect 200; wrong command audience 403. Redirect-only
JWKS returned assertion 503 without a request to its target. Both
omitted/default and explicit public transport rejected HTTP at startup.
- A separate NIP-FI-off release CLI smoke test created an open channel,
sent an accepted message, read it back, and fetched its thread. The
assertion probe closes after upgrade; it does not prove a complete
NIP-42 session. Local loopback tests do not prove deployment mesh
authentication, endpoint authorization, egress controls, or real `240/4`
routing.

Generated with Codex

## Human Testing
valid assertion / configured community audience: HTTP 101 (expected 101)
expired assertion: HTTP 403 (expected 403)
wrong issuer: HTTP 403 (expected 403)
wrong community audience: HTTP 403 (expected 403)
valid command JWT / configured command audience: HTTP 200,
body=b'{"disconnected": true}' (expected 200)
valid command signature / wrong audience: HTTP 403 (expected 403)

---------

Signed-off-by: seiler <seiler@squareup.com>
Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz>
2026-10-16 14:26:08 -04:00
Tom Brow
2de484baf7 docs(ios): document Xcode 27 development setup (#8400)
Document Xcode 27.0 as the recommended iOS development toolchain while
retaining Xcode 26 compatibility. Explain per-command selection, the
known Flutter native-assets compiler/SDK mismatch on hosts with multiple
Xcode versions, and where CI and release toolchains are configured.

The compatibility changes are already merged in
[#8379](https://github.com/block/buzz/pull/8379) and
[#8398](https://github.com/block/buzz/pull/8398). Related open work is
[#8330](https://github.com/block/buzz/pull/8330); this PR changes
documentation only. The release version bump remains separate in
[buzz-releases#110](https://github.com/squareup/buzz-releases/pull/110).

### Validation

- Ordinary unsigned Release build passed on [main at
73962435b](73962435b6)
with exact Xcode 27.0 (27A266a), the pinned Flutter SDK, and normal
CocoaPods installation. Buzz.app contains NotificationService.appex. No
dependency patches or plugin-validation bypasses were used.
- The merged direct-C change passed native tests on Xcode 26.5 and 27.0,
full local CI, and hosted Native and unsigned Release validation. This
documentation-only follow-up preserves that code.
- Documentation diff checks and local pre-push checks passed.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-16 10:35:43 -07:00
klopez4212
c8ea4fb4eb feat(mobile): polish Home navigation and Solo workspace (#8393)
## Summary
Polish mobile Home and Solo: group Search/Inbox beside a separate
profile avatar, move Inbox and Settings to bottom-up pages with Close
controls, and place tabs opposite the plus/compose action. Morph plus to
compose clockwise when entering Solo and reverse counterclockwise when
returning Home, with a directional quarter-turn of the icon inside a
level button and a subtle scale pulse, respecting reduced motion. Align
Search’s Close control with Inbox, simplify its placeholder, and
initialize selected filter contrast correctly. Collapse the plus menu
when leaving Home. Center Solo’s native-glass New chat empty state and
keep it stationary behind the keyboard.

Standardize Tabler icons and neutral backgrounds, retain SF Back/Close
on iOS, use native Liquid Glass headers, tab container, and actions
(solid grey selected tab, slightly darker on iOS) on iOS and black
actions on Android, and smooth the expanded menu corners. Repair native
header safe-area/return layout and compose movement after cancellation.

### Related issue
None found for this UI refinement; follows #8337.

### Testing
- Formatting, analysis, full mobile suite: 3,487 passed, 4 skipped; 3
push-configuration tests passed. File-size checks passed.
- Base UI built and installed on Pixel and iPhone in place. Pixel
navigation/New chat cancellation checked. Subsequent glass and rotation
refinements are previewed on iPhone; motion feel awaits human
confirmation.
- Draft pending human confirmation of the final Settings entrance/return
behavior on iPhone.

### Pixel snapshots
| Solo | Expanded actions |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/16dc0f8af971e63d788fee20974997fab925dec9/docs/screenshots/mobile-home-solo/pixel-solo.png"
width="280" alt="Solo empty state with New chat" /> | <img
src="https://raw.githubusercontent.com/block/buzz/16dc0f8af971e63d788fee20974997fab925dec9/docs/screenshots/mobile-home-solo/pixel-actions.png"
width="280" alt="Expanded channel actions with smooth corners" /> |

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-16 12:28:02 -04:00
Tom Brow
73962435b6 Use upstream C secp256k1 for native Nostr signatures (#8398)
Replace the Swift secp256k1 wrapper with a small native adapter over
Bitcoin Core libsecp256k1. This is an alternative to the wrapper upgrade
in [#8381](https://github.com/block/buzz/pull/8381), not an additional
change to merge alongside it.

The adapter covers the three Nostr operations Buzz needs: x-only
public-key derivation, BIP-340 signing, and signature verification. It
compiles pinned, unmodified upstream C source through SwiftPM. There is
no build plugin, plugin approval script, or underscored Swift import.
The app and notification extension use an explicit C module map and
header search path.

This trades the third-party Swift wrapper for a Buzz-owned adapter and
responsibility for updating the vendored C source. Upstream v0.8.0
source, its license, and update provenance are included. The release
Xcode pin remains separate in
[buzz-releases#110](https://github.com/squareup/buzz-releases/pull/110).

### Validation

- Native package tests passed on Xcode 27.0 and 26.5: 96 XCTest tests
and 65 Swift Testing tests per toolchain.
- Added tests cover the official BIP-340 vectors for 32-byte Nostr
messages, invalid secret keys, and malformed input lengths.
- Full unsigned Release app and notification extension build passed on
exact Xcode 27.0.
- Vendored files were compared byte-for-byte with upstream v0.8.0.
- Full local `just ci` passed.
- [Hosted full unsigned iOS Release
build](https://github.com/block/buzz/actions/runs/38924020583/job/117051237094)
passed. [Hosted Native Swift
validation](https://github.com/block/buzz/actions/runs/38924020583/job/117051237153)
passed, including the complete simulator build and native controls/push
navigation tests.
- Codex code review and security review completed with no findings.

### Before merge

- [x] Tom selected the direct-C approach with vendored upstream source;
[the wrapper alternative](https://github.com/block/buzz/pull/8381) is
closed.

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-16 09:26:55 -07:00
Luke Tornquist
c0ab789499 feat(buzz-db): retire the channel-TTL trigger (#8210)
## Summary

This drops the channel-TTL event trigger. The application already
refreshes ephemeral-channel deadlines in the event transaction
(`event_follow_up::refresh_channel_ttls`, from #8135), so the trigger
only repeats that work.

This PR originally retired both follow-up triggers. #8352 has since
retired the push-match trigger in migration 0063, so after the rebase
this PR covers only the TTL half.

Migration `0065_retire_channel_ttl_trigger.sql`:
- `SET LOCAL lock_timeout = '5s'` (the same bound as 0063)
- drops `events_refresh_channel_ttl` and
`refresh_channel_ttl_after_event_insert()`

Dropping the trigger on the partitioned parent also drops its partition
clones. Published migrations, including 0022 and 0024, stay
checksum-frozen.

The same objects are removed from `schema/` (`schema/functions/…`,
`schema/triggers/events.sql`, `schema/schema.sql`) and from the pgschema
reconcile script. After this, migrated and desired-state databases have
no event follow-up triggers.

Tests:
- `follow_up_retirement_tests.rs` (renamed from
`push_retirement_tests.rs`): the push-retirement upgrade test now stops
at 0063. A new test upgrades 0064 → 0065 and checks that the TTL trigger
is gone from the parent and every partition, that the function is gone,
and that the published checksums haven't changed.
- `event_follow_up_postgres_tests.rs`: the contract now has two arms.
`AppOnly` is the shipped schema, where both triggers are absent. `Dual`
reconstructs the historical overlap of both triggers. The `PushRetired`
arm (the push trigger dropped, the TTL trigger kept) was the shipped
state before this PR. Its tests are removed because `Dual` and `AppOnly`
together cover it.
- `embedded_migrator_contains_consolidated_initial_schema` pins the 0065
statements and checks that both objects are absent from the desired
schema.

**Deploy gate:** do not run 0065 against a database still served by a
relay older than #8135. After 0065, raw SQL writers no longer refresh
TTL deadlines. `docs/push-gateway-deployment.md` is updated to match.

### What got simpler

The trigger and the app no longer both refresh TTL deadlines; the app is
now the only producer. One plpgsql function and one trigger are gone
from both schema paths, and 8 per-partition drops are gone from the
reconcile script. The follow-up contract drops from three arms to two.

### Related issue

BUZZ-313 (Linear), part of the trigger-retirement work.

### Testing

Head is `4f7d0baf0`. Results below are at `678927730` unless noted.
`678927730..4f7d0baf0` changes only one test in
`crates/buzz-db/src/store/event.rs`, described in the first bullet.

- **Fixed test,
`permanent_channel_event_commits_do_not_lock_the_channel_row`**
(`4f7d0baf0`).
- **Why it changed:** after 0065, its raw-pool inserts no longer ran any
TTL refresh, so the test passed whatever `refresh_channel_ttls` did.
This was raised in review.
- **What it does now:** it inserts through the production `AdmittedTx`
path and keeps the barrier and the tuple-lock probe. It commits all N
transactions while an external transaction holds `FOR SHARE` on the
channel row. It also asserts that no
`buzz_db_channel_ttl_refresh_failures_total` is counted.
- **Mutation check:** with `SELECT … FOR UPDATE` injected into
`refresh_channel_ttl`, the new test fails (`must not wait on the channel
row`, timeout at 10s). The old test passes under the same mutation.
  - **Blox:** fmt and `buzz-db` clippy pass.

- **GitHub CI at `4f7d0baf0`:** every required check passes (56 pass, 0
fail). CI builds the merge ref with `main` `93ffc37cb`.
- **New and renamed tests, checked by name in the CI logs at
`4f7d0baf0`.** These pass in PostgreSQL Domain / PostgreSQL Tests
([117033725006](https://github.com/block/buzz/actions/runs/38915998692/job/117033725006)):
  - `permanent_channel_event_commits_do_not_lock_the_channel_row`
-
`migration_schema_ttl_retirement_drops_every_partition_trigger_and_keeps_checksums`
-
`migration_schema_push_retirement_preserves_ttl_and_published_checksums`
-
`event_insert_ttl_refresh_handles_permanent_ephemeral_duplicate_and_activation_race`
- `{desired,migration_schema}_event_follow_up_contract_{dual,app_only}`
(four tests)

`embedded_migrator_contains_consolidated_initial_schema` passes in Rust
/ Unit Tests
([117024125423](https://github.com/block/buzz/actions/runs/38915998692/job/117024125423)).
No `*_push_retired` test runs anywhere.
- **Blox gates at `678927730`** (private PG16 + Redis, on a workstation
shared with other sessions):
- `cargo fmt --check`, workspace `clippy -D warnings` and `buzz-db
--lib` all pass.
- The `buzz-admin`, `buzz-audit`, `buzz-deletion`, `buzz-push-gateway`,
`buzz-search` and `buzz-workflow` Postgres packages pass.
- `buzz-db`: 517 of 519 tests passed. The 2 that hung, both
`writer_pool_*` tests, were killed. `buzz-relay`: 413 of 426 passed. The
13 failures are covered below.
- **Uncontended reruns at `4f7d0baf0`** on a fresh, idle workstation
(PG16.15, `competing=0` for every arm):
- **`writer_pool_rejects_non_read_committed_database_default` predates
this PR.** It timed out 3/3 at head and 3/3 at `main` `93ffc37cb`, with
a 300s timeout per run.
- **`writer_pool_metrics_stop_after_session_timeout_setup_failure` also
predates this PR.** It timed out 2/3 at head and 2/3 at `main`, and
passed in the remaining run on each side.
- Both tests pass in CI. The gate accepts them as a named exception for
this PR, as a pre-existing hang on the PG16 test environment.
- **`buzz-db`:** 518 of 519 tests passed. The single failure is
`writer_pool_rejects_non_read_committed_database_default`, killed after
it hung (the exception above).
- **`buzz-relay`: still open.** 413 of 426 tests passed. The same 13
`handlers::group_state_postgres_tests` failed as on the shared box, each
panicking at once in its fixture's `ensure community` step with
`Sqlx(PoolTimedOut)`. Contention does not explain them. The fixture's
community setup is in `buzz-db`, which this PR changes. A head/`main`
comparison of the full `buzz-relay` package on the same idle box is
running to find out whether this predates the PR. All 13 pass in CI at
this head.
- **E2E (Gimli, Blox)** at `050e593af`. `050e593af..678927730` changes
only tests and docs; the migration, schema and runtime code are
byte-identical. Gimli's audited verdict: **EXERCISED AND WORKS**, for DB
and relay runtime only. Gimli ran it at `050e593af`; this head's runtime
is byte-identical but was not freshly executed.
- **Upgrade:** a real relay startup took the schema from 0064 to 0065.
That removed the TTL function and the trigger from the parent table and
from all 8 partitions. Checksums for migrations 1–64 are unchanged.
- **Lock-timeout rollback:** with a real lock held, the migration failed
with `55P03` at 5.0007s and the schema rolled back fully to 64. Once the
lock was released, a retry applied 65.
- **Fresh desired schema:** after pgschema plus reconcile, the retired
objects are absent.
- **Real signed kind-9 sends:** on both schema paths, these refreshed
ephemeral-channel deadlines to about +600s. Permanent channels stayed
NULL.
- **Raw SQL insert:** at 64 it refreshed the deadline. After 65 it left
the deadline unchanged, on both the upgraded and the fresh schema.
  - **Not covered:** native or device testing, and deploy.
- **Review:** Legolas found 0 P0 and 0 P1. His three optional P2s are
fixed in `678927730`.
- **No human app test:** this is a DB-only change.
- **Migration numbering:** #8237 and #8372 also add a `0065_*`
migration. Whichever PR merges second must renumber.

---------

Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-10-16 11:22:21 -04:00
Will Pfleger
dff0d16b2a docs(nips): define agent-key assertions in NIP-FA and NIP-FI (#8384)
Defines how an enterprise identity adapter issues assertions for agent
keys and how a NIP-FI relay admits them.

- **NIP-FA:** adapters issue agent-key assertions only under an
owner-authorized grant, created through the owner's own authentication;
revoking the owner invalidates every earlier grant. The "agent keys"
out-of-scope bullet is removed.
- **NIP-FI:** new `Agent keys` subsection under the assertion contract
(`nostr_owner_pubkey` claim, the verifier returns the owner, owner
recording for the session or request, ban/deny checks on both keys,
owner-derived authorization), `agent_assertions` discovery field, owner
deny-set check in WebSocket and HTTP admission, owner-matching
disconnect close scan, and the updated out-of-scope delegation lines.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-16 10:58:58 -04:00
Logan Johnson
93ffc37cbd feat(mobile): find people by name through the relay's name-only profile search (#8333)
🤖
## Summary
In the mobile app, searching for "wes" did not find Wes. People search
matched text anywhere in a profile, including the image data of a shared
default avatar. Many agent profiles use that avatar, so they filled the
results and the real Wes never appeared.

Now every people search in the app uses the relay's new name search: the
Search page, adding channel members, new messages, and @mention
suggestions. It matches only a profile's name and display name and puts
exact names first. The desktop app (block/buzz-app#1084), agent tools
(block/buzz-app#1124) and the CLI (#8331) make the same change.

Compatibility: a relay without the new search answers 404 or 405. That
search uses the old search, and so do later searches on the same relay
for five minutes; then the app tries the new search again, so a relay
that is upgraded while the app runs is used soon after. A query or
result count that the new search cannot take also uses the old search.
Other errors are shown as before.

Order: search results keep the relay's ranking, so "Wes" comes before
"Abe Wes". Before, the Search page and the member and new-message
pickers sorted results by name. The list with no query is still
alphabetical. Each screen still applies its own filters.

Not in scope: the community invite screen accepts only an npub and has
no name search, so this PR does not change it.

### Related issue
Relay endpoint: #8301. This PR does not depend on it; either can merge
first.

### Testing
- Relay session tests: the name query goes to the new search with a
signed request; a 404 falls back and stays on the old search until the
retry time; a 405 falls back; other errors do not fall back; an
unsupported result count uses the old search.
- Updated tests for add members and @mention search, and a test that
search results keep the relay's order while the browse list stays
alphabetical.
- `just mobile-check` and the relay, channel, mention, invite and search
tests (1,962) pass.
- No screenshots: results look the same; only which people are found
changes.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-15 17:33:15 -07:00
Tom Brow
46159fcf91 fix(ios): respect app minimum in generated CocoaPods targets (#8379)
Xcode 27 rejects generated CocoaPods targets with deployment versions
below iOS 15, even though the app already requires iOS 16. Raise lower
explicit pod deployment targets to the existing iOS 16 minimum during
installation, preserving targets that require a newer version.

### Validation

On main at 7852b1670, the ordinary unsigned Release build with exact
Xcode 27.0 failed on pod targets set to iOS 9–13. After this change and
a normal pod install, none of the 189 generated build configurations
specifies a target below iOS 16, and the same build passes
deployment-target validation without a command-line override.

The full build remains blocked by the independent swift-secp256k1 0.21.1
`UInt256.words` compiler ambiguity. This PR does not fix or change that
dependency. Xcode pin
[buzz-releases#110](https://github.com/squareup/buzz-releases/pull/110)
remains gated on a complete ordinary release build and its other
prerequisites.


Related: Wes’s [PR #8330](https://github.com/block/buzz/pull/8330)
independently includes a similar Podfile fix alongside DeviceHub
changes. This PR contains only the Podfile floor and its lockfile
checksum. Full local `just ci` passed; focused iOS validation above is
complete. The regenerated lockfile changes only the Podfile checksum;
`pod install --deployment` succeeds with CocoaPods 1.16.2.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-15 17:32:31 -07:00
Bradley Axen
d039d9a2d5 fix(relay): confirm requested channels in one membership query (#8378)
## What

A request that names channels with `#h` used to check each channel
missing from the cached access set with its own `is_member` query. These
checks ran one after another, so the database cost of a request grew
with the number of channels it named. That cost is why the relay capped
explicit `#h` values at 128, and why mobile splits large communities
into many requests ([#8342](https://github.com/block/buzz/pull/8342)).

This PR makes the check one query, and then raises the cap.

- **One membership query.** WS REQ, WS COUNT, and HTTP `/query` and
`/count` now share `confirm_requested_channel_access`. It checks every
unconfirmed channel with one `membership_pairs` statement (`channel_id =
ANY($2)`), so one request costs one query, whatever number of channels
it names. `membership_pairs` uses the same SQL rules as `is_member`:
deleted channels and removed members don't count, and it runs on the
writer pool.
- **Hash-set lookups.** Comparing the requested channels with the
accessible set no longer costs requested × accessible comparisons. This
covers `apply_channel_scope_to_query`, the system-channel scope helpers,
the COUNT and bridge loops, and `#h` dedupe.
- **`MAX_EXPLICIT_CHANNEL_VALUES` goes from 128 to 2048.** Huddle
liveness used the same constant to limit session IDs. It now has its own
constant, `MAX_HUDDLE_LIVENESS_SESSIONS`, which stays at 128, because
each linked session still costs a separate mesh-directory lookup.

Authorization rules are unchanged:
- A scoped token still sets the upper limit. A channel outside the token
is never checked.
- Channels already cached and readable system channels are skipped.
- Repaired channels are added in request order, each once, so
`keep_repaired_system_channels_named` still sees the same repaired tail.
- A database error still closes the request (or returns 500 over HTTP).
One difference: the old loop could leave a partial repair if a later
query failed, and that can no longer happen.

## Not in this PR

- **Client chunk sizes.**
`desktop/src/shared/api/relayClientShared.ts:39` and
`mobile/lib/shared/relay/nostr_filters.dart:8` stay at 128. They can go
up only after every relay they talk to runs this change.
- **Per-connection limit on live channel scope.** Each authorized
channel in a live WS subscription still gets a fan-out index entry for
each kind, plus a Redis topic retain. Real membership limits this, not
the cap. Still, an account in at least 2048 channels could now register
up to 1024 × 2048 channel scopes on one connection; before, the most was
1024 × 128. If we want to keep the old worst case, the fix is a limit on
the total channel scope per connection, not per REQ.

## Testing

- `cargo clippy -p buzz-relay --tests` and `cargo fmt` are clean.
- `cargo test -p buzz-relay --lib`: 1449 passed, 6 failed. All 6 are in
`api::media::tests` and fail because the local Postgres on :5432 has no
`buzz` role. Nothing in this change touches them.
- I ran the Postgres lane (`scripts/postgres-test-run.sh`) for the
bridge, req, count, group-state, and system-channel tests on local PG14:
55 passed. That includes the new
`named_channels_missing_from_the_cache_are_confirmed_together`, which
checks that the reader's channels missing from the cache are added in
request order, each once, and that a stranger's channel stays out.
- Not done yet: a live run against a local relay sending a REQ with more
than 128 `#h` values.

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

Signed-off-by: Bradley Axen <baxen@squareup.com>
Co-authored-by: murderbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:22:37 -07:00
Wes
66d5354586 fix(mobile): split channel history batches at the relay channel cap (#8342)
## Problem

The relay rejects any request that names more than 128 explicit channels
in aggregate (`MAX_EXPLICIT_CHANNEL_VALUES`,
`crates/buzz-relay/src/handlers/req.rs`). Mobile sent every active
channel in one batch:

- **Latest-message and unread catch-up batches** were rejected with `400
too many explicit channels` and fell back to websocket requests four at
a time. For 144 active channels that took about 5 s each.
- **The Huddle backing-channel query** (one filter with every
membership) was closed as `restricted: too many explicit channels` and
returned no results, so that visibility filter was effectively disabled.

On a first pairing with a large community, these serialized fallbacks
consumed most of the community switcher's 20 s landing budget. That's
one cause of "This community is taking longer to load."

## Change

- `_fetchChannelHistoryBatch` splits filters into requests whose
combined `#h` values stay within `kMaxExplicitChannelValues`, four at a
time. A deadline in any request still makes the whole batch unavailable.
Requests that fail for other reasons are collected, and one websocket
fallback runs over their filters after every request settles, so a batch
never has more than four relay requests in flight.
- `_fetchHuddleStarts` splits its parent channel IDs into REQs within
the same cap, also four at a time.

## Evidence

iPhone 18 Pro simulator (iOS 27, debug build) on a community with 411
memberships and 144 active channels, with temporary stage timing that is
not part of this PR. Eight cold launches before and after:

| Median since app start | Before | After |
| --- | --- | --- |
| Channel rows painted | 8.3 s | 4.0 s |
| Unread catch-up complete | 14.4 s | 5.1 s |

The Huddle query now returns successfully instead of being closed.

## Tests

The fake relay session now rejects over-cap requests the way the relay
does, and answers Huddle starts only for the requested parent channels.

- `channel batches stay within the relay explicit-channel cap` (129
channels): every history batch and Huddle filter is within the cap, and
a Huddle backing channel in the over-cap tail stays hidden. Fails when
either split is removed.
- `channel batches keep four relay requests in flight` (513 channels,
query bridge down): peak in-flight requests are 4 for latest-message,
unread catch-up and Huddle, and the fallback covers every channel. The
previous version of this branch measured 16, 16 and 5.
- Full mobile suite via the pre-push hook (`just mobile-check && just
mobile-test`): passes.

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

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:18:56 -06:00
Logan Johnson
7852b1670c fix(search): normalize profile name tiers in one place (#8334)
🤖

## Summary

Profile name search (`POST /profiles/search`, added in #8301) sorts
results into tiers: an exact name first, then a name that starts with
the query, then a name with a later word that starts with it. This PR
stacks on #8301 and fixes two review items.

**Tier normalization in one place.** Before this change, the query was
lower-cased in Rust (`to_lowercase`), and the names were lower-cased in
SQL (`lower()`). These can disagree. Under a `C` collation, PostgreSQL's
`lower()` changes only ASCII letters, while Rust also lower-cases
letters like `É`. So a search for `Émile` did not put the profile named
`Émile` in the exact tier. Now the query and both name fields go through
the same SQL expression: lower-case, collapse whitespace runs to one
space, then trim. The query is bound once and both sides compare equal
by construction. The trim also moved after the collapse, so surrounding
whitespace that the regex turns into a space is now trimmed too.

**Ranking wording.** The doc comment and `docs/profile-search.md` said
"ties keep the NIP-50 order". That was wrong. Ties are ordered by
`ts_rank_cd` over the two name fields only (not the whole profile, as in
NIP-50), then newest first, then event id.

Out of scope, not changed here: matching names that contain escaped
Unicode or newlines, and any new name index.

## Related issue

Review on #8301:
https://github.com/block/buzz/pull/8301#pullrequestreview-5524491230

## Testing

- New case in `profile_names_match_name_words_only`: profiles `Émile`
and a newer `Émilee`, query `Émile`. The exact match must come first.
- Ran the Postgres integration tests against a `postgres:17-alpine`
database created with `--locale=C`:
- this branch: `profile_names_match_name_words_only` and
`profile_names_survive_unparsable_content` pass;
- #8301 code with the new case: `profile_names_match_name_words_only`
fails (`Émilee` sorts before `Émile`).
- Nine other tests in that file fail locally on both this branch and
#8301 because they reuse one database (duplicate community hosts). CI
gives each test its own database.
- `cargo fmt --check`, `cargo clippy -p buzz-search --all-targets -D
warnings`, and `cargo test -p buzz-search`: clean.
- The pre-push hook's `rust-tests` and `desktop-tauri-checks` passed.

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-15 18:02:10 -04:00
Logan Johnson
f148c0f22c feat(relay): add profile search over name and display_name only (#8301)
🤖
> **Review as a pair with block/buzz-app#1084** (the app change that
uses this endpoint). Either can merge first: the app falls back to the
old search on relays without this endpoint.

## Summary
Searching for a person by name over the relay's general search returns
profiles that only *mention* the text somewhere else: in an avatar image
embedded as text, in the bio, or in the verification address. A search
for "wes" could fill every result page with profiles whose avatar data
happens to contain "Wes", and never return the person actually named
Wes.

This adds a relay endpoint, `POST /profiles/search`, that looks only at
a profile's **name** and **display name**. Every other profile field is
ignored.

**Matching and paging are the same as the relay's existing full-text
profile search** (NIP-50 search over kind:0 profiles with `search_mode:
"prefix"`). A client can switch to this endpoint without a visible
change in which people a query finds:
- Each query word must match a whole word of the name; the last word can
be a prefix. Words can be in any order, so "johnson wes" finds "Wes
Johnson". Case is ignored.
- Pages work the same way: up to 100 results per page, up to page 1000.
- The results are exactly the old results minus profiles that match only
outside their names.

The order is better for people search: exact name first, then names that
start with the query, then names with a later word that starts with it,
then other matches. Ties are ordered by `ts_rank_cd` over the two name
fields only (not the whole profile, as in NIP-50), then newest first,
then event id.

The response is a JSON array of the original signed profile events
(Nostr kind:0), so clients can verify them as they do today. The
buzz-app client change is block/buzz-app#1084.

## Details
- Request: `{"query": "wes", "limit": 20, "page": 1}`. `query` is 1–100
characters after trimming; `limit` is 1–100 (default 20); `page` is
1–1000 (default 1); unknown fields are rejected with 400.
- The search reuses the full-text search's own query builder
(`buzz-search`), so the word rule cannot drift from it. The existing
full-text index on events stays the access path; the name check runs
only on rows the index already matched. There is no new index. One small
migration (`0061`) adds the function `profile_name_fields()`; it reads
no table data, so it runs instantly.
- Only current profiles count: a replaced profile stops matching.
Profiles that are not a JSON object, or whose name fields are not
strings, never match. Content that PostgreSQL cannot read as `jsonb`
(for example a `\^@` escape or very deep nesting) also never matches,
and it cannot make the search fail for everyone else.
- The handler loads the matched events through the normal routed event
read and applies the same reader visibility check as other reads, in
search order.
- Access uses the same checks as the relay's GIF search endpoint: a
signed HTTP request (NIP-98), identity admission (NIP-FI), rate limit,
replay protection, and relay membership. That code moved out of
`gifs.rs` into one shared helper in `bridge.rs`; GIF behavior is
unchanged.
- The route is added to the existing protected-route and NIP-FI
shadow/enforce route tables. Docs: `docs/profile-search.md`.
- Two test-only lines get `sadscan:disable` comments, as other fixtures
already do: an existing public ES256 test key in `router.rs`, and the
local Postgres test URL in the search integration test.

### Related issue
None found.

### Testing
- Postgres test for the search query
(`profile_names_match_name_words_only`): only name fields match (avatar
data URL, `about`, `nip05`, `banner`, non-string names and non-object
content are excluded); words in any order and across both name fields;
whole words only for completed words; ranking order; pages split without
overlap or gaps; replaced profile; other community; `%` literal.
- Postgres end-to-end test through the router: an admitted member gets
only the name-matching signed event, and the returned event still
verifies.
- Request validation unit tests; existing GIF admission tests and router
route-table tests pass.
- Local end-to-end run with buzz-app#1084 against a relay built from
this head, with about 195 seeded profiles. For every query tried, the
endpoint's results were a subset of the full-text search's results; the
only profiles missing were avatar-only matches.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-15 17:35:33 -04:00
klopez4212
b1d60f98e2 Bring synced Solo chats and projects to mobile (#8337)
## Summary
Bring desktop Solo chats and project organization to mobile, with synced
collapsible sections, shared unread highlights and tab badges, agent
selection, shared search, native iOS creation sheets, and inline typing
bubbles that transition into replies.

Resolve Solo placement for every chat entry route, retain encrypted
organization snapshots for offline reading, and let users resume or
discard unfinished chat and project setup without deleting existing
messages.

### Related issue
Related: #6565; expandable agent transcripts remain separate work.

### Testing
- Fresh Solo read state seeds each active chat from its captured initial
last-message timestamp before exposing row/section/tab unread state.
Baselines survive intervening channel updates and reset across
identity/community boundaries while surviving read-state readiness
changes. Regressions cover multiple chats, events before read-state
readiness and during/after seeding, and identity changes; removing
either the seed or the display gate fails them.
- Chat completion reconciles a retained Solo-save journal even when
placement is already confirmed. False/throwing cleanup regressions
ensure the outer intent remains until reconciliation and only one
placement is published. Typing terminal-close loops remain bounded; the
retry budget resets after an uninterrupted eight-second health interval
or transport reconnect.
- Typing retry budgets reset after sustained recovery; invalid Solo
updates retain cached rows with a retry warning that survives failed
refreshes. Project names use the editors’ 120-grapheme limit, including
emoji and combining characters. Regression coverage exercises repeated
recovery, invalid live/refresh heads, and character-limit boundaries.
- Rebased onto current main, preserving the newer Search controls,
Huddle timeline filtering, and message-media layout.
- Failed project journal writes reload storage before claiming recovery
and reopen the entered name for retry. Typing subscription rejections
are caught; terminal closures clear stale indicators and retry with a
bounded backoff, canceled on disposal. Regression tests cover both
false/throwing storage writes and initial/late subscription rejection.
- Explicit ordinary destinations skip optional Solo lookup waits.
Generic destinations perform one bounded lookup in the destination
(search no longer duplicates it); an unavailable classification falls
back to ordinary sending per the product owner's explicit choice.
Positively identified Solo destinations use Solo providers.
- Terminal Solo subscription closure preserves cached rows and exposes
live-update Retry. Page coverage verifies warning visibility, usable
rows during recovery, and a subsequent terminal close. Removing the
warning or the immediate ordinary-route guard fails its regression.
- Generic channel entries share an ordinary default when Solo
classification is unavailable, per the requested product behavior. Known
Solo placement takes precedence; callers can explicitly require
unresolved placement to remain blocked. Bounded initial lookup preserves
known Solo deep-link routing.
- Dirty newest snapshots and offline-save warnings survive transport
reconnects; Retry persists that same event even if the relay returns an
older head. Community changes replace the service, and agent-directory
reconnect refresh remains intact.
- Pending-save discard reloads failed removals. Repeated reconnect
testing confirms typing subscriptions are released on provider rebuild
and disposal; removing the existing cleanup fails the regression.
- Ordinary Home/create routes explicitly preserve private-channel
sending during Solo failures; identified Solo sends use the Solo sender
and explicit unresolved routes remain fail-closed. Solo unread replies
beyond the initial 200-event page are loaded through the oldest-unread
boundary.
- Chat/project/agent cleanup reloads failed removals, retains
same-process recovery, and protects replaced records. False/throw
regressions verify retries do not duplicate creation or membership.
Route, unread, and cleanup guard-removal mutations each fail their
regression.
- Ordinary channels, DMs, and forums remain sendable when optional Solo
sync fails; explicit unresolved placement stays blocked. Controlled
cache races retain the newest verified snapshot, keep usable chats
visible, and retry offline persistence without stale writes. Both
protection-removal regressions fail as expected.
- Confirmed journal cleanup checks false/throwing removal and
exact-record identity. Interrupted agent choices are visible
immediately, identify the saved agent, and cover Resume/Later/Discard
with accessible name/key-fallback controls.
- Notification deep links preserve the contiguous history cursor;
same-second messages infer the newest agent consistently. Failed
offline-cache writes surface Retry, with false/throwing storage failures
and offline restart covered.
- Composite history cursors cover 350 same-second events plus older
history; removing the ID cursor makes the regression fail. Agent changes
now journal before granting access, with restart/failure and
Resume/Later/Discard widget coverage. Signed malformed profiles remain
isolated.
- Native iOS avatar tests passed on the simulator: visible-row loading,
three-request concurrency, streamed byte limits, response validation,
downsampling/pixel limits, cancellation, and visible avatar updates.
- Solo and Home share observed-event/read-marker logic. Widget coverage
verifies unread rows, collapsed-section indicators, the tab badge before
first Solo visit, message/thread reads, forced unread, and filtering
archived/nonmember/unplaced chats.
- Chat intent is saved before ownership and organization preflight;
tests cover interrupted ownership lookup and a disconnect while the
native creation form is open.
- Mobile analysis, formatting, file-size checks, and full mobile tests
passed at `1d4c41354`.
- Project creation persists its encrypted name/UUID before connectivity
checks or relay reads. Restart tests cover Resume/Later/Discard, scope
isolation, stale-intent rejection, and lost acknowledgements. Removing
the pre-network save makes all four production-flow regressions fail.
- Regression coverage includes notification/Activity routing, inline
replies, unknown-placement send protection, restart recovery/discard,
and encrypted offline cache isolation/refresh.
- Earlier feature build exercised on a physical iPhone with existing
account/community data; review follow-ups have not been reinstalled on
the device. UIKit agent-row accessibility checks passed on an iOS
simulator.
- Earlier cross-platform `just ci` stopped on the existing `buzz-acp`
test `signals_cancel_startup_turn_and_pending_stdin_without_hanging`
(process exit-code assertion); no Rust production changes are included
in this PR.

### Remaining review limitation
Agent discovery is bounded to 10 pages / 1,000 profiles with automatic
retries disabled. Owner-scoped discovery remains open because the
current relay ignores `#auth` filters; supporting that query requires a
relay change.

### Snapshots
Sample data, shared Flutter/Android surface. Native iOS uses platform
sheets and controls.

| Solo: projects and chats | New chat | Choose an agent |
| --- | --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/d9b29aa079a3b9a88e8ed89b70df8d6af06fa41b/pr-8337--01-solo.png"
width="240" alt="Solo with collapsible projects and chats" /> | <img
src="https://raw.githubusercontent.com/block/buzz/d9b29aa079a3b9a88e8ed89b70df8d6af06fa41b/pr-8337--02-new-chat.png"
width="240" alt="New chat form" /> | <img
src="https://raw.githubusercontent.com/block/buzz/d9b29aa079a3b9a88e8ed89b70df8d6af06fa41b/pr-8337--03-agent-picker.png"
width="240" alt="Agent selection list" /> |

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-15 17:07:23 -04:00
Kalvin C
cdfc38ecbd feat(search): add newest-first sort to bridge search (#8371)
## Summary

Adds an opt-in newest-first order to bridge search, so clients can offer
"Most recent" alongside relevance.

- `buzz-search`: `SearchQuery` gains `sort: SearchSort`. `Relevance`
keeps the existing `ts_rank_cd` ordering. `Newest` orders hits by
`created_at DESC, id`, and `page` steps through that order. Newest skips
the exact-profile-lexeme prioritization, which only applies to relevance
ranking.
- Bridge `POST /query`: search filters accept `search_sort: "newest"`.
When the field is missing or has another value, results stay in
relevance order. Community scope, channel intersection and per-hit
authorization are unchanged.
- NIP-11 lists `buzz-search-sort` in `supported_extensions`. Clients
only send the field to relays that advertise it, so they never get
relevance results labelled as newest.
- WebSocket NIP-50 search stays relevance-only. `NOSTR.md` documents the
bridge extension.

### Related issue

N/A

### Testing

- New Postgres integration test
`newest_sort_pages_by_time_then_id_within_scope`. It pages a channel in
newest order, including an equal-timestamp tie split across a page
boundary and a newer hit outside the channel scope. It checks that the
pages join up in `(created_at desc, id asc)` order and that relevance
still ranks the strongest match first.
- Unit tests:
  - `bridge_search_sort_extension_selects_newest_only_when_named`
  - `advertises_nip_cl_and_search_sort_extensions`
- Ran the buzz-search Postgres integration suite, the relay `nip11` and
`bridge_search` unit tests, `cargo fmt --check`, and `cargo clippy -p
buzz-search -p buzz-relay --all-targets -D warnings` on Linux.
- Local relay exercised through signed `POST /query`:
  - NIP-11 lists `buzz-search-sort`.
  - Relevance and newest return different orders.
- Newest pages 1–3 return 25 hits with no gaps or duplicates, including
same-second ties across page boundaries.
- A non-member gets no hits from a private channel; a member gets all of
them.
- Client integration is in a companion block/buzz-app change. It was
exercised end to end against this relay through the production broker:
switching sort, paging, scope changes and capability fallback.

Deployment note: clients only offer newest sorting after a relay with
this change advertises `buzz-search-sort`.

---------

Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:27:49 -07:00
Tom Brow
fea02d174d fix(ios): fetch profiles and avatars in notification extension (#8369)
A notification can arrive from someone whose profile the main app has
never loaded. The iOS notification extension now fetches and caches
sender profiles, channel details, and avatars itself. The main app
supplies account configuration and Keychain credentials only.

After resolving the message, the extension allows 1.5 seconds for sender
and channel details. It uses available stale data when a refresh is slow
or fails, delivers once, and lets late results update its private cache.
Profiles, channel details, and avatar refresh checks share a one-hour
freshness threshold. A verified avatar removal clears the previous
image. Account removal prevents further use of that sign-in's cached
data.

Profile, channel metadata, and membership use separate bounded
downloads, so an oversized roster cannot discard the smaller records.
Sender enrichment can finish while channel requests are pending. A
successful partial channel refresh atomically removes its old
counterpart, preventing a mixed group identity or recipient count.
Group-specific notification presentation still requires a verified
recipient count.

The
[specification](https://github.com/block/buzz/blob/native-notification-enrichment/docs/native-notification-enrichment.md)
describes the display, caching, and account-removal behavior. General
persistent image caching in Flutter remains separate.

### Validation

[Hosted CI](https://github.com/block/buzz/actions/runs/38780108487)
passed on the final commit, including all Flutter shards, Android, the
full iOS release and simulator builds, and native
controls/push-navigation tests. The [current-commit security
review](https://github.com/block/buzz/pull/8369#issuecomment-6197508385)
found no issues.

The native package passed 159 tests, including new coverage for cache
freshness, avatar replacement and removal, sign-in isolation, concurrent
storage, late results, single delivery, bounded downloads, and SVG
validation. A regression test exposed identical HTTP authentication
event IDs for requests in the same second; each request now includes a
unique nonce, matching the Dart and CLI clients.

Full local CI passed before that small authentication fix. The complete
native suite and signed device build passed again on the latest commit,
including response-size limits, overlapping avatar downloads, canonical
relay-origin matching, independent profile/channel refreshes,
account-key changes before and during requests, and a single initial
snapshot read across 64 accounts, signed IPv6 avatar authorities, and
partial channel refreshes. Focused Flutter regression tests passed 198
tests with three configuration-specific skips; seven avatar widget tests
also passed. Flutter analysis is clean, and the notification extension
builds for the simulator.

On an iPhone SE, the notification displayed the sender name, avatar,
channel, and message before the updated main app was opened. Repeat
notifications used a byte-identical private cache. Three notifications
delivered together displayed the avatar after the authentication fix.
The next notification also displayed correctly after terminating the
extension process. The existing generic-push design can select the same
newest unread message for several pushes; this change does not alter
message selection.

![Notification before first app launch after
upgrade](https://raw.githubusercontent.com/block/buzz/37d450855e026178836e2a9e05b0081ebbc3b479/pr-8369--before-first-app-launch.png)

Authenticated HTTP avatar fetching and persistent HTTP cache reuse also
passed on the SE. The first notification downloaded a relay thumbnail.
After terminating the extension and expiring its separate avatar entry,
the next notification displayed the avatar and refreshed that entry
without another media request in the relay logs. The main app remained
unopened throughout. The original app build and test profile were
restored afterward.

Automated tests cover slow and failed requests, stale images, removal,
and account revocation; those cases have not all been exercised on the
phone.

### Before merge

- [x] Verify authenticated HTTP avatar fetching and cache reuse on iOS
against a working relay media service.

<!-- buzz-review-completed -->

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
mobile-v0.23.0-rc.1
2026-10-15 11:16:04 -07:00
klopez4212
d61fe2071d Use native UIKit scrolling for iOS photo carousels (#8361)
Builds on the mobile polish merged in #8359; this PR now targets `main`.
On iOS, inline photo galleries now use a UIKit collection view for
dragging, deceleration, and snapping. Photo taps retain the existing
full-screen viewer and reply/more actions; gallery scrolling continues
to fade the message avatar. The final photo stops at the full message
gutter rather than the smaller gap between photos. Android keeps the
current Flutter carousel.

Visible cells subscribe to continuing frames from Flutter's existing
authenticated image providers, preserving animated GIF/APNG/WebP media.
Request IDs fence late frames and cancel subscriptions on
reuse/disappearance/disposal. A four-slot bound includes retired
unresolved loads until their transport terminates. Shared media fetches
now use abortable HTTP requests with a 30-second deadline through
response-body completion, so stalled requests release their slots
without leaving live transports behind; native eager precaching is
disabled so it cannot bypass that bound. Frame conversion coalesces to
the newest pending frame. Native cells expose photo labels and positions
to VoiceOver, support RTL, and preserve the selected photo during
resizing.

Validation:
- Mobile formatting and analysis passed.
- Native simulator regression checks passed for geometry, RTL, snapping,
taps, frame replacement, cancellation, and resizing. The native CI lane
now runs the carousel executable.
- Full mobile suite and pre-push gates passed, including two-frame GIF
delivery, delayed-provider bounds/cancellation, real HTTP
stalled-header/body recovery with four requests maximum, disposal, and
unconfigured-push tests.
- Signed iPhone Release build of the frame/subscription implementation
passed. Physical iPhone testing covered the earlier carousel/gutter
revision; the new animation/cancellation changes have local Flutter and
native simulator coverage, not a new physical-device journey.
Preview-only signing overrides preserve its bundle/Keychain group;
production entitlements are unchanged. Local provisioning excludes
production push/App Attest capabilities.
- Full `just ci` was started but stopped after unrelated Rust/desktop
compilation consumed about 5 GB; broad CI is incomplete.

Device build required local Xcode 27 workarounds: iOS 16 deployment
target and a generated swift-secp256k1 cache compatibility edit. Neither
changes repository source.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-15 13:22:12 -04:00
klopez4212
9296660d1e Match mobile reply links to desktop connectors (#8362)
## Summary
Reply summaries now appear below mobile message bubbles as inset
avatar/count links with the same rounded connector used on desktop.
Reaction trays keep their overlap with the bubble; connector clearance
adjusts when reactions are present. Channel and nested reply summaries
share the same 20px avatar stack, capped at three, with a 44px minimum
tap target and accessible thread-opening label.

### Related issue
Follows merged #8359 and now targets `main`. This diff contains only the
reply-link changes. No matching duplicate was found in the open issue/PR
search for reply connectors.

### Testing
- Full mobile formatting, analysis, and tests passed before stacking:
3,348 tests plus 3 push-configuration tests, 4 skips.
- Normal pre-push checks passed on the rebased head against main:
formatting, analysis, full mobile tests, and repository file-size
checks.
- Before stacking, installed and launched the preview on Pixel 10 and
iPhone Air with existing app data. Ten profile/community/draft
preference values remained unchanged on each. Verified both connector
spacing variants and thread opening on Pixel; the owner tried and
approved the preview. iPhone screenshot verification was unavailable.
- The rebased build has not been reinstalled on devices. Device evidence
above is from the approved preview before rebasing.

Before: channel replies were filled pills beside reactions; nested
replies used a separate larger avatar row. After, as verified in the
device preview:

![Pixel preview showing reply connectors with and without
reactions](https://buzz.block.builderlab.xyz/media/119f0fbd358895ef3fe5e6c1bc4f2c399c660b1debded5efff08864ff796792c.png)

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-15 13:08:41 -04:00
klopez4212
42b395cdef Polish mobile bubbles, mentions, galleries, and huddle cards (#8359)
Builds on the merged themes work in #8358 and inherits its appearance
and chat-color settings.

Polishes mobile conversations with uniform rounded bubbles,
desktop-style text-only mentions and agent icon, aligned gallery counts,
fading gallery avatars, and standard iOS scrolling. Huddles use rounded
cards with attached reaction trays, without duplicate start/end notices;
other activity rows stay centered with smaller text.

Validation: mobile checks, the full mobile test suite, and the gallery
regression test passed. Release build installed on iPhone. Full `just
ci` could not finish: desktop compilation exhausted local disk space.

### Snapshots
Flutter-rendered light and dark previews using seeded sample messages.

| Light | Dark |
| --- | --- |
|
![light](https://raw.githubusercontent.com/block/buzz/62346699199dd707579f46d21608c2a49a8095ef/pr-8359--light.png)
|
![dark](https://raw.githubusercontent.com/block/buzz/62346699199dd707579f46d21608c2a49a8095ef/pr-8359--dark.png)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-15 12:39:21 -04:00
Will Pfleger
9092950620 docs(nip-ap): narrow relay support and allow offline start (#8368)
🤖 Two corrections to NIP-AP's agent and team config section.

**Relay support.** A client now needs only NIP-CL system channels and
labels plus NIP-AR artifacts before storing config on the relay. The
four other required advertisements are dropped: read-only guests, a
retention exemption, complete-history artifact sync, and telling a
never-used artifact address apart from a fully redacted one. No relay
advertises them, so a conforming client could never store config on the
relay. The retention exemption stays in "History" as the relay's
obligation. Without a never-used signal an empty lookup is still
`unavailable`, never `absent`, so clients fail closed.

**Offline start.** When the relay can't be reached (a transport failure,
not a read that returned an answer), a client may start the agent from
the last revision it confirmed as present. It still must block if the
relay's most recent answer showed that the config is invalid, deleted,
redacted, archived, or no longer accessible, and it never uses another
source.

**Consistency.** "Editing" no longer assumes every relay's guest role is
read-only. "Artifact state" points its no-substitution rule at the new
offline exception.

Related: #8295

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-15 12:08:53 -04:00
Tom Brow
6766174673 chore(push): retire push notification database trigger (#8352)
Buzz must schedule mobile push notifications when it saves an eligible
message. The relay, the server that receives and stores messages, now
does this in application code. An older database trigger still exists.
This trigger is a rule that runs automatically when a message is saved.

This PR removes the old trigger and its supporting database function.
The relay remains responsible for scheduling notifications. Migration
0063 makes this change in existing databases; updated setup scripts
apply it to new databases.

The change does not turn on push notifications. Production migration and
deployment need separate approval. Before deployment, verify the release
images and all pending database migrations. If push needs to be stopped
after release, disable it with the feature flag and keep the qualified
application version and database schema. Do not downgrade the
application image.

### Validation

- Local checks passed: 519 isolated database integration tests, 166
database unit tests, 17 source-policy tests, and the relay push-runtime
tests.
- Tests verify that the migration removes the old trigger, preserves
earlier migration records, and lets the application schedule push work
correctly. A concurrency test exercises the real application path;
deliberately removing its lock makes the test fail.
- [Current-head CI passed on
retry](https://github.com/block/buzz/actions/runs/38752849383/attempts/2).
The first attempt had one database-connection timeout during test setup;
the failed jobs passed when rerun on the unchanged commit.
- Initial local CI and push checks hit unrelated ACP test failures,
which passed when run separately. Only the Rust-test push hook was
skipped under the repository's local-check exception; other applicable
hooks passed.

### Before merge

- [x] Migration 0063 is available and the push trigger remains on [main
at
5397ea4](5397ea49e2).
Recheck if main advances before merge.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-15 08:37:55 -07:00
Tom Brow
5397ea49e2 fix(push): verify extended App Attest proofs with a focused owned module (#8329)
## Summary

Apple SDK 27.1 device captures include authenticator extension data that
the current verifier rejects. Replace the general appattest dependency
with a private gateway module that verifies the complete original bytes,
frames enrollment and assertions separately, and accepts well-formed
optional extensions without SDK-specific length or flag allowlists.

The module retains established signature, P-256, CBOR, and
certificate-chain libraries. App identity, exact enrollment environment,
credential/key binding, nonce, challenge, and increasing-counter checks
remain enforced. Bounded parsing rejects ambiguous duplicate keys,
truncation, trailing data, and oversized inputs. Fixture helpers are
test-only. Adapted code retains both upstream MIT notices and source
provenance.

Category/version properties remain authenticated observations under the
chosen compatibility-release policy. This does not implement Apple's
additional distribution-category/version enforcement; introducing those
restrictions requires a separately defined deployment policy. The
[verification
contract](https://github.com/block/buzz/blob/app-attest-owned-verifier/docs/app-attest-compatibility.md)
records this and other limits.

Related: https://github.com/block/buzz/pull/8020 addresses assertion
extension handling. This change also covers enrollment extensions,
credential-key parsing, and certificate validation.

### Validation

- Gateway suites: 77 default tests and 79 personal-development tests
passed, plus startup policy and Clippy.
- Added signed enrollment/certificate, generated CBOR mutation,
maximum-counter, and PostgreSQL race/replay coverage. All 12 isolated
Postgres tests passed. Apple's historical fixture is tested
structurally, not accepted as a current enrollment. Positive synthetic
leaf certificates are generated at test time.
- Protected SDK 26.5/27.1 enrollments and assertions pass the verifier,
including the same key across SDK upgrades; development enrollment
rejects under production policy. Captures are personal-development
identities, not supported production-identity evidence.
- Full local CI passed Rust tests and reached 6,828 passing desktop JS
tests before an unchanged 10 ms focus-timer test failed; its five-test
file passed in isolation. The pre-push native suite had 3,421 passes and
an unchanged 300 ms child-PID test failure, also passing in isolation.
Prior ACP timing flakes and these unrelated hook exceptions do not waive
required CI.
- Windows CI exposed a missing system OpenSSL installation for the test
fixture generator. The test dependency now builds bundled OpenSSL; all
30 focused App Attest tests pass locally. [Current-head
CI](https://github.com/block/buzz/actions/runs/38636062347) passed,
including Windows Rust, PostgreSQL, and desktop integration tests.
- [Prior-head Codex
review](https://github.com/block/buzz/pull/8329#issuecomment-6177515598)
found no major issues. Carl subsequently identified a dropped
credential-length workaround; fixed in
[27f9f26](27f9f2624f),
with 34 focused/protected tests and Clippy passing. [Current-head Codex
review](https://github.com/block/buzz/pull/8329#issuecomment-6178361143)
found no major issues; physical iOS 26 testing was explicitly waived by
Tom on October 14, 2026. No iOS 26 device test was performed. The SDK
comparison used the same iOS 27.0.1 device, so it demonstrates
SDK-linked variation without establishing OS-version causation.
- Isolated candidate HTTP/device validation passed with separate
authority state and no APNs delivery: SDK 26.5 enrollment/delegation,
SDK 27.1 delegation with the exact same key and installation, and fresh
SDK 27.1 enrollment/delegation. Assertions changed from 37 to 102 bytes
across the upgrade; accepted counter increased from 1 to 3 and
delegation generation from 1 to 2. All three runs rejected tampering and
replay (401) and consumed challenges (404). Existing Buzz app builds
were preserved. No production rollout is included.

- Existing-installation validation passed against the current-head Dev
gateway: a guarded SDK 27.1 diagnostic reused the stored key and
installation, advanced the assertion counter from 88 to 89 and
delegation from 89 to 90, and published the existing active relay lease
at generation 9 (previously 8). Independent database reads confirmed the
changes and preserved key, epoch, and expiry. The original app build was
restored in place with current data and keys intact.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-15 07:15:19 -07:00
klopez4212
a5dfdd6d55 Align mobile appearance and channels with desktop (#8358)
Aligns mobile with desktop's neutral palette and six chat colors.
Appearance now offers Light, Dark, and System instead of themes, while
preserving community preferences and the single Home and Chat preview
pair.

Chat colors survive local preference reloads without historical-index
remapping, and the Chat preview shows the draft outgoing bubble color.

Channel and section icons are removed, section chevrons sit beside
names, and section dividers use 7% opacity. DM avatars remain.

Validation: `just mobile-check`, 35 focused tests, and the full mobile
suite passed (3,351 tests; 4 skipped). Updated light/dark previews were
rendered and visually checked. The earlier release was verified on
iPhone; this follow-up has not been reinstalled. The earlier
repository-wide lint passed, but full `just ci` was stopped for low disk
space. Hosted CI is running for the new head.

**Intentional design parity:** Section labels and chevrons retain
desktop’s `--text-unavailable` colors: light `#959595`, dark `#595959`.
Verified against `block/buzz-app` main at
`0201b54c496120a44e020834663dc534cae9a79f`. These colors do not meet
4.5:1 text contrast on the mobile section surfaces; the product decision
for this PR is to preserve desktop parity and leave them unchanged.
Kenneth explicitly accepts this known contrast tradeoff for this
PR/release and requests that reviewers treat it as a scoped product
exception rather than a blocker. This does not claim WCAG compliance or
change the repository-wide accessibility target. Any future contrast
adjustment should be coordinated across desktop and mobile.

### Snapshots

Channel list: physical iPhone. Appearance and chat colors:
Flutter-rendered previews.

| Channels | Light | Dark | Chat colors |
| --- | --- | --- | --- |
|
![channel-list-iphone](https://raw.githubusercontent.com/block/buzz/1effb78b36cbcd77bd6339f11742133ec9e8214a/pr-8358--channel-list-iphone.png)
|
![appearance-light](https://raw.githubusercontent.com/block/buzz/1effb78b36cbcd77bd6339f11742133ec9e8214a/pr-8358--appearance-light.png)
|
![appearance-dark](https://raw.githubusercontent.com/block/buzz/1effb78b36cbcd77bd6339f11742133ec9e8214a/pr-8358--appearance-dark.png)
|
![chat-colors](https://raw.githubusercontent.com/block/buzz/1effb78b36cbcd77bd6339f11742133ec9e8214a/pr-8358--chat-colors.png)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-15 08:45:16 -04:00
Logan Johnson
d28f6b2f70 feat(relay): let channel-state filters opt in to system channels (#8182)
🤖
## Summary
After part 2, a client can read a system channel only if it already
knows the ID. This PR lets a client find system channels on purpose,
with an ordinary Nostr filter and no new filter key.

A filter on the channel-state kinds (39000–39003) now also returns the
system channels the reader can read when it has either:

- `#t:["system"]`, the channel type tag that every channel-state event
already carries; or
- any `#P`, the creator tag from #8174. Asking for the channels of a
named creator returns a small result, so it does not fill generic
channel lists.

The filter then matches as usual. Existing clients send neither on these
kinds, so they still never see a system channel.

| Reader wants | Filter |
|---|---|
| My system channels | `{kinds:[39002], #p:[me], #t:["system"]}`, then
their kind:39000 by `#d` |
| Open system channels | `{kinds:[39000], #t:["system"]}` |
| One creator's labeled channel, system or not | `{kinds:[39000],
#P:[creator], #t:[label]}` |

Private system channels stay hidden from non-members, as for any private
channel. Values in one tag filter combine with OR, so `#t:["system",
label]` means `system` **or** the label; use `#P` plus the label
instead.

Part 3 of 3, stacked on part 2.

### Related issue
None found.

### Details
- The opt-in is decided per filter in `handlers/system_channels.rs` and
applies on REQ, COUNT and the HTTP bridge. It applies only when every
kind in the filter is 39000–39003.
- A label alone is not an opt-in.
- `NOSTR.md` documents the system channel query rules.
- **Spec.** NIP-CL says how a filter asks for system channels.
- The relay information document (NIP-11) now lists `"nip-cl"` in
`supported_extensions`. Clients use this to tell if a relay supports
labels and system channels, and refuse those calls on a relay that does
not. NIP-CL documents this.

### Testing
- End-to-end relay test: `#t:["system"]` finds both system channels for
a member and only the open one for a non-member; 39002 `#p:[me]` with
`#t:["system"]` lists the member's system channels; `#P` includes a
creator's system channels; creator plus label returns exactly one
channel with `limit: 1` and excludes another creator's channel with the
same label; a label alone does not return the system channel; COUNT and
HTTP `/query` and `/count` agree.
- The test fails when the opt-in is disabled.
- Unit tests for which filters opt in.

### Stack
These are two stacks. Merge them in this order. Client support
(creating, labeling and finding these channels) will be in
block/buzz-app, not the buzz SDK and CLI.

**Stack 1: channel labels and creator lookup (#8174–#8176)**

1. #8174 channel type and creator tags, and the channel creation time
2. #8175 channel labels
3. #8176 database-side label and creator match, and the rule for
choosing one channel

**Stack 2: system channels (#8180–#8182)**, stacked on stack 1 because
it uses labels and the creator tag

4. #8180 the system channel type
5. #8181 leave system channels out of reads that do not name them
6. #8182 channel-state filters opt in to system channels ← this PR

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-15 08:40:16 -04:00
Logan Johnson
cdf03f618a feat(relay): leave system channels out of reads that do not name them (#8181)
🤖
## Summary
After part 1, a system channel is still returned by every query that
returns ordinary channels, so existing clients would show it as a chat.
This PR makes the relay leave system channels out of every read that
does not name them. Clients do not need to filter, so clients that know
nothing about system channels stay correct.

| Query | Before | After |
|---|---|---|
| A query that does not name a channel (no `#h`), COUNT, full-text
search, the HTTP `/query` and `/count` routes, artifact queries
(`{"artifact": ...}`), the sidebar | Includes system channels | Leaves
them out |
| "Channels I belong to" (kind:39002 `#p:[me]`) | Includes them | Leaves
them out |
| A filter that names the channel with `#h`, or with `#d` on kinds
39000–39003 | Ordinary read rules | Unchanged: members can read it, and
anyone can read it if it is open |

Push notifications and "you were added to a channel" notifications
(kinds 44100/44101) skip system channels, because a client that received
one would show the channel.

A client that is given a system channel's ID, for example in a pasted
link, can still open it. Naming the ID is the opt-in. This keeps system
channels ordinary NIP-29 groups.

Part 2 of 3, stacked on part 1.

### Related issue
None found.

### Details
- The rule lives in one place: `get_accessible_channel_ids`, the set of
channels a query may read when it does not name one. It is the union of
the reader's memberships and every open channel; both halves now exclude
`channel_type = 'system'`. Because the filter is in SQL before `LIMIT`,
pages stay full.
- A filter that names a system channel adds it to that filter's scope
only (`handlers/system_channels.rs`). A sibling filter in the same
request keeps the implicit set, so naming one system channel cannot
widen another filter.
- Thread windows and the workflows API name their channel, so they
follow the same named-channel rule.
- Artifact queries have their own read check in SQL. It reads a system
channel only when the query's `#h` names it.
- When a request re-checks membership for a named channel that its first
lookup missed, a confirmed system channel joins only the filters that
name it. Before, a reader who joined a system channel during the request
could have it added to a sibling filter's scope.
- The system-channel lookup runs only when a filter names a channel
outside the implicit set, so ordinary queries do not pay for it.
- **Spec.** NIP-CL lists the reads that leave system channels out, and
says when a filter names one. `AGENTS.md` notes this for `get_channels`.

### Testing
- End-to-end relay test (Postgres and Redis) with a private system
channel, an open system channel and an ordinary channel, as a member and
as a non-member: REQ without `#h`, NIP-50 search, COUNT, HTTP `/query`,
HTTP search, HTTP `/count`, the channel list, and 39002 `#p:[me]` return
only the ordinary channel. Named reads by `#h` and `#d` follow
member-or-open on REQ, COUNT, search and HTTP.
- Relay test: adding a member to a system channel sends no kind:44100;
an ordinary channel still does.
- Both tests fail when the SQL exclusion or the named-channel scope is
removed.
- Database test: an artifact in an open system channel is missing from
`{"artifact":"current"}` and present with `#h:[<system channel>]`, for a
member and a non-member. It fails without the channel-type condition.
- Relay test: the membership re-check confirms a system channel that the
system-channel lookup missed. The channel goes to the filter that names
it, and a sibling filter's scope does not include it. It fails without
the fix.
- Unit tests for the per-filter scope. They now run in `just test-unit`
and `scripts/run-tests.sh`.

### Stack
These are two stacks. Merge them in this order. Client support
(creating, labeling and finding these channels) will be in
block/buzz-app, not the buzz SDK and CLI.

**Stack 1: channel labels and creator lookup (#8174–#8176)**

1. #8174 channel type and creator tags, and the channel creation time
2. #8175 channel labels
3. #8176 database-side label and creator match, and the rule for
choosing one channel

**Stack 2: system channels (#8180–#8182)**, stacked on stack 1 because
it uses labels and the creator tag

4. #8180 the system channel type
5. #8181 leave system channels out of reads that do not name them ← this
PR
6. #8182 channel-state filters opt in to system channels

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-15 08:40:15 -04:00
Wes
b77ba86262 perf(mobile): cut test wall time and run mobile CI lanes in parallel (#8347)
## Problem

Mobile pushes and PRs are slow.

- **Pre-push takes ~133s on a mobile-only change.** About 115s of that
is `flutter test`, and the finish time is set by a few files that wait
on real clocks.
- `push_presentation_export_test` (75s) and
`push_presentation_recovery_test` (19s) sleep through the full 7.75s
retry backoff.
- `channel_messages_provider_test` (70s) sleeps through recount and
ownership retry backoff on real time.
- **The required Mobile CI job takes ~15–16 min.** It runs tests
(390–430s on a 4-vCPU runner, 2 test files at a time) and then the
Android debug APK build (~400s, no Gradle cache) back to back.
- **Mobile Swift takes 18–21 min.** The unsigned release build (~4.5
min) waits behind the simulator tests.

Pre-commit is ~3s and isn't part of the problem.

## Change

**Tests** (each one still asserts what it did before)
- **Push exports.** Production retries wait through
`debugPushPresentationRetryWait`, which defaults to `Future.delayed`.
The export and recovery tests skip the real waits and assert the exact
250ms → 4s backoff schedule instead. Export fixtures sign each event
once per file.
- **`channel_messages_provider_test`.** Every test runs on `fakeAsync`.
A file-local `test` wrapper keeps the 90 call sites unchanged. Timers
fire in their real order, and a test that waits on work outside fake
time fails instead of hanging. Raising the production retry bound
(`attempt < 2` → `3`) still fails the 5 exhausted-retry tests.
- **`signed qualified caller`.** This test mounts the page 48 times and
opens the profile sheet twice per mount. It now disables the sheet
animation; it asserts which pubkey and labels show, not motion.

**CI**
- **Mobile runs as five parallel matrix lanes:** checks (format,
analyze, unconfigured push test, recipe test), three `flutter test
--total-shards=3` shards, and the Android build. The required `Mobile`
gate still reads the combined result.
- **Android restores a Gradle cache,** which is saved only on pushes to
`main`.
- **Mobile Swift builds the unsigned release** in a parallel lane beside
the native build, tests and simulator checks.

## Validation

Measured locally on an 18-core Mac with warm caches:

| | Before | After |
|---|---|---|
| `flutter test` (full suite) | 115s | 74s |
| Summed per-test time | 411s | 307s |
| `push_presentation_export_test` | 75s | 38s |
| `channel_messages_provider_test` | 70s | 22s |
| Pre-push, mobile-only push | 133s | 95s |

- The CI lane timings come from this PR's own run.
- `actionlint` passes, and so do
`scripts/test-ci-required-context-isolation.sh` and
`scripts/ci-required-gates.test.mjs`.
- The pre-push hook (format, analyze, full `flutter test`) passed on
push.

## Not in this PR

- `channel_detail_page_test` (~55s) is now the local long pole.
Splitting it into shard files would let it run in parallel, but it is
one 16k-line library with `part` files, so that is a separate refactor.
- Caching Hermit and pub on macOS would cut ~100s of setup from each
Mobile Swift lane.

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

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:57:47 -06:00
Wes
7b11c6147d test: fix recurring CI flakes in desktop and Postgres lanes (#8343)
Fixes the CI flakes that failed most often across `main` and unrelated
branches from Oct 5 to Oct 14. The counts come from 150 failed `CI`
runs. Every change except the nextest config is test-only.

| Flake | Runs hit | Root cause | Fix |
|---|---|---|---|
| `probe_codex_acp_version_…descendant_holds_pipe_open` | 5 | The test
writes its script in-process and then execs it. If a sibling test spawns
a process in that window, the child inherits the open write fd and Linux
rejects the exec with `ETXTBSY`. The probe swallows that error as
`None`. | Write probe scripts from a short-lived `/bin/sh`
(`write_executable_script`) |
| `empty-edit-delete.spec.ts` (`:51`, `:74`, `:95`) | 11 | The seeded
`mock-general-welcome` id isn't hex, so `formatTimelineMessages` can't
overlay its edit. The post-subscribe window refetch then reverts the
row. | Emit a per-test message with a real id after the exact channel
subscription is live |
| `huddle-transcription.spec.ts:798` | 9 | The composer's delayed
initial autofocus (TipTap `focus()` runs in rAF) lands after the voice
popover opens and dismisses it. | Wait for `message-input` focus before
opening the popover |
| `voice-note.spec.ts:906` | 6 | The fixture `ping.mp3` is about 1s
long, so playback can end before `paused === false` is observed. | Loop
the fixture audio |
| `workflows.spec.ts:291` | 5 | The `Message text` fill runs before the
inspector swaps to the new step, so it fills the trigger's condition
instead. | Scope the fill to the step inspector after its `Action` menu
appears |
| `cluster_global_probe_commits_tokens_and_sessions_prove_coverage` | 3
| The `max-threads = 1` group only serializes these tests with each
other. Other tests' sessions still mask cluster-wide `pg_stat_activity`
(`MaskedActivity`). | `threads-required = "num-test-threads"` |
| `test_usage_metrics_lock_has_single_owner_and_releases_on_drop` | 1
(this PR) | The test retries the lock right after dropping the leader.
Postgres releases a session advisory lock only after the backend notices
the disconnect, so the retry can run first. | Poll for the successor,
bounded at 5s |

### Validation
- **ETXTBSY:** a Linux experiment with Rust 1.95 using the same
write→chmod→spawn pattern under concurrent spawns hit `ETXTBSY` 4–7% of
the time. Using the `sh` writer, it hit 0 of 6,000. I did not catch it
inside the full suite locally, so this fix is based on the mechanism,
not an in-suite repro. All 11 probe tests pass on Linux and macOS.
- **empty-edit-delete:** the baseline failed 16 of 45 runs
(`--repeat-each`, 6 workers, no retries). The spec fix alone brings that
to 4/45. The remaining failures come from a separate product race,
described below.
- **huddle:** the baseline failed 5 of 10 runs. With the fix, 60 of 60
passed without retries.
- **nextest:** checked with a throwaway crate on 0.9.136. A grouped test
with `num-test-threads` ran alone, even with `max-threads = 1`.
- I have not stress-run the voice-note and workflows fixes; they rely on
CI.

### Not in this PR
- **Product bug in `useChannelLinks`:** the debounced `#channel`
autocomplete close lets Enter accept a stale suggestion for about 120ms.
That accounts for the remaining `empty-edit-delete` failures, and it
affects users too. With the spec fix plus a synchronous close, the spec
passed 120 of 120 runs. This needs a product decision, so it's held for
a separate PR.
- **Composer autofocus race:** the delayed autofocus can close an
overlay the user just opened. The real fix is to revalidate when the
delayed focus runs. Overlaps #6530.
- **`thread-head-stale-edit.spec.ts:55`:** a forced click on a
pointer-events-none action bar is suspected, but I couldn't reproduce
it.
- **Remaining ETXTBSY exposure:** about 40 other Rust tests write and
then exec scripts, and 17 specs still edit the non-hex seed message.

Root-cause lanes by Princess Donut, Mongo and Mordecai. Carl integrated
and verified them.

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

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <5d97ac8c272fa949af56c71e586146b1706e5c4c3daa125db5b4625e12e0686c@buzz.block.builderlab.xyz>
Co-authored-by: Mongo <81cabd2ca1792494372ed410283c00770c0cf35afca052e940fa522883212b45@buzz.block.builderlab.xyz>
Co-authored-by: Mordecai <f314b6033b6e13df94c8e9e1187d3666b6b9ce54e66ee986e31cdaa909e8dcbc@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:32:00 -06:00
Tom Brow
8222e99915 fix(mobile): keep thread references consistent across push and Inbox (#8332)
Follow-up to [#8294](https://github.com/block/buzz/pull/8294). Malformed
trailing thread tags could make a notification open the right thread
while Flutter indexed its reply elsewhere; uppercase references could
similarly miss lowercase event IDs.

Share one validated thread-reference parser between the mobile timeline,
Inbox, and feed helper. It ignores malformed markers, keeps the last
valid root/reply, and normalizes lookup IDs to lowercase. Native push
parsing now uses the same casing. Existing thread fixtures use valid
event IDs so they continue exercising real thread relationships.

Add RunnerTests coverage for the production native-to-Flutter argument
conversion, including buffered cold-start navigation and omitted
optional thread IDs, and run it in the native CI lane.

### Validation

- New parser and Inbox regressions cover malformed markers and uppercase
references. Widget regressions open and highlight the exact reply for
valid, uppercase, and trailing-malformed tags.
- After integrating [#8106](https://github.com/block/buzz/pull/8106),
Flutter analysis and the complete mobile suite pass: 3,337 tests, four
existing skips. Integration preserves per-event read predicates and
migrates incoming thread fixtures to valid IDs. An unchanged
keyboard-history test also failed with main's original parser; recording
reads before the scroll transition fixes its timing assumption while
retaining visibility assertions.
- Native package tests passed (127 tests), and both Runner bridge tests
passed on an iOS simulator with Xcode 26.5. Removing the production
`threadRootId` assignment made the bridge regression fail; restoring it
passed. Native code is unchanged by the main integration.
- [Current-head CI
passed](https://github.com/block/buzz/actions/runs/38659307247),
including Mobile and Mobile Swift. [Codex
review](https://github.com/block/buzz/pull/8332#issuecomment-6181458528)
and [security
review](https://github.com/block/buzz/pull/8332#issuecomment-6181449235)
found no issues on the integrated commit. [Owner
approval](https://github.com/block/buzz/pull/8332#pullrequestreview-5526812957)
covers the exact integrated commit.
- Standards and spec reviews found no blockers, including the merge
resolutions and test adjustments. Validation is scoped to mobile/native
per VISION_MOBILE; unrelated repository-wide checks were stopped, not
counted as passing.

The bridge test covers serialization and buffering, not end-to-end APNs
delivery or app launch. No new physical-device acceptance is claimed;
routine post-merge dogfood remains human-owned.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-14 18:42:39 -07:00
Will Pfleger
349f566e93 docs(nip-ap): store agent and team config as NIP-AR artifacts (#8295)
🤖 Stores agent and team config as
[NIP-AR](https://github.com/block/buzz/blob/main/docs/nips/NIP-AR.md)
artifacts in private
[NIP-CL](https://github.com/block/buzz/blob/main/docs/nips/NIP-CL.md)
system channels, replacing the owner-encrypted `kind:44300` instruction
versions.

- **Config channels.** Each agent has an agent config channel holding
`buzz.agent.settings` and `buzz.agent.instructions`. Each team has a
team config channel holding one `buzz.team` artifact with the team name,
team instructions and a `team` tag carrying the exact team id, and no
member list. The owner creates both kinds as private system channels
with no TTL. Each carries a family label (`agent-config` /
`team-config`) and a subject label: the agent pubkey, or `team-` plus a
hash of the team id, which clients check against the `team` tag.
Discovery queries by creator and subject label. Agents find their teams
through a `39002` query that opts in to system channels. The oldest
matching channel wins, and channels that aren't selected are never
deleted.
- **Ownership and teams.** An agent's owner is its relay-verified NIP-OA
owner, and a team's owner is its channel's creator. Changing an owner
isn't supported. A team's members are the owner's agents in the team
config channel. Changes to who is on a team follow the channel's role
rules, including add policies, owner/admin-only role changes, and
last-owner protection against leaving, removal and demotion. Guests are
read-only. Every non-guest member can edit config, including humans and
agents that aren't on the team. Edits keep unrecognised JSON fields and
tags, and a client whose create collides fetches and edits the existing
artifact instead of picking a new `d`.
- **Artifact states.** A config artifact is present, invalid, absent,
deleted, or unavailable. An artifact is absent only when the relay
confirms its address was never used. Redacting every revision does not
make an address unused. Only an absent artifact lets a client fall back
to local instructions or a definition prompt. Invalid, deleted, and
unavailable states block the start.
- **History and launch.** History is kept until the owner purges it or a
moderator removes a revision. Config channels must be exempt from
age-based retention, and keeping only the latest revision is not
allowed. A revert is an `op=update` with a full snapshot and keeps the
team's name. The instruction resolution order and per-team launch
sections are unchanged.
- **Limits and lifecycle.** Config content is at most 131,072 bytes
after encoding (settings: 65,535), and the signed event must fit the
relay's limits. Content is never truncated. Settings exclude
credentials, local paths and device state. An unsupported setting is an
error, never a silent default. Deleting an agent archives its channel
and blocks new launches until it's unarchived. Deleting a team archives
its channel.
- **Relay prerequisites.** Clients keep config local unless the relay
supports system channels, artifacts, read-only guests, the retention
exemption, sync with complete history, and telling an artifact address
that has never been used apart from one whose revisions have all been
redacted.
- **Removed.** `kind:44300` and its relay rules, deletion rules and
owner-encryption text. `30175`, `30177`, `30178` and the team id format
are unchanged.

Depends on the NIP-CL label and system-channel stack (#8176,
#8180–#8182).

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-14 21:19:00 -04:00