Skip to content

chore: merge upstream block/buzz@96ae14176 (136 commits) and shrink the fork to 51 files - #24

Merged
mattbalza merged 144 commits into
mainfrom
chore/upstream-96ae14176
Aug 7, 2026
Merged

chore: merge upstream block/buzz@96ae14176 (136 commits) and shrink the fork to 51 files#24
mattbalza merged 144 commits into
mainfrom
chore/upstream-96ae14176

Conversation

@mattbalza

@mattbalza mattbalza commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Merges block/buzz@96ae14176 (136 upstream commits ahead of our fork base 4632c5504) and, in the same pass, deletes every fork patch upstream has since made redundant. The point is not the new features — it is that the permanent fork gets smaller, so the next ingest is cheaper.

Fork surface

files diff
before (vs 4632c5504) 56 +4,277 / −793
after (vs upstream/main) 51 +4,241 / −775

(51, not 50: the merge brought in a brand-new upstream workflow,
.github/workflows/sprig-image.yml, which needs the same owner-namespace patch
docker.yml already carries — see Two CI reds below.)

The fork cannot be dropped entirely — upstream has zero equivalent for owner-only channel creation, ACP credential isolation / DM policy, browser scoping, the media size cap, the nip29_*_tags shared helpers, or our repos rm / issues rm / issues comment / users whoami CLI commands.

Deleted from the fork (upstream covers it now)

  • Our agent-mention patch (fix(desktop): let anyone mention an agent that is already in the channel #5). Upstream fix(desktop): allow shared agent mentions block/buzz#4913 (AgentEligibilityScope, relayAgentCanRespondInChannel()) is strictly more capable and supersedes 7 earlier upstream attempts. Removes ~744 diff lines.
    • ⚠️ Hard dependency: upstream's gate admits a relay-discovered agent only when agent.channelIds contains the active channel. c834a81ca fix(cli): publish the channels an agent is in is what publishes that, and it is kept. Without it the gate fails closed and @erp/@codex/@claude/@Seek go unmentionable again.
  • deploy/compose/compose.yml pool pins. deploy/ is now byte-identical to upstream. The pin was never load-bearing here: prod runs its own compose.override.yml with BUZZ_DB_POOL_SIZE: "30" (mirrored in the ERP repo at scripts/buzz-prod-compose.override.yml), and an override beats the base file. Verified on the live container — see below.
  • The compose grep in scripts/test-release-ref-contract.sh — CI cannot see the droplet's .env, so the guard could only ever fail or lie.
  • Our dependency bumps (nostr 0.44.7, RUSTSEC-0224) — superseded by upstream 9d6726e5b / 318fbf896, which also covers RUSTSEC-2026-0225..0232.

Kept after re-checking (the original plan was wrong about both)

  • crates/buzz-core/src/observer.rs + buzz-auth/rate_limit.rs + buzz-relay/handlers/event.rs are not cosmetic — connection.rs routes KIND_AGENT_OBSERVER_FRAME to LimitType::ObserverFrames (1s window, 100 frames/s) instead of the 60s message window. Deleting them would have thrown observer frames into the human rate limit.
  • desktop/src-tauri/src/key_backup.rs holds the real PR fix(desktop): two independent CI flakes in the Rust suite #19 fix (word_is_delimitable — the EFF short wordlist's only hyphenated entry, yo-yo, makes a hyphen-joined passphrase read as one extra word), not a dead-code attribute. Auto-merged cleanly with upstream's write_portable_backup_file().

Conflicts, and how they were resolved

Rule: take upstream's control flow, re-apply our gate on top.

  • crates/buzz-acp/src/pool.rs — unioned create_session_and_apply_model's parameters (upstream's agent_core / agent_canvas / channel_name first, our browser scoping last). The auto-merge had silently shadowed upstream's git-origin env injection with a second let mcp_servers; the per-session browser server is now chained after it, since that server is addressed by its own activity id rather than by git origin.
  • desktop/src/features/messages/lib/useMediaUpload.ts — adopted upstream's uploadFiles / queueFiles / shouldQueueFile split and funnelled our size cap through a single acceptFiles helper. This is a behaviour fix, not just a merge: every batch entry point (paperclip, drop, paste) now goes through the cap, so a queued 400 MB video is refused at attach time instead of at send time, after the composer already accepted it.
  • desktop/src/features/sidebar/ui/AppSidebar.tsx — extracted useScalarlySections so the file stays under the 1000-line ratchet with a 3-line delta against upstream.
  • desktop/playwright.config.ts — upstream's shard list won, which restores channel-activity-popover.spec.ts and voice-settings.spec.ts (both specs upstream added after our config deleted them, so they would never have run). Our ["json", …] reporter, which scripts/summarize-flaky-tests.mjs reads, is preserved.
  • .github/workflows/docker.yml / sprig.yml — the owner-namespace defaults (format('ghcr.io/{0}/buzz', github.repository_owner)) survived and are still guarded by test-release-ref-contract.sh.

One fork-side test fix

Upstream grew chooseLargeVideo to a 16 MiB buffer. Our 10 MiB client cap refuses it before it can queue, so five file-attachment.spec.ts tests failed on "the queued attachment never appeared". The fixture is now a named LARGE_VIDEO_BYTES = 8 MiB — still exercises every progress path (there is no size threshold in the progress UI), and the next upstream bump of that fixture will conflict here instead of silently going red.

Two CI reds, both ours, both root-caused

1 — community-rail.spec.ts:509 ("restores the last channel after a reload"). Red 3/3 on CI, ~1/5 locally. Upstream is green at 96ae14176 and our five pre-merge main runs were green, so this was ours. The prime suspect — effect ordering from the useScalarlySections extraction — was wrong. The actual mechanism:

ensureOptionalWelcomeChannel (our PR #15 onboarding-tolerance patch) swallowed every error, not just a policy refusal. So a transient channel-read failure was recorded as "this community has no Welcome channel", markWelcomeChannelEnsured settled that verdict permanently, and first-run then went on to setQueryData(channelsQueryKey, …) for a relay it had never successfully reached.

That cache write is what broke the spec. useChannels seeds from a snapshot with initialDataUpdatedAt: 0 precisely so AppShell's destination-repair effect can tell "cached" from "live-validated" (AppShell.tsx:265). setQueryData moves dataUpdatedAt to now — indistinguishable from a successful live read — so the effect ran against a channel list that never came from the relay, found the saved channel absent, and overwrote the destination with {kind:"home"} (AppShell.tsx:291).

Fixed at the shared classifier rather than by guarding the caller: tolerance is now narrowed to NIP-20's authorization prefixes, which is exactly what channel_create_policy=owner-only answers with (restricted: only workspace owners may create durable channels or forums, surfaced as either relay rejected event: … or relay returned 403 Forbidden: …). Everything else rethrows into the caller's existing outer catch, restoring upstream's control flow.

This is also a real product fix, not just a green-CI fix: before it, one transient failure marked a member's Welcome channel unavailable forever.

Verified 20/20 under six background CPU burners (was 16/20 before), and the diagnostic that found it was the browser warning Continuing without a private Welcome channel. temporary channel read failure appearing in 5/20 runs, 4 of which failed.

2 — Build (linux/amd64) / Build (linux/arm64): Get "https://ghcr.io/v2/": denied: denied. Not docker.yml — the newly merged upstream sprig-image.yml, whose GHCR login runs for same-repo pull requests. In a fork, GITHUB_TOKEN is read-only even on a same-repo pull_request, so the login itself fails the job. Ported our existing docker.yml pattern (owner-namespace IMAGE_NAME, login + cache-to gated on github.event_name != 'pull_request') and extended scripts/test-release-ref-contract.sh to hold the same three invariants for the sprig workflow, so the next upstream merge fails the guard instead of silently reverting to block's namespace.

Verification

check result
cargo check --workspace --all-targets ✅ (incl. the new buzz-backend-kubernetes crate)
cargo test --workspace 3,971 passed. 11 failures, all environment or upstream-red — see below
pnpm typecheck
pnpm check (biome + file-size ratchet + px/pubkey guards)
pnpm test (desktop unit) 4,415 passed / 0 failed
Playwright — mentions, team-mentions, persistent-agent-audience, channel-activity-popover, voice-settings, thread-focus-mode ✅ 94 passed
Playwright — file-attachment ✅ 16 passed (after the fixture fix)
Playwright — full --project=smoke 931 passed / 13 failed / 1 skipped (27.4m) — 0 regressions, see below
Playwright — community-rail + onboarding + channels + navigation (post-fix) ✅ 141 passed / 1 skipped / 0 failed
scripts/test-release-ref-contract.sh (incl. the new sprig invariants)

The 11 cargo failures, each run down individually:

  • 9 × buzz-relay api::media / api::admin — these build an AppState on PgPool::connect_lazy, so with no local Postgres the query returns 500 instead of 404. CI supplies postgres/redis/minio (ci.yml), this machine has no docker. Their siblings named …requires_admin_host_before_database_access pass, which is the tell.
  • 1 × git-sign-nostr test_parse_envelope_rejects_invalid_oa_pubkeyreproduced on pristine upstream/main in an isolated CARGO_TARGET_DIR: upstream-red, byte-identical file, identical secp256k1 0.29.1 in the lock. Not ours.
  • 1 × buzz-agent cancelled_turn_with_usage_emits_notification_before_response — an artifact of a stale test binary: three worktrees share one global build.target-dir, so a 16-test build clobbered the 20-test one. After cargo clean -p buzz-agent the binary passes 20/20, five runs, with and without the host's ~/.claude/skills on the prompt path.

The 13 smoke failures, triaged the same way. Re-running the 9 affected files serially cleared 5 of them (channel-controls sticky footer, huddle voice menu, messaging link-preview corner radius, onboarding concurrent installs, relay-reconnect seam) — load flakes from a 27-minute run on a contended laptop. The rest were checked against a pristine upstream/main worktree, freshly pnpm build:e2e'd, which fails the identical set:

spec ours pristine upstream
composer-selection-formatting — 5 caret-format tests ✘ same 5 titles
inbox-edit — the empty-edit pair
messaging — own-avatar attribute
video-attachment:229 — review speed menu ✘ 1 of 2 runs ✘ 1 of 2 runs
community-rail.spec.ts:1078 — keyboard reorder ✘ 7 of 15 ✘ 7 of 15

community-rail.spec.ts:1078 deserves its own line, because I first filed it as a load flake and that was wrong — it fails standalone, on an idle machine. --repeat-each=15 on both sides returned exactly 7 failed / 8 passed, and the spec is byte-identical to upstream/main; our sidebar diff (sidebar/lib/*, AppSidebar.tsx) never touches the community rail. The mechanism is in the test itself: it drives dnd-kit's KeyboardSensor with synthetic KeyboardEvent dispatch and awaits nothing between the pick-up Space and the ArrowUp, so passing runs finish in ~470 ms and failures sit at the 5 s poll timeout. Upstream flake at an unchanged rate — the two shorter 5-run samples that pointed the other way (1/5 upstream vs 3/5 ours) were noise, which is why the 15-run comparison was necessary. Not patched deliberately: a fix means a fork patch on a pristine upstream test file, and CI's 2 retries already absorb a 47% per-attempt rate down to ~10% per shard. Queued under Upstreamable next instead.

So: upstream-red on macOS with this chromium headless shell, plus two genuine upstream flakes. Nothing in the failing set is a file our fork touches, and none of them regressed. CI is the arbiter for the integration project, which needs a live relay.

Deploy — no config gate, checked against the live droplet

An earlier draft of this plan claimed the droplet's .env had to gain BUZZ_DB_POOL_SIZE=12 / BUZZ_DB_READ_POOL_SIZE=12 before the relay could ship. Both halves are wrong, and the live host says so:

  • docker exec buzz-prod-relay-1 envBUZZ_DB_POOL_SIZE=30, set by /opt/buzz/deploy/compose/compose.override.yml, against max_connections=50 (40 connections currently in use). The tracked compose file the merge reverted never fed prod.
  • BUZZ_DB_READ_POOL_SIZE is inert without a read replica: Db::connect builds the reader pool only match &config.read_database_url { Some(url) => …, None => None }, and prod sets no replica URL. There is no 30 + 30 = 60 > 50 exhaustion risk.

So deploy is the normal path: relay + acp via the sprig rolling bundle, then rebuild / ad-hoc-re-sign the teammate dmg (BUZZ-SETUP.md).

The #4913 dependency, verified on the live relay

Queried prod Postgres for the newest kind:10100 (agent profile) per pubkey:

agent channel_ids respond_to published
ERP 58 anyone 2026-08-06 13:55Z
Codex 58 anyone 2026-08-06 13:55Z
Claude 54 anyone 2026-08-06 13:55Z
Seek 53 anyone 2026-08-06 13:55Z

Non-empty on all four, which is what upstream's gate requires. The full chain also type-checks end to end: buzz-cli writes channel_ids into the profile content → nostr_convert.rs defaults it to [] when absent → tauri.ts maps agent.channel_ids ?? [] to channelIdsrelayAgentCanRespondInChannel requires agent.channelIds.includes(channelId). Note the ?? [] is the failure mode: a profile missing the key produces an empty array, not an error, so the agent silently goes unmentionable. Confirm in Desktop from a teammate's device after the dmg ships; if one is missing, buzz-cli agents publish-profile before blaming the merge.

What the 136 commits buy us

9 RUSTSEC advisories · _meta.systemPrompt for claude-agent-acp · cache-read tokens in NIP-AM kind:44200 · per-provider usage rounds · NIP-OA raw tag form · reconnect gaps that previously needed CMD+R · relay perf · Huddle redesign · entity links · Buzz Term · CSP hardening · private managed-agent wire protocol (kind:30179) · private-channel invitation hardening.

Upstreamable next (would shrink the fork further)

buzz-admin reconcile-channels archive-tag fix · the nip29_*_tags shared helpers · the anchoredScrollPolicy scroll-settle guard · the community-rail.spec.ts:1078 keyboard-reorder race (measured 7/15 on pristine upstream/main; needs the drag-active state awaited between Space and ArrowUp — belongs upstream, not in our fork).

tlongwell-block and others added 30 commits July 31, 2026 09:37
## What

Adds `VISION_REMOTE_AGENTS.md` — the vision doc for remote agents,
joining the VISION family (`VISION_AGENT.md`, `VISION_MESH.md`,
`VISION_SOVEREIGN.md`, …).

The one-line thesis: **the relay is the management plane** — an agent's
identity, history, presence, and ordinary control all live on the relay,
so the body (a pod today, anything tomorrow) is replaceable, and
deployment never grows a second control plane.

## Provenance

- Distilled from the remote-agents spec (`docs/remote-agents.md`, PR
block#3748); this doc stays deliberately generic where the spec is
Kubernetes-specific.
- Five review rounds in the #buzz-remote-agents channel; both reviewers
(Wren: thesis/shape/scope, Dawn: truthfulness/minimalness/elegance)
converged at 9/9/9, scored against spec head `b4f4ed1a6` with
command-level receipts.
- Final editorial pass by Tyler (opening line, vignette phrasing,
closing tagline), applied live in-channel before this PR.

Doc-only change — no code, no effect on block#3748, which remains blocked
solely on the Open Decisions A–I rulings.

---------

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
…ttings (relands block#2467 + block#3208) (block#3910)

Relands **block#2467** (extract `buzz-voice` crate) and **block#3208** (Pocket
voice settings) onto main, after block#3266 and block#3180 merged.

## Why a fresh PR
The repo is squash-only with delete-branch-on-merge. Squashing block#3266
deleted `jtennant/pocket-tts-2026-04`, which was block#2467's base — GitHub
auto-closed block#2467 and it cannot be reopened. Squash merges also sever
ancestry, so GitHub's natural merge-base reports phantom conflicts for
the whole remaining stack.

## Content provenance
- Byte-identical to the blessed `jt/buzz-voice-refactor` branch
(`93029c577`, tree `6729e0eff` — reviewed by Dawn (block#2467) and Max
(block#3208) at exact heads) **except** the three files where block#3180 and block#3208
genuinely interact.
- Three-file resolution (union of both sides):
- `huddle/mod.rs` — block#3180's pipeline re-exports + block#3208's
`agent_tts_routing` imports.
- `huddle/state.rs` — `reset_preserving_generation` preserves both
`huddle_generation` (block#3180) and `tts_enabled` (block#3208); test sets merged
into one `tests` module.
- `desktop/src/testing/e2eBridge.ts` — both switch arms kept; no
duplicate case labels.

## Verification at cf32dac
- `cargo test` (desktop/src-tauri, pinned 1.95.0): **2047 + 3 pass / 0
fail** (14 ignored: 8 keychain, 4 real_relay, 2 flag-gated)
- `cargo clippy --all-targets -- -D warnings`: clean; `cargo fmt
--check`: clean
- `cargo check --workspace` (root, includes new `buzz-voice` member):
clean; `cargo test -p buzz-voice`: 5/0
- `pnpm test`: **3885 / 0**; `tsc --noEmit`: clean; lint: clean

The 3180×3208 interaction resolution is getting an independent team
re-review before merge.

Buzz channel: buzz-desktop-voice `fd5fb402-b651-4238-89b1-bb3e2fa4dc96`,
thread `b4798ecc`.

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
## Summary
- show profile descriptions in hover cards as a single truncated line
- open the profile panel when avatars are clicked across desktop
surfaces
- make the direct-message intro avatar clickable

## Validation
- Desktop static checks
- 3,807 desktop tests via pre-push

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
## Context

Pocket TTS currently offers bundled reference voices. People also need a
local, private way to add a voice without sending audio to a cloud
service.

## Summary

Add a Pocket voice import flow to Voice settings. Buzz opens the native
file picker, decodes common audio formats in the reusable `buzz-voice`
crate, canonicalizes the selected audio, stores it under a
content-derived identity in app data, selects it, and lets the user
delete it later.

## Changes

- Accept WAV, M4A, MP3, FLAC, OGG, and AIFF files between 2 and 30
seconds, including multichannel sources.
- Decode and downmix accepted audio to canonical mono 32 kHz PCM16 WAV
before hashing and storage.
- Store imported voices behind stable `pocket:imported:<sha256>`
identities and content-addressed files.
- Keep absolute file paths inside the native process and expose only
voice metadata to React.
- Include imported voices in Pocket preview and live huddle playback.
- Add Add voice and delete controls while preserving the bundled Pocket
voice catalog.
- Fall back to Mary when the selected imported voice is deleted.
- Keep durable import, selection, and deletion successful when a live
TTS worker acknowledgement is delayed.
- Preserve bundled voices when optional import metadata is unreadable
and keep failed deletion retryable.

## Related issue

None found.

## Testing

Production decoding was exercised with WAV, M4A with AAC, MP3, FLAC, OGG
Vorbis, and AIFF fixtures. Each format canonicalized to mono 32 kHz
PCM16 WAV. Manual validation in the combined daily-driver build covered
native-picker import, Preview, live-huddle playback, deletion, and Mary
fallback.

## Screenshots

The Voice settings card preserves the bundled Pocket catalog and adds
the local Add voice action.

![Pocket TTS voice
import](https://raw.githubusercontent.com/block/buzz/c03ba29060ca544c5ac3394c212f376651b386a3/pr-3259--pocket-voices.png)

## Reviewer-reproducible examples

Create common-format fixtures and run them through the production
importer:

```bash
. ./bin/activate-hermit
fixtures="$(mktemp -d)"
ffmpeg -hide_banner -loglevel error -f lavfi -i "sine=frequency=220:duration=3" -ac 2 -ar 44100 "$fixtures/voice.wav"
ffmpeg -hide_banner -loglevel error -i "$fixtures/voice.wav" -c:a aac "$fixtures/voice.m4a"
ffmpeg -hide_banner -loglevel error -i "$fixtures/voice.wav" "$fixtures/voice.mp3"
ffmpeg -hide_banner -loglevel error -i "$fixtures/voice.wav" "$fixtures/voice.flac"
ffmpeg -hide_banner -loglevel error -i "$fixtures/voice.wav" -c:a libvorbis "$fixtures/voice.ogg"
ffmpeg -hide_banner -loglevel error -i "$fixtures/voice.wav" -c:a pcm_s16be "$fixtures/voice.aiff"
BUZZ_VOICE_IMPORT_TEST_DIR="$fixtures" \
  cargo test -p buzz-voice imports_common_audio_format_fixtures -- --ignored --nocapture
```

Exercise import persistence, synthesis, deletion, and bundled-voice
fallback with an installed Pocket model:

```bash
BUZZ_POCKET_MODEL_DIR=/path/to/pocket-model-bundle \
  cargo test -p buzz-voice --test pocket_import_audio \
  objective_import_synthesis_delete_and_mary_fallback \
  -- --ignored --nocapture
```

Exercise the native-picker boundary, selection, preview dispatch,
deletion, cancellation, and invalid-file states:

```bash
cd desktop
pnpm build:e2e
pnpm exec playwright test tests/e2e/voice-settings.spec.ts --project=smoke
```

---------

Signed-off-by: John Tennant <jtennant@block.xyz>
Signed-off-by: John Tennant <johnmatthewtennant@gmail.com>
Signed-off-by: John Tennant <jtennant@squareup.com>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Co-authored-by: John Tennant <jtennant@block.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
## Summary

- document `Prepare Desktop Release` as the canonical desktop release
entry point
- describe the frozen candidate, exact-head approval, and true
merge-commit contract
- document all platform outputs and complete release App/signing
configuration
- link the release runbook from the README
- allow stable reruns to repair the rolling updater manifest after the
versioned release has already published

## Release blocker

The live repository cannot currently complete this flow: repository
settings disable merge commits and the `main` ruleset allows only
squash, while `scripts/verify-desktop-release-merge.sh` requires a
two-parent merge whose second parent is the approved candidate. Those
settings must allow merge commits before a desktop release PR is merged.

## Validation

- `bash scripts/test-desktop-release-candidate.sh`
- `bash scripts/test-release-ref-contract.sh`
- `git diff --check`
- verified live repository merge settings, `main` ruleset, release tag
ruleset, Actions variable names, and secret names with GitHub API
- independent review by Princess Donut; incorporated all findings,
including the rolling-manifest retry gap and unsigned Windows labeling

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
Co-authored-by: Release Automation <release-automation@users.noreply.github.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
chore(release): release Buzz Desktop version 0.5.3
…rification model to NIP-RS (block#2864)

## Summary

Amends `docs/nips/NIP-RS.md` with the manual mark-as-unread override
layer and includes `docs/formal/nip-rs-unread/`, the bounded exhaustive
verification model that preceded and informed the spec.

All `ov_*` override state lives in exactly one coordinate per
installation. That single constraint is what makes the rest of the
amendment small: override state never moves between coordinates, so
there is no slot lifecycle to make crash-safe, and the only durability
obligation is carry-forward on `client_id` rotation.

## Spec changes (`docs/nips/NIP-RS.md`)

- **Non-Goals:** drop the stale line stating mark-as-unread is out of
scope; state the `ov_*` durability exception to the
best-effort/time-horizon model.
- **Reserved Namespace:** `ov_` stem and `esc:` escape marker reserved.
Escape on publish (prepend `esc:` to raw IDs beginning with `ov_` or
`esc:`), unescape on receive (strip exactly one `esc:`). Bijection, with
the pre-amendment backward-compat residual documented as a stated
limitation.
- **Content Validation:** override entries are collected and validated
as a complete logical group *before* any decoding, zero-filling,
merging, or canonicalizing. Only two wire shapes are accepted — a
complete live three-key group, or an `ov_c:`-only tombstone floor. Any
other shape rejects the whole group while retaining the frontier entry;
applying the generic per-entry discard rule first is prohibited.
- **`d` Tag:** `<slot-id>` is exactly 32 lowercase hexadecimal
characters, replacing "a random opaque string" of 1–64 ASCII characters.
The fixed shape lets a relay recognize a read-state coordinate
structurally from the `d` tag alone, without decrypting anything, and
apply per-coordinate protections to it — under the old wording a
conforming client could pick a shape that silently forfeits them.
Recognizable coordinates are also what let a relay replace superseded
versions outright rather than accumulating one retained row per publish,
which keeps the coordinate count a full-state load must enumerate near
one per installation. Every client designates one **primary** coordinate
with a stable `<slot-id>` for the installation's lifetime. All `ov_*`
entries, and the frontier entries of the contexts they belong to, MUST
live in the primary. Additional coordinates remain legal for frontier
volume but MUST NOT carry `ov_*`, which keeps them freely rewritable and
freely deletable.
- **`t` Tag:** described as a discoverability marker rather than a
guarantee of relay-side selectivity. A relay MAY apply tag constraints
after its result cap, and `kind:30078` is shared with unrelated
application data, so clients MUST apply the tag as a correctness filter
locally, MUST NOT infer completeness from a short result, and MUST omit
the tag entirely when performing a full-state load.
- **Fetching / Full-State Load:** clients implementing the override
layer MUST NOT apply a finite `since` filter — an encrypted payload
means a relay filter cannot select for override-bearing events, so any
event-level window can exclude the only coordinate holding a tombstone
floor. Removing `since` is not sufficient: relays MAY cap historical
results, MAY cap below the requested `limit`, and emit
end-of-stored-events after the capped query, so neither EOSE nor a short
page proves completeness. No test against the client's requested `limit`
can detect truncation either: the effective cap belongs to the relay, a
relay MAY cap below what was requested, and an advertised maximum limit
is not necessarily the limit enforced.

A full-state load is therefore enumerated on `{"kinds": [30078],
"authors": [<pubkey>], "limit": <n>}` with **no tag constraint**. A
relay MAY apply tag constraints only after its result cap and withhold
the events that fail them, so under a tag-constrained filter the
delivered count is not the count the cap selected — a delivered page can
be empty while older coordinates still exist below it, and `kind:30078`
is arbitrary application data whose `d` tag namespace is open to every
application that has written under the user's key. Omitting the tag
makes delivery observable; read-state selection moves client-side, where
the validation rules already place it.

Completeness is then established by enumeration on a strictly decreasing
cursor: collect a page, descend on the lowest `created_at` across all
delivered events, exhaust that second with a window pinned to it,
continue below it, and treat only an empty delivery as complete. Every
query carries the same explicit `limit` `n` with `n >= L`. Per-second
exhaustion is discharged by comparing the pinned window's delivery
against the largest delivery the relay has already demonstrated in the
same load, floored at `L = 2` so that the ordinary single-coordinate
installation can reach *complete* at all. The comparison fails safe: an
inconclusive window reports *cannot prove complete* rather than
*complete*, and that verdict is terminal for the load.

Because these are addressable events, a coordinate republished mid-load
moves *above* the descending cursor while its previous version stops
existing, so neither is reachable by any later query. A full-state load
is therefore fenced by a live subscription on the same tag-free filter,
established — defined as receipt of end-of-stored-events — before the
first enumeration query and held unbroken on the same connection for the
load's duration. Fence deliveries are collected like enumerated events
but do not contribute to the cursor or to the demonstrated-delivery
bound. Collection deduplicates coordinates on the full NIP-01
addressable ordering — greatest `created_at`, lowest event id on ties —
because an equal-timestamp replacement is legal and is the version the
relay retains. A lapsed or reconnected fence makes the load potentially
incomplete, and a client MUST NOT publish to its own coordinates during
its own load.

Five relay behaviours the *complete* verdict rests on are stated as
normative conformance preconditions rather than assumptions, because
none is verifiable from the responses a client receives: newest-first
prefix delivery with lowest-id tie-breaking (what NIP-01 already
specifies for `limit`), a non-decreasing effective cap within a load,
the floor `L`, push delivery on an open subscription, and a delivery
barrier ordering accepted matching events ahead of a query's
end-of-stored-events on the same connection. Conditioning *complete* on
positive proof of these instead would withdraw the override layer from
every client rather than from the non-conforming relays. A client MUST
NOT load against a relay it has evidence violates them, and MUST treat
any such load as potentially incomplete.

A load that is potentially incomplete, or that failed on any relay the
client publishes to, MUST NOT authorize canonical compaction, publishing
a canonicalized override blob, deleting or abandoning a coordinate, or
reporting a mark-read as successful; the client falls back to local
state.
- **Client-ID Rotation / Orphaned Blob Deletion:** rotation is the only
event that changes an override-bearing coordinate. Before deleting or
abandoning its previous primary, a client MUST republish the
componentwise `max()` of every register that primary holds — every
tombstone ceiling included — under its new primary, and MUST confirm
acceptance on **every relay** from which the old primary will be deleted
or allowed to lapse. Acceptance on one relay does not authorize deletion
on another. Frontier-only orphans are deletable unconditionally; an
unknown same-`client_id` coordinate is treated as a live carrier until
merged.
- **Live Subscription and Convergence:** the re-publish trigger and its
suppression are evaluated on canonicalized state, so a retained live
peer blob the client has already tombstoned cannot trigger an identical
write on every replay.
- **Manual-Unread Override Layer** (new section):
- **Wire encoding:** `ov_s:<ctx>`, `ov_c:<ctx>`, `ov_b:<ctx>` as uint32
siblings in the existing `contexts` map.
- **Merge rule:** componentwise `max()` per counter — no new wire merge
logic.
- **Liveness predicate:** `S > 0 AND F <= B AND S > C`, transcribed from
`model.py::override_set_b`.
- **Actions:** mark-unread bumps S and captures the effective frontier
as B; mark-read bumps C; a natural frontier advance past B deactivates a
stale set with no counter update. Every action requires a complete
full-state load. At the uint32 ceiling, wrapping and resetting are
prohibited: mark-unread is refused, and mark-read completes only if the
resulting state has `override_active == false` — otherwise it fails
visibly rather than reporting success over a still-live override.
- **Tombstone floor:** a dead ever-active register compacts to `RegB(0,
max(S,C), 0)` — a single `ov_c:` key. A virgin register is omitted
entirely. This blocks counter reuse and the resulting resurrection.
- **Mandatory canonical publication:** a protocol requirement, not an
optimization. Publishing raw dead registers lets two independently-dead
registers from different devices produce a live join.
- **Override group co-location rule:** a context's frontier entry and
all its `ov_*` siblings MUST travel in the same event, and that event
MUST be the primary coordinate. An override-bearing context therefore
has exactly one legal destination for its whole group; only
frontier-only groups may be distributed across additional coordinates.
Grouping is per logical context, never per key.
- **Unescape-before-group rule:** the frontier wire key MUST be
unescaped to its raw logical context ID before use as group identity.
Equal normative weight to atomic grouping.
- **Tie policy:** clear-wins is MUST. The tie verdict is not encoded on
the wire, so a selectable policy makes two conforming clients diverge
permanently on both the unread verdict and the canonical wire form.
- **Override State Durability:** `ov_*` entries are exempt from age
pruning and budget eviction permanently, and durability is defined over
retrievable logical state — the containing event must stay reachable and
the load must establish completeness, not merely retain keys. There is
no safe finite GC horizon.
- **Bounds and budget:** byte/key analysis at both small-counter and
uint32-maximum values. Confining `ov_*` to one blob makes its plaintext
budget a hard lifetime ceiling on ever-overridden contexts — roughly 600
tombstones at the worst-case ~54 bytes against 32 KiB, ~730 at the
common ~45 bytes, ~199 simultaneously live overrides at ~164 bytes. At
the ceiling a client MUST refuse mark-unread and MUST NOT split override
state, drop floors, or publish a truncated override set. Same policy
shape as counter exhaustion: visible failure, never silent degradation.
- **Verification artifact:** `docs/formal/nip-rs-unread/`. The model is
a broader predecessor of this NIP: its `split_blob_into_slots` permits
override groups in any slot, so verified atomicity covers every
arrangement this NIP allows, but the converse does not follow. The model
does not verify the single-primary rule, the completeness procedure, the
relay conformance requirements or the mutation fence, or carry-forward;
malformed-group wire validation is likewise normative but outside
verified scope.

- **Abstract / Non-Goals / Backwards Compatibility:** the absolute "no
relay-side logic" and "no relay behavior changes" claims are narrowed to
what remains true — no new event kind, no new wire message, no
relay-stored read-state logic — with the override layer's relay
conformance contract named as the exception. Frontier sync and clients
that skip the override layer are unaffected on any relay.

## Verification model (`docs/formal/nip-rs-unread/`)

Four Python files constituting a bounded exhaustive verification model
for the override layer's register algebra.

**What it does:** constructs a toy universe — 2–3 devices, 2 channels,
every action that can happen (mark-unread, mark-read, late/duplicate
syncs, app reinstall, storage compaction) — and brute-forces every
reachable ordering (14,258 BFS states; 672-point deep-history parameter
cube; 9-mutant harness over ~45,000 merge pairs). After each world-state
it asks: did all devices converge? Did any unread flag get resurrected
after being cleared, or vanish while live?

**What it found and fixed:**

1. **Killed candidate A.** The model produced a concrete kill sequence:
an old client that doesn't know about the new field rewrites its
read-state blob and silently erases unread flags. That witness is why
the spec uses candidate B (two counters that only count up, plus a
snapshot) instead.
2. **Candidate B passes everything.** All delivery orders converge; the
frontier high-water mark never regresses; duplicated/replayed syncs are
harmless; old clients can't destroy it; compaction never resurrects a
dead unread or drops a live one, including
cleanup-followed-by-weeks-late-stale-sync and
tombstone-landing-on-unrelated-live-state corner cases.
3. **Caught a second real bug late.** Two devices each publishing "this
unread is cleared" could, on merge, reactivate it. The fix (canonicalize
before publishing) is a mandatory rule in the spec; the model re-checks
it across ~45,000 merge pairs.

**Scope and caveats:** bounded to 2–3 devices and 2 channels. Can't
prove the infinite case. `NOTE.md` documents the exact verification
scope and the gap between the model's `split_blob_into_slots` generality
and the single-primary rule the spec adds on top.

**Why it's in the repo:** the spec asserts "verified by bounded
exhaustive model checking." Keeping the artifact in-repo means anyone
who later amends the merge/compaction rules can `python3 exhaustive.py
&& python3 mutation.py` (deterministic, exit 0) and confirm the
guarantees hold. Without it the spec claims a proof nobody can check.

## Diff scope

`docs/nips/NIP-RS.md` — spec amendment, zero product code.

`docs/formal/nip-rs-unread/{NOTE.md,model.py,exhaustive.py,mutation.py}`
— bounded exhaustive verification model, zero product code.
`.gitignore` — `__pycache__/` and `*.pyc` entries for the model
directory.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
## Summary
- validate desktop release candidates before merge and keep the
repository squash-only
- tag the squash commit only after proving frozen-base parent and
complete-tree identity with the validated PR head
- accept either an exact-head approval or the durable Default-ruleset
bypass record as release authorization
- remove the unusable App-backed preparation workflow; retain `just
release-desktop`

## Ruleset follow-up
After this PR merges, update Default ruleset `13596885` to:
- enable strict required status checks
- dismiss stale reviews on push and require approval after the last push
- require the integration-bound `Desktop Release Candidate` check

The next desktop release should be cut only after that settings update.

## Verification
At commit `d8c254db427eedbcffac1a6e078e90d1d0f5e151` with a clean
worktree:
- `scripts/test-release-ref-contract.sh`
- `scripts/test-desktop-release-candidate.sh`
- `bash -n scripts/verify-desktop-release-merge.sh
scripts/prepare-desktop-release.sh scripts/test-release-ref-contract.sh`
- `git diff --check`

The bypass test fixture is the captured rule-suite shape from real
squash merge PR block#2864 / suite `3520068134`.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
## Summary
- require an exact-head trusted approval before desktop auto-tagging
- remove rule-suite authorization that `GITHUB_TOKEN` cannot access
- pin review pagination to `page=1` and test the deployed `gh` control
flow

## Why
The previous verifier unconditionally queried repository rule-suite
endpoints with `github.token`. Those endpoints require Administration:
read, which Actions `GITHUB_TOKEN` cannot receive. Its paginated list
request also duplicated page one when no explicit page was supplied.

This deliberately removes admin-bypass authorization rather than
introducing a second credential during release recovery. Desktop release
PRs must now have GitHub's overall `APPROVED` decision and a
MEMBER/OWNER/COLLABORATOR approval attached to the exact candidate SHA.

## Validation
- `scripts/test-desktop-release-authorization.sh`
- `scripts/test-release-ref-contract.sh`
- `bash -n scripts/verify-desktop-release-merge.sh
scripts/verify-desktop-release-authorization.sh
scripts/test-desktop-release-authorization.sh
scripts/test-release-ref-contract.sh`
- `git diff --check origin/main...HEAD`

The new flow test uses a stub `gh` executable, asserts the exact
`page=1` request, fails any rule-suite API call, and rejects stale-SHA,
untrusted-author, changes-requested review, and non-approved
aggregate-decision cases.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
## Buzz Desktop release v0.5.3

- **Frozen main:** `54c8ef30a9bb9c59a4415a8a7ee84c7c5454b48a`
- **Reviewed candidate:** `d0c06978bbf494ded6fe1a55d69d810ae9b65863`
- **Previous desktop release:** `v0.5.2`
- **Proposed immutable tag:** `desktop-v0.5.3`

This PR must be **squash merged** only after the Desktop Release
Candidate check passes. The branch must remain based directly on current
; stale base, payload drift, incomplete notes, or an unauthorized merge
produce no tag.

The checked-in changelog accounts for every non-merge commit in the
release range. Publication remains bound to the immutable candidate tag.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Release Automation <release-automation@users.noreply.github.com>
## Summary
- escape the Markdown backticks around `main` in the desktop release PR
body
- prevent the shell from executing `main` as command substitution
- lock the heredoc contract into the release-ref test

## Verification
- `scripts/test-release-ref-contract.sh`
- `bash -n scripts/prepare-desktop-release.sh
scripts/test-release-ref-contract.sh`
- `git diff --check origin/main...HEAD`

This is a follow-up to the cosmetic PR-body issue observed on block#3972. It
does not modify that frozen release candidate.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
)

Buzz renders one card per `kind:30617`, so a project spanning several
repositories has no representation.
[NIP-MP](block#3163) defines `kind:30621`
as an addressable container holding a group's name, description, channel
binding, and member coordinates. This adds the kind to `buzz-core` and
its structural validation to the relay ingest path.

## Event shape

```json
{
  "kind": 30621,
  "tags": [
    ["d", "platform"],
    ["name", "Platform"],
    ["description", "Relay, desktop, and mobile."],
    ["a", "30617:<owner-a-hex>:buzz"],
    ["a", "30617:<owner-b-hex>:buzz-infra"],
    ["buzz-channel", "<channel-uuid>"],
    ["buzz-visibility", "listed"]
  ]
}
```

## Validation at ingest

| Rule | Behavior |
|------|----------|
| `d` tag | exactly one, non-empty (length already bounded by the
generic `D_TAG_MAX_LEN` check) |
| member `a` tag arity | exactly 2 or 3 elements per NIP-01's `a` tag
grammar; a 4th element has no defined meaning and is rejected |
| member `a` tag coordinate | must parse as
`30617:<lowercase-64-hex-owner>:<non-empty-d>` |
| duplicate members | rejected on exact string match of the canonical
coordinate |
| member cap | 64, counted over raw `a` tags |
| metadata cardinality | at most one each of `name`, `description`,
`buzz-channel`, `buzz-visibility` |
| metadata length | `name` ≤ 256 bytes, `description` ≤ 2048 bytes,
`buzz-channel` ≤ 256 bytes, `buzz-visibility` ≤ 256 bytes |
| zero members | valid |
| unknown tags | ignored |

Rejection order is normative so a client can predict which rule fires:
`d`-cardinality → `d`-empty → member-cap → member-arity → coordinate
parse → member-duplicate → metadata cardinality → metadata length.

## Design notes

**No membership authorization.** Members are `a` tags, so one project
may name repositories owned by different pubkeys — the entire point of
the kind. That is safe because membership grants nothing: push policy
reads a repository's own `kind:30617` (`api/git/policy.rs`) and never a
project. `buzz-channel` is a metadata reference, not a routing
directive, so projects are classified global-only.

**Owner-only editing is free.** NIP-33 addressing keys replacement on
`(pubkey, kind, d)`, so one signer can never overwrite another's
project. No relay-side permission check exists or is needed, and
`test_project_same_d_under_two_authors_are_independent` pins it.

**Duplicates are rejected, not deduped.** A relay cannot rewrite tags
inside a signed event without invalidating its id and signature, so the
alternative to rejection is a stored duplicate-member head that every
consumer must apply a first-wins rule to.

**The cap is checked before the duplicate set is built.** Counting raw
`a` tags rather than distinct coordinates means an event naming one
coordinate thousands of times is refused on count, instead of being
bounded only by the relay frame limit.

**No side-effect handler.** Generic NIP-33 replacement and generic
NIP-09 coordinate soft-delete already cover replacement and deletion;
`kind:30621` needs no entry in `is_side_effect_kind`.

## Generic NIP-09 fix carried along

`soft_delete_by_coordinate` (`crates/buzz-db/src/event.rs`) previously
deleted the live coordinate head regardless of the tombstone's own
`created_at`, so a delayed or replayed `a`-tag deletion signed between
two versions destroyed the newer replacement. NIP-09 scopes an `a`-tag
deletion to versions at or before the deletion request, so the `UPDATE`
now carries `created_at <= $5` and `handle_a_tag_deletion` threads the
deletion event's `created_at` through.

The bug predates `kind:30621` and affected every
parameterized-replaceable kind on the generic path — `kind:30617`
repository announcements included — so the fix lands there rather than
as a project special case. `events.created_at` is immutable per row, so
the predicate guarantees a tombstone can never erase a version newer
than itself; the UPDATE re-evaluates its WHERE clause after any lock
wait. Under READ COMMITTED, a same-coordinate replacement racing the
deletion may cause the deletion to evaluate before the new head lands,
returning `Ok(false)` — but that outcome is state-identical to the
deletion having arrived first, a valid Nostr ordering Nostr never fixes.
The return value feeds only a debug log. No coordinate-level lock is
needed.

## Coverage

32 unit tests in `crates/buzz-relay/src/handlers/ingest.rs` pin the
envelope contract (accept: minimal, cross-owner, zero-member, same repo
`d` under two owners, colon-bearing repo `d`, cap boundary, unknown
tags, relay hint on member `a` tag, max-length metadata, stranger-owned
member, uninterpreted metadata values, non-empty content; reject: every
rule above plus valueless `d`/`a` tags). A fixture-driven test
(`project_envelope_validates_all_shared_fixtures`) runs every case in
the shared `NIP-MP.fixtures.json` oracle (11 accept + 20 reject) against
`validate_project_envelope`, so any future change that breaks a case
turns the test suite red.

6 `#[ignore]`d e2e tests in
`crates/buzz-test-client/tests/e2e_project.rs` cover behavior that only
exists past storage — coordinate round-trip, newer-wins replacement, two
authors sharing a `d`, an `a`-tag tombstone that removes the project
while leaving referenced `kind:30617`s intact, and a tombstone
timestamped between V1 and V2 that must leave V2 live. The negative e2e
case asserts on the rejection message so a refusal for an unrelated
reason cannot satisfy it; that is what proves the validator is reachable
from the live write path rather than merely correct in isolation. The
new e2e binary is wired into the Relay E2E job.

The timestamp predicate is additionally pinned at the storage layer by
`coordinate_delete_spares_head_newer_than_the_deletion` in
`crates/buzz-db/src/lib.rs`, which asserts both directions: a stale
tombstone deletes nothing and leaves the newer head readable, and a
tombstone at the head's own timestamp still deletes it. This test is
wired into the Backend Integration job.

Related: block#3163 (the NIP-MP spec and shared conformance fixtures).
Independent — either can merge first.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
…block#3999)

## Problem

`buzz-agent` measures and sends `accumulatedCachedInputTokens` on the
wire (`usage.rs:93`). `buzz-acp` deserializes it correctly — but then
drops it: `TurnUsage` had no cache field, and `build_turn_metric_counts`
hardcoded `cache_read_tokens: None` and `cache_write_tokens: None` into
both `turn` and `cumulative` `TokenCounts`. Every kind:44200 event
published permanently lacked data the harness measured. The archive is
append-only — this is unrecoverable data loss per turn, every turn,
until fixed.

NIP-AM already specifies the fields (`cacheReadTokens` /
`cacheWriteTokens` inside `turn` and `cumulative`). This is a pure
threading fix.

## Changes

**`crates/buzz-acp/src/usage.rs`**

- `SessionState` gains `last_cached_input: u64` to track the committed
cache-read baseline.
- `TurnUsage` gains `turn_cache_read_tokens: Option<u64>` (field-local;
`None` when no baseline or counter decreased) and
`cumulative_cache_read_tokens: u64` (always present; zero when no cache
hits reported).
- `record()` computes the cache-read delta with field-local taint
semantics: a decrease in the cumulative counter nulls only
`turn_cache_read_tokens` — it does not flip `delta_reliable` or
invalidate `turn_input_tokens`/`turn_output_tokens`. Identical to the
`accumulatedTotalTokens` pattern already present.
- `take()` and the setup-notification branch both advance
`last_cached_input` in the committed baseline.

**`crates/buzz-acp/src/pool.rs`**

- `build_turn_metric_counts` wires `turn_cache_read_tokens` into
`turn.cache_read_tokens` (when `delta_reliable`) and
`Some(cumulative_cache_read_tokens)` into
`cumulative.cache_read_tokens`.
- `cache_write_tokens` remains `None` on both counts with an explanatory
comment: buzz-agent does not emit a write-side count on the wire today.
- Six existing `TurnUsage` struct literals in tests updated with the two
new fields.

## Tests

**`usage.rs` — new cache-read section (5 tests):**
-
`cache_read_first_turn_produces_none_turn_delta_and_passes_cumulative_through`
— no baseline → delta None, cumulative passes through
- `cache_read_second_turn_delta_computed_correctly` — delta = current −
previous
- `cache_read_decrease_nulls_turn_cache_but_leaves_delta_reliable` —
field-local taint: decrease nulls cache delta only, input/output stay
reliable
- `cache_read_zero_payload_after_baseline_produces_zero_delta` — zero on
both sides → `Some(0)`, not `None`
- `cache_read_threads_through_setup_notification_baseline` — setup
notification baseline correctly seeds the cache counter

**`pool.rs` — new acceptance test (1 test):**
- `test_build_turn_metric_counts_cache_read_tokens_thread_through` —
wire-parses a buzz-agent payload with nonzero
`accumulatedCachedInputTokens`, runs two turns through the tracker and
`build_turn_metric_counts`, and asserts nonzero `cacheReadTokens` in
cumulative + correct per-turn delta in `turn`; also asserts
`cache_write_tokens` is `None` throughout

## Quality gates at tip `c6405eb43f532572e3b7775e0dee826dc9cb3f82`

| Gate | Result |
|---|---|
| `cargo test -p buzz-acp` | **655/655**, 0 failed |
| `cargo clippy -p buzz-acp --all-targets -- -D warnings` | clean |
| `cargo fmt --check` | clean |

Note: the pre-push hook `mobile-test` gate fails on `origin/main` before
this branch (Flutter test in `channels_page_test.dart` /
`compose_bar_test.dart` — verified independently). My changes touch only
`crates/buzz-acp/src/`; the mobile failure is unrelated and
pre-existing.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
…s with optional NIP-44 lock (block#3278)

## Agent Trading Cards

"Create Agent Card" action in the agent panel that mints an AI-generated
trading card PNG which **is** the agent: the card carries the
`buzz_agent_snapshot` tEXt chunk and is drag-in importable like any
snapshot PNG.

### What's in here
- **Mint pipeline (Rust):** one OpenAI Responses call — `gpt-5.6-sol` as
card designer with `gpt-image-2` via the `image_generation` tool (~2–3
min). New `mint_agent_card` / `save_agent_card` commands; preview with
reroll; save or send as `.agent.png` with round-trip verification before
any bytes leave the app.
- **Snapshot/chunk work stays in Rust,** reusing the existing
encoder/decoder seams (byte-compat golden vector proves the plain path
is identical to the pre-envelope encoder for placeholder, PNG-injection,
and JPEG-transcode paths).
- **Locked cards (NIP-44):** optional `buzz-agent-snapshot-encrypted`
envelope encrypted to the (owner, agent) pair. `parse_canonical_pubkey`
performs lift-x curve validation before any API spend; wrong-key decrypt
returns a fixed refusal; the plain decoder refuses locked cards.
- **Guardrails:** 10 MiB ceiling on final bytes, memory structurally
`none` in the snapshot, full-manifest import disclosure, API-key hygiene
via env layering (record > persona > global > process), fail-early
validation ordering (all key/lock/NIP-44-cap checks before Responses
spend).
- **Import side:** full-manifest disclosure dialog, locked-card import
disclosure, bounded avatar fetch.

### Review
Code reviewed by Wren across the full arc; final locked-card
cross-review **APPROVED 9/9/9** at exactly this head (`64f819dc8`), with
independent same-SHA verification: Rust lib 1,843/1,843, clippy
`--all-targets -D warnings`, desktop file-size gate.

### Live-mint evidence (real API, shipping seams, this SHA)
- **Plain (Honey):** 188s, 1500x2250, 5,101,503 bytes (< 10 MiB);
decoded manifest == built manifest; memory=none.
- **Locked (Fizz):** 176s, 4,670,184 bytes; owner-key and agent-key
decrypt both verified via logical manifest compare; wrong-key refusal
exact; plain decoder refuses.
- **Live finding:** built-in agents' ~171 KB inline avatars exceed the
NIP-44 65,535-byte plaintext cap and the fail-early guard fires before
API spend — clean error path, noted as a UX follow-up for large-avatar
agents choosing lock.

Full evidence (cards + dialog screenshots) posted in the originating
thread.

---------

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
## Context

On the first huddle after launching Buzz Desktop, a live agent reply can
arrive after agent membership is known but before the initial
TTS-enabled state has loaded. The subscription previously released
buffered messages at the membership boundary, so that first reply was
evaluated while speech was still disabled and was silently skipped.
Later replies worked, and later huddles usually worked because the state
was already warm.

## Summary

Hold initial live agent replies until both authoritative agent
membership and the initial TTS state are known. This preserves the first
eligible reply after a cold app launch without changing live-only
routing, ordering, or fail-closed behavior.

## Changes

- Replace the membership-only startup gate with a two-signal readiness
gate for membership and TTS state.
- Release buffered live messages in arrival order only after both
signals resolve.
- Drop buffered messages if either initial lookup fails.
- Add a deterministic regression for the observed ordering: membership
resolves first, TTS enables second, and the first reply is spoken.

## Related issue

None found.

## Testing

Manual validation in the daily-driver build confirmed that the first
agent reply is spoken in the first huddle after a fresh app launch.

The regression scenario was also run against both revisions:

```text
main: FAIL — actual spoken replies: []; expected: ["first agent reply"]
PR:   PASS — 10 passed, 0 failed
```

## Screenshots

N/A, nonvisual speech behavior.

## Reviewer-reproducible examples

1. Quit Buzz Desktop completely.
2. Reopen it with Pocket TTS enabled.
3. Start the first huddle of the session with a running agent.
4. Send a prompt that produces a spoken agent reply immediately after
the huddle starts.
5. Confirm the first reply is spoken, not only the second reply.
6. Stop the huddle, start another one, and confirm subsequent huddles
retain the same behavior.

For a deterministic red/green check, run the same
membership-before-TTS-state ordering from `desktop/`.

On `main`:

```bash
node --import ./test-loader.mjs --experimental-strip-types --input-type=module -e '
import assert from "node:assert/strict";
import { createInitialMembershipGate, createOrderedSpeaker } from "./src/features/huddle/lib/ttsLiveMessages.ts";
const spoken = [];
const speaker = createOrderedSpeaker(async text => spoken.push(text), error => { throw error; }, false);
const gate = createInitialMembershipGate(text => speaker.enqueue(text, 1));
gate.push("first agent reply");
gate.succeed();
speaker.setEnabled(true);
await new Promise(resolve => setTimeout(resolve, 0));
console.log("spoken:", JSON.stringify(spoken));
assert.deepEqual(spoken, ["first agent reply"]);
'
```

Observed failure:

```text
spoken: []
AssertionError: Expected values to be strictly deep-equal
```

On this PR branch:

```bash
node --import ./test-loader.mjs --experimental-strip-types --input-type=module -e '
import assert from "node:assert/strict";
import { createInitialTtsReadinessGate, createOrderedSpeaker } from "./src/features/huddle/lib/ttsLiveMessages.ts";
const spoken = [];
const speaker = createOrderedSpeaker(async text => spoken.push(text), error => { throw error; }, false);
const gate = createInitialTtsReadinessGate(text => speaker.enqueue(text, 1));
gate.push("first agent reply");
gate.markMembershipKnown();
speaker.setEnabled(true);
gate.markTtsStateKnown();
await new Promise(resolve => setTimeout(resolve, 0));
console.log("spoken:", JSON.stringify(spoken));
assert.deepEqual(spoken, ["first agent reply"]);
'
```

Observed output:

```text
spoken: ["first agent reply"]
```

---------

Signed-off-by: John Tennant <jtennant@squareup.com>
…ck#3909)

## Problem

Sharing compute with a large model (e.g. `gemma-4-26B`) put the desktop
app into a **restart loop**: toggle Share → app appears to "download" /
stall → the whole app restarts → repeat. Small models (E4B) were
unaffected, which made it look model-specific and flaky.

It is not model-specific and not flaky. It is a **false-positive
liveness check**.

## Root cause (proven by black-box measurement)

A `serve` node's OpenAI ingress (`:9337`) serializes **all** HTTP —
including the `/v1/models` liveness probe — behind the current in-flight
inference. It is *also* HTTP-unresponsive during model load and
package-layer download. In every one of those phases the node is alive
and progressing, but it cannot answer an HTTP probe.

Measured on a standalone `gemma-4-26B` node (randomized ~30k-token
prompt, cache-miss):

| during one ~30s inference | result |
|---|---|
| concurrent `GET /v1/models` | **27.0s**, then 200 |
| concurrent small `/chat/completions` | **28.8s**, then 200 |
| `tcp_connect(:9337)` throughout | **~0ms** |

Both HTTP calls simply queued behind the turn; TCP kept accepting
instantly. A probe with any timeout shorter than the turn reads the node
as dead.

Buzz then acted on that false "dead" reading in two places, **both
restart paths added in block#2823**:

1. **Ingress watchdog** — after 2 consecutive `/v1/models` timeouts,
evicts the node; for a serve node eviction means
`app.request_restart()`. Two dead probes landing inside a prefill window
→ restart loop.
2. **Start / restore paths** — on a `wait_for_mesh_inference` timeout,
`stop()` the node and (fresh start) `request_restart()` the app "to
guarantee cleanup" — even though the node was still loading weights or
downloading layers. This is the exact line in the incident log: `started
node failed inference readiness … Buzz is restarting`.

## Fix

Treat a **bound TCP port as alive**. Death has exactly one unambiguous
signal: a *closed* port.

- **Watchdog** (`recovery.rs`): only `PortClosed` may evict. A
bound-but-HTTP-unresponsive `Unhealthy` port is never evicted, at any
probe streak or urgency. Closed-port eviction is unchanged.
- **Start / restore** (`commands/mesh_llm.rs`): install the runtime
**before** probing readiness (so it is always tracked by `AppState` and
can never be orphaned — which is what the restart was guarding against),
and on a readiness timeout **leave it warming up** instead of
stopping/restarting. Launch-restoration stays disarmed until real
inference is confirmed, so a genuinely broken start is retried next
launch rather than silently disabling Share Compute.

### What this deliberately does *not* do

Detecting a node that is bound-but-internally-wedged needs a liveness
signal that bypasses the inference lock. There is none today, so this
fix cannot distinguish "wedged" from "busy" and errs toward not
restarting. That gap is a mesh-llm bug, filed upstream:
**Mesh-LLM/mesh-llm#1126** (lock-free `/live`+`/ready` on the ingress).
A follow-up here can consume it once it lands.

## Tests

- Watchdog never evicts a bound/busy port at any probe streak or urgency
(the regression).
- Closed-port eviction still fires (dead listener still reclaimed).
- Black-box: a listener that accepts TCP then stalls HTTP classifies as
`Unhealthy`, not `PortClosed`.
- **Mutation-proven**: reverting the eviction rule to the old
count-based logic fails the busy-node test.

`cargo test` (desktop, `--features mesh-llm`) green, fmt + clippy clean.

## Not covered here

The intermittent nature means I could not force the live loop
deterministically on a warm machine; the proof is the measured
serialization + the mutation-proven unit/black-box tests. Live behaviour
(app no longer restarts while a 26B node loads/serves) still merits a
manual check before merge.

---------

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
## Summary

Points the Oh My Pi preset at the `omp.sh` installation page instead of
the GitHub repository.

The project serves its current installer from `omp.sh/install.sh`.

### Related issue

Extracted from the maintainer request in block#3111. I found no matching open
pull request in a final duplicate check.

### Testing

`https://omp.sh/` returned HTTP 200 with the installation page.

`https://omp.sh/install.sh` resolved to the current installer and
returned HTTP 200.

`cargo test --manifest-path desktop/src-tauri/Cargo.toml preset_entry --
--nocapture` passed 5 tests.

`just ci` passed.

This changes metadata only, so screenshots do not apply.

Signed-off-by: Shreyash Vengurlekar <262980978+kiranmagic7@users.noreply.github.com>
Co-authored-by: Shreyash Vengurlekar <262980978+kiranmagic7@users.noreply.github.com>
Adds a **"I want my own hosted relay"** path to *Getting started* with a
one-click Railway deploy button.

Buzz today asks anyone who wants a real relay to take the
build-from-source route. This gives non-developers a hosted option: the
template provisions the relay plus Postgres, Redis, and media storage,
runs migrations, and generates the owner identity on first boot — no
configuration.

The listing is flagged **community-maintained, not an official Block
build**, so there's no implied ownership. Happy to adjust wording,
placement, or drop the button and keep just a link if you'd prefer.

Template deploys green end-to-end; the owner key is surfaced as a
paste-ready `nsec1…` in the deploy logs, and one deployment can host
multiple communities by hostname.

_Note: this supersedes the stale block#984 — that template modeled a
since-removed Typesense service and didn't run migrations._


### Checklist

`README.md` only, +8 −0 — no source files touched, so the build/test
items don't apply.

- [x] `just ci` passes (fmt + clippy + unit tests + mobile) — n/a, no
code changed
- [x] Integration tests pass (`just test`) — n/a, no code changed
- [x] New public APIs / tools / endpoints are documented — none added
- [x] No new `unwrap()` in production code paths
- [x] No new `unsafe` blocks

### How to verify

Click the button in the rendered README. The template stands up the
relay
with Postgres, Redis and media storage wired, runs migrations, and
prints the
owner key once in the deploy logs. Walkthrough with screenshots:
https://hmseeb.github.io/buzz-railway

---------

Signed-off-by: Haseeb Azhar <hsbazr@gmail.com>
Co-authored-by: Tyler <109685178+tlongwell-block@users.noreply.github.com>
…#4012)

## Problem

Threaded replies "disappeared" from archived Buzz channels: the **"N
replies →"** summary row and the huddle-started **"View thread"** button
vanished, so existing threads were unreachable from the channel
timeline. The thread data was intact — this was a UI gate, not data
loss.

## Root cause

A single `onReply` prop drove two distinct affordances:
- the **compose** affordances (hover "Reply" button, inline reply
target), and
- the **view** affordances ("N replies →" summary row, huddle "View
thread").

`ChannelPane` nulls `onReply` on archived channels to keep them
read-only. That correctly hid composing — but also hid the view
affordances, since they keyed off the same prop.

## Fix

Two independent props, one per concern:

- **`onReply`** drives the compose affordances and is gated on
`archivedAt` — nulled on archived channels, so no new replies can be
started.
- **`onOpenThread`** drives the view affordances and is passed
regardless of archived state, threaded `ChannelPane → MessageTimeline →
TimelineMessageList → MessageRow`.

Opening a thread on an archived channel is read-only: the thread panel's
composer is independently gated via `isComposerDisabled` (includes
`archivedAt !== null`, `ChannelPane.tsx:318`).

### Before
<img width="811" height="794" alt="Screenshot 2026-07-31 at 20 26 00"
src="https://github.com/user-attachments/assets/670d9db4-30da-4c6d-97dc-275b5dbebca8"
/>

### After
<img width="873" height="791" alt="Screenshot 2026-07-31 at 20 28 04"
src="https://github.com/user-attachments/assets/88525231-2539-4eb3-8117-8e58a0cb3855"
/>

## Validation

- `pnpm typecheck` clean
- biome lint clean on touched files
- full `pnpm test` suite green (3885 tests)
- pre-push `branch-skew` / `desktop-check` / `desktop-test` hooks passed

Signed-off-by: Trey Wood <treyw@squareup.com>
Co-authored-by: npub14h0tw3uj7jm77qfxcwn6um2s5h55l0klrt2w9srzp3m3yvjc0mpsjsuk6e <addeb74792f4b7ef0126c3a7ae6d50a5e94fbedf1ad4e2c0620c771232587ec3@buzz.block.builderlab.xyz>
…Reading (block#2613)

## Problem

Three small documentation defects, each verified against the code at
06e3d82:

1. **ARCHITECTURE.md (Event Kinds section)** says `buzz-core` defines
"all 81 kinds". The registry has grown: `ALL_KINDS` in
`crates/buzz-core/src/kind.rs` now has **127** entries (all unique
values). The sentence also says every kind is `pub const KIND_*`, but
registry entries such as `RELAY_ADMIN_ADD_MEMBER` do not use that
prefix.

2. **NOSTR.md Quick Start** numbers its steps 1, 2, 3, 5 — there is no
step 4. PR block#797 (2a03851) collapsed the old steps 1-4 (dropping the
separate "Start infrastructure" step) into 1-3, but the final "Connect
any NIP-29 + NIP-42 client" comment kept its old number 5.

3. **NOSTR.md "Further Reading"** is an empty heading — the section's
only content (a link to `crates/buzz-proxy/README.md`) was removed in PR
block#1321 (14fba21) along with the proxy crate itself, leaving a dangling
header as the last line of the file.

## Fix

1. Reworded the ARCHITECTURE.md sentence to defer to
`crates/buzz-core/src/kind.rs` as the source of truth, with the current
count (127) as an explicit "at the time of writing" snapshot, so the
sentence stays truthful as kinds are added. Also removed the incorrect
`KIND_*`-naming claim.
2. Renumbered the final quick-start step 5 → 4.
3. Populated Further Reading with three durable links: the upstream
nostr-protocol/nips repo, this repo's `docs/nips/` extension documents,
and `ARCHITECTURE.md`.

Docs-only; no code changes, no build impact.

## Verification (each claim ~30 seconds)

- Kind count: `python3 -c "import re;
s=open('crates/buzz-core/src/kind.rs').read(); m=re.search(r'ALL_KINDS:
&\[u32\] = &\[(.*?)\];', s, re.S); print(len([e for e in
m.group(1).split(',') if e.strip()]))"` → 127. All 127 values are
distinct. Non-`KIND_*` entry example: `RELAY_ADMIN_ADD_MEMBER` (kind.rs,
in `ALL_KINDS`).
- Missing step: `grep -n '^# [0-9]' NOSTR.md` on main shows `# 1.`, `#
2.`, `# 3.`, `# 5.` in the Quick Start block; `git show 2a03851 --
NOSTR.md` shows the renumbering that orphaned step 5.
- Empty section: `tail -1 NOSTR.md` on main is `## Further Reading` with
nothing after it; `git log -S'buzz-proxy/README' --oneline -- NOSTR.md`
shows the content removal in 14fba21 (block#1321).

## Links

- `crates/buzz-core/src/kind.rs` — `ALL_KINDS` registry (source of truth
for the count)
- PR block#797 / 2a03851 — introduced the step-numbering gap
- PR block#1321 / 14fba21 — emptied the Further Reading section

Signed-off-by: Sean Gearin <sgearin@gmail.com>
Co-authored-by: Sean Gearin <sgearin@gmail.com>
)

The channel scoping note in `AGENTS.md` reads as universal:

> **Channel scoping**: Channels use `h` tags (NIP-29 group tag), not `e`
tags.
> Filters and queries must scope to `h` tags when operating within a
channel.

It holds for events inside a channel, but not for the addressable events
that
describe one. kind:39000, kind:39001 and kind:39002 carry the channel id
in
their `d` tag, which is what `get_channels` already reads.

Taking the existing wording at face value while working on kind:39002
produces
an empty result rather than an error, since those events do carry `h`
tags in
other flows, so the mistake is quiet and costs a debugging cycle. Came
up while
working on block#4023.

Four lines, no behaviour change.

Signed-off-by: Szymon Tanski <szymontanski8@gmail.com>
…d:9033) (block#3998)

## Problem

The desktop deliberately shows the workspace icon editor on open relays
(block#2640, gate: `canEditIcon` in
`desktop/src/features/communities/ui/EditCommunityDialog.tsx`) and
defers to the relay-side kind:9033 check — which required an admin/owner
row in `relay_members`. For a community with **no admin/owner row at
all** (the `ensure_configured_community` path, which never writes an
owner), every 9033 was refused and the icon was permanently unsettable.

**Correction from review (thanks @dawn):** the original version of this
PR claimed nobody holds a role on an open relay. That's false —
`main.rs` bootstraps `RELAY_OWNER_PUBKEY` as owner regardless of
`BUZZ_REQUIRE_RELAY_MEMBERSHIP`, so a production open relay like
bb-block *does* have an owner row, and the old gate was refusing
everyone except that owner. The first revision of this diff would have
silently widened that owner-only control to any NIP-42-authenticated
sender.

## Fix — steward-wins

`may_set_workspace_profile(sender_role, membership_enforced,
community_has_steward)`:

| Relay mode | Community has admin/owner row? | Who may set the icon |
|---|---|---|
| Closed (`require_relay_membership=true`) | any | admin or owner
(unchanged) |
| Open | yes (e.g. bb-block) | admin or owner (unchanged posture) |
| Open | no (genuinely rosterless) | any NIP-42-authenticated sender |

- New DB helper `has_admin_or_owner(community)`
(`crates/buzz-db/src/relay_members.rs`); the call site only queries it
on open relays.
- The rosterless admit logs a `warn!` with the sender pubkey — 9033
writes no audit row and publishes no announcement event (unlike
9030/9031), so this is the only durable attribution.
- Kinds 9030–9032, NIP-42 auth, `AdminUsers` scope, ban gate, and icon
validation are all untouched.
- Doc comment fixed: cited nonexistent `canEditCommunityProfile`; real
symbol is `canEditIcon`.

## Test coverage — closing the mutation gap

Dawn's mutation testing showed the original unit tests pinned only the
helper's truth table: inverting the flag at the call site or deleting
the gate entirely survived the full suite.

- Unit tests now cover the 3-arg truth table (closed
steward-independent, open-with-steward stays steward-only,
rosterless-open admits).
- Two `#[ignore]`d Postgres integration tests drive
`handle_relay_admin_event` with a real `AppState` (open rosterless admit
→ steward appears → roleless refused again; closed relay member
refused). Wired into the Backend Integration CI job as a dedicated
nextest step.
- **Both of Dawn's mutants verified killed** at this head: flag
inversion fails 1 unit test; gate deletion fails both integration tests
(`Ok(())` where `Rejected` expected).

## CI wrinkle found and fixed: pre-existing schema drift

The first Backend Integration run of the new 9033 tests failed with
`column "icon" of relation "communities" does not exist` — migration
`0003_community_icon.sql` added the column, but `schema/schema.sql` (the
desired-state file that CI job applies via pgschema) was never updated.
Pre-existing drift, invisible until a test in that job actually wrote
the column. Fixed in `297148f62` (3-line addition to
`schema/schema.sql`).

## Receipts (at `1b4b52db8` code / `297148f62` head)

- `cargo test -p buzz-relay`: 835 pass, 1 fail —
`api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo`,
pre-existing (fails identically at the old base and on clean main);
`telemetry::trace_context_lookup_does_not_enable_callsites` is a known
order-dependent flake, passes in isolation.
- `cargo test -p buzz-db`: 94 pass.
- Both ignored integration tests pass live against local Postgres.
- `cargo fmt --all -- --check`: clean.
- Live-local pass per TESTING.md at this head (release build, relay on
:3199, real WS + NIP-42 via nak):
- open rosterless: roleless key sets icon → NIP-11 serves it; `warn!`
with sender pubkey in the relay log
- open + owner row inserted: fresh roleless key refused ("must be admin
or owner"); owner sets icon
- closed relay (owner bootstrapped, `BUZZ_RELAY_PRIVATE_KEY` set): plain
member refused, owner sets icon, `javascript:` URL rejected, empty icon
clears (NIP-11 → null)

---------

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
…lock#3481)

## Summary

The "I just want to try the app" section names platforms generically
(macOS `.dmg`, Linux `.AppImage` / `.deb`, Windows `.exe`), but the
release publishes five assets, including two separate macOS builds. A
first-time user on a Mac has no way to tell whether they need `aarch64`
or `x64`, and nothing sets expectations for the SmartScreen warning on
the unsigned Windows build.

This replaces that sentence with a platform-to-filename table, a
one-line note on how to check which Mac you have, and a note that the
Windows build is unsigned and what the warning looks like.

Filenames use `<version>` rather than `0.5.0` so the table doesn't go
stale each release.

### Related issue

None found. Searched open issues and PRs for README/download/install
topics.

### Testing

Docs-only change, no code paths touched. Verified the table and
paragraph breaks render correctly in GitHub's markdown preview.

---------

Signed-off-by: Dan Sheehan <dannysheehan90@gmail.com>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
… repoURL + path) (block#3426)

## Problem

`examples/argocd-app.yaml` uses the split form:

```yaml
repoURL: oci://ghcr.io/block/buzz/charts
chart: buzz
targetRevision: 0.1.0
```

On ArgoCD >= 3.0 (native OCI sources), the `chart` field is **ignored**
for `oci://` repoURLs, so ArgoCD tries to pull the `charts` path itself
and fails with `403 … repository:block/buzz/charts:pull denied` — a
misleading error that reads like an auth problem. Additionally, spec
validation rejects the Application without a `path`
(`spec.source.repoURL and either spec.source.path or spec.source.chart
are required`), since `chart` isn't recognized for OCI.

Hit both on ArgoCD 3.4.4 following the example verbatim.

## Fix

Use the full chart artifact path as `repoURL`, add `path: "."`, bump the
pinned example version to the latest published chart (0.1.6), and leave
a comment explaining both traps:

```yaml
repoURL: oci://ghcr.io/block/buzz/charts/buzz
path: .
targetRevision: 0.1.6
```

Verified working in production (ArgoCD 3.4.4, anonymous GHCR pull, chart
0.1.6).

Related open PRs/issues: none found.

---------

Signed-off-by: Kampe <blindside328@gmail.com>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Co-authored-by: Kampe <blindside328@gmail.com>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
…lock#3487)

## What this fixes

`fan_out_scoped` (`crates/buzz-relay/src/subscription.rs:278-394`)
enforces a deliberate, symmetric scoping invariant — documented in the
code itself:

> Global subscriptions (channel_id = None) do NOT receive channel-scoped
events. Channel-scoped subscriptions do NOT receive global events.

The relay derives a reaction's stored channel from its `#e` target at
ingest — client-supplied `#h` is ignored for channel determination
(`NOSTR.md:50` documents this for *writing*). The consequence for
*reading* is that every reaction is a channel-scoped event, so a live
subscription `{"kinds":[7]}` without `#h` is a global subscription and
**silently receives no reactions at all** — no error, no CLOSED, just
nothing. The working form is `{"kinds":[7],"#h":["<channel-uuid>"]}`,
and it works regardless of how the reaction was signed: explicit `h`
tags on the event are matched directly, and tagless reactions match via
the stored channel fallback (`crates/buzz-core/src/filter.rs:78-91` —
fallback applies only when the event has no `h` tags; explicit tags are
authoritative).

`NOSTR.md` already documents this exact pitfall for group-metadata
events:

> **Note:** Channel-scoped storage means live global subscriptions
(`{kinds:[39000]}`) won't receive these via fan-out.
(`NOSTR.md:124-126`)

…but has no equivalent note for reactions, which is the case a
bot/integration author is far more likely to hit: any client that wants
to observe approvals/reactions live (workflow reaction-triggers make
this a first-class pattern in Buzz) will naturally try a kinds-only REQ
first and conclude reactions are broken. We lost real debugging time to
exactly this while building a headless integration
(https://github.com/OriginTrail/buzz-dkg-integration); the behavior is
by design, only the docs are missing.

## What this PR changes

Docs only (`NOSTR.md`): a subscribe-to-reactions example in "Sending
Messages", plus one note mirroring the existing 39000 note. No code
changes.

## How to verify

- Behavior: with the relay running, open a live REQ `{"kinds":[7]}` (no
`#h`) and react to a channel message from another client → nothing is
delivered; re-subscribe with `{"kinds":[7],"#h":["<channel-uuid>"]}` →
the reaction arrives.
- Claims against code (verified at `485d03a`): scoping invariant
`crates/buzz-relay/src/subscription.rs:386-393`; channel derivation
`derive_reaction_channel()` in
`crates/buzz-relay/src/handlers/ingest.rs`; `#h` fallback
`crates/buzz-core/src/filter.rs:78-91` and its test
`h_tag_fallback_uses_stored_channel_id`.

Duplicate search: no existing issue/PR found for `reactions
subscription`, `fan-out kinds` (searched 2026-07-29). DCO signed-off.

---------

Signed-off-by: Žiga Drev <ziga.drev@gmail.com>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Co-authored-by: Žiga Drev <ziga.drev@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Bump `nostr-relay-pool` from 0.44.1 to 0.44.2 to clear
[RUSTSEC-2026-0224](https://rustsec.org/advisories/RUSTSEC-2026-0224),
which addresses verification-cache poisoning that could let forged Nostr
events bypass signature validation on redelivery.

The dependency is transitive through `nostr-sdk`; this PR updates only
the corresponding package version and checksum in `Cargo.lock`. The
advisory currently marks every open PR red until this fix merges.

- `cargo test -p buzz-sdk -p buzz-cli` passes: 271 + 241 tests
- `cargo deny check advisories` passes
- `just fmt-check` passes

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub16v54tttfqacx9ycvc3k0ut0npj564ahcuajzy6qjvh57ntmsf4uq4806j2 <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@buzz.block.builderlab.xyz>
…ck#4124)

## Summary

Route `Db::is_relay_member` — the membership check that runs on every
authenticated HTTP request and WS AUTH — through the standard
`route_read` machinery on the bounded arm, instead of adding a bespoke
cache (replaces block#3844).

- `crates/buzz-db/src/relay_members.rs`: add `is_relay_member_on(&mut
PgConnection, ...)` executor seam; the pool version delegates to it.
- `crates/buzz-db/src/lib.rs`: `Db::is_relay_member` now routes via
`route_read("relay_membership", RoutePredicate::Bounded)` — replica only
on a proved fresh session, writer on any route rejection, writer re-run
on replica query error. Exactly the shape of every other routed read.

This is the one permission read served from the replica, by explicit
product decision (Tyler accepted ≤1s bounded staleness on reads we
choose): the fleet-wide fence guarantee (`BUZZ_REPLICA_READ_MAX_AGE_MS`,
deploy target 1s) is an order of magnitude tighter than the 10s TTL
proposed in block#3844 and needs no invalidation machinery. Staleness is
symmetric for admits and revokes. `BUZZ_REPLICA_READ_MAX_AGE_MS` unset =
writer-only = kill switch. It is not precedent for routing other
permission reads.

## Validation

At this exact commit (`git rev-parse HEAD` confirmed in the same shell,
rustc 1.95):

- `cargo test -p buzz-db` — 94 passed, 0 failed
- PG-gated suite single-threaded — **151 passed, 2 failed**; the 2
failures are the per-owner-limit tests broken on main by block#3829 (limit
3→5, tests still seed 3) — they fail identically at base `19d57b0d4` in
a pristine control checkout; separate trivial fix to follow
- New PG-gated test `is_relay_member_is_bounded_routed_and_fails_closed`
— divergent writer/replica fixtures prove: budget unset ⇒ writer; budget
set + fresh proof ⇒ replica; over-budget entry ⇒ writer
- clippy `-D warnings` + fmt clean; pre-push hooks green (desktop
check/test, rust tests, tauri checks)
- **Live-local pass** (TESTING.md, release binary,
`BUZZ_REQUIRE_RELAY_MEMBERSHIP=true`, fresh DB):
- writer-only (no `READ_DATABASE_URL`): member accepted, outsider 403
`relay_membership_required`; metrics
`route_decision{path="relay_membership",decision="writer",reason="disabled"}`
- replica configured + `BUZZ_REPLICA_READ_MAX_AGE_MS=1000`: member
accepted / outsider denied via `decision="replica",reason="fresh"`;
admit visible to the routed check within ~1.2s; revoke enforced within
~1.2s
- reader outage mid-flight (TCP proxy killed): member send still
succeeds in <200ms via
`decision="writer",reason="reader_acquire_timeout"`; outsider still
denied — fails closed, no availability loss

Reviewed by Wren: 9/10 minimalness, 9/10 elegance, 9.5/10 correctness at
this SHA.

Supersedes the 10s-cache approach in PR 3844, which should be closed
unmerged once this lands.

Signed-off-by: Tyler <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
## What

`BUZZ_AUTH_TAG` stored in the **raw Nostr tag form** `[auth,hex,,hex]`
(unquoted, comma-delimited — how an `auth` tag serializes inside a Nostr
event and how `.env` files commonly store it) was rejected by the CLI:

```
BUZZ_AUTH_TAG is malformed: invalid JSON: expected value at line 1 column 2
```

…and even when the CLI *could* parse it, it forwarded the raw string as
the `x-auth-tag` header, so the relay's `verify_auth_tag` (which expects
JSON) rejected it with `403 relay_membership_required`.

Two commits close both gaps.

## Commits

### 1. `fix(nip-oa): accept raw Nostr tag form in parse_json_array`

`parse_json_array` (`crates/buzz-sdk/src/nip_oa.rs`) only accepted
well-formed JSON arrays. Added a fallback: when strict JSON parsing
fails *and* the trimmed input is bracket-delimited, split on `,` and
treat each field as a string (empty field `,,` → empty string, matching
`["auth","hex","","hex"]`). All consumers (`parse_auth_tag`,
`verify_auth_tag`, the CLI, `buzz-acp`) benefit from one change at the
lowest layer.

### 2. `fix(cli): canonicalize BUZZ_AUTH_TAG to JSON before sending
x-auth-tag header`

The CLI stored the raw input string and sent it verbatim as the
`x-auth-tag` header (`client.rs:618`). Added `canonicalize_auth_tag` in
`buzz-sdk`: parse either form, re-serialize to canonical JSON. The CLI
now canonicalizes before storing as `auth_tag_json`, so the header is
always valid JSON regardless of input form.

Together: local parse + wire canonicalization means the raw form works
end-to-end.

## Why

The raw form `[auth,hex,,hex]` is exactly how an `auth` tag serializes
inside a Nostr event. That shape leaks into `.env` files and shell
variables because there's no canonical "stored form" outside an event.
The SDK + CLI should accept it rather than push quoting/conversion logic
onto every consumer (harnesses, agent shells, external tools).

## Security

Both changes are purely syntactic — they only change how a 4-element
string array is extracted and containerized. All downstream validation
is unchanged:
- `parse_auth_tag`: still checks exactly 4 elements, `"auth"` label,
64-char lowercase-hex pubkey, 128-char signature.
- `verify_auth_tag`: still reconstructs the preimage and verifies the
BIP-340 Schnorr signature against the owner pubkey.

No new attack surface — a malformed or forged tag is still rejected at
the same validation points.

## Tests

4 new tests in `nip_oa::tests`:
- `test_parse_auth_tag_raw_nostr_form` — raw form with conditions +
empty conditions
- `test_parse_auth_tag_raw_form_with_whitespace` — raw form with
surrounding whitespace
- `test_canonicalize_auth_tag_raw_to_json` — raw→JSON and JSON→JSON
normalization

All 25 `nip_oa` tests pass (21 existing + 4 new). `cargo fmt --check`
and `cargo clippy -p buzz-sdk -p buzz-cli` clean.

## Verification

Confirmed end-to-end against a live community relay
(`wss://hermesagent.communities.buzz.xyz`):
- **Before:** raw `BUZZ_AUTH_TAG` → CLI parse error, or `403
relay_membership_required` if somehow parsed.
- **After:** raw `BUZZ_AUTH_TAG` → CLI parses it, canonicalizes to JSON
for the header, relay accepts via NIP-OA owner delegation, `buzz
channels members` returns the full roster.

## Context

Originated from a community investigation where agent-side relay access
was failing because the harness-exported `BUZZ_AUTH_TAG` (raw Nostr
form) was rejected by the CLI (expecting JSON). This removes the
impedance mismatch at the source.

---------

Signed-off-by: amanning3390 <adam.manning@pro-serveinc.com>
Signed-off-by: Tyler <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler <109685178+tlongwell-block@users.noreply.github.com>
wpfleger96 and others added 21 commits August 5, 2026 16:58
…block#4946)

Provider `context_length_exceeded` 400s permanently wedged agent
sessions: the turn errored, the oversized history persisted in the
in-memory session, and the usage baseline stayed frozen at the last
successful sub-threshold reading (failed requests report no usage), so
the preflight handoff gate never fired again — every later prompt failed
identically until an agent restart. The byte-truncation fallback never
intervened because it is a request-body limiter (`estimated_bytes`), not
a context-window defence; at context-window scale it is a measured
no-op.

This adds the reactive recovery path:

- **Typed classification.** `AgentError::LlmContextExceeded` is
classified at both non-success provider terminals — the shared `post()`
(Anthropic, OpenAI, Databricks) and `openrouter_post()` — on `status ==
400` plus a context-window body match, so ordinary 400s stay terminal.
- **Forced handoff.** A context-400 forces a summarize-handoff that
bypasses `should_handoff()` and `BUZZ_AGENT_MAX_HANDOFFS`, bounded by
its own per-turn budget (`MAX_CONTEXT_RECOVERIES_PER_RUN = 3`).
- **Shrink ladder.** The summarize prompt budget halves from the
observed rejected history size — not from `max_context_tokens`, the
number the provider just contradicted — rung to rung, with a 4096-byte
floor. A summarize call rejected for the same reason takes the next rung
instead of re-sticking. At the floor (overflow dominated by unshrinkable
frame: system prompt, tool schemas, live prompt) recovery is refused and
the provider error surfaces clearly instead of self-healing.
- **Baseline reset.** The stale usage baseline is cleared when a request
fails, so the preflight gate cannot stay frozen sub-threshold on
retries.

Named behavior changes:

1. **Anthropic and OpenRouter errors now carry the `(model)` stamp.**
Provider arms return their `Result` into the central error mapper
instead of early-returning past it, making the code match its documented
single-convergence contract at that mapper.
2. **`max_rounds` now counts completions the loop acts on.** A request
rejected with a context-400 that is then successfully recovered refunds
its round before the retry, paired 1:1 with a consumed recovery rung, so
the round cap is neither weakened nor able to drop a recovered turn
unanswered.

Related: block#4805 — the complementary proactive fix (per-session
handoff-cap kill switch that let sessions grow to the provider wall).
block#4805 prevents reaching the wall; this PR recovers at it.

---------

Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
**Category:** improvement
**User Impact:** People can leave their final Buzz community and return
to **Join or create a community** without losing their signed-in
identity.

**Problem:** Buzz Desktop blocked people from leaving when only one
community remained. Its existing remove action also changed local
configuration without ending relay membership.

**Solution:** Allow the final community to be left. Buzz now asks the
relay to end membership, removes the community locally only after
acceptance, and returns the person to the community selector while
keeping their identity signed in. If other communities remain, Buzz
switches to one of them. Relay rejection or timeout keeps the community
in place and shows an actionable retry error.

<details>
<summary>File changes</summary>

**desktop/src/features/communities/leaveCommunity.ts**
Adds signed kind 28936 publishing for active and inactive community
relays with actionable timeout handling.

**desktop/src/features/communities/leaveCommunity.test.mjs**
Covers event shape, relay selection, acceptance gating, rejection,
timeout messaging, and cleanup.

**desktop/src/features/communities/useCommunities.tsx**
Allows final-community removal and clears community-specific storage
without touching identity.

**desktop/src/features/communities/resolveCommunityRemoval.test.mjs**
Covers final, active, and inactive community removal state transitions.

**desktop/src/app/useCommunityNavigationTransitions.ts**
Gates local removal on relay acceptance and routes to a fallback
community or setup selector.

**desktop/src/app/AppShell.tsx**
Passes the asynchronous leave operation through shell entry points.

**desktop/src/features/communities/ui/EditCommunityDialog.tsx**
Replaces the local-only remove action with a pending-aware Leave
Community action that retains actionable errors.

**desktop/src/features/communities/ui/CommunitySwitcher.tsx**
Enables leaving the final community and carries the asynchronous
callback.

**desktop/src/features/sidebar/ui/AppSidebar.tsx**
Carries the asynchronous leave callback through sidebar props.

**desktop/src/features/sidebar/ui/CommunityRail.tsx**
Enables leaving the final community from rail settings.

**desktop/src/features/sidebar/ui/SidebarProfileCard.tsx**
Carries the asynchronous leave callback through profile community
settings.

**desktop/src/testing/e2eBridge.ts**
Teaches the mock relay to accept NIP-43 leave events.

**desktop/tests/e2e/community-rail.spec.ts**
Updates leave interactions and verifies final-community setup
navigation, storage cleanup, and identity preservation.

</details>

### Reproduction steps

1. Run Buzz Desktop with a signed-in identity and one joined community.
2. Open Community settings and choose **Leave Community**.
3. Confirm the app shows **Join or create a community** and the existing
identity remains signed in.
4. Repeat with two communities and confirm leaving the active one
switches cleanly to the remaining community.
5. Reject or withhold the relay `OK` response and confirm the community
remains configured with an actionable error in the dialog.

### Test plan

- `pnpm check`
- `pnpm build`
- `pnpm test` (3,913 passing)
- `pnpm build:e2e && pnpm exec playwright test
tests/e2e/community-rail.spec.ts --grep "final community"`


<img width="557" height="316" alt="image"
src="https://github.com/user-attachments/assets/b628182f-cba5-451d-ae4b-bee8d8dd19aa"
/>

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: npub14ndfusear8wdpe4kss8h7juc7wjk78atnqzf63zvppcpneknv4sq6x9370 <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Co-authored-by: npub14ndfusear8wdpe4kss8h7juc7wjk78atnqzf63zvppcpneknv4sq6x9370 <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
**Category:** fix
**User Impact:** Custom emoji with valid 64-character names can now be
used as reactions without errors.

**Problem:** Buzz accepted 64-character custom emoji names during
registration, but rejected them as reactions after the required
surrounding colons made the payload 66 characters. Validation also
differed between desktop, SDK, relay, and storage boundaries.

<img width="554" height="47" alt="image"
src="https://github.com/user-attachments/assets/4013452f-210e-4dd3-9003-f45ff3b28dc8"
/>

**Solution:** Keep the product limit at 64 ASCII characters for custom
emoji names, enforce it consistently when emoji sets are registered, and
allow only valid matching custom reaction payloads up to 66 characters.
Widen the reaction projection to preserve the wrapped payload while
retaining the existing 64-character limit for ordinary reactions.

<details>
<summary>File changes</summary>

**crates/buzz-sdk/src/builders.rs**
Defines the shared custom emoji boundaries and covers accepted
64-character and rejected 65-character shortcodes.

**crates/buzz-relay/src/handlers/ingest.rs**
Validates emoji-set shortcodes and permits 66-character reactions only
when they are valid colon-wrapped custom emoji with a matching tag.

**crates/buzz-db/src/event.rs**
Adds storage regression coverage for maximum-length custom emoji
reactions.

**crates/buzz-db/src/migration.rs**
Verifies the reaction column migration is applied correctly.

**desktop/src/shared/api/customEmoji.ts**
Enforces the existing 64-character shortcode maximum during desktop
normalization and registration/import.

**desktop/src/shared/api/customEmoji.test.mjs**
Covers the desktop shortcode boundary.

**migrations/0027_long_reaction_payloads.sql**
Widens stored reaction payloads to 66 characters for the two required
surrounding colons.

**schema/schema.sql**
Keeps the desired schema aligned with the migration.

</details>

## Reproduction Steps

1. Register or import a custom emoji whose ASCII shortcode is exactly 64
characters.
2. Select that emoji as a reaction to a message.
3. Confirm the reaction publishes, persists, and renders without an
error.
4. Attempt to register a 65-character shortcode and confirm it is
rejected.
5. Publish an ordinary or malformed reaction over 64 characters and
confirm the relay rejects it.

## Verification

- `cargo test -p buzz-sdk`: 243 passed
- `cargo test -p buzz-db`: 94 passed, 152 Postgres-required tests
ignored
- `pnpm test` in `desktop`: 3,859 passed
- `cargo test -p buzz-relay`: 795 passed, 9 existing
Postgres-unavailable failures, 35 ignored; new reaction boundary tests
pass directly
- `cargo fmt --all -- --check`
- `git diff --check`

Originating Buzz channel: `f2ec9671-d78e-4cde-894c-9f4c458c7f1f`

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
… 'Attach file' (block#2381) (block#4304)

Fixes block#2381.

## What was broken

The message composer's paperclip accepts generic attachments — images,
videos, PDFs, archives, and any other supported file — but its tooltip
and accessible name still read **"Attach image"**. Sighted users might
reasonably believe the control is image-only, and screen-reader users
get an incomplete description of what the button does.

## The fix

Rename the accessible name and tooltip text on the generic composer
paperclip in `MessageComposerToolbar.tsx`:

- `aria-label` — `"Attach image"` → `"Attach file"`
- `<TooltipContent>` — `"Attach image"` → `"Attach file"`

Plus update the 12 affected Desktop e2e selectors across five spec files
to reference the new accessible name:

- `desktop/tests/e2e/file-attachment.spec.ts` (2 selectors)
- `desktop/tests/e2e/spoiler.spec.ts` (2)
- `desktop/tests/e2e/composer-image-draw.spec.ts` (2)
- `desktop/tests/e2e/image-attachment-gallery.spec.ts` (4)
- `desktop/tests/e2e/video-attachment.spec.ts` (2)

## Scope (per the issue)

The feedback screenshot dialog
(`desktop/src/features/settings/ui/SendFeedbackDialog.tsx`) is
**unchanged** — that dialog itself is image-only, so its "Attach image"
wording is accurate. This PR only touches the generic composer control.

## Test plan

- All **105** unit tests in
`desktop/src/features/messages/ui/*.test.mjs` pass locally.
- Verified no remaining `"Attach image"` string outside the
intentionally preserved feedback dialog:
  ```sh
  grep -rn '"Attach image"' desktop/
  # → only hits in SendFeedbackDialog.tsx
  ```
- The six e2e specs are only exercised in CI; the selector updates are
mechanical and verified by grep to reference the new a11y name.

## Blast radius

- **Files touched**: `MessageComposerToolbar.tsx` (two strings); five
e2e spec files (12 selector updates).
- **User-facing behaviour**: one tooltip + one screen-reader name
change; no functional or visual changes otherwise.
- **No API or state change.**

## Out of scope

- The feedback dialog's "Attach image" wording — kept per the issue's
own "Scope" guidance.
- Any i18n plumbing — Buzz Desktop doesn't currently localize these
strings.

Signed-off-by: Sarthak Singh <sarthak.singh@juspay.in>
Signed-off-by: Ravneet Arora <rarora@squareup.com>
Co-authored-by: Ravneet Arora <rarora@squareup.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
**Category:** improvement
**User Impact:** Message usernames are now bolder, making it easier to
distinguish who said what at a glance.

**Problem:** Usernames and surrounding message metadata had too little
visual separation, which made message headers slower to scan.
**Solution:** Increase the shared message-author label from semibold to
bold while preserving its existing size, spacing, and interaction
behavior.

<details>
<summary>File changes</summary>

**desktop/src/features/messages/ui/MessageHeader.tsx**
Raises the shared message-author font weight so standard and system
message usernames gain consistent visual contrast.

</details>

## Reproduction steps

1. Open a channel containing messages from multiple people or agents.
2. Compare each message username with its timestamp and message body.
3. Confirm the username renders in bold while the surrounding typography
and layout remain unchanged.

## Screenshots

| Before | After |
| --- | --- |
| ![Message usernames
before](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4948/before-message-usernames.png)
| ![Message usernames
after](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4948/after-message-usernames.png)
|

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
**Category:** fix
**User Impact:** Expanded thread panels now stay fully visible within
the desktop channel area instead of being cut off.

**Problem:** The resize handler clamped the thread panel against the
full window width, even though the panel renders inside a narrower
channel surface. On a 1720px window, this allowed a 1160px requested
width where only 1111px could render, leaving persisted and visible
geometry out of sync.

**Solution:** Clamp resizing against the measured channel-surface width
so the stored width matches what the layout can render while preserving
the minimum 300px main pane.

<details>
<summary>File changes</summary>

**desktop/src/features/channels/ui/ChannelScreen.tsx**
Passes the measured channel-surface width into the thread-panel sizing
hook.

**desktop/src/shared/hooks/useThreadPanelWidth.ts**
Clamps drag-resize updates against the available channel width instead
of the full viewport.

**desktop/tests/e2e/threadpane-ultrawide.spec.ts**
Adds a 1720px regression proving the requested and rendered panel widths
match, while retaining the ultrawide expansion case.

</details>

### Reproduction steps

1. Open a channel thread in the desktop app at a 1720×900 window size.
2. Drag the thread panel's left resize handle toward the left edge to
expand it as far as possible.
3. Confirm the panel remains fully bounded inside the channel surface
and the main channel pane remains at least 300px wide.
4. Reload the channel and confirm the persisted expanded width renders
without clipping.

### Testing

- `pnpm --dir desktop build:e2e`
- `pnpm --dir desktop exec playwright test
tests/e2e/threadpane-ultrawide.spec.ts` — 2 passed
- Push hooks: `desktop-check` and `desktop-test` passed
- `git diff --check origin/main..HEAD`

### Screenshot

![Expanded thread panel remains bounded at
1720×900](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4965/threadpane-expanded-after-fix.png)

### Related issue

None found.

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
**Category:** improvement
**User Impact:** Selected communities now use a clear offset outline
without tinting or covering their icon.

**Problem:** The selected community state replaced the icon surface with
an accent fill, obscuring image icons and changing the tile's content
treatment. Hover also changed the fill, text color, shape, and opacity,
making navigation states visually jumpy.

**Solution:** Preserve each community tile's neutral surface and content
while using a primary CSS outline for selection and a lighter outline
for hover. The transparent outline offset leaves the space around image
edges unpainted, and adjusted spacing prevents neighboring outlines from
colliding.

<img width="200" height="152" alt="Screen Recording 2026-08-05 at 3 23
32 PM"
src="https://github.com/user-attachments/assets/5c25b1c0-4be8-41c4-8f1d-ad0010310c92"
/>


<details>
<summary>File changes</summary>

**desktop/src/features/sidebar/ui/CommunityRail.tsx**
Replaces selected and hover fills with offset outlines, keeps icon
presentation stable across states, and adjusts rail and tooltip spacing
for the new outline geometry.

**desktop/tests/e2e/community-rail.spec.ts**
Covers the shared active/inactive surface, radius, text color, opacity,
and outline behavior, including hover invariants.

</details>

## Reproduction steps

1. Run the desktop app with two or more communities.
2. Give the active community an image icon.
3. Confirm the active icon keeps its original image and receives a 2px
primary outline with a transparent 2px gap.
4. Hover another community and confirm only a lighter outline appears;
its fill, text color, opacity, and corner radius remain unchanged.
5. Switch communities and confirm the outline follows the active
community.

## Screenshots

**Full desktop context**

![Selected community outline in the desktop
app](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4969/selected-community-full.png)

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…ZZ_DRAIN_JITTER_MS) (block#4542)

## Problem

On SIGTERM the relay sends every live WebSocket a **1012 Service
Restart** close frame via `ConnectionManager::drain_all()` — all in the
same instant (`main.rs` shutdown task → `state.rs::drain_all`). On a pod
holding thousands of sessions, that makes every client reconnect
simultaneously: the thundering-herd reconnect behind the DB pool-timeout
bursts observed on each rolling deploy. Client-side jitter can't fix
this — the desktop client *resets* its backoff to base on a 1012 and
reconnects with only ±25% jitter (`relayClientSession.ts`), so the
spread has to come from the server.

## Change

Add `BUZZ_DRAIN_JITTER_MS` (default `0` = unchanged behavior). The two
paths are kept **deliberately separate** so the default is byte-for-byte
the previously shipped shutdown:

- **Jitter off (`0`/unset, the default):** the original synchronous,
all-at-once `drain_all()` runs unchanged — queue the 1012 on each
connection's control channel, cancel, return. No new machinery on the
default path.
- **Jitter on (`> 0`):** a separate async
`drain_all_jittered(jitter_ms)` spreads each connection's restart close
over an independent uniform delay in **`[1, jitter_ms]`**. Each delayed
close travels a dedicated `RestartClose` channel; the writer flushes the
1012 frame and **acknowledges the flush over a oneshot**, so drain waits
for confirmed delivery (up to `RESTART_CLOSE_ACK_TIMEOUT` = 5s) rather
than assuming it, falling back to cancellation if the channel is
full/closed or the ack times out. The drain future is **owned and
awaited** by the shutdown task, and the 30s hard-drain backstop is
aborted only after a clean drain — so a clean roll exits `0`.

The two methods can be unified and the old one dropped later once the
jittered path is proven for all cases.

- **`config.rs`** — `drain_jitter_ms`: non-negative parse, clamped to
`MAX_DRAIN_JITTER_MS` = **20s** (leaving 10s of the 30s budget for
flush). Junk fails loudly at startup; **empty/whitespace-only is treated
as unset (jitter off)** so a `BUZZ_DRAIN_JITTER_MS=""` kill switch does
not crashloop the relay (matches the sibling env vars in this file).
- **`state.rs`** — `drain_all()` (unchanged synchronous default) +
`drain_all_jittered()` (jittered + flush-ack). Both set the sticky
`draining` flag before the first await. A registration that lands
mid-shutdown always self-signals via the **immediate** control-frame +
cancel path — jitter smears already-established sockets, not late
arrivals.
- **`main.rs`** — shutdown task dispatches: `drain_jitter_ms == 0` →
`drain_all()`, else `drain_all_jittered(...).await`.

## Safety

- **Default off is the currently-committed path.** With jitter unset/0
the shutdown runs the original synchronous `drain_all()` — no restart
channel, no ack wait. Safe to deploy dark and dial up.
- **Shutdown-boundary race preserved.** Sticky flag set before any
await; a late registration self-signals its close with no jitter.
- **Owned + backstopped.** The jittered drain future is awaited; the 30s
hard-drain `process::exit(1)` remains the ceiling. `MAX_DRAIN_JITTER_MS`
(20s) + `RESTART_CLOSE_ACK_TIMEOUT` (5s) = 25s, inside the 30s budget;
5s pre-sleep + 25s = 30s against `terminationGracePeriodSeconds: 60`.

## Known behavior to note (not a blocker, flagged from review)

On a **successful** flush the jittered path deliberately does not cancel
the connection token — teardown then depends on the client echoing our
Close, or on process exit. Compliant clients echo; a silent client rides
to the 30s hard exit. The default (jitter-off) path cancels
deterministically as before.

## Tests

- `config::tests::drain_jitter_defaults_off_and_rejects_junk` — default
off, `20000`, clamp `60000`→`20000`, explicit `0`, junk `"soon"` fails,
**empty `""` and whitespace-only treated as off**.
- `state::tests::drain_all_is_immediate` — default path queues frame +
cancels synchronously.
- `state::tests::drain_all_sends_restart_close_and_cancels_every_conn`,
`drain_all_full_control_buffer_still_cancels`,
`register_after_drain_self_signals_restart_close_and_cancel`.
-
`state::tests::drain_all_jittered_defers_close_until_within_jitter_window`
(paused time).
-
`state::tests::drain_all_jittered_waits_for_writer_acknowledgement_without_cancelling`.
-
`state::tests::drain_all_jittered_cancels_when_restart_channel_is_full_or_closed`.
- `state::tests::drain_all_jittered_cancels_when_flush_ack_times_out`
(paused time — the 5s ack-timeout fallback).

Validation at `46c690940`: `cargo fmt -p buzz-relay --check`, `cargo
clippy -p buzz-relay --all-targets -- -D warnings`, and the drain/config
unit suite all clean. Local live SIGTERM test with a real relay process
+ 200 NIP-42-authenticated sockets — see the PR comment for the
before/after distribution and exit codes.

## Rollout

Ship with default `0`, then set `BUZZ_DRAIN_JITTER_MS` (e.g.
10000–20000) on bb-block first, watch the roll-window pool-timeout
metric, then bb-public. `""` is a safe kill switch. Complements the
preStop `sleep` (stops routing before close).

---------

Signed-off-by: npub1srl70fhzyu3fsnahl06vw2czvqc2w3ds37hyzvjnk8ve8f03ngcqg9le2w <80ffe7a6e22722984fb7fbf4c72b026030a745b08fae413253b1d993a5f19a30@buzz.block.builderlab.xyz>
Signed-off-by: npub128x7j3pwgm4vs8yra3c42fcgcwcvh94g3luwzkqa376du2q6l0esqcrwch <51cde9442e46eac81c83ec71552708c3b0cb96a88ff8e1581d8fb4de281afbf3@buzz.block.builderlab.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: npub1srl70fhzyu3fsnahl06vw2czvqc2w3ds37hyzvjnk8ve8f03ngcqg9le2w <80ffe7a6e22722984fb7fbf4c72b026030a745b08fae413253b1d993a5f19a30@buzz.block.builderlab.xyz>
Co-authored-by: npub128x7j3pwgm4vs8yra3c42fcgcwcvh94g3luwzkqa376du2q6l0esqcrwch <51cde9442e46eac81c83ec71552708c3b0cb96a88ff8e1581d8fb4de281afbf3@buzz.block.builderlab.xyz>
### What changed?

Inbox detail now gives the current user's messages the same
ownership-gated Edit action as channel view. Editing reuses the existing
composer and mutation flow, preserves attachment metadata, and refreshes
structural overlays so the edited content appears immediately.

Foreign authors' messages remain non-editable, including grouped Inbox
conversations whose selected event is not the representative item.

| Own Inbox message exposes **Edit message**. | Saving the edit updates
the Inbox detail immediately. |
| --- | --- |
| ![Before: Edit message action in Inbox
detail](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/2198/inbox-edit-before.png)
| ![After: edited Inbox message
content](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/2198/inbox-edit-after.png)
|

### Why?

Inbox rows did not pass an edit handler into the shared message action
bar, so a user's own messages could be edited from channel view but not
from Inbox detail.

### How is it tested?

Desktop checks, unit tests, and the full local CI gate passed. The
focused Inbox Playwright regression passed 3 consecutive runs and covers
current-user edit/save, foreign and archived-channel denial, and
attachment preservation when a just-sent reply is edited before its
relay echo arrives.

Added tests:

-
[`inbox-edit.spec.ts`](https://github.com/block/buzz/blob/inbox-message-edit-action/desktop/tests/e2e/inbox-edit.spec.ts)
-
[`inboxViewHelpers.test.mjs`](https://github.com/block/buzz/blob/inbox-message-edit-action/desktop/src/features/home/lib/inboxViewHelpers.test.mjs)

*🤖 This PR was authored [with an
agent](buzz://message?channel=7f2d7e02-f4d5-4fb0-a426-0ca60ed3a1c3&id=c09ee04d18399b90296c3f932d22ab0377fa05f7e690ec7b08c36483ee633fbb).*

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Signed-off-by: npub1ft62tztwwm2x9xamk25smmuaj4sfckdkldksruf2x2jwqalffkrq0g7arr <4af4a5896e76d4629bbbb2a90def9d95609c59b6fb6d01f12a32a4e077e94d86@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1ft62tztwwm2x9xamk25smmuaj4sfckdkldksruf2x2jwqalffkrq0g7arr <4af4a5896e76d4629bbbb2a90def9d95609c59b6fb6d01f12a32a4e077e94d86@sprout-oss.stage.blox.sqprod.co>
…lock#4633)

## Summary

- Keep mobile thread reply badges current by merging relay recounts with
replies observed locally.
- Retain replies in the local channel store while continuing to filter
them from the main timeline.
- Match the thread summary behavior already used on desktop, including
the reply count, latest reply time, and participant avatars.

## Why

On mobile, the "N replies" badge under a channel message can stall at a
stale count or remain missing after a reply arrives. This makes the
badge unreliable and can cause people to miss replies.

The badge has two inputs: best-effort recounts from the relay and
replies the client sees arrive. Mobile previously let any positive relay
recount override the local view, while also discarding replies from its
local message store. A delayed or lost recount, or a reply received
after the recount, could therefore leave the badge behind.

This change combines both inputs by using the higher reply count, the
later last-reply time, and a merged participant list. Relay timestamps
have one-second precision, so equal timestamps do not prove that a
recount included a locally observed reply. Comparing counts preserves
that reply instead of trusting recency alone. Desktop already uses this
merge behavior.

## Validation

At commit `4e3356636f5ad62e8f07910af305c532186c6c08` with a clean
worktree:

- `flutter test` for mobile: 1105 passed, 1 skipped
- `flutter analyze` for mobile: no issues found
- Reverting the merge so a positive relay recount shadows local replies
fails 4 of the new tests, including the same-second and
reply-after-recount cases. Restoring the store-level reply drop fails
both new provider tests.

Added tests:

-
[`timeline_message_test.dart`](https://github.com/block/buzz/tree/main/mobile/test/features/channels/timeline_message_test.dart),
covering relay-only recounts, a reply newer than the recount, a reply in
the same second as the recount, a lost recount, a zero recount, nested
replies at the root and at the reply they answer, a deleted reply, and
participant merging and capping.
-
[`channel_messages_provider_test.dart`](https://github.com/block/buzz/tree/main/mobile/test/features/channels/channel_messages_provider_test.dart),
covering a live reply reaching the store while staying out of the main
timeline, and a reply newer than the relay recount raising the badge.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: npub12uu53ml9upy7ww9apmtv6vm0u8xlcldx7znsjvwgsr7uvy5g0kssw943ca <573948efe5e049e738bd0ed6cd336fe1cdfc7da6f0a70931c880fdc612887da1@buzz.block.builderlab.xyz>
This change enables a Tauri content security policy that limits
executable content to the packaged application and does not allow inline
scripts.

Relay, media, asset, and Tauri IPC schemes remain available for desktop
compatibility. The policy contains the impact of a future renderer
injection; it does not itself remove an injection bug.

## Testing

- `git diff --check origin/main...codex/security-desktop-csp`
- Rebased onto `origin/main` at `5c98932`
- Full CI pending

Originating Buzz thread:
`buzz://message?channel=3928fe05-df61-4b5d-b9c7-d623b9b10ea1&id=3c6c02312f763fbe0d2bfc33a6c1a362f91d0354f3d18b039cf7a0558c1439d1`

---------

Signed-off-by: Jordan Mecom <jm@squareup.com>
Signed-off-by: Eli Foster <efoster@squareup.com>
Co-authored-by: Eli Foster <efoster@squareup.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…opes (block#4917)

## Problem

Observer telemetry is the noisiest client of the relay: the old pacer
(167ms spacing + 90/min rolling cap) let a busy session bill up to 6
events/second against the owner's message quota, and the rolling cap
silently *dropped* frames once exceeded.

Ruling from the rate-limiting investigation thread (channel
`826fc99b-1472-40e7-a529-6b9db8943b8c`): pace at 1/s, always emit,
minimal PR.

**Review round 1 (Max, Sami)** found the first cut wrong in three ways —
tick burst (all pending frames per tick), startup burst (`interval`
fires at t=0), and per-channel quota arithmetic. All fixed and
mutation-verified in round 1.

**Review round 2 (Sami, Max)** found two more against the round-1 head:
1. **Drain-rate collapse (Sami, blocker):** front-run-only packing meant
a frame held ONE event whenever channels interleaved — measured 275 B/s
vs 63.5 KB/s, so an ordinary 2-channel session fell minutes behind with
zero drops and no warning. Silent unbounded latency.
2. **Coalescer byte-cap bypass (Max):** chunks pending in
`ObserverChunkCoalescer` were unbounded and outside the 4 MiB cap — 500
distinct-`messageId` 50KB chunks retained ~48MB with `pending_bytes ==
0` and zero drops.

**Review round 3 (Max)** found the drop accounting undercounted merged
chunks: a coalescer entry that merged N same-`messageId` chunks counted
as **1** in `dropped_events` when evicted (50 merged 1KB chunks evicted
→ counter read 1, 49 generated events unaccounted). Fixed: accounting is
now denominated in **source (generated) observer events** end to end —
each pending entry tracks how many chunks it absorbed, eviction charges
that count, and the count survives flush into the publish FIFO.

**Review round 4 (Sami, Max)** found three more against the round-3
head:
1. **Coalescer byte undercount (both, independently):** a pending merged
entry retains its first chunk's text **twice** until flush — once inside
the serialized event skeleton and once in the extracted text accumulator
— but was charged only `serialized_len`, so true retention overshot the
4 MiB cap ~2× (measured 8.3 MB). Fixed: `push_pending` charges
`serialized_len(&event) + text.len()`.
2. **Cap regressions asserted the accumulator against itself,** which is
how the undercount hid. All three cap tests now assert on independently
**walked** retained bytes (`serialized_len` per FIFO entry +
`serialized_len + text.len()` per coalescer entry), with a secondary
`accumulator >= walked` sanity check. Reverting the fix makes them fail
at exactly 8,328,386 / 8,328,272 bytes.
3. **FIFO-arm source accounting was implemented but untested (Sami M13;
Max reproduced at `cc9333b7c` with 102/151):** the round-3 regression
only evicted a merged entry while still in the coalescer. New test
forces a merged entry (50×1KB, `source_events=50`) through flush into
the publish FIFO, then evicts it from there — mutating the FIFO eviction
to `dropped += 1` fails with the reviewers' exact numbers (102 vs 151).

**Review round 5 (Sami 9/9/9, Max 9/9/9)** — production judged
merge-safe by both; remaining items are tests only, all landed at
`63d821620`:
1. **The walker instrument was itself unverified (Sami M17–M20; Max
independently confirmed the `return 0` mutant survives):** every cap
test asks `walked_retained_bytes()` only for `<= CAP`, so a blinded
walker passes everything — and paired with a reverted `push_pending` fix
the two mutations cancel, hiding exactly the 8.3 MB overshoot it exists
to detect. New two-sided pin: the walker must SEE the first chunk's text
twice, and must agree with the accumulator EXACTLY while both stores are
non-empty. Kills M17, M18, M19, M20.
2. **Two pre-existing snapshot-clone siblings (Sami D5b/D5c;
byte-identical at merge-base `7334ad1e1` — not this PR's regression, but
the PR made the class visible):** aliasing the inner turns map leaks a
post-save turn into the snapshot; aliasing the inner tombstones map
leaks a post-save terminal that blocks a legitimate post-restore
resurrection. Two isolation tests with in-test controls — all three
inner-map clones in `saveActiveAgentTurnsForCommunity` are now pinned.

## Change

**Harness (`crates/buzz-acp`)**
- **Global pacer: AT MOST ONE relay frame per second**, regardless of
channel count or backlog size. `interval_at(now + 1s)` restores the
no-startup-burst property; `MissedTickBehavior::Skip` is now pinned by a
paused-time test (a stalled tick arm fires one catch-up frame, not one
per missed deadline). At 1 frame/s telemetry spends ≤60/min of the
shared 120/min quota; `OBSERVER_PUBLISH_TICK` documents the tradeoff as
the knob.
- **`ObserverPublishQueue` with gather-packing:** events wait as
byte-accounted events (FIFO). `next_frame()` packs the front event's
channel **gathered queue-wide in FIFO order** — frames never mix
channels, and each channel's events keep their FIFO order, but
cross-channel frame order MAY differ from arrival order. That is what
keeps the drain rate in **bytes per slot** (one ~64KB frame/s) instead
of front-run-length events per slot. **Null-channel events
(`agent_panic`-class) are packing barriers** nothing gathers across, so
causally-global events keep exact order against every channel.
- **One byte cap over BOTH stores:** the event FIFO and the coalescer's
pending chunk buffer count against the 4 MiB budget together; eviction
is oldest-first across both (queue front, then coalescer front —
structural age order) with accounting (warn + counter). A
high-cardinality chunk flood is bounded exactly like a plain event
flood. Coalescer entries are charged their **true** retention
(`serialized_len + text.len()` — the first chunk's text lives in both
the serialized skeleton and the extracted accumulator until flush).
- **Shutdown is not a burst bypass:** paced one-frame-per-tick drain
until empty.

**Desktop**
- `unwrapObserverBatch` expands envelopes on the live relay path and
archive-ingest seam (round 1, unchanged).
- **`activeAgentTurnsStore` watermark re-keyed per (agent, channel)**
with a dedicated null-channel bucket: the per-agent `(timestamp, seq)`
gate would silently skip a delayed channel's frames as stale under
gather-packing's intentional cross-channel reorder. Safe because every
turn-mutating path is channel-scoped by the event's own `channelId`
(endTurn's null-turnId fallback matches `turn.channelId`; resurrectTurn
keys on `event.channelId`), so per-channel serialization preserves each
guard the per-agent gate provided. The tombstone-cap justification is
rewritten for the new keying (worst case for an evicted tombstone is a
ghost badge the prune reaps — bounded cosmetic staleness, not
corruption). Community-switch save/restore deep-clones the nested map.
Other per-agent maps stay agent-keyed: the clock offset is a running
minimum (order-insensitive); turns/tombstones mutate only through
channel-scoped paths.

## Version skew — old desktop + new harness

Gather-packing *intentionally* emits cross-channel-reordered frames. An
**old desktop** (per-agent watermark) against a **new harness** will
silently skip a delayed channel's turn-state events as stale — working
badges on that channel can go stale/missing until its next fresh event.
Transcript and archive are unaffected (the transcript store sorts +
rebuilds on out-of-order arrival; the archive is per-channel by
construction). Ship desktop and harness together; skew degrades badges
only, not data at rest.

## Throughput ceiling — "lossless" is qualified

Sustained lossless rate is what fits in one ~64KB frame per second, now
genuinely in bytes under interleaving:

| event payload | events per frame | sustained ceiling |
|---|---|---|
| 100 B | 250 | 250 ev/s |
| 500 B | 99 | 99 ev/s |
| 2 KB | 30 | 30 ev/s |
| 10 KB | 6 | 6 ev/s |

With C channels producing concurrently, publish slots round-robin
between them: per-channel drain is ~64KB/C per second and the 4 MiB
burst budget (~64s single-channel) shortens accordingly. Beyond budget,
oldest-first drops **with accounting** — visible, designed loss.

**Accounting semantics:** `dropped_events` counts SOURCE (generated)
observer events, not retained entries — evicting a coalesced entry that
merged N chunks charges N. On the published side, a merged entry ships
all N sources' text in ONE event, so the reconciliation invariant is
`ingested == dropped_events + Σ source_events over published events`
(for unmerged events, source_events = 1).

## Verification

At `63d821620d3513505e8766ac691a8002f9d4a96f` (this head; `git rev-parse
HEAD` matched in the same shell as every run), rustc 1.95.0:
- `cargo test -p buzz-acp`: **689 lib + 9 integration, 0 failed** —
regressions: interleaved 2-channel drain packs into ≤4 frames not 200
slots; null-channel barrier; queue-wide gather with within-channel FIFO;
distinct-key 50KB chunk flood bounded by the cap with event-level
accounting (published + dropped == ingested, survivors newest);
paused-time `MissedTickBehavior::Skip` pin (verified to fail under
`Burst`: 3 frames vs 1); merged-key eviction accounts every absorbed
source chunk in BOTH arms — coalescer-side (Max's round-3 probe) and
post-flush FIFO-side (Sami M13 / Max's round-4 probe: fails 102 vs 151
under `+= 1`). All three cap tests assert on independently walked
retained bytes, not the accumulator (verified to fail without the
`+text.len()` fix: 8,328,386 / 8,328,272 vs 4 MiB); the walker itself is
pinned two-sided against the accumulator (all four blinding mutants
M17–M20 verified to fail it, including the walker+fix cancellation
pair).
- `cargo clippy -p buzz-acp --all-targets -- -D warnings` clean, `cargo
fmt --check` clean
- Desktop: `tsc --noEmit` clean; node tests **4366 passed, 0 failed** —
snapshot-clone family fully pinned: watermark aliasing (round 4), turns
aliasing and tombstone aliasing (round 5, pre-existing gaps; each mutant
verified to fail exactly its target test with an in-test control). Prior
rounds: cross-channel reorder processed, cross-channel-delayed
null-turnId `turn_error` evicts only its own channel's turn, null-bucket
replay idempotency, same-channel stale/duplicate still skipped,
watermark survives community-switch save/restore
- All pre-push hooks green at the pushed commit (branch-skew,
desktop-check, desktop-test, rust-tests, desktop-tauri-checks)

Part of the rate-limiting fix stack; independent of
`eva/rate-limit-fixes` by design (separate minimal PR per Tyler's
ruling).

---------

Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz>
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz>
**Category:** fix
**User Impact:** Pull requests can once again pass the Desktop smoke
test suite.
**Problem:** The inbox attachment-edit smoke test still looked for the
composer's former “Attach image” label after the shared action was
renamed to “Attach file,” causing shard 3 and the aggregate Desktop CI
job to fail on every PR.
**Solution:** Update the stale accessible-name selector to match the
current composer control while preserving the test's media-tag coverage.

<details>
<summary>File changes</summary>

**desktop/tests/e2e/inbox-edit.spec.ts**
Updates the attachment button selector to use the current accessible
label so the existing attachment-edit regression test reaches the
behavior it is meant to verify.

</details>

## Reproduction steps

1. Build the Desktop E2E application with `pnpm -C desktop build:e2e`.
2. Run `cd desktop && pnpm exec playwright test --project=smoke
tests/e2e/inbox-edit.spec.ts -g "editing an immediate attachment reply
preserves its media tags"`.
3. Confirm the test locates the “Attach file” control and passes.

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
## Problem

Managed agents in internal Buzz builds should answer only their owner.
Previously, an agent could keep a broader access setting and respond to
other people, which did not match the access policy for internal builds.

This PR makes owner-only access effective for every managed agent in
internal builds and makes that restriction clear in the Desktop UI. Open
source builds remain configurable.

## Changes

- Enforce owner-only access when any managed agent starts or is deployed
from an internal build.
- Show the agent access control as locked to **Only me** in Desktop,
with an explanation of why it cannot be changed.
- Keep Welcome teammates working under the same rule without triggering
unnecessary restarts.
- Leave open source build behavior unchanged. This changes effective
runtime access without rewriting stored or relay-advertised settings.

The companion [block#4064](block#4064) explains
the restriction in-thread when someone without access mentions an agent.

The enforcement will remain inactive in shipped builds until
[squareup/buzz-releases#74](squareup/buzz-releases#74)
marks internal releases during the build.

## Screenshots

| Before | After |
| --- | --- |
| ![Editable agent access control before the
change](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4053/4053-before.png)
| ![Agent access locked to Only me in an internal
build](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4053/4053-after-v2.png)
|

## Tests

Added coverage for:

- Runtime enforcement for [locally run
agents](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src-tauri/src/managed_agents/runtime/tests.rs#L196)
and [deployed
agents](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src-tauri/src/commands/agents_tests.rs#L510).
- The [current-build deployment
path](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src-tauri/src/commands/agents_tests.rs#L455),
[invalid stored
access](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src-tauri/src/managed_agents/access_policy.rs#L98),
and the [local startup
guard](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src-tauri/src/managed_agents/env_vars/tests.rs#L149).
- Consistent enforcement across [both agent
backends](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src-tauri/src/managed_agents/access_policy.rs#L112).
- Welcome teammates created as [locally
run](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src/features/onboarding/welcomeGuide.test.mjs#L384)
or
[deployed](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src/features/onboarding/welcomeGuide.test.mjs#L393)
agents, including
[access-only](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src/features/onboarding/welcomeKickoff.test.mjs#L202)
and
[runtime-related](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src/features/onboarding/welcomeKickoff.test.mjs#L225)
restart behavior.

The full Desktop Rust and JavaScript suites, type checks, formatting,
clippy, and file-size checks passed. Playwright E2E was not run.

---

Originated from Buzz channel
[buzz-agent-control](buzz://channel?id=cf5dada7-e26a-4887-ae41-b3bd5f42d3b2).
Supersedes block#2537.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Signed-off-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz>
Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz>
Co-authored-by: Amp <amp@ampcode.com>
## Summary

- virtualize the unfiltered channel member roster instead of eagerly
mounting every member card
- retain the existing member search/add flow and archived-member
behavior
- cover a 500-member roster, bounded mounted rows, and scrolling to the
final member in E2E

## Cause

The members sidebar rendered every active member card at once. On large
channels this mounted hundreds or thousands of avatars, profile/presence
consumers, menus, and DOM rows, blocking the renderer even though
fetching the roster itself is fast.

## Testing

- `pnpm typecheck`
- `pnpm exec biome check src/features/channels/ui/MembersSidebar.tsx
tests/e2e/channels.spec.ts`
- `pnpm build:e2e`
- `pnpm exec playwright test tests/e2e/channels.spec.ts --grep 'members
sidebar (virtualizes large channel rosters|can invite relay-authorized
agents|can invite and remove managed agents|collapses same-persona
managed agents)'` (4 passed)
- pre-push: `desktop-check`, full `desktop-test` (4,371 passed),
branch-skew

Implemented by Carl on Wes's behalf.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
…ny — with real nodes (block#3862)

## Summary

CI now proves the full Buzz shared-compute join story end to end: a
member can discover another member's served model **through the Buzz
relay alone** and run inference over the mesh, while a non-member gets
nothing — the relay rejects its auth, and the mesh refuses to route for
it even holding a leaked endpoint address.

This is deliberately different from mesh-llm's own CI smokes (which
bootstrap two nodes with a hand-carried invite token / mdns): here the
**relay is the control plane**, exactly like the desktop app:

1. **Membership** — identities A and B are added via `buzz-admin`
(kind:13534 NIP-43 roster); C is not.
2. **Advertise** — each member publishes a client-signed kind:30003
discovery note carrying its MeshLLM owner binding and (for the serve
node) `serveTargets[].endpointAddr`, covered by an endpoint-binding
signature — the exact payload shape the desktop coordinator publishes.
3. **Trust** — the serve node derives its admission allowlist from the
relay (statuses ∩ roster) and requires the **exact expected {A, B}
owner-id set** before starting with `TrustPolicy::Allowlist`.
4. **Join** — the client verifies owner + endpoint bindings and
membership, then dials the relay-discovered endpoint (the desktop
join-watcher's `dial_endpoint_addr` step). No out-of-band token.
5. **Infer** — a chat completion against the client's local OpenAI
endpoint routes over QUIC to the serve node's model (CPU, SmolLM2-135M,
~105MB).
6. **Deny (differential)** — the stranger's NIP-42 auth must fail with
the relay's own membership rejection (`restricted: not a relay member` —
successful auth or any unrelated connect error fails the run), and
dialing the leaked endpoint must not produce a routed inference —
**while the trusted client re-proves inference immediately afterwards**,
so a dead serve node can't masquerade as an admission denial.

## What's in the PR

- `crates/buzz-relay/examples/mesh_relay_lifecycle_smoke.rs` — the
harness. One process per node (mesh-llm keeps process-global state under
`~/.mesh-llm`), orchestrator + serve/client/stranger roles,
byte-identical binding payloads to
`desktop/src-tauri/src/mesh_llm/identity.rs` (called out with
keep-in-sync comments). Child stdout is pumped through a reader thread
so every wait has a hard deadline; timed-out children are killed; exit
statuses are checked.
- `scripts/ci-mesh-lifecycle-smoke.sh` — provisions a membership-gated
relay (throwaway owner + signing identities via `buzz-admin
generate-key`), runs the harness, cleans up. Fails fast if :3000 is
already occupied (a stale open relay would mask gating).
- `scripts/start-relay-for-tests.sh` — gains opt-in NIP-43 membership
env passthrough (`BUZZ_REQUIRE_RELAY_MEMBERSHIP` + `RELAY_OWNER_PUBKEY`
+ `BUZZ_RELAY_PRIVATE_KEY`). Default behavior unchanged.
- `.github/workflows/mesh-lifecycle.yml` — separate, path-filtered,
non-required workflow (mesh paths, the harness's dependency crates,
`Cargo.lock`, dispatch), pinned to `ubuntu-24.04`. Caches the mesh
native runtime + HF model keyed on the lockfile hash, so a mesh pin bump
rolls the runtime cache. Uploads relay + harness logs on failure.

## Scope

This is an **independent protocol harness**: it speaks the same wire
protocol and payload shapes as the desktop but re-implements the
binding/verification logic (the desktop crate is outside the workspace).
Regressions inside the desktop's own discovery filtering are the desktop
unit tests' job; what this smoke proves is that the relay + mesh-llm SDK
+ admission stack support the lifecycle end to end.

## Relationship to mesh-llm's CI

Follows the shape mesh-llm's own CI proved stable (tiny CPU model, one
runner, multiple real mesh-llm processes over real QUIC — cf. their
`ci-two-node-client-serving-smoke.sh`), but swaps the token bootstrap
for the relay-driven lifecycle, which is the part only Buzz can test.

## Validation

Green on GitHub Actions (ubuntu-24.04) across three runs, including
after rebases onto the mesh v0.74 upgrade (block#3467) and latest main:

```
PASS 1/6: relay-derived allowlist is exactly {A, B}
PASS 2/6: serve member ready + advertised model: jc-builds/SmolLM2-135M-Instruct-Q4_K_M-GGUF:Q4_K_M
PASS 3/6: client member discovered + joined via relay
PASS 4/6: inference routed over the mesh: "PONG"
PASS 5/6: relay rejected the stranger's NIP-42 auth (membership gate)
PASS 6/6: stranger denied (gossip visible, inference rejected: 503 all tunnels failed) while trusted inference still routes
PASS: full relay-driven mesh lifecycle verified
```

Also validated locally on macOS. `cargo fmt --all --check` and `cargo
clippy -p buzz-relay --all-targets -- -D warnings` pass.

## Notes

- The harness follows the repo's mesh `[dev-dependencies]` pin
automatically, so it doubles as a canary for future mesh upgrades (it
already caught the v0.73.1 → v0.74.0 bump during development).
- The stranger "deny" accepts either shape mesh-llm exhibits: no model
visibility at all, or gossip visibility with inference refused —
mesh-llm applies the receiving node's owner policy after the gossip
handshake, so admission gates *routing*, not gossip. The differential
trusted-inference re-check (PASS 6/6) is what makes that a real denial
rather than a dead server.
- Model-visibility windows are tunable via `MESH_CLIENT_WINDOW_SECS` /
`MESH_STRANGER_WINDOW_SECS` if shared runners prove slow — pin a longer
window in the workflow env rather than re-running the job.

---------

Signed-off-by: Michael Neale <michael.neale@gmail.com>
## Summary

- require the macOS process to be running from an actual `.app` bundle
before initializing `UNUserNotificationCenter`
- keep the existing bundle-identifier requirement
- cover packaged, case-insensitive `.app`, raw `target/debug`, and
extensionless paths

## Why

PR block#4799 guarded native notification initialization with
`NSBundle.mainBundle.bundleIdentifier != nil`. Tauri embeds a bundle
identifier in raw development executables, so `tauri dev` passed that
guard and `UNUserNotificationCenter.current()` raised an uncaught
`NSInternalInconsistencyException` because LaunchServices had no bundle
proxy.

## Validation

- focused macOS notification tests: 6 passed
- direct raw debug executable no longer raises the notification-center
exception
- pre-commit formatting hook passed
- pre-push package checks passed on pushed commit
`f29a6664d2a863e7b8aa527f6149fd00b183e4de`

The first push attempt hit an unrelated timing-test failure in
`relay_admission::tests::concurrent_429_extends_the_window_for_parked_waiters`;
its focused rerun passed, and the complete pre-push package suite passed
on the next push.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
deploy/compose/compose.yml is a file upstream edits every release, so the
four lines pinning BUZZ_DB_POOL_SIZE / BUZZ_DB_READ_POOL_SIZE to 12 cost a
conflict on every merge. Both variables are read by upstream's relay config
(crates/buzz-relay/src/config.rs) and the relay service already declares
`env_file: - .env`, so the droplet's deploy/compose/.env sets them instead.

Upstream defaults BUZZ_DB_POOL_SIZE to 50, which is exactly the bundled
Postgres connection ceiling — the droplet .env MUST carry both values, and
the post-deploy check is the relay's pool utilisation metric.

Drops the matching guard from scripts/test-release-ref-contract.sh, which
can only see the repo, not the droplet.
…ches

Brings the fork onto upstream main (136 commits ahead of our base
4632c55) and deletes every fork patch upstream has since made
redundant, so what remains is only Scalarly-only behaviour.

Dropped from the fork (upstream now covers it):
- our agent-mention patch (#5) — upstream block#4913 `AgentEligibilityScope` /
  `relayAgentCanRespondInChannel()` is strictly more capable. Keeps the
  hard dependency on `c834a81ca` publishing `agent.channelIds`, without
  which the upstream gate fails closed and our agents go unmentionable.
- `deploy/compose/compose.yml` pool pins — upstream's relay service
  already reads `env_file: .env`, so the droplet `.env` carries them.
  NOTE: upstream defaults BUZZ_DB_POOL_SIZE to 50, which is the bundled
  Postgres ceiling; the droplet `.env` must set 12/12.
- the compose grep in `scripts/test-release-ref-contract.sh` (CI cannot
  see the droplet `.env`).
- our nostr 0.44.7 and RUSTSEC-0224 bumps — superseded by upstream
  `9d6726e5b` / `318fbf896` (which also covers RUSTSEC-2026-0225..0232).

Conflicts resolved by taking upstream's control flow and re-applying our
gate on top:
- `buzz-acp/pool.rs`: unioned `create_session_and_apply_model` params
  (upstream's agent_core/canvas/channel_name first, our browser scoping
  last) and chained the per-session browser server after upstream's
  git-origin env injection instead of shadowing it.
- `useMediaUpload.ts`: adopted upstream's `uploadFiles`/`queueFiles`
  split and funnelled the size cap through one `acceptFiles` helper, so
  the cap now also covers the queue path (a queued 400 MB video used to
  be refused only at send time).
- `AppSidebar.tsx`: extracted `useScalarlySections` so the file stays
  under the 1000-line ratchet with a 3-line delta vs upstream.

Kept after re-checking (the plan was wrong about both): the observer
frame rate limit is a real relay feature consumed by `connection.rs`,
and `key_backup.rs` holds the `yo-yo` wordlist fix, not dead code.

Verified: cargo check --workspace --all-targets, pnpm typecheck,
pnpm check all green.
…nt cap

Upstream grew `chooseLargeVideo` to a 16 MiB buffer, which our fork's
client-side cap (`mediaSizeLimit.ts`, 10 MiB, mirroring the relay's
BUZZ_MAX_*_BYTES) refuses before the composer can queue it — so five
file-attachment tests failed on "the queued attachment never appeared".

8 MiB still exercises every progress path (there is no size threshold in
the progress UI), and naming the constant makes the next upstream bump of
this fixture conflict here instead of silently going red.
@mattbalza mattbalza changed the title chore: ingest upstream block/buzz@96ae14176 and shrink the permanent fork chore: merge upstream block/buzz@96ae14176 (136 commits) and shrink the fork to 50 files Aug 6, 2026
`create_session_and_apply_model` took 8 parameters after the upstream merge
unioned our browser scoping with upstream's memory blocks and channel title,
which `-D warnings` rejects on the Windows clippy job. A `SessionSeed` struct
carries the five per-session values, so the heartbeat path says "no channel,
no memory" as `SessionSeed::default()` instead of five bare `None`s.

Also normalizes one `.await` indentation that `cargo fmt --all -- --check`
flagged.
`ensureOptionalWelcomeChannel` swallowed every failure, so a transient channel
read error was recorded as "this community offers no personal Welcome room":
`markWelcomeChannelEnsured` settled it forever, and first run went on to
`setQueryData(channelsQueryKey)` + `invalidateQueries` for a relay it had never
actually reached.

That cache write is what broke `community-rail.spec.ts:509` on CI. Writing the
channels query moves `dataUpdatedAt` off 0, which is exactly the signal
AppShell's destination repair reads as "a live channels read succeeded" — so it
validated the remembered channel against a stale snapshot, found it missing,
and overwrote the destination with `home`. Under CPU contention the race landed
4 times in 20 locally and 3 of 3 on CI.

Only NIP-20 `restricted:` / `blocked:` mean the relay refused us, which is what
`channel_create_policy=owner-only` answers with; everything else now throws as
it did upstream.
The newly merged upstream `sprig-image.yml` hardcodes
`ghcr.io/block/buzz-sprig` and logs in to GHCR for same-repo pull
requests. In a fork `GITHUB_TOKEN` is read-only even for a same-repo PR,
so that login fails the whole job with `Get "https://ghcr.io/v2/":
denied: denied` — which is what reddened `Build (linux/amd64)` and
`Build (linux/arm64)` on this PR.

Ports the pattern `docker.yml` already uses: owner-namespace image
default, login and cache-to gated on `github.event_name != 'pull_request'`
so pull requests stay build-only. Extends
`test-release-ref-contract.sh` to hold the same three invariants for the
sprig workflow, so the next upstream merge fails the guard instead of
silently reverting to block's namespace.
5c027b8 moved the image to the repo owner's GHCR namespace but left three
comments naming ghcr.io/block/buzz-sprig. One of them tells an admin to flip
a package the fork never creates, another gives a verify command against an
owner that will never hold the attestation. Point all three at IMAGE_NAME.
@mattbalza mattbalza closed this Aug 7, 2026
@mattbalza mattbalza reopened this Aug 7, 2026
@mattbalza mattbalza changed the title chore: merge upstream block/buzz@96ae14176 (136 commits) and shrink the fork to 50 files chore: merge upstream block/buzz@96ae14176 (136 commits) and shrink the fork to 51 files Aug 7, 2026
@mattbalza
mattbalza merged commit bfa150b into main Aug 7, 2026
39 of 45 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.