Commit Graph

3395 Commits

Author SHA1 Message Date
ravarora2
3e9d2e90ce Add manual-only relay image publishing (#9303)
## Summary

Add **Manual relay image**, a manual-only GitHub Actions workflow that
builds and uploads a Buzz relay image from a chosen repository branch,
tag or full commit SHA. It does not wait for normal CI checks or require
the chosen source to be merged to `main`.

This PR changes only `.github/workflows/docker-manual.yml`. It adds no
scripts or runbook. The normal Docker release workflow and existing
staging-only workflow are unchanged.

### What the workflow does

1. Resolves `source_ref` to one exact source commit.
2. Builds the existing Dockerfile's `runtime` target on native AMD64 and
ARM64 runners.
3. Uploads each architecture to `ghcr.io/block/buzz` using the existing
`GITHUB_TOKEN`.
4. Publishes a unique `manual-<source-sha12>-<run-id>-<attempt>` tag
when both builds succeed.
5. Verifies that the published image contains exactly one runtime for
each architecture and that both runtime source labels match the selected
commit.
6. Reports the source SHA, registry tag and combined digest in the run
summary and a downloadable JSON artifact.

There are no push, pull-request or scheduled triggers. The workflow does
not run the test suite, wait for `ci.yml`, or require a successful CI
qualification. The source must still build successfully, and image
verification must succeed before the workflow reports success.

### Run from GitHub UI

1. Open [Buzz Actions](https://github.com/block/buzz/actions).
2. Select **Manual relay image**.
3. Click **Run workflow** and select `main` or the repository branch
containing the workflow definition you want to run.
4. Enter your block/buzz branch, tag or full SHA in **source_ref**.
Example: `fix-db-timeout`. The commit must be reachable from a
repository branch; PR refs and PR-only commits are rejected.
5. Click **Run workflow** and wait for both builds and **Publish and
verify the combined image** to succeed.
6. Open the run summary and copy its registry tag and digest.

**`source_ref` is the code you want to build.** The workflow branch
selects the build instructions and may be non-main; CLI `--ref` selects
that branch. Your source branch does not need to merge. This new
workflow appears in the UI once its definition is on the default branch.

### Run from CLI

You need GitHub CLI authenticated as a collaborator who can run
workflows. No repository checkout, laptop Docker setup, AWS login or
personal package-publishing token is required.

```bash
# First-time GitHub CLI login only; skip if already authenticated.
gh auth login --hostname github.com --web

# Replace this with your Buzz branch, tag or full commit SHA.
export BUZZ_SOURCE_REF='fix-db-timeout'
gh workflow run docker-manual.yml \
  --repo block/buzz --ref main \
  -f source_ref="$BUZZ_SOURCE_REF"

# Find YOUR new run; its title includes the selected source_ref.
gh run list --repo block/buzz --workflow docker-manual.yml \
  --event workflow_dispatch --limit 5

export BUZZ_RUN_ID='REPLACE_WITH_YOUR_RUN_ID'
gh run watch "$BUZZ_RUN_ID" --repo block/buzz --exit-status
gh run view "$BUZZ_RUN_ID" --repo block/buzz --web
```

### Source and workflow selection

Dispatch may use `main` or another branch in `block/buzz`. CLI `--ref`
selects the workflow definition; `-f source_ref=...` separately selects
the code to build. For example, `--ref feature/build-tooling -f
source_ref=fix-db-timeout` runs the feature branch's workflow against
the fix branch's code. The dispatcher is responsible for selecting
trusted workflow instructions.

The resolver has no package-write permission and runs no selected-source
scripts. It validates the input, fetches canonical branch histories, and
proves the selected commit is reachable from a repository branch before
either publishing job can start. A full SHA cannot bypass the PR-ref
restriction. Tags that point only to unreachable or PR-only commits are
rejected too.

Before this new workflow is registered on the default branch, its exact
definition can be tested through the existing workflow path on the
isolated test branch:

```bash
gh workflow run docker.yml --repo block/buzz \
  --ref codex/manual-relay-image-test -f source_ref=fix-db-timeout
```

This runs a fresh build of the selected repository source. It does not
modify the default branch's normal workflow.

### Image output and deployment

The summary reports an immutable reference such as
`ghcr.io/block/buzz@sha256:...`. The `manual-image-<run-id>-<attempt>`
artifact contains the same digest, source identity and per-architecture
digests. Keep the actual `manual-*` tag as deployment/version metadata
alongside the digest. No synthetic `sha-*` deployment tag or registry
alias is emitted.

The image registry is shared by consumers; the workflow does not select
staging or production. Downstream deployment configuration selects the
image digest and environment. This workflow does not edit deployment
manifests, sync Argo CD, restart workloads, overwrite normal release
tags, or upload directly to AWS ECR. Any downstream cache import and
deployment are separate operations.

Manual images do not receive the normal CI deployment-eligibility
attestation. They are not automatically eligible for canonical
promotion. The selected code and any skipped checks must be evaluated
before an explicit deployment.

### Testing

The earlier implementation completed a real two-architecture build and
publish in [run
42704887052](https://github.com/block/buzz/actions/runs/42704887052).
Its image was also verified in the downstream ECR cache.

The build-and-publish path passed [run
42713322329](https://github.com/block/buzz/actions/runs/42713322329) for
unmerged source `beda6eeaec8ca4a00231a05a079eb168d2a6aec2`. Both native
builds and combined-image verification succeeded. That run predates the
source restrictions added during review.

The current resolver and workflow-origin guard were executed directly
from this YAML against local Git fixtures: 19 source cases and 3 origin
cases passed. These include accepting internal branches/tags/ancestor
SHAs and rejecting PR refs, the raw SHA of a PR-only commit, unreachable
tags, malformed inputs and a foreign repository. Main and non-main
workflow refs are both accepted in block/buzz. Live resolution of a
block/buzz feature branch also passed. The exact image verifier passed
valid-image cases and rejected missing/duplicate platforms, wrong
architecture and wrong source. No deployment was started.

Full local `just ci` stopped at the existing mobile formatting check
(exit 65), with an unresolved `flutter_lints/flutter.yaml` include. The
repository-wide check is not green; no mobile source was changed by this
PR.

### Related issue

No matching issue found. The existing staging-dev workflow publishes to
a distinct staging-only package and accepts branches/tags; this workflow
adds general manual publication and raw commit selection.


<img width="1465" height="821" alt="Screenshot 2026-11-09 at 5 55 42 PM"
src="https://github.com/user-attachments/assets/6a78b31f-9646-4f19-b44a-116c16818331"
/>

---------

Signed-off-by: Ravneet Arora <rarora@squareup.com>
2026-11-09 19:10:25 -05:00
Tom Brow
402bd1897f fix(mobile): bump Flutter to 3.47.9 for Android TLS fix (#9309)
Flutter 3.47.7's bundled Dart TLS verifier on Android misses Android's
compromised-intermediate blocklist and, on Android 14+, reads the older
CA directory
([GHSA-frr6-h895-5pxp](https://github.com/dart-lang/sdk/security/advisories/GHSA-frr6-h895-5pxp)).
An on-path attacker can therefore impersonate the relay to Android
builds with a certificate chained to the compromised eMudhra
intermediates. Flutter 3.47.8+ ships the patched verifier. This bumps
the hermit pin to the latest patch, 3.47.9 (Dart 3.13.7), and raises the
SDK floor in `pubspec.yaml` and CONTRIBUTING to match.

Tracked in BSEC-29. iOS and macOS use Apple's certificate verification
and aren't affected by this advisory.

Patch-level bump within 3.47: no source, formatter or dependency changes
were needed; `pubspec.lock` only changes the Flutter floor. No visible
change, so no screenshots.

### Validation

- `flutter analyze`: no issues. `dart format`: no changes.
- Full mobile suite on 3.47.9: 4,596 passed, 67 skipped.
- Not done: an Android device run that repeats the eMudhra interception
test against a 3.47.9 build.

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

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
2026-11-09 23:23:06 +00:00
klopez4212
773849665e Serve video and poster previews for invite links (#9306)
Invite links currently return HTML without preview metadata. Add
server-rendered `og:video` and `og:image` tags for `/invite/<code>`, and
bundle the approved MP4 and wordmark-only poster from #9282 into the
relay image. Media URLs use the request’s resolved community host, with
only TLS posture from `RELAY_URL`. Unknown hosts and lookup failures
receive no metadata; previews stay generic and never redeem invites.

Validation: production web build, workspace Clippy, 52 router tests
(including real HTTP and MP4 range requests), metadata tests, and
independent agent review pass. `just ci` stops at unrelated mobile
dependency/format checks. The full relay suite was attempted but stopped
during database-dependent tests without local infrastructure. Broad
local Rust/desktop hooks were stopped for disk pressure; desktop/mobile
hooks also lack dependencies and were skipped to publish this draft.
Full-stack and native Messages playback remain unverified. The new
preview regressions run in both CI test-runner paths.

Rollout: merge and deploy the resulting relay image with its web bundle,
verify the invite HTML and both public media URLs, then share a fresh
invite in Messages. Full-stack and human validation remain outstanding.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-09 23:12:34 +00:00
Diem Nguyen
18f3e35413 docs: point the README to Buzz 1.0 in block/buzz-app (#9296)
The README doesn't mention Buzz 1.0, so anyone who lands here can't tell
that a newer app exists. The block/buzz-app README already links back
here ("Buzz 0.X is still available and supported in block/buzz"). This
adds the link the other way: a note at the top that Buzz 1.0 lives in
block/buzz-app and that this repository is the 0.X line, which is still
supported. It also says plugins are new in 1.0 and their API is a
preview, linking to buzz-app's plugin architecture doc. This repository
has no plugin API, so the note points there instead of repeating
buzz-app's stability details.

Docs only, a 4-line README change.

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

---------

Signed-off-by: Diem Nguyen <diem@squareup.com>
Co-authored-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 14:39:51 -08:00
Jemiah Westerman
5b1698ec24 fix(db): give the replica-fence probe its own pool and instrument it (#9297)
## Problem

The replica-fence probe (`replica_fence::run_probe`) commits a heartbeat
token every 500 ms. It borrowed its connection from the **serving writer
pool**, so under load each beat queued behind request traffic.

In a production deployment during a reconnect/replay storm:

-
`buzz_db_pool_acquire_duration_seconds{pool_role="writer",operation="maintenance"}`
went from ≤50 ms max to 2.7–3.0 s, which is the writer pool's acquire
timeout.
- `buzz_db_replica_heartbeat_age_seconds` went from about 0.47 s to
1.25–3.02 s, in the same 10 s buckets.
- Bounded reads then failed the freshness check
(`buzz_db_route_decision{decision="writer",reason="stale"}` went from
0–6 to 1.3k–6.4k per 10 s), so read traffic spilled onto the writer
exactly when it was saturated. Replica lag stayed at tens of
milliseconds the whole time.
- No `replica fence probe failed` warning fired, because each beat
eventually succeeded, just late. The probe had no span or metric of its
own (`maintenance` is shared by 15+ call sites), so this was only
visible indirectly.

## Change

- **Dedicated pool** (`replica_fence::connect_probe_pool`):
  - lazy, one connection, `application_name=buzz-replica-fence-probe`;
- same credentials and `max_lifetime` as the writer, so it follows
failover;
  - modelled on the push producer's pool.
- It doesn't run the writer pool's `after_connect`: the probe never
writes `events`, so no floor-guard arming is needed. Instead it installs
its own session timeouts:
- `lock_timeout` 1 s. While the probe waits for the exclusive floor
lock, new channel writes queue behind it, so failing a beat fast is
cheaper than holding the queue for the serving writer's 5 s budget.
Retained proofs stand.
- `statement_timeout` and `idle_in_transaction_session_timeout` 2 s. The
idle timeout reaps a stalled client that still holds the exclusive lock.
  - A 5 s client-side deadline bounds the whole probe.
  - Cost: one extra writer connection per relay process.
- The `Db::connect_writer_pool` doc now names this pool and the
push-enqueue pool as deliberate exceptions to its "every writer pool
uses this constructor" rule, and says why.
- **Spans** (via `datastore_span`, which also records
`buzz_db_operation_duration_seconds{operation=…}`):
- `replica_fence_probe` carries `acquire_ms`, `lock_ms`, `scan_ms`,
`heartbeat_ms`, `commit_ms`, `beat_gap_ms`, `token` and `error_kind`.
- Child spans `replica_fence_probe_acquire` and
`replica_fence_probe_lock` cover the two waits.
- **Logs:**
- Failure: `warn` with `error_kind` (`acquire_timeout`, `lock_timeout`,
`statement_timeout`, `deadline`, `masked_activity`, …),
`consecutive_failures`, `heartbeat_age_ms` and per-phase timings.
  - Recovery after failures: `info`.

- **Reader spill metric:** new `buzz_db_reader_spill_total{path,
reason}`. It counts only the routed reads the replica could have served
but the writer did. `buzz_db_route_decision{decision="writer"}` already
existed but merges three different causes under `reason="stale"` and
also counts configuration (`disabled`). Reasons:

  | Reason | Meaning |
  |---|---|
| `heartbeat_stale` | The newest beat is older than the bounded-read
budget. The probe is late, not the replica. |
| `heartbeat_missing` | No live proof: before the first beat, or nothing
within `FENCE_STALENESS`. |
| `fence_wall_behind` | The read's upper bound is newer than the fence
wall. |
| `replica_lag` | The session proved only an older entry than the read
needs. |
  | `reader_acquire_timeout` | Couldn't check out a reader connection. |
| `reader_proof_timeout`, `reader_token_behind`,
`reader_validation_error` | The reader couldn't prove the fence. |

`buzz_db_route_decision` keeps its labels and values unchanged for
existing dashboards. History reads rejected on a saturated reader stay
`decision="rejected"` and aren't counted as spills.
- **Heartbeat interval versus budget:**
- The problem: at 500 ms beats, a 1000 ms `BUZZ_REPLICA_READ_MAX_AGE_MS`
tolerates no missed beat. Just before each commit the newest entry is
already about one interval old, so a single late or failed beat puts
every bounded read over budget.
- The default beat interval stays at **500 ms**. Every beat takes the
deployment-wide exclusive floor lock that channel writes queue behind,
so beating faster costs more lock drains. The budget is what should
absorb a slow beat.
- New `min_read_budget(interval)` = interval + `PROBE_LOCK_TIMEOUT` (1
s) + 200 ms, which is **1700 ms** at the default. That covers one beat
that waits out the lock timeout, plus commit latency. `Db::new` warns at
startup when the configured budget is below it. A deployment running a
1000 ms budget will see this warning until the budget is raised.
- The interval is now configurable with
`BUZZ_REPLICA_FENCE_PROBE_INTERVAL_MS`. Unset means the 500 ms default.
Values outside 300–10000 ms fail startup. The 300 ms floor keeps the
120-entry proof ring covering more than the 30 s staleness gate, and the
ring assertion checks against that floor.

The proof ordering is unchanged: exclusive lock, then `S`, then activity
scan, then token commit last. `committed_at` is still captured before
the `UPDATE` round trip.

## Tests

- New `fence_probe_beats_while_serving_writer_pool_is_exhausted`: goes
through `Db::new` + `spawn_fence_probe`, holds the serving pool's only
connection, and requires two fresh beats and exactly one probe session.
It fails on `main`'s wiring, which I checked by temporarily reverting
the `spawn_fence_probe` change.
- New `cluster_global_probe_pool_bounds_the_floor_lock_wait`: a held
shared floor lock makes the probe fail with `lock_timeout` after about 1
s, the retained proof survives, and the next beat succeeds after
release.
- New `reader_spills_are_counted_by_cause`:
  - fresh heartbeat → no spill;
- late heartbeat → `heartbeat_stale`, with the legacy `stale` still
emitted;
  - past the staleness gate → `heartbeat_missing`;
  - routing disabled → no spill.
- Unit tests cover the log throttle and the interval/budget helpers. The
epoch-rotation test now runs through the one-connection probe pool.
- The `observability_source` source-policy test now asserts the probe
never takes a serving-writer `Maintenance` checkout and is spawned
through `connect_probe_pool`.
- Existing replica-fence and `spawn_fence_probe` tests pass unchanged,
apart from signature updates.

## Not done / follow-ups

- Locally: `buzz-db` unit tests and clippy (`-D warnings`) pass, and 38
replica-fence and routing Postgres tests pass on a fresh database. I
didn't run the full Postgres suite, because it needs the nextest
per-test-database wrapper, so CI's Postgres lane is the gate there.
- No local relay run yet. This needs a reader-configured relay, then
pool exhaustion to see the spans and logs end to end.
- The deployed `BUZZ_REPLICA_READ_MAX_AGE_MS` lives in deployment
config. Check it against the new startup warning.
- The `PROBE_DEADLINE` path has no test. It's only reachable through a
stall that no server-side timeout catches.

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

---------

Signed-off-by: Jemiah Westerman <jemiah@squareup.com>
Co-authored-by: Son of Anton <8e0ef2ab02d69a7ac089ef5eca90d1a3fbbdc546f6322dc34d0c4f3deecea7f4@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 17:06:53 -05:00
Luke Tornquist
dd9da3ac6d fix(relay): never close connections when cross-node fan-out falls behind (#9302)
## Summary

Cross-node fan-out no longer closes connections when it falls behind.

Before this change, an event closed its local recipients when:
- it was not delivered within 30 s of this node reading it from Redis,
- its worker queue was full,
- or the access gate still could not check a recipient by that deadline.

Dispatcher lag also closed every subscribed connection. A rate-limited
closer spread the closes out, at 1% of a node's connections per second
with a floor.

The trouble is that a database stall slows every node's deliveries at
once. So the closes landed on every node together. Each reconnect then
does AUTH, membership reads, NIP-11 and REST catch-up against the
database that was already slow. That turned a short stall into a
reconnect storm and bursts of 503s. On small nodes the closer's floor
dominated, so a node could close all of its connections within tens of
seconds. Review-Proven Rule 9 (design every client-driven path for the
whole fleet running it at once) is the reason: a server-forced close
starts a fleet-wide reconnect.

## What changes

Kept from #9195 and #9235:
- One dispatcher plus 16 workers, routed by `(community, topic)`. Each
channel stays in order and channels deliver concurrently.
- The bulk membership read in the access gate, and denying an event for
a channel that no longer exists.

Removed:
- The delivery deadline and `ChannelEvent.received_at`.
- The live fence (`fence_live_delivery`, `live_fenced`).
- `LagCloser` and its rate limit.
- The queue-full closes and the dispatcher-lag closes.
- The access-gate retry loop
(`buzz_multinode_fanout_gate_retries_total`).

Behavior now:
- A slow delivery holds only its own worker and arrives late.
- A full worker queue or dispatcher lag drops the event on this node.
The drop is counted in
`buzz_multinode_fanout_dropped_total{reason="queue_full"}` or
`buzz_multinode_fanout_lag_total`, and the affected artifact syncs get
`CLOSED "resync: live delivery lagged"` (NIP-AR Sync rule 5). This is
the same as before the closer existed.
- A recipient the access gate cannot check misses that event, and a sync
among them resyncs. Same as the local fan-out path.

Trade-off: a plain subscription that misses a dropped event is not told.
Its client may replay the event on its next reconnect, but not if later
deliveries moved its resume cursor past the event by more than its
replay slack (5 s for the ACP client). That was the behavior before
#9195. Queue-full and lag drops stay visible in the counters, so we can
see whether they actually happen before adding any recovery mechanism.

## What got simpler

Net −700 lines.
- `multinode_fanout.rs` drops from ~940 lines to ~350.
- `deliver_pubsub_event` loses its retry loop and goes back to the
shared `route_fanout_matches` path. The Redis path and the local path
once again apply the access gate the same way.
- `ConnectionManager` loses the fence flag and the split send helper.
`SubscriptionRegistry` loses `subscribed_connections`.

## Testing

- `handlers::multinode_fanout::tests`:
  - in-order delivery through the workers;
- a full queue resyncs only the matched syncs and closes nothing, and a
local echo is not counted as a drop;
  - dispatcher lag resyncs every sync and closes nothing;
- a recipient the gate cannot check misses the event with no retry,
stays connected, and still gets the next event.
- `handlers::event::tests` is unchanged apart from removing
`received_at`.
- Mutants killed: the queue-full resync, the lag resync and the
local-echo skip.

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

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 17:00:56 -05:00
Matt Toohey
85996fdff4 test: fix timing-dependent unit tests in buzz-cli, buzz-agent and buzz-admin (#9273)
## Summary
Split out of the `flakey-monday` flake-fix series. Three Rust unit tests
in `buzz-cli`, `buzz-agent` and `buzz-admin` depended on wall-clock
timing or on a production-sized pool budget and failed on loaded CI
runners. Each now waits on, or counts, the thing it actually asserts.

Commits (cherry-picked from `flakey-monday`, original messages kept):
- `test(cli): throttle the first desktop sign-in by count, not clock`
- `test(agent): wait for both responses in
steer_rejected_on_empty_prompt`
- `test(agent): pin steer_rejected_on_empty_prompt to the empty-prompt
guard`
- `test(admin): give the NIP-FI Postgres tests a test-lane pool budget`

### Root cause and fix per test


**`client::desktop_throttle_tests::a_throttled_sign_in_reconnects_with_fresh_evidence`**
(`crates/buzz-cli/src/client.rs`): failed once in CI with `conns` left
1, right 2.
- Root cause (confirmed): the fake desktop throttled a sign-in only when
`last.elapsed() < 300ms`, and `last` was captured before the provider
handshake and credential setup. When that setup took over 300 ms, the
first sign-in was signed straight away, nothing was throttled, the
client never reconnected, and the relay saw one connection.
- Fix: the fake desktop throttles exactly its first `sign_event` call
via an atomic counter, which is the scenario the test describes. The
relay's unsigned-socket deadline goes from 200 ms to 5 s, as in the
sibling deadline tests, so it no longer shapes the scenario. Assertions
are unchanged: two connections, only "c1" signed, a fresh assertion on
the reconnect.

**`steer_rejected_on_empty_prompt`**
(`crates/buzz-agent/tests/fake_llm.rs`): panicked with "empty steer
prompt was not rejected".
- Root cause (confirmed): `session/prompt` runs in a spawned task while
`_goose/unstable/session/steer` is answered inline from the stdin loop.
The fake LLM finishes the turn almost at once, so the prompt response
can arrive before the agent has read the steer. The receive loop broke
on the prompt response and never saw the steer's -32602.
- Fix: keep reading, bounded at 40 messages as before, until both the
steer and prompt responses have arrived in either order. The follow-up
commit also asserts `error.message == "steer: prompt must not be
empty"`, because `steer_session` returns -32602 for "no active run to
steer" and an `expectedRunId` mismatch too, which are exactly the
replies a steer gets when the turn ends first. The empty-prompt check
runs right after decoding, so the message is deterministic in both
orderings.


**`nip_fi::postgres_tests::{backfill_binds_only_bindable_hosts_and_reports_the_rest,
backfill_skips_a_community_being_deleted,
bind_refuses_hosts_outside_the_suffix_and_unbind_keeps_the_last_issuer}`**
(`crates/buzz-admin/src/nip_fi.rs`): `connect to test DB:
Sqlx(PoolTimedOut)` after about 3.5 s.
- Root cause (confirmed): the tests built their pool from
`DbConfig::default()`, sized for a warm relay pod (3 s acquire timeout,
two eager spare connections). In the first wave of the PostgreSQL lane
every nextest worker clones its database from the template at once, so a
first connection can take longer than 3 s.
- Fix: keep the production writer pool (`Db::new`, so the session policy
is still exercised) but set `min_connections` 0 and a 30 s acquire
timeout, the budget sqlx's `PgPool::connect` defaults give the other
PostgreSQL tests. `Db::connect_cold_start` uses the same values.

### Related issue
N/A (flake fixes; none found).

### Testing
Human testing is still required. This PR does not carry
`buzz-review-completed`; the author has not yet run these tests
themselves on this branch.

#### Verification
These changes were verified locally on the `flakey-monday` branch before
the split:
- buzz-cli throttle test: old code under CPU saturation (40 busy loops
on 32 cores) failed every iteration with `left: 1`, the CI failure. New
code, `desktop_throttle_tests` with `--test-threads=16`: 30/30 passed
unloaded, 15/15 under the same saturation.
- buzz-agent steer test: forced CI ordering (temporary 500 ms delay
before sending the steer) made the old test panic and the new test pass.
`fake_llm steer` tests with `--test-threads=16`: 30/30 passed. The
follow-up ran 100/100, and was falsified: with the empty-prompt guard
removed and the steer sent after the turn, the code check still passed
and the new message assertion failed.
- buzz-admin NIP-FI tests, against docker Postgres: the three tests, 10
iterations at `--test-threads=3`: 10/10. Behind a proxy delaying each
new connection by 4 s: the old config failed with the CI `PoolTimedOut`
at 3 s, the new config passed.

Note: `crates/buzz-cli/src/client.rs` also changed on `main` since the
series forked. The cherry-pick applied cleanly; only hunk offsets differ
from the original commit.

Nothing was re-run on this split branch: the workstation was overloaded,
so no tests, builds or linters were executed after cherry-picking. CI on
this PR is the verification for the split branch.

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

---------

Signed-off-by: mtoohey <mtoohey@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:40:29 +11:00
Matt Toohey
d2c3234cb9 fix(ci): classify hermit retry timeouts by watchdog kill, not whole seconds (#9274)
## Summary
Split out of the `flakey-monday` flake-fix series.
`scripts/ci-hermit-retry.sh` decided an attempt had "timed out" by
comparing whole-second elapsed time with the deadline, so an instant
failure that straddled a second boundary was misreported as a timeout.
The watchdog now reports through its exit status whether it actually
killed the attempt.

Commit (cherry-picked from `flakey-monday`, original message kept):
- `fix(ci): report a hermit retry timeout only when the watchdog fired`

### Root cause and fix

**node:test "persistent failure backs off between attempts and stops at
the budget"** (`scripts/ci-hermit-retry.test.mjs`): expected "attempt
3/3 failed (exit 7)" and got "attempt 3/3 timed out after 1s".
- Root cause (confirmed): the script checked `$((SECONDS - started)) -ge
deadline`. Bash's `$SECONDS` counts whole wall-clock seconds, and the
test uses a 1 s deadline (`CI_HERMIT_ATTEMPT_SECONDS=1`). An instant
`exit 7` that crossed a second boundary looked like it had run for the
whole deadline.
- Fix: the watchdog subshell exits 0 if it is stopped before signalling
the attempt, and 124 once it has sent TERM (or TERM then KILL). The
script reports a timeout only when the watchdog exits 124; elapsed time
is no longer used. Bash 3.2 (macOS) supports this. A new test starts 0.8
s into a wall-clock second and runs a 0.4 s attempt that exits 7, so it
crosses a second boundary but finishes well inside its 1 s deadline, and
asserts it is reported as "failed (exit 7)".

### Related issue
N/A (flake fix; none found).

### Testing
Human testing is still required. This PR does not carry
`buzz-review-completed`; the author has not yet run the script or its
tests themselves on this branch.

#### Verification
Verified locally on the `flakey-monday` branch before the split:
- The new test against the old script: failed with "attempt 1/1 timed
out after 1s", the CI symptom.
- The whole `ci-hermit-retry.test.mjs` suite with the fix: 20/20 passed.

Nothing was re-run on this split branch: the workstation was overloaded,
so no tests, builds or linters were executed after cherry-picking. CI on
this PR is the verification for the split branch.

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

Signed-off-by: mtoohey <mtoohey@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:40:05 +11:00
Matt Toohey
ea548da808 test(ios): wait for a key, foreground-active window before presenting in RunnerTests (#9275)
## Summary
Split out of the `flakey-monday` flake-fix series. Two iOS RunnerTests
acted right after `makeKeyAndVisible()` without waiting for the window
to become key in a foreground-active scene, which is the state the code
under test resolves its presenter from. A shared test helper now waits
for that state before the tests present anything.

Commits (cherry-picked from `flakey-monday`, original messages kept):
- `test(mobile): wait for a key window before presenting in iOS tests`
- `test(mobile): wait for a foreground-active scene in the key-window
helper`

**These Swift changes were NOT compiled or run locally.** The fixes were
written on a Linux workstation without Xcode or an iOS simulator, and
this split branch was not built either. iOS CI on this PR is the first
place they compile and run.

### Root cause and fix per test


**`RunnerTests.testRejectedCommunityEntryPresentationCompletesAndAllowsRetry`**
(`mobile/ios/RunnerTests/RunnerTests.swift`): timed out after 2 s
waiting for "Temporary native modal appeared".

**`IdentityFilePluginTests.testPickerResolvesPresenterAtRequestAndCancelsExactlyOnce`**
(`mobile/ios/RunnerTests/IdentityFilePluginTests.swift`): waited 5 s for
`parent.presentedViewController is UIDocumentPickerViewController`,
which never became true.
- Root cause (inferred from the code paths and CI symptoms; not
reproduced): `NativeCommunityEntryCoordinator` and
`IdentityFilePlugin.pick()` both resolve their presenter as the root of
the `isKeyWindow` window in a `.foregroundActive` scene.
`makeKeyAndVisible()` can return before key status moves to the new
window, so `pick()` presented on a different window's controller and the
expectation on `parent` could never be met. The community-entry test
also attached its window to the first connected scene rather than the
foreground-active one, swapped `rootViewController` and presented
immediately; UIKit silently drops a presentation from a controller whose
view is not yet in a window.
- Fix: a shared `XCTestCase` helper in `RunnerTests.swift`, internal so
`IdentityFilePluginTests` can use it without changing the Xcode project.
`keyWindow(showing:)` attaches a window to the `.foregroundActive`
scene, makes it key, and returns once a predicate expectation sees
`window.isKeyWindow` and the root's view in that window.
`keyWindowReady(_:showing:)` is that predicate; the community-entry test
waits on it again after swapping the root. The follow-up commit replaces
the `XCTUnwrap` of the foreground-active scene with the same
predicate-expectation wait (5 s budget), since the first test to run
could otherwise fail while its scene is still activating. The
community-entry test became `async`; its fixed 2 s presentation wait
became 5 s, the budget the picker test already uses. The readiness waits
are the fix, not the longer timeout. Assertions are unchanged.

### Related issue
N/A (flake fixes; none found).

### Testing
Human testing is still required. This PR does not carry
`buzz-review-completed`; the author has not yet run RunnerTests
themselves on this branch.

#### Verification
Not run locally at any point: Swift tests need Xcode and an iOS
simulator, which the authoring workstation lacked, and nothing was built
on this split branch either (the workstation was overloaded, so no
tests, builds or linters were executed after cherry-picking). The review
pass on `flakey-monday` confirmed the `keyWindowReady` shape compiles;
the follow-up follows that same shape. iOS CI on this PR is the
verification for both the fix and the split branch.

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

---------

Signed-off-by: mtoohey <mtoohey@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:39:12 +11:00
Matt Toohey
15c2ca1b01 test(desktop): wait on real readiness signals in audience and community-rail specs (#9277)
## Summary
Split out of the `flakey-monday` flake-fix series. Three Desktop E2E
tests in `persistent-agent-audience.spec.ts` and
`community-rail.spec.ts` acted on signals that could arrive before the
app state they implied (a logged command before the submit lock
rendered, a URL check that only held before a view transition landed).
They now wait on the readiness signals the product actually exposes. No
product code changes.

Commits (cherry-picked from `flakey-monday`, original messages kept):
- `test(desktop): wait for composer focus and send cycles in audience
specs`
- `test(desktop): assert the kept remembered route after the community
switch`
- `test(desktop): assert Home after repairing the remembered channel`

### Root cause and fix per test

**`a manual mention persists when automatic mentions are enabled`**
(`desktop/tests/e2e/persistent-agent-audience.spec.ts`):
`locator.fill("follow up")` failed because `message-input` was not
editable.
- Root cause (from the code and CI symptom; not reproduced locally): the
test sends with a 1.5 s mock send delay, during which the composer is
submit-locked and `contenteditable="false"`. The lock is React state and
renders a frame after Enter. The test's last two checks before the fill
could both pass before that frame: the outgoing-mention poll (the e2e
bridge logs a command when invoked, before the delay) and the
`contenteditable="true"` wait. Playwright's `fill` rejects a
`contenteditable="false"` element outright instead of waiting.
- Fix: after Enter, wait for the full send cycle: `data-submit-locked`
"true", `contenteditable` "false", `data-submit-locked` "false",
`contenteditable` "true". Only then assert on the editor. Existing
assertions are unchanged.

**`the mention setting is reachable and operable by keyboard`** (same
spec): the thread composer's mention autocomplete never became visible.
- Root cause (likely; not reproduced locally): opening a thread by URL
mounts both the channel composer and the thread composer.
`useComposerAutofocus` focuses whichever mounts first and TipTap defers
`focus()` by a frame. A click and fill that land before that deferred
focus lose it, and the focus-gated mention menu unmounts.
- Fix: a `readyThreadComposerInput()` helper waits for the thread editor
to be editable and for exactly one `message-input` to hold focus, so the
app's own autofocus has landed. Both tests use it.

**`community rail > does not repair a remembered channel until live
validation succeeds`** (`desktop/tests/e2e/community-rail.spec.ts`):
`expect(page).not.toHaveURL(/#\/channels\/general$/)` failed right after
the click on community B.
- Root cause (confirmed; 21/40 failures at 14 workers on the old spec):
`switchCommunity` runs inside a view transition whose callback writes
B's remembered channel into the hash and then activates B, a few frames
after the click returns. By design the URL is then `#/channels/general`,
which `AppShell` keeps until a live channel read succeeds and only then
repairs to Home. The inverted check from 631b05c88 passed only when its
first poll beat the view transition.
- Fix: wait for B's rail button to have `aria-current="true"` (switch
finished), then assert the URL is `#/channels/general`, the kept route.
After live validation repairs storage to Home, assert the Home route
`/#\/$/` (the follow-up commit; `not.toHaveURL(general)` would have
passed for any route, and `AppShell`'s repair calls `goHome({ replace:
true })`).

### Related issue
N/A (flake fixes; none found).

### Testing
Human testing is still required. This PR does not carry
`buzz-review-completed`; the author has not yet run these specs
themselves on this branch.

#### Verification
Verified locally on the `flakey-monday` branch before the split
(Playwright smoke project, e2e build):
- Audience spec: neither flake reproduced before the fix, even with CPU
throttled 4x and 8x. After the fix, both tests 40 repeats each at 10
workers: 80/80; whole spec: 35/35.
- Community-rail test: 40 repeats at 14 workers: 40/40; whole spec:
27/27 at 4 workers. The Home assertion was falsified: with the repair
temporarily sending the app to `#/channels/random`, the new assertion
failed and the previous `not.toHaveURL` check passed.
- The four desktop flake tests from the series together, 10 repeats each
at 8 workers: 40/40.
- Two untouched tests in these specs are pre-existing flakes a baseline
build fails as often, and are out of scope: community-rail "enters a
remembered channel before live validation completes" and
persistent-agent-audience "the mention button preserves manual
rementions after undo until explicitly readdressed".

Nothing was re-run on this split branch: the workstation was overloaded,
so no tests, builds or linters were executed after cherry-picking. CI on
this PR is the verification for the split branch.

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

---------

Signed-off-by: mtoohey <mtoohey@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:38:40 +11:00
Bradley Axen
2b16d0ea1a feat(relay): let an operator mint invites for a community owner or admin (#9265)
## Summary

Adds `POST /operator/communities/invites`, so a deployment's trusted
operator (its account service) can mint an invite link on behalf of a
community owner or admin. The website where people manage their
communities never holds the owner's own key, so it can't call `POST
/api/invites` (owner-signed NIP-98).

- **Trust model:** the same as
`/operator/communities/{archive,transfer}`. A configured operator signs
the request (NIP-98, payload-bound, replay-checked) and vouches only for
**which identity** its signed-in caller is bound to (`actor_pubkey`).
The relay decides that identity's authority from **its own roster at
mint time**: owner or admin only, the same check `/api/invites` makes.
Nothing the operator says about roles is trusted.
- **Same invite:** it mints through `db.mint_relay_invite`, with the
same lifecycle gate and restriction check, the same
`ttl_secs`/`max_uses` bounds and defaults (`validate_mint_request`), and
the same response (`minted_invite_json`, extracted from `/api/invites`).
The link is an ordinary `https://<host>/invite/<code>` that every
client's join flow already claims.
- Archived, deleted and deleting communities return 404
(`lookup_community_host` only resolves active ones).

Request body (unknown fields rejected):
```json
{ "community_id": "<uuid>", "actor_pubkey": "<64-hex>", "ttl_secs": 259200, "max_uses": 5 }
```

Errors keep the `/api/invites` text in `error` and add a stable `code`,
so the operator can tell its own misconfiguration from the actor's
refusal without matching on text:

| Status | `code` | When |
|---|---|---|
| 403 | `not_operator` | the signer is not a configured operator |
| 400 | `invalid_request` | bad JSON, an unknown field, a bad pubkey, or
out-of-range limits |
| 404 | `community_not_found` | unknown, archived, deleted or deleting
community |
| 403 | `not_owner` | the actor isn't an owner or admin in the relay's
roster |
| 403 | `restricted` | the actor is banned or restricted
(`invite_restricted`) |
| 503 | `unavailable` | community writes are paused |

This is an HTTP operator route rather than an event kind because it
belongs on the existing operator plane (operator-signed, outside the
event data plane), like the other operator community actions.

### Stack (invite links on the Builderlab Communities page)

1. **This PR**: the relay route. It ships first.
2. The account service endpoint that calls it (private repo). Depends on
this PR.
3. The website UI (private repo). Depends on 2.

### Related issue

None found.

### Testing

- New Postgres journey
`operator_mints_invites_only_for_relay_owners_and_admins` (run against a
freshly migrated local database):
- An owner mints with the defaults. The URL, null `max_uses` and the 72h
expiry match `/api/invites`.
- A joiner **claims the minted code through `POST /api/invites/claim`**
and becomes a member.
  - An admin mints with limits.
  - A banned admin is refused.
- A member and an outsider get 403 with the same refusal text as
`/api/invites`. The test pins each error's `code`.
- Out-of-range `ttl_secs` and `max_uses`, an unknown field, a bad pubkey
and a non-operator signer are all refused.
  - An unknown community and an archived community get 404.
- `cargo test -p buzz-relay --lib -- --include-ignored api::invites
api::operator`: 78 passed and 2 timed out under heavy machine load. Both
timeouts (`claim_follow_up_survives_a_client_disconnect`,
`temporary_listing_failure_is_recovered_for_each_admission_path`) also
pass on this branch when run on their own, as on `main`.
- `cargo fmt` and pre-push hooks (clippy) passed.
- **Not yet done:** a run against a local relay with `curl`, and a human
check.

---------

Signed-off-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Co-authored-by: drone03 <3efda7d0089450ac8094dd25cfa734546dc0d6bad0ab87b64dacfff867bdc4d3@buzz.block.builderlab.xyz>
Co-authored-by: Kalvin Chau <kalvin@block.xyz>
Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:23:56 -08:00
Salman Mohammed
66095e5598 fix: inline ARM64 atomics in Sprig image build (#9291)
## Summary

Sprig's ARM64 container fails to link because bundled D-Bus emits a call
to GCC's `__aarch64_ldadd4_sync` helper, which Rust's static musl link
does not supply. AMD64 succeeds, but the failed ARM64 job blocks
publication of the multi-architecture image.

Set the ARM64 musl C compiler flags to `-mno-outline-atomics` in the
image builder. This emits baseline ARM64 atomic instructions inline
instead of requiring the missing helper. Other targets and the runtime
image configuration are unchanged.

### Related issue

No duplicate fix found. [Failing
build](https://github.com/block/buzz/actions/runs/42667527024/job/129335071943).
The [previous main
build](https://github.com/block/buzz/actions/runs/42642040718) has the
same failure, before the MCP dependency update.

### Testing

The exact local ARM64 image build passed: `docker build --platform
linux/arm64 --file Dockerfile.sprig --tag buzz-sprig:arm64-link-fix .`.
The resulting `sprig --version` command passed. The `buzz-agent`
personality reached its expected missing-provider configuration error.
Broad local checks were not run.

Generated with Codex

Signed-off-by: Salman Mohammed <smohammed@squareup.com>
2026-11-09 15:38:41 -05:00
klopez4212
49511f02b6 fix(mobile): show observed thread replies in Activity (#9286)
## Summary

A reply in a thread you started, participated in, or followed can light
a mobile channel's attention dot without appearing in Activity. Activity
now includes eligible replies already observed by the channel unread
pipeline in **All** and **Threads**. Tapping a row uses the existing
oldest-unread reply and thread navigation.

Retain the reply payload alongside its bounded unread evidence, then
merge by event ID with the addressed feed. Mentions retain their
category and do not duplicate. Deletion, eviction, and account/community
resets share the same registry. This adds no relay queries or
subscriptions.

Coverage follows the existing channel unread catch-up and live
subscriptions. This does not introduce complete historical thread
discovery: replies outside that retained window, and replies not
eligible for the channel attention dot, are not newly fetched.

### Related issue

Related to #9271 (clearing channel rows) and closed #9186 (broader
Activity feed expansion). #9213 supplied the existing Activity filters.
This PR connects existing observed replies to those filters.

### Testing

- Full mobile suite, push-unconfigured tests, analysis, formatting and
file-size checks.
- Production-provider regression: cold catch-up, live reply, mention
deduplication, excluded unrelated/quiet replies, thread read markers,
deletion/replay and identity reset.
- Real Activity widget journey with fake relay/storage boundaries: show
the unmentioned reply, select Threads, tap the row, and verify the reply
ID and thread ID passed to the conversation page.
- Mutation check: removing reply-payload retention makes the widget
journey fail because the reply is absent.
- Independent agent source review: no blockers.

### Preview

Actual Flutter widgets at phone width with synthetic data and the new
reply wiring disabled/enabled. These are widget-test captures, not
installed-device or live-relay evidence.

| Before | After |
| --- | --- |
| ![Activity omits the unmentioned
reply](https://github.com/user-attachments/assets/a6fc8193-b1ef-4022-a5f1-a1cbbcdf5fb2)
| ![Activity shows the unmentioned thread
reply](https://github.com/user-attachments/assets/b2a6ba13-384a-4d0f-bda3-88d83307d31e)
|

### Human validation pending

Draft per the repository readiness checklist. On a device, receive an
unmentioned reply in a thread you started, replied in, or followed.
Check Activity → All and Threads, tap the row to reach the reply, then
read it and confirm Unread clears without reading unrelated channel
messages. Native/live-relay testing and human confirmation remain
pending.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 11:48:08 -08:00
klopez4212
2bbaa061d0 Enable mobile huddles with Codex agents (#9269)
Show the Huddle action in owned Codex agent DMs and invite the agent
before connecting audio. Revalidate eligibility on starts/recovery, ask
before inviting into an existing call, and distinguish pending
invitations from connected audio peers with actionable timeout feedback.
DM hangup leaves shared room cleanup to the relay so another owner
device stays connected.

Pairs with https://github.com/block/buzz-app/pull/3048. Desktop and the
Codex agent must remain running.

Validated: 4,559 mobile tests, analysis, formatting, push hooks, and
independent source review. Live phone-to-desktop audio and human trial
remain pending.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:19:43 +00:00
Blake McAnally
c1ce2ca800 fix(mobile): read mentions taller than the screen (#9243)
## Problem

Bug report (mobile 0.28.0 (46)): opening a mention from the Inbox in an
archived Solo chat leaves the Inbox row unread.

The video shows the cause. Each unread row is a long agent answer. On a
phone, the answer is taller than the screen, or it lands partly cut off
after the deep-link jump. Mobile automatic reading writes a `msg:` mark
only for a row that is fully visible for the 300 ms dwell. A mention row
is high priority, so `activity:<channel>` does not read it. The mention
can stay unread until the reader marks it read by hand.

The archived state is not the cause. A widget test shows that an
archived Solo chat writes read marks for short messages. Archived chats
make the problem easy to see, because there is no composer and the
reader only reads.

## Fix

A message counts as read only when it actually showed, for the 300 ms
dwell. A row counts when any of these holds:

- **It fits:** the whole row is inside the reading area (unchanged).
- **It fills the screen:** the row is taller than the reading area and
covers all of it. This applies to the channel timeline and the thread
view.
- **A link opened it:** the message a deep link or Inbox tap opened
counts once any part of it shows, because the jump places it at 35% and
a long one lands cut off.
- **It is the newest message at the live bottom:** its end is showing,
in a channel, a Solo chat (replies inline) or a thread. This covers a
long final answer that only ever shows its end.

Reaching the bottom does **not** read mentions or broadcasts above the
newest message that never came on screen. Desktop does that sweep
(block/buzz-app#1679). Mobile deliberately does not. Ordinary
`activity:` / `thread-activity:` catch-up is unchanged, and messages the
reader marked unread stay unread.

## Tests

- `reading_marks_test.dart`: the bottom does not read mentions above it;
the newest message is read at the live bottom (and respects manual
unread); a flat Solo timeline reads only its newest inline reply; a tall
thread tail reply is read at its end; DM catch-up does not mark
mentions; `isReadableRow` cases, including linked rows.
- `channel_detail_page_test.dart`: an archived Solo chat and a Solo
inline reply each read a tall newest mention at the bottom; a tall row
away from the bottom is read once it fills the screen; tall linked
messages (channel and thread) are read when the jump leaves them cut
off; in a thread, the tall last reply is read at the end and an earlier
tall reply is read once it fills the screen. These fail on `main`.
- `just mobile-check` and the full mobile suite pass.

## Human test steps

1. Get a long agent reply that mentions you in a Solo chat, then archive
the chat.
2. Open the row from the Inbox and hold still for about one second.
3. Go back. The row should now show as read, with no dot.

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

---------

Signed-off-by: Blake McAnally <bmcanally@squareup.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: BlakeBot <85bbd70443139fd5c5762e34c2e214240939eb243c37f98380f69fed3b3bb4a6@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Morgarita <6e967af659416160ab4f2fd56d74c8c6f273791ee7bfc9468a1be37c387947be@buzz.block.builderlab.xyz>
2026-11-09 11:13:55 -08:00
Matt Toohey
b082b1464c test(relay): give every media e2e upload its own blob hash (#9272)
## Summary
Split out of the `flakey-monday` flake-fix series. Two Relay E2E media
tests shared one deterministic blob hash across every test in their
binaries. In enforce mode (`BUZZ_MEDIA_READ_MODE=enforce`, on for the
Relay E2E lane since #9077), a rejected publish of that shared hash left
a sticky publication marker that made ranged GETs from the other tests
return 404. Every media e2e upload now has its own blob hash.

Commits (cherry-picked from `flakey-monday`, original messages kept):
- `test(media): give each video e2e test its own MP4 blob`
- `test(media): publish images of their own in the media e2e tests`

### Root cause and fix per test

**`test_video_range_request_206` / `test_video_range_request_416`**
(`crates/buzz-test-client/tests/e2e_media_video.rs`): a ranged GET
returned 404 instead of 206/416 in 9 of 55 Relay E2E jobs.
- Root cause (confirmed): `build_test_mp4()` was deterministic, so every
test in the binary uploaded the same sha256, and the tests run in
parallel against one relay. In enforce mode, `validate_media_references`
records a sticky `media_publications` marker for every hash an event
publishes, before dispatch, so a later rejection does not undo it.
`test_video_poster_imeta_rejects_video_as_poster` published the shared
hash and was then rejected by imeta validation. From then on
`media_access` gave an uploader with no readable reference `Restricted`,
served as 404, until the accepted poster test stored its event in an
open channel.
- Fix: `build_test_mp4()` appends a top-level `free` box holding a
random UUID, so each call yields a distinct, still valid fast-start MP4.
The upload validator allows `free` and does not inspect its payload. The
relay is unchanged: the pre-dispatch marker is the documented
fail-closed contract in `docs/operator-private-media.md`.

**`test_video_poster_imeta_accepted_via_ws`** and
**`e2e_media_extended::test_ws_valid_imeta`**: hardening, not a fix for
an observed failure. Both published a fixed 1x1 JPEG byte-identical to
`tiny_jpeg()` in `e2e_media.rs`, which `test_upload_and_get` reads back
through an uploader preview. That currently passes (a readable live
reference wins before the marker matters), but a publishing test that
shares a fixed blob with other media tests is one rejected or private
event away from the same flake. Both now publish an 8x8 PNG of random
pixels from a new `unique_png()` helper; `test_ws_valid_imeta`'s imeta
declares `image/png` and the real size; `tiny_jpeg()` is removed from
`e2e_media_video.rs`.

### Related issue
N/A (flake fixes; none found).

### Testing
Human testing is still required. This PR does not carry
`buzz-review-completed`; the author has not yet run these tests
themselves on this branch.

#### Verification
These changes were verified locally on the `flakey-monday` branch before
the split, against an enforce-mode relay with docker Postgres/MinIO:
- Old code, fresh enforce-mode relay: reject test then range tests
failed both range tests with 404 at the CI lines (reproduction).
- Whole `e2e_media_video` binary, `--test-threads=8`: 20/20 passed.
- Reject test then range tests against the polluted hash: 3/3 passed.
- The CI "Media read-auth e2e" command (`e2e_media`,
`e2e_media_extended`, `e2e_media_video`, `--ignored`), 5 back-to-back
runs on the same scratch database (the persistent-relay case): 5/5 runs,
40/40 tests each time.
- `cargo fmt --check` and `clippy --tests`: clean.

Nothing was re-run on this split branch: the workstation was overloaded,
so no tests, builds or linters were executed after cherry-picking. CI on
this PR is the verification for the split branch.

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

---------

Signed-off-by: mtoohey <mtoohey@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:56:58 -05:00
klopez4212
838976d6e4 Add invite preview video and fallback poster (#9282)
Adds a 16-second Buzz invite-preview video and wordmark-only poster,
matching onboarding colors and fuzzy texture. Includes a local
preview/poster picker and reproducible media export. This prepares
assets; live invite Open Graph metadata and relay deployment are
follow-up work.

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

Video:


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

Fallback poster:

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

---------

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

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

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

### Related issue

Fixes NEXUS-4336.

### Testing

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

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

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

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

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

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

### Related issue

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

### Testing

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

Generated with Codex

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

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

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

### Related issue

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

### Testing

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

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

---------

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

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

## What

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

## Testing

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

## How a human can test

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

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

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

Now the picker:

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

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

### Validation

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

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

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

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

Before (`main`):


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

After (this PR):


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

### Before merge

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

buzz-review-completed

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

---------

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

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

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

## Why

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

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

## What changes

### Images on disk

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

### Thumbnails for small images

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

### Agent artwork

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

## Demo

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

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


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

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


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

## Tests

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

## Before merge

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

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost>
2026-11-09 07:21:21 -08:00
Luke Tornquist
15174bc2b2 fix(relay): deliver slow cross-node events late instead of closing their recipients (#9235) 2026-11-09 10:10:54 -05:00
Tom Brow
f38285562c fix(mobile): read only shown events from filtered Activity rows (#9215)
## Summary

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

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

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

## Testing

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

## Before merge

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


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


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

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 06:59:18 -08:00
Tom Brow
58696ff577 fix(mobile): show the channel name as the notification subtitle (#9173)
On Apple Watch, a Buzz notification showed the community name ("buzz")
under the sender, taking a line of the small display without saying
where the message came from. iPhone never showed it, because iOS draws
the banner from the communication intent and drops `content.subtitle`.
The Watch renders the plain fields.

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

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

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

### Validation

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

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

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

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

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

### Validation

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

  Opening:

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

  Closing:

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

buzz-review-completed

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

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

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

Now:

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

### Before / after

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

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

### Validation

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

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

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 06:59:03 -08:00
Tom Brow
40cfa72cd4 mobile: add follow and remind actions to inbox row menu (#9142)
Long-pressing an Inbox row opened a sheet with only **Mark as
read/unread** and **Open conversation**. To follow a thread or set a
reminder, you had to open the conversation and long-press the message
itself.

The row sheet now also offers:

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

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

### Demo

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


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

### Validation

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

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

---------

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

This matches desktop on mobile:

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

### Demo

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

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


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

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


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

### Validation

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

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

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

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

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

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

### Behavior

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

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

### Demo

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

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


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

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


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

### Validation

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

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

---------

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

### Cause

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

### Fix

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

### Before and after

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

Before (main):


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

After (this branch):


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

### Validation

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

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

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

---------

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

## Bug


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

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

## Cause

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

## Fix

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

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

## Critical risk

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

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

## Before and after evidence

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

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

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

**Before**

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

**After**

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

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

</details>

---------

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

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

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

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

---------

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

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

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

---------

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

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

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

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

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

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

### Related issue

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

### Testing

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

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

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

### Related issue
No duplicate found.

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

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

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

---------

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

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

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

### Related issue

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

### Testing

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

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

### Rollout

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

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

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

Generated with Codex

---------

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

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

### Demo

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


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

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


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

### Validation

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

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

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

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

### Validation

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

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

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


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

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

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
mobile-v0.28.0-rc.9
2026-11-07 15:40:16 -08:00
Tom Brow
e86d0e23a3 feat(mobile): long-press a Solo chat to open its details sheet (#9163)
Long-pressing a team channel in the Messages list opens a bottom sheet
of channel actions. Long-pressing a Solo chat did nothing, so its
settings (star, mute, members, move, copy, leave/archive/delete) were
only reachable from inside the chat.

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

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

### Demo

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


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

### Validation

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

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 15:40:11 -08:00
Tom Brow
1a0dae77bf fix(mobile): shimmer loading photos in the iOS carousel (#9167)
On iOS, a message with several photos renders them in a native UIKit
carousel. While a thumbnail downloads, each cell showed the words
"Loading image". Single images and the Flutter carousel already show a
shimmering skeleton, so the native carousel stood out.

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

### Demo

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

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


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

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


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

### Validation

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

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

---------

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

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

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

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

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

### Behavior

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

### Screenshots (internal build)

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

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

### Validation

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

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

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

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

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

---------

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

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

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

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

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

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

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

### Related issue

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

### Testing

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

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

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

### Rollout prerequisites

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

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

### Schema change checklist

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

Generated with Codex

---------

Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
2026-11-06 22:35:00 +00:00
Brad Seiler
2cd40badb8 Manage community NIP-FI policy with enable, disable and status (#9176)
## Summary

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

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

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

### Rollout and containment

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

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

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

### Related issue

N/A

### Testing

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

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

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

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

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

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

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

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

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

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

Generated with Codex

---------

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

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

**Cause**

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

**Fix**

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

Retries preserve the existing membership rules:

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

**Limits and rollout risks**

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

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

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

---------

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

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

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

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

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

No protocol, schema, metric or client change.

### Testing

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

### Rollout

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

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

## What got simpler

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

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

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

---------

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