Commit Graph

2944 Commits

Author SHA1 Message Date
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
Bradley Axen
8b60dcf8e3 feat(relay): deliver full signed events to operator listeners (#8309)
## Summary

Operator-listener mention notifications now carry the full signed event,
so a listener such as kgoose can wake an agent without first fetching
the event (one signed round trip less on time-to-first-reply).

Because the notification now carries message content, two mitigations
come with it, as agreed in #buzz-security:

1. **Each target signs its own registration.** `POST
/operator/listener/pubkeys` takes `{"registrations": [proof, …]}`. Each
proof is a kind `24245` event signed by the target pubkey, with exactly
one `p` tag (the listener pubkey) and one `u` tag
(`{RELAY_OPERATOR_API_ORIGIN}/operator/listener/pubkeys`), and a
`created_at` within ±60s. The old unsigned `{"pubkeys": […]}` POST body
is rejected with 400. `DELETE` is unchanged.
2. **Only notify a target that can read the event.** Before each
delivery the relay re-checks, on the writer, that:
   - the registration still exists;
   - the event isn't deleted;
   - neither the target nor its recorded NIP-OA owner is banned;
- the target passes the relay-membership gate (with the owner path when
`allow_nip_oa_auth` is on);
   - the channel is open, or the target is an active member.

A delivery that fails the check retries with backoff instead of being
dropped, because a mention can commit just before the target is added to
the channel. It fails permanently, with no HTTP request, once attempts
are exhausted.

The payload is now `v: 2`: the v1 fields plus `event`, the full signed
NIP-01 event.

There's no migration. Existing registrations stay in place and age out
under the 30-day retention; only a configured listener could have
created them. Listeners must sign proofs to register new targets, so
receivers have to accept `v: 2`.

Companion kgoose change: squareup/cash-server#136794 signs registrations
with the agent key. The kgoose receiver in squareup/cash-server#136004
currently accepts only `v: 1` and bodies up to 4 KB, so it needs to
accept v2 and a larger body.

## Testing

- Unit tests:
- proof verification (forged signer, wrong kind, stale or future, other
or ambiguous listener, other or missing endpoint);
  - signed-only request bodies;
  - delivery payload carries a verifiable event;
- unreadable or failed loads retry, then fail, without an HTTP request.
- Postgres tests:
- every access predicate (open, member and non-member private,
channel-less, closed community with target or owner membership, owner
ban, removed member, deleted channel or event, removed registration);
- the route test (signed registration accepted; a proof for another
listener and the unsigned body both rejected).
- Mutation runs confirmed that the membership, owner, ban and
reader-visibility guards each fail a test when removed.
- Local relay exercise with a fake listener:
- signed registration returned 200; unsigned, stale and wrong-listener
proofs returned 400;
- open-channel mentions delivered v2 with an event whose id and
signature verify independently;
- a private-channel mention of a non-member retried without delivering,
then delivered once the target was added.

🤖 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-14 17:53:25 -07:00
Will Pfleger
e645918c63 fix(relay): match pushed-down tags by position, not containment (#8335)
🤖 The relay pushes several tag filters into SQL as JSONB containment
(`tags @> [[name, value]]`). Containment ignores element position, so a
tag like `["x","e",<id>]` or `["meta","t",v]` satisfies `#e:[<id>]` or
`#t:[v]`, and `#t:["t"]` matches `["t","other"]`. Because the predicate
runs before `LIMIT`, a newer event that only matches by containment can
take a `limit: 1` slot; the exact NIP-01 post-filter then drops it and
the real match is never returned. Where no post-filter follows, the
decoy is returned or counted outright.

`push_exact_tag_match` in `buzz-db/src/store/event.rs` keeps containment
as the GIN-indexed prefilter (one array-bound `tags @> ANY(...)`, so the
e-tag planner shape is unchanged) and adds a positional `EXISTS (...
tag->>0 = name AND tag->>1 = ANY(values))`. It now backs every
`EventQuery` tag pushdown:

| Pushdown | Before | Effect of over-match |
|---|---|---|
| `#e` (`e_tags`, query and COUNT) | containment | REQ `limit` slot
theft; COUNT has no post-filter and counted decoys; thread aux closure
returned decoy reactions/replies |
| artifact `#d` (`d_tag_values`) | containment | `limit` slot theft on
NIP-AR identity lookups |
| `custom_tag` (bridge `#buzz-channel`) | containment | no post-filter:
a decoy project/repository was returned for its channel |
| shared-gated visibility (`shared_gated_reader`) | containment of
`["shared","true"]` | a private event tagged `["x","shared","true"]`
took a page slot before `event_visible_to_reader` dropped it, so the
page came back short. Never a visibility leak: the post-filter stays in
place |

**Bridge `/count` honors `#buzz-channel`.** `POST /query` scoped
project/repository reads by `#buzz-channel`, but `POST /count` ignored
it, so a count for one channel counted matching projects in every
channel the reader can see. `/count` now sets the same `custom_tag` on
both its channel-scoped and access-scoped paths, and the COUNT SQL
builder pushes `custom_tag` down exactly like the query builder, so
`/count` and `/query` select the same events on both the fast count and
the post-filtered fallback.

Left unchanged: the retention-free purge predicate for mesh status
(`tags @> [["k","buzz-mesh-status"]]`). It mirrors the migration 0019
trigger, has no `LIMIT`, and only affects how the author's own deleted
event is purged. REQ `#t` outside group-state kinds is still
post-filtered only.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-15 00:37:11 +00:00
Logan Johnson
9ade5d1ad1 feat(mobile): write catch-up and visible-message read marks like the web app (#8106)
🤖
## Summary
When you read a channel or thread on the phone, the web and desktop app
(buzz-app) should show it as read too, and the reverse. buzz-app no
longer saves a "read" mark for each ordinary message. It saves one
"caught up to here" mark per channel and per thread, and keeps
per-message marks only for mentions, DMs, broadcasts and replies. Mobile
already reads those marks. This PR makes mobile write them, so reading
on the phone clears unread on other devices and saved read state stays
small.

What changes on the phone:
- Reaching the bottom of a channel, and staying there for 300 ms, marks
it caught up. Reaching the end of a thread does the same for that
thread.
- A message you see fully on screen, away from the bottom, is marked
read on its own.
- Mentions, DMs, broadcasts and thread replies you see keep their own
read marks, as in buzz-app.
- Automatic reading never marks the whole channel read. Only the
explicit **Mark read** action does that. DMs and forums are the
exception: opening one marks it all read, as mobile does today.
- Reaching the bottom of a channel you marked unread ends that manual
unread. Unseen mentions and replies stay unread.
- Activity rows clear when you have read every message in the row, not
just the newest.


### Related issue
None found. buzz-app makes the same DM and manual-unread changes in
https://github.com/block/buzz-app/pull/616.

### Testing
- Unit tests cover the new write rules, Activity rows and the size limit
on saved read state.
- Widget tests cover reading on the channel page and the thread page,
opening a DM, and reaching the bottom of a channel you marked unread.
- No visual changes.

## Details
**Read marks.** "Caught up" is saved as `activity:<channel>` for a
channel and `thread-activity:<root>` for a thread. A caught-up mark
reads only ordinary top-level messages (channel) or that thread's
replies (thread). A per-message mark is `msg:<id>`.

**Caught-up time.** The mark is set to the time of the bottom row, and
never later than the current time. The time of a newer reply is not
used. So a top-level message that arrives late, with an earlier
timestamp, stays unread. A clock that runs fast, on this phone or the
sender's, cannot mark messages read before they arrive.

**Nested threads.** A thread opened on a nested reply shows only one
branch. Reaching its end writes per-message marks only, so unread
messages in other branches stay unread.

**Reading delay.** The 300 ms reading delay now restarts only when
messages arrive or leave. Before, any page rebuild restarted it, for
example a typing indicator.

**Activity rows.** A row that groups several events is done only when
each event is read. Each event is checked against the channel mark, its
own `msg:` mark, and its thread marks if it is a reply. Opening a row
goes to the oldest unread event.

**Publishing.** Mobile no longer republishes per-message marks it got
from other devices. buzz-app removes those once a caught-up mark covers
them. Broad marks (channel, thread, caught-up) are still republished, so
they stay available after the fetch window. The published blob is capped
at buzz-app's 40 KiB limit. Channel marks are kept first, then thread
marks, then caught-up marks, then the newest per-message marks. Before,
a blob over the encryption limit (about 64 KiB) turned off sync for the
rest of the session.

**DMs and manual unread.** Opening a DM reads it through the newest
loaded message, replies included, as mobile does today. Reaching the
bottom of a channel ends a manual unread. Manual unread on mobile is
local to this session, so ending it does not change what other devices
see.

**Compatibility.** Older desktop builds read only channel and
per-message marks. They may show extra unread for channels read on the
phone.

**Follow-ups, not in this PR**
- Unread catch-up fetches start after the channel mark and are capped at
1,000 events. In a busy channel, an old unread mention can drop out of
the badge. The fetch should also start from the caught-up mark, or load
more pages.
- Each mark triggers its own save and rebuild. They could be batched.
- Broadcast replies are counted slightly differently from buzz-app.
- buzz-app can also move its caught-up mark past the current time.

---------

Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-14 17:04:57 -07:00
Logan Johnson
71d7aa0436 feat(relay): add the system channel type (#8180)
🤖
## Summary
Buzz uses channel membership to decide who can read what, and every
channel a person belongs to shows up in their client as a chat. So there
is no way to make a group that exists only for access control, for
example "only this agent and its owner can read the agent's config".
Such a channel would appear in both people's sidebars, search results
and unread counts.

This stack adds a **system channel**: an ordinary channel that queries
leave out unless they ask for it. This first PR adds the type itself.

- `kind:9007` (create channel) with `["channel_type", "system"]` creates
a system channel.
- It is private unless the creator sends `["visibility", "open"]`.
- While it is private, its channel metadata event (kind:39000) carries
the NIP-29 `hidden` marker, which means "hide this group's metadata from
non-members". Until now the relay set `hidden` only for DMs.

On its own this PR does not yet hide system channels from queries. Part
2 does that.

Part 1 of 3, stacked on #8176 (the channel labels and creator lookup
stack, which this feature uses).

### Related issue
None found.

### Details
- Migration 0062 adds `system` to the `channel_type` Postgres enum. No
new columns.
- `buzz_core::channel::ChannelType::System`, and `system` is now a
reserved label name (labels cannot equal a channel type name).
- `buzz-admin` reconcile writes `hidden` with the same rule as the
relay. Usage metrics count system channels.
- **Rollback.** A relay older than this change cannot read `channel_type
= 'system'`. Do not roll back past it while system channels exist.
- **Spec.** NIP-CL gets a "System channels" section. `ARCHITECTURE.md`
lists the `System` type.

### Testing
- Relay test: a system channel is private and `hidden` by default;
opening it removes `hidden`; its first `t` tag is `system`.
- Migration embed and usage-metrics tests.

### 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 ← this PR
5. #8181 leave system channels out of reads that do not name them
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-14 19:03:34 -04:00
Logan Johnson
a3909acc67 feat(relay): match label and creator filters before the page limit (#8176)
🤖
## Summary
After parts 1 and 2, a client can ask for "channels with label X" or
"channels created by key K". But the relay applied these tag filters
after it read one page of the newest channel events. So a request such
as `{kinds:[39000], #t:["workspace"], limit:10}` could return fewer than
10 channels, or none, while matching channels existed. This PR moves the
label and creator match into the database query, before the page limit.

Part 3 of 3, stacked on part 2.

### Related issue
None found.

### Details
- Applies only when every kind in the filter is a relay-signed
channel-state kind (39000–39003). Other kinds keep today's behavior.
- The database match uses JSONB containment on `tags`, which is
GIN-indexed. Containment can match a little too much (for example, a tag
that contains both strings in another position), never too little. So
the relay still checks each returned event with the normal Nostr filter
rules, and `COUNT` requests keep that check too.
- The same match is applied to the query path and the count path.
- **Spec.** NIP-CL now says that `#t` and `#P` match before `limit`.
- **Choosing one channel.** The relay does not make labels unique, so
two creates for one label can both succeed, for example when two
processes of one agent start at the same time. NIP-CL gets a "Choosing
one channel" section: a client that needs one channel per creator and
label looks it up with `#P` + `#t`, creates it only if none exists, and
otherwise uses the oldest one (the lowest `created_at` tag, then the
lowest channel ID). Every reader that follows this rule picks the same
channel, even after an edit.

### Testing
- Relay test: many channels, one match, small `limit`. The request
returns the match for both `#t` and `#P`. This test fails without the
change.
- Unit test: `#t` and `#P` reach the database query only for
channel-state kinds.
- Relay test: one creator sends two creates for the same label at once.
Both channels exist, a `#P` + `#t` lookup finds both, and the
oldest-channel rule picks the same one in either order, even after the
older channel's metadata is re-published by an edit.

### 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 ← this PR

**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

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-14 19:03:34 -04:00
Logan Johnson
2f79374f48 feat(relay): channel labels (#8175)
🤖
## Summary
A plugin or agent often needs to tag its own channels and then find them
again, for example "my attention-config channel" or "all channels of
this workspace". Today a channel has no labels: the relay ignores any
`t` tags in create or edit requests. This PR adds channel labels.

- A create request can carry up to 8 labels as `["t", "<label>"]` tags.
- An edit request with `t` tags replaces the label set. `["t", ""]`
alone clears it, the same convention as `["ttl", ""]`. Only owners and
admins can change labels, as for the name.
- The relay publishes each label as a `t` tag on the channel's state
events, after the channel type tag.

Part 2 of 3, stacked on part 1.

### Related issue
None found.

### Details
- **Rules.** A label uses 1–64 characters from `a-z`, `0-9`, `.`, `:`
and `-`. The relay refuses a label equal to a channel type name
(`stream`, `forum`, `dm`, `workflow`, `system`), so a label cannot make
a channel look like another type.
- **No trust from labels.** Any admin can set a label. Only the
relay-written creator tag (part 1) says who made a channel.
- **Storage.** New migration `0061_channel_labels.sql` adds
`channels.labels TEXT[] NOT NULL DEFAULT '{}'`. Channels without labels
look and behave as before.
- **Readers.** The CLI and `buzz-acp` read the channel type from the
first `t` tag. Before this PR they used the last one, which would now be
a label.
- **Spec.** NIP-CL gets a "Channel labels" section with these rules.

### Testing
- Relay tests: labels at creation appear after the type tag, edit
replaces and clears them, invalid labels are refused, and members who
are not admins cannot change them.
- Unit tests for the label rules: allowed characters, length, reserved
type names, the 8-label cap and the clear marker.
- `buzz-acp` test: the channel type comes from the first `t` tag, not a
label.

### 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 ← this PR
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

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
mobile-v0.22.0-rc.2
2026-10-14 17:25:46 -04:00
Will Pfleger
b3795236cc fix(relay): reject unreadable write targets as missing (#8249)
🤖 A sender who cannot use or read a referenced event, artifact or
workflow now gets exactly the response it would get for an ID that does
not exist. #8197 (merged) set this rule for deletions; this applies it
to the other writes that look up a target, plus the workflow read and
webhook endpoints. Artifact `create` and workflow definition saves are
the exceptions, described below. Access is checked before any message
that depends on the target, readability goes through
`event_visible_to_reader` (including #8197's private-deletion rule), and
soft-deleted targets keep matching missing ones.

| Path | Checks, in order | Response for any unusable target |
|---|---|---|
| Reaction (kind 7) | target lookup → `event_visible_to_reader` → every
channel gate for the derived channel, including archived | `invalid:
reaction target event not found` |
| Edit (kind 40003) | target in the edit's `h` channel and readable →
author / agent-owner check, including an author since removed from a
private channel | `invalid: edit target not found or not editable by
you` |
| Forum vote (kind 45002) | target in the vote's `h` channel and
readable → kind check | `invalid: vote target event not found` |
| Report by event (kind 1984 `e`) | `event_visible_to_reader` → token
channel scope → channel membership or open | `invalid: report target
event not found` |
| Reply (NIP-10 and workflow `send_message` replies) | parent in the
reply's channel and readable | `reply parent not found` |
| Artifact update / delete / restore / move | head exists and is homed
in `h` (move: in the source the sender can write) → revision, type,
deleted and root checks | `conflict: artifact head unavailable` |
| Workflow trigger (kind 46020) | workflow exists and sender is its
owner | `invalid: workflow not found or not triggerable by you` |
| `GET /workflows/{id}/runs` and `/runs/{run_id}/approvals` | workflow
exists, has a channel, and the caller can read it | `404
{"error":"workflow not found"}` |
| `POST /hooks/{id}` | workflow exists and the provided secret matches
its stored secret → definition and trigger-type checks | `404
{"error":"workflow not found"}` |

Two of these reject writes that used to be accepted: a reaction to an
event the sender cannot read (such as someone else's author-only event),
and a report on an event the reporter cannot read. Reports by pubkey and
by blob are unchanged. In an archived channel, writes whose channel
comes from their target (reactions and deletions) get that target's
not-found response, and an artifact move out of an archived source
channel gets `conflict: artifact head unavailable` like any other source
the sender cannot write; every other write keeps `invalid: channel is
archived`. A webhook call with an absent or wrong secret, or to a
workflow without a webhook secret, now gets `404` instead of
`401`/`400`. Artifact `create` and workflow definition saves still
report a taken ID, as NIP-AR allows for artifacts.

Removed messages, none of which any client depends on from these
endpoints: `edit target event not found`, `must be event author to
edit`, `target event belongs to a different channel`, `target event has
no channel`, `parent event belongs to a different channel`, `parent
event has no channel association`, `artifact home changed`, `workflow is
not accessible`, `workflow is not channel-scoped`, the webhook's
`authentication failed` and `webhook secret required but not
configured`, `forbidden: not authorized to trigger this workflow` for
non-owners (it remains for an owner whose channel authority lapsed), and
`restricted: not a channel member` for an edit by an author removed from
a private channel.

`e2e_existence_oracle` checks each event write over HTTP and WS, and the
workflow read and webhook endpoints over HTTP, against missing,
soft-deleted, other-channel, private-channel, archived, author-only and
wrong-kind targets as applicable, with a positive control per path. It
runs in the Backend Integration job alongside
`e2e_author_only_deletion`. `removed_author_edit_does_not_reveal_target`
checks that a removed author's edits of their remaining message, their
deleted message and a missing ID get identical answers. In the
PostgreSQL suite, `head_outside_authorized_channel_matches_missing_head`
covers the artifact transaction,
`workflow_reply_to_unusable_parent_matches_missing` covers workflow
replies through `RelayActionSink::send_message`,
`scoped_token_report_does_not_reveal_target` and
`scoped_token_reaction_does_not_reveal_target` cover channel-scoped
tokens, and `channelless_workflow_runs_read_as_missing` covers the runs
read for a workflow with no channel.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-14 17:18:38 -04:00
klopez4212
a83ea69b72 fix(mobile): replay native navigation state when returning Home (#8314)
Restore the iOS navigation bar after any covering route pops, including
opaque custom Settings and community routes. Use the shared app
RouteObserver and didPopNext, then replay scroll depth and request
native layout after Flutter paints the uncovered view. Voice recording
retains the same observer through its existing alias.

Regression coverage uses the real HomePage with both MaterialPageRoute
and opaque PageRouteBuilder, at scrolled and top-of-list positions. The
custom-route case fails before this change.

Validation: 107 focused tests and analysis passed. Full mobile runs
stalled under local disk pressure, so full-suite validation is
incomplete. On-device verification of this follow-up remains pending.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-14 16:37:59 -04:00
klopez4212
231a469de5 Refresh mobile message bubbles, reactions, and activity cards (#8281)
## Summary

Refresh mobile conversations with left-aligned grouped bubbles without
tails, tighter consecutive-message spacing, distinct sender colors,
theme-aware incoming backgrounds, and compact overlapping reaction
trays. Attach reply counts beside reactions with up to three participant
avatars; keep outgoing mentions and links readable in both themes.
Coordinate a 500 ms composer-to-message transition with spring
contraction and an anchored avatar. Message ownership follows the
signing identity so profile reloads cannot recolor outgoing bubbles.

Center channel activities without timestamps or icons, keep names
tappable, and show two affected members plus a **See all** sheet. Hide
legacy purpose activities, match media corners to bubble groups, and add
iOS status-bar scroll-to-top. This is an independent mobile PR; no
desktop changes or color picker.

## Validation

- Full `just mobile-check` passed; `just mobile-test` passed 3,275 tests
with 4 skips, plus 3 unconfigured-gateway checks.
- Fixed message-widget lint warnings and a stale notification fixture
that blocked analysis. Made the video-controller disposal test wait for
lifecycle events instead of fixed delays. Mobile pre-push checks are
enabled.
- Physical iPhone previews were installed throughout development. The
latest bubble, reply-avatar, contrast, and spacing updates were
installed and launched on the physical iPhone. Final motion and
status-bar behavior still need device visual verification; Android has
automated coverage but no physical-device verification.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-14 16:35:26 -04:00
Tom Brow
7b9551aba8 fix(ios): open push replies in their containing thread (#8294)
Tapping a push notification for a thread reply currently opens the
channel at its parent. Preserve the verified event's direct-parent
thread ID through the iOS notification payload and Flutter bridge so the
tap opens the containing thread and highlights that exact reply, even
when newer replies have arrived. This matches Inbox's destination
selection for nested replies; broadcast replies still open in the
channel.

Closest related work: https://github.com/block/buzz/pull/6159 (original
push-tap routing).

### Validation

- Follow-up
[5b70e1a](5b70e1a5cb)
selects the last valid 64-character ASCII-hex reply marker, matching
relay ingest. Regression tests failed before the fix; all 80 XCTest and
47 Swift Testing tests passed afterward with Xcode 26.5. [Current-head
CI](https://github.com/block/buzz/actions/runs/38607128406) passed,
including Flutter tests, Android build, native package tests, complete
iOS simulator and unsigned release builds, and native accessibility
tests. The [current-head security
review](https://github.com/block/buzz/pull/8294#issuecomment-6174205374)
reports no findings. Physical evidence below predates this
validation-only follow-up.

- [Baseline CI on
f465f91](https://github.com/block/buzz/actions/runs/38436936153) passed,
including Flutter and native package tests, Android build, complete iOS
simulator and unsigned release builds, and native accessibility tests.
- Full local validation passed on
[f465f91](f465f9196f),
including 3,215 Flutter tests (four skipped) and three unconfigured-push
tests. The 49 focused navigation tests also passed after the baseline
merge.
- Added coverage for cold/warm bridge delivery, exact reply highlighting
with newer arrivals, direct-parent and broadcast destinations, and
malformed optional thread IDs. The Codex finding for an empty reply
marker is fixed and resolved.
- Physical acceptance passed on the managed iPhone 12 mini using signed
Buzz Dev 10140, built from f465f91 with SDK 26.5. Real push taps and
Inbox opened the same containing thread and highlighted the exact reply.
An older alert retained its target after a newer reply arrived; a
retained alert also worked after terminating the app. Top-level push
navigation reached the channel.
- Installed in place with the existing identity, app data and pairing
retained. No key reset or rotation was performed. Notifications were
restored to off and device control released. Xcode 27 hits an unchanged
dependency compiler error; the matching Xcode 26.5 toolchain built and
signed this tested candidate successfully.

### Physical acceptance

| Warm push: older reply A | Inbox: reply B | Cold push: reply B |
| --- | --- | --- |
| ![Older reply A highlighted with newer B
visible](https://raw.githubusercontent.com/block/buzz/87eb00d209cf0b0dd3f4146e3219b15884133b9d/pr-8294--warm-older-reply.png)
| ![Inbox highlights reply
B](https://raw.githubusercontent.com/block/buzz/87eb00d209cf0b0dd3f4146e3219b15884133b9d/pr-8294--inbox-exact-reply.png)
| ![Cold push highlights reply
B](https://raw.githubusercontent.com/block/buzz/87eb00d209cf0b0dd3f4146e3219b15884133b9d/pr-8294--cold-push-exact-reply.png)
|

[Physical test
details](https://github.com/block/buzz/pull/8294#issuecomment-6151991840).

### Before and after

Synthetic Flutter widget captures with a newer reply already present.

| Before: channel at outer parent | After: exact reply in containing
thread |
| --- | --- |
|
![Before](https://raw.githubusercontent.com/block/buzz/d9a0ff6c7a5ea481b04cb9c61d301553b4c23c10/pr-8294--before.png)
|
![After](https://raw.githubusercontent.com/block/buzz/d9a0ff6c7a5ea481b04cb9c61d301553b4c23c10/pr-8294--after.png)
|

After merge, repeat warm/cold push versus Inbox in the integrated mobile
candidate, including older alerts, top-level and broadcast
notifications.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-14 12:35:58 -07:00
Logan Johnson
ea24a3ee3d fix(ci): remove Detect Changed Paths timeout and deletion test race (#8300)
🤖
## Summary
This PR removes two CI flakes that failed relay-stack runs on
2026-10-13. Both passed when rerun.

- **Detect Changed Paths timeout.** This first CI job had a 2-minute
limit. Its steps take about 75 seconds, but GitHub sometimes finishes
the job minutes after the last step. In three runs, every step had
finished, but GitHub still cancelled the job at the limit, and all later
jobs failed. The limit is now 10 minutes.
- **PostgreSQL deletion test.**
`approval_boundary_blocks_claim_until_exact_inventory_is_approved`
sometimes could not claim an approved deletion request. This was a race
in the test, not a product bug.

How the test race happens:
- The test sends an UPDATE that the database must reject. Then it claims
the same row. PostgreSQL sends the error to the client before it rolls
back the statement and releases its row lock. sqlx returns as soon as it
gets the error.
- The claim runs on another pooled connection with `FOR UPDATE ... SKIP
LOCKED`. It can skip the row while the lock is still held, and return no
claim.
- The test now runs the rejected UPDATE on its own connection. Then it
sends `SELECT 1` on that connection before it claims. That round trip
completes only after PostgreSQL has finished the rollback.

Production code does not change. A claim that skips a row locked by
another transaction is correct behavior. I checked the other tests in
this file. Only this test has a rejected write on the request row
immediately before a `SKIP LOCKED` claim. In the other tests, a blocking
`approve` call runs before the claim.

### Related issue
N/A. No existing PR found. Failing runs: [timeout
#8174](https://github.com/block/buzz/actions/runs/38468285648/job/115540525751),
[timeout
#8176](https://github.com/block/buzz/actions/runs/38468321774/job/115541550557),
[timeout
#8182](https://github.com/block/buzz/actions/runs/38468329504/job/115541197994),
[deletion test
#8182](https://github.com/block/buzz/actions/runs/38468329504/job/115550003726).

### Testing
- I confirmed the order in the PostgreSQL 17 source: `EmitErrorReport()`
(which flushes to the client) runs before `AbortCurrentTransaction()` in
`tcop/postgres.c`. A BEFORE UPDATE trigger locks the row first
(`GetTupleForTrigger` in `commands/trigger.c`).
- I could not reproduce the failure locally: 0 failures in 410 runs,
including runs on a CPU-limited PostgreSQL container. The fixed test
passes against PostgreSQL 17.

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-14 15:31:05 -04:00
Ian Oberst
2270b66e14 test(buzz-db): drop partition scratch schemas on panic (#8293)
## Summary

The partition Postgres tests dropped their scratch schema with a
`drop_schema` call at the end of each test body. When a test panicked,
that call never ran, so the schema leaked into the shared database,
along with any `{schema}_*` sibling schemas or reader role the test
created. A reviewer on #7993 hit this locally: the leaked schema plus a
database-wide catalog query made later runs fail. CI hides it because
the test wrapper gives each process its own database.

`scratch_pool` / `scratch_pool_with_max_connections` now return `(pool,
ScratchSchema)`. The guard owns the admin pool and schema name and
cleans up on `Drop`:

- **Fresh thread and connection.** `Drop` runs on the test's runtime
thread, which can't drive the test's pools while it blocks, so cleanup
runs on its own thread with its own runtime and connection.
- **Terminates the test's backends first.** Both pools record each
connection's backend pid. A test that panics mid-transaction keeps its
locks, because the rollback would only run on the blocked runtime.
Without this step `DROP SCHEMA` waits on those locks.
- **Drops `{name}` plus every schema and role named `{name}_*`.** This
replaces the per-test sibling-schema and `DROP ROLE` cleanup. The reader
role is now named `{schema}_reader`.
- **Bounded.** `lock_timeout = '10s'`. Failures print a warning and
never panic inside `Drop`.

The 52 call sites are migrated mechanically. Test-only change.

### Related issue

Follow-up from review on #7993. No existing issue found.

### Testing

- New `scratch_schema_is_dropped_when_a_test_panics_holding_a_lock`: the
test body creates a sibling schema, takes `ACCESS EXCLUSIVE` on a
scratch table in an open transaction, and panics. The test then asserts
no `{name}` / `{name}_*` schema remains.
- Proved it can fail: with the backend-termination step disabled,
cleanup hits the lock timeout and the test fails, reporting both leaked
schemas.
- Partition Postgres lane: `scripts/postgres-test-run.sh -p buzz-db
--lib -E 'test(partition)'`, 53/53 passed.
- The same filter run directly against the shared `buzz` database: 53/53
passed, then 0 `partition_audit_test_%` schemas and 0
`partition_audit_%` roles remained.
- `cargo clippy -p buzz-db --tests` clean. Pre-push hooks passed.

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

---------

Signed-off-by: Ian Oberst <ioberst@block.xyz>
Co-authored-by: Ian Oberst <ioberst@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:36:26 -07:00