Commit Graph

98 Commits

Author SHA1 Message Date
Luke Tornquist
7ce422417b fix(mesh): expire dead peer records and instrument mesh boot (#8503)
## Summary

Relay pods can stall in the `mesh_boot` startup step, and the mesh peer
table grows without limit. One prod pod held 7,793 peer records. Most of
those records belong to pods that no longer exist.

**Cause.** Every boot gets a fresh runtime id, so a restarted pod leaves
its old record behind. Nothing ever removed records, and scuttlebutt
gossip kept spreading the dead ones around the fleet. Each reconcile
pass, including the first one inside `boot_mesh`, then dials every
record one at a time.

This PR makes dead records expire. It also adds the measurements we need
to see the next stall directly.

### Peer-table lifetime (`crates/buzz-relay-mesh/src/membership.rs`)
- A peer record expires 5 minutes after its last heartbeat
(`DEFAULT_PEER_RECORD_TTL`). For comparison, live runtimes refresh every
2s, and the Redis ready registry expires a silent runtime after 45s.
- What happens to expired records:
- They are **rejected on arrival**, so peers that haven't been upgraded
can't re-add them.
  - They are **left out** of digests, deltas, and dial candidates.
  - They are **pruned** at the start of each reconcile pass.
- Between prunes, they are not admitted (`has_peer`) and not routed
(`peers`, `peer_has_capability`).
- An expired entry that hasn't been pruned yet counts as absent, so any
fresh record replaces it whatever its version. That lets a live peer's
v1 Redis hint re-admit it before the next prune.
- **The TTL never prunes or hides a peer with a live connection**, even
if its heartbeat is stale. Its gossip can stop arriving while the
transport stays up: a dead control stream, or a clock behind by more
than the TTL. The connection proves the runtime is alive. Its stale
record is still kept out of digests and dials. (The existing phi filter
in `peers()` still drops a connected peer once its gossip stalls; this
PR doesn't change that.)
- **`Connected` tracks the transport map.** Because the TTL exemption
trusts that flag, `install_peer` and `remove_peer` write it while
holding the peers lock, so a connection that closes during install
always ends `Disconnected`. A dial's `Connecting`/`Disconnected` writes
are skipped when a connection is already installed, so a failed outbound
dial can't overwrite a live inbound connection. Each reconcile pass also
re-syncs the state from the transport map.
- **Gossip no longer sets connection state.** Before, any gossiped
record was marked `Connected`, even if this pod had never dialed it.
That inflated the `/_mesh` counts. Now a record learned from gossip or
the ready registry enters as `Disconnected`. Only the real connect and
disconnect paths change the state.
- Before building its candidate list, reconcile now skips peers that are
already connected. A pass dials serially, and a dial to a dead runtime
can take about a minute, so `dial_peer` re-reads the record before every
address attempt. It stops once the record has expired or started
draining, or once a connection has landed.

### Instrumentation
- **`mesh_boot` sub-steps** use the existing startup-step gauges:
`mesh_bind`, `mesh_publish`, and `mesh_initial_reconcile`. A pod stuck
in `mesh_boot` now also shows which await it is stuck on.
- **New metrics** (`crates/buzz-relay-mesh/src/telemetry.rs`):

  | Metric | Shows |
  |---|---|
| `buzz_mesh_dial_total{outcome}` / `buzz_mesh_dial_duration_seconds` |
Cost of each dial |
  | `buzz_mesh_reconcile_pass_seconds` | Full pass time |
| `buzz_mesh_reconcile_candidates` | Dials planned for this pass (set
after the registry scan) |
  | `buzz_mesh_peer_records{state,draining}` | Table size |
  | `buzz_mesh_peer_record_max_age_seconds` | Oldest heartbeat |
| `buzz_mesh_peer_records_expired_total{reason=pruned\|rejected}` |
Expiry activity |

- The dial and pass histograms get buckets up to 600s. The default
`_seconds` buckets stop at 5s, which would hide exactly the stall these
metrics are meant to show.

### CI
The `buzz-relay-mesh` tests and the `buzz-relay` `mesh_boot::tests` did
not run in any CI lane. This PR adds both to `just test-unit`, and adds
the matching stanzas to `scripts/run-tests.sh`.

### What got simpler
- The peer table now has a defined size, about one record per live pod,
where before it grew forever.
- One wrong state write was deleted (gossip marking records
`Connected`).
- Two test suites that silently never ran now run.

### Rollout notes
- Rejecting expired records works one pod at a time. During a rollout,
pods that haven't been upgraded keep gossiping dead records. Upgraded
pods reject them, which shows up as `reason="rejected"` on the expired
counter.
- A pod whose clock runs more than 5 minutes behind has all of its
gossip rejected. It stays reachable through its Redis ready record and
any live connection. A `reason="rejected"` count that keeps climbing
outside a rollout points to that skew.
- Upgraded pods leave expired records out of their digests. Pods that
haven't been upgraded therefore resend the whole ghost set (about 7.8k
records, roughly 1 MB) on every 2s gossip tick, on each old↔new
connection. So during the rollout, `reason="rejected"` will climb by
about the ghost count per tick per connection. This is expected and
stops when the rollout finishes.

### Not in this PR
The boot dial itself is still unbounded: it runs serially, has no
per-dial timeout, and dials gossip-learned records. Bounding it changes
how a pod joins the mesh, so it goes in a separate PR, and these metrics
will measure it.

### Related issue
None found.

### Testing
- New tests:
- `membership::tests`: expired gossip is rejected; prune drops only
expired records; prune keeps a connected peer with a stale heartbeat;
expired unconnected records are neither admitted nor routed before a
prune; a fresh record replaces an expired, unpruned entry whatever its
version; expired records are neither dialed nor gossiped before a prune;
gossip never sets connection state; table stats.
- `runtime::tests::reconcile_pass_publishes_dial_and_peer_table_metrics`
- `runtime::tests::reconcile_pass_prunes_expired_peer_records` (binds
the prune to the reconcile pass)
-
`runtime::tests::dial_skips_candidate_that_expired_after_the_pass_snapshot`
-
`runtime::tests::finishing_dial_does_not_overwrite_a_connection_that_landed`
-
`runtime::tests::connection_closed_during_install_ends_disconnected_and_expires`
-
`runtime::tests::reconcile_pass_resyncs_connection_state_from_transport`
  - `mesh_boot::tests::mesh_boot_sub_steps_name_the_step_that_failed`
- Blox at `e06f55a4` (tree `866add1c`): `just fmt-check`, `just clippy`,
`buzz-relay-mesh` nextest 3x, and the `buzz-relay`
`mesh_boot`/`startup_steps`/`metrics` tests all pass. Three of the new
tests fail against a mutant that removes the fix they pin (dial ignores
expiry; dial state writes ignore the transport map; no reconcile
resync).

---------

Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-10-26 09:47:30 -04:00
ravarora2
2d289f720d BUZZ-301: Add user deletion request intake (#8496)
## Summary

Add the first testable piece of user deletion: submit a synthetic
request, list requests, and inspect one by UUID. Operators can verify
that intake reaches PostgreSQL before we add inventory, approval, and
execution.

Add one operator-global `entity_deletion_requests` table with an
additive migration and matching desired-state schema. Identical
submissions return the same row. Conflicting submissions fail without
changing it. Only `demo:` targets are accepted, and requests stay
`submitted`.

Keep the commands in `buzz-deletion`, reached through a thin `buzz-admin
user-deletions` adapter. Reuse the existing database connection setup.
This change adds no worker, stage history, community gates, or deletion
operations.

### Rollout plan

This is an expand-only schema change plus an operator CLI. Existing
relay code does not use the new request table. No backfill or real
deletion is part of this release.

1. **Order the PRs.** Land #8496 first, using 0068; main now owns 0067
for channel join roles. Rework #8489 to build on this table with the
next available migration number. Renumbering its existing CREATE TABLE
migration alone is insufficient; both PRs currently create the same
request table.
2. **Apply on staging.** For deployments that manage schema separately,
apply the reviewed additive migration before using the new CLI. Where
auto-migration is enabled, the new relay binary applies it at startup.
Keep existing tables and records unchanged.
3. **Verify on staging.** Use the new `buzz-admin` binary with
`DATABASE_URL` pointing to staging. Submit a unique synthetic label,
list requests, and inspect the returned UUID:

   ```bash
buzz-admin user-deletions submit --target demo:staging-intake-1
--requested-by <operator>
   buzz-admin user-deletions list --limit 5
   buzz-admin user-deletions inspect <returned-uuid>
   ```

Verify one `entity_deletion_requests` row with status `submitted`.
Repeat the identical submission and verify the same UUID and timestamps.
Use a new label if this label was used by an earlier staging test.
4. **Apply beyond staging.** After verification, use the deployment's
reviewed schema/release workflow to add the table in the remaining
environments. Requests run only through the operator CLI; this release
starts no deletion worker. No feature flag is needed for this synthetic
intake: the database accepts only `demo:` targets, and real deletion is
absent.
5. **Rollback.** Keep the additive table and saved requests; stop using
the new commands and roll back the binary if needed. After 0068 is
applied, an older binary with auto-migration enabled can fail at startup
with sqlx `VersionMissing`. A binary rollback therefore requires
migration execution to be disabled on the older relay
(`BUZZ_AUTO_MIGRATE=false`, including the corresponding deployment
configuration).

### Related work

This is a smaller first slice of the full-workflow draft
[#8489](https://github.com/block/buzz/pull/8489), which is preserved for
later extraction. The two PRs overlap and should not both be merged as
written.

### Testing

The author submitted `demo:rarora-manual-1` to a separate local
PostgreSQL 17 database. Repeating the same submission returned its
original UUID and timestamps. List, inspect, and a direct SQL query all
returned the same saved request.

<details>
<summary>Actual command inputs and outputs</summary>

Commands ran from the new worktree with `DATABASE_URL` set to the local
`buzz_user_deletion_submit` database. Outputs below are stdout;
connection diagnostics on stderr are omitted.

**Submit again — returns the existing request without creating a
duplicate**

```bash
./target/debug/buzz-admin user-deletions submit \
  --target demo:rarora-manual-1 --requested-by rarora
```

```json
{
  "id": "2e5bcdec-ab68-44d2-9743-1dd9fa7d2f89",
  "entity_type": "user",
  "target": "demo:rarora-manual-1",
  "status": "submitted",
  "requested_by": "rarora",
  "reason": null,
  "created_at": "2026-10-20T19:57:44.516698Z",
  "updated_at": "2026-10-20T19:57:44.516698Z"
}
```

**List saved requests**

```bash
./target/debug/buzz-admin user-deletions list --limit 5
```

```json
[
  {
    "id": "2e5bcdec-ab68-44d2-9743-1dd9fa7d2f89",
    "entity_type": "user",
    "target": "demo:rarora-manual-1",
    "status": "submitted",
    "requested_by": "rarora",
    "reason": null,
    "created_at": "2026-10-20T19:57:44.516698Z",
    "updated_at": "2026-10-20T19:57:44.516698Z"
  }
]
```

**Inspect one request**

```bash
./target/debug/buzz-admin user-deletions inspect 2e5bcdec-ab68-44d2-9743-1dd9fa7d2f89
```

```json
{
  "id": "2e5bcdec-ab68-44d2-9743-1dd9fa7d2f89",
  "entity_type": "user",
  "target": "demo:rarora-manual-1",
  "status": "submitted",
  "requested_by": "rarora",
  "reason": null,
  "created_at": "2026-10-20T19:57:44.516698Z",
  "updated_at": "2026-10-20T19:57:44.516698Z"
}
```

**Read the table directly**

```bash
docker exec buzz-user-deletion-dev-20261020 \
  psql -X -U buzz -d buzz_user_deletion_submit -c \
  "SELECT id, entity_type, target, status, requested_by
   FROM entity_deletion_requests ORDER BY created_at DESC;"
```

```text
                  id                  | entity_type |        target        |  status   | requested_by 
--------------------------------------+-------------+----------------------+-----------+--------------
 2e5bcdec-ab68-44d2-9743-1dd9fa7d2f89 | user        | demo:rarora-manual-1 | submitted | rarora
(1 row)
```

</details>

A separate CLI smoke test also verified that a conflicting operator and
a non-demo target fail without changing the saved request. The existing
community-deletion `list --limit 1` command passed against the smoke
database.

See [the intake
walkthrough](729233c726/docs/operator-user-deletion-intake.md)
for setup, commands, expected fields, and the SQL query.

Generated with Codex

---------

Signed-off-by: Ravneet Arora <rarora@squareup.com>
2026-10-22 10:49:27 -04:00
Will Pfleger
91e7b469b8 fix(relay): wait for the peer's Close before dropping any WebSocket (#8426)
🤖 A NIP-42 login refused for a NIP-FI key mismatch could reach the
client as an abnormal close (`1006`, no reason) instead of the `NOTICE
"restricted: authorization denied"` followed by close `1008` that every
other authorization denial produces.

The relay did write the NOTICE and the `1008` Close, but the connection
task dropped TCP as soon as the writer finished, before the peer
finished the close handshake. The client could then lose frames it
hadn't read yet. It depends on timing, so it showed up intermittently
and is not specific to key pairing.

## Relay route

- `recv_loop` now hands back the read half even while a frame handler is
still running. Frames are handled one at a time while the connection is
live. Once it is cancelled, an in-flight handler (for example a Nostr
`CLOSE` waiting on the subscription lock behind a REQ stuck in Redis) is
spawned to finish instead of holding the reader. It is never dropped
partway, so subscription retirement stays atomic, and cleanup joins it
before the expiry barrier and application cleanup.
- As soon as the writer finishes, `await_client_close` keeps the
transport open until the client's Close arrives or the stream ends. It
runs concurrently with joining the heartbeat, auth-timeout, expiry and
auth-cancel tasks and the connection's application cleanup
(subscriptions released, registry entry removed, presence cleared).
Cleanup never waits on the peer, so a nonresponsive client cannot delay
registry counts, presence or `has_admitted_connection`. The socket never
waits on cleanup, so a stalled Redis (the pool has no wait timeout) or
an expiry join blocked on its quiescence barrier cannot hold the socket
and its file descriptor open past the bounded wait. The
active-connection gauge and the connection permit are released after
both finish.
- The connection permit (`BUZZ_MAX_CONNECTIONS`) is now taken at the
start of `handle_connection`, before the NIP-FI gate and the
community-active bootstrap check, and held through the active handler or
the refusal drain and its bounded wait. Previously a socket in
bootstrap, or refused there, held no permit, so a burst of refusals
could open sockets beyond the limit. Over the limit, the socket is still
dropped with no frames. `buzz_ws_connections_active` still counts only
connections that reached the active handler.

## Huddle-audio route

- The relay and audio bootstrap drains share one bounded helper. A
socket refused before admission waits after its community registry entry
is released.
- Every exit of the audio handler gets the bounded peer wait. Exits
whose cleanup follows their terminal frames (`room_full`, `room_ended`,
version mismatch, archived channel, owner rejection or unreachable,
failed commit confirmation) and the normal teardown hand the socket to a
background task that waits for the client's Close, so lease releases,
`48102` writes and room archiving on Postgres and Redis never hold the
socket open. The remaining exits send their frames last, and
`handle_active_audio_connection` waits once they return. The wrapper
joins the background task before returning, so the audio connection
permit is held until the socket ends, as on the relay route.
- Audio error exits now follow their existing JSON error with a Close
frame:

  | Exit | Close code |
  | --- | --- |
  | `huddle_relay_draining` | `1012` service restart |
| `join_rejected` (owner resolution failed), `huddle_owner_unreachable`,
failed channel lookup | `1011` internal error |
| `huddle_audio_unavailable`, `unsupported_version`, `upgrade_required`,
`room_full`, `room_ended`, archived channel, owner rejection, NIP-FI
authorization denial | `1008` policy |

Audio login failures and authorization refusals outside NIP-FI
`authorization_denied` still send only their existing error frame, then
get the bounded wait.
- The audio read half is an enum, so the hand-off has no `expect()` that
can panic.

## Timing

- The peer wait is bounded by `WS_TERMINAL_FLUSH_TIMEOUT` (1s), and
drains to end-of-stream so a client-initiated Close gets its reply. The
terminal writer flush has its own 1s budget, so the worst case for
terminal transport work is about 2s per refused or closed socket.
- Terminal frame writes (`flush_terminal_frames`,
`send_exit_frames_bounded`) still give up on the first failure, but a
failed or timed-out send is now logged at `debug` instead of being
discarded.
- The graceful-shutdown comment in `main.rs` now describes per-stage
budgets (restart jitter, restart-Close acknowledgement, terminal flush,
peer wait) rather than a per-socket total. The 30s hard drain bounds
every socket, and the 35s process shutdown budget fits within the
chart's 60s `terminationGracePeriodSeconds`.

## Tests

Real-socket tests in the infra-free relay lane of `just test-unit` and
`scripts/run-tests.sh` unless marked PostgreSQL:

- The key-mismatch login and a session-expiry denial (as a control) both
assert NOTICE then `1008` / `authorization denied` on the wire.
- A raw-TCP client that never answers Close: the relay holds the socket
for at least the bounded wait, has deregistered the connection by the
time the socket ends, and ends it on its own under a 10s watchdog.
- A raw-TCP client that withholds its Close until the connection has
deregistered: the relay ends the socket in under the 1s cap, which fails
if cleanup waits on the peer.
- A raw-TCP client that never answers while final cleanup is stalled at
a test pause point: the socket still ends after the bounded wait.
- A raw-TCP client that never answers Close on the relay bootstrap
drain, the audio bootstrap drain and an early audio-handler exit: the
relay holds each socket for the bounded wait.
- On the relay route with a one-slot budget through the real
`handle_connection`: a refused bootstrap holds the slot until its socket
ends, and a bootstrap stalled on the community check already holds the
slot before the AUTH challenge is sent.
- An established relay session whose reader is blocked in a Nostr
`CLOSE` handler on a held subscription lock when the session expires:
the socket ends within the bounded wait while the lock is held, and
cleanup completes with the subscription retired once it is released.
- An established relay session whose expiry task is stalled behind a
held session effect permit: the socket still ends within the bounded
wait.
- PostgreSQL: an established audio session whose teardown is stalled
after its Close, and a stalled archived-channel exit, both end within
the bounded wait from the hand-off.
- PostgreSQL: a fast refusal (unsupported protocol version) through the
real `handle_audio_connection` with a one-slot permit budget keeps the
permit taken until that socket ends.
- PostgreSQL: `huddle_audio_unavailable` sends its error and then Close
`1008` as the very next frame.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-19 16:13:27 -04:00
Will Pfleger
5b433d9292 feat(relay): resolve NIP-FI issuer bindings from the database (#8377)
Stack: #8372 (merged) → this PR → #8399 → #8401

Makes relay login checks read the per-community NIP-FI issuer bindings
that #8372 stores. Without this, the relay can't keep up with bb-public,
where new communities are created at every signup and a static
`BUZZ_NIP_FI_COMMUNITIES` list won't do.

**Source switch.** `BUZZ_NIP_FI_COMMUNITY_SOURCE=database` resolves each
community's trusted issuers from `community_nip_fi_issuers`, filtered by
the issuer registry, instead of the static list. Each community gets its
own policy ID: the base ID no longer hashes every community audience,
and the final ID hashes the base plus this community's audience, so
policy IDs change once (see `docs/operator-nip-fi-bindings.md`).
`Config::from_env` refuses to start, in every mode, on an unknown
source, on `database` together with `BUZZ_NIP_FI_COMMUNITIES`, and on
`database` without `BUZZ_NIP_FI_PROVISION_BINDING`.
`BUZZ_NIP_FI_RESOLVER_*` and `BUZZ_NIP_FI_SNAPSHOT_*` are validated in
every mode, so a bad value fails the deploy that sets it. In `off` mode
no resolver is built and no snapshot is loaded.

**Resolver (`nip_fi_resolver.rs`).**
- **Snapshot:** each pod loads every binding into memory, capped at
`BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS` (going over is a failed load). It
refreshes every 30 s, start to start, with jitter that only shortens the
delay and backoff after failures. Each attempt is limited by
`BUZZ_NIP_FI_SNAPSHOT_REFRESH_TIMEOUT_MS`, which must be shorter than
the interval. A refresh whose deadline passes before it publishes
publishes nothing. The old snapshot is dropped off the runtime.
- **Freshness:** a host in a snapshot loaded under two minutes ago is
answered from memory. Any other host, and every host once the snapshot
is stale, goes to a live lookup. Every answer carries a deadline two
minutes after the read behind it started, and WS, HTTP and audio recheck
it just before admitting.
- **Live lookups:** bounded and single-flight. In-flight lookups are
capped by `BUZZ_NIP_FI_RESOLVER_MAX_INFLIGHT` (default 8, clamped below
the database pool size), and the pending hosts and their waiters have
fixed caps. Positive answers are used for that admission only. A "not
found" is cached for 5 s, up to
`BUZZ_NIP_FI_RESOLVER_NEGATIVE_CACHE_MAX` hosts, and only when no
snapshot was published and no binding was created while it ran. A lookup
that panics still leaves the pending map.
- **Removal:** a removed binding stops admitting new connections within
30 s plus the refresh timeout (40 s by default) while refreshes succeed,
and within two minutes of the last good load while they fail. Open
WebSocket sessions are not rechecked when a binding is removed; they end
at their own session deadline.

**Shadow mode** resolves through the same path as enforce, including the
database lookup and the freshness rules. It keeps the resolved binding's
deadline and checks it at the same point as enforce; a binding that goes
stale during the tenant lookup is recorded as `stage=deadline,
outcome=unavailable`, without changing the legacy response or closing
the socket. With `source=database` its `community` metric label is only
`bound` or `unmapped`; with `source=env` it keeps the existing
per-community audience label, so current shadow dashboards are
unchanged. Each request resolves its Host once: the router guard's
resolution, failures included, is reused by shadow recording and
admission.

**Provisioning.** Both operator provisioning paths drop this pod's
cached "not found" for the new host after a commit that bound issuers,
so the creator's first login looks it up.

**Startup and readiness.** With `source=database` and a mode other than
`off`, the first snapshot loads before any listener binds, under its own
startup phase `nip_fi_snapshot_load`. A failed load exits non-zero, so a
pod is never ready without a snapshot. **Operational consequence:** boot
already fails when the database is unreachable; what is new is that a
snapshot over `BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS` or a load past its timeout
also crash-loops new pods. After two minutes the old pods' snapshots go
stale and most logins fall through to the 8-slot live lookup, which
sheds the rest with 503. The way out is raising the cap or timeout, or
`mode=off`.

**Metrics.** `buzz_nip_fi_binding_snapshot_*` covers loads, failures,
rows and freshness.
`buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds` is written on
every refresh attempt, so alert on `time() - x > 120` together with
`absent(x)`. `buzz_nip_fi_binding_snapshot_rows` against
`buzz_nip_fi_binding_snapshot_max_rows` gives the headroom to the row
cap. `buzz_nip_fi_resolver_*` covers live lookups and the negative
cache. The env vars and alerts are documented in `.env.example`, the
chart README and `docs/operator-nip-fi-bindings.md`, which also has the
staged off → shadow → enforce rollout for `source=database` with
go/no-go signals at each step, and the over-cap and load-timeout failure
modes with their ways out.

**Regression coverage.** Router-level tests run the database resolver
through the real `build_router` for root WS, audio and protected HTTP,
in enforce and shadow mode. Over PostgreSQL and Redis, two pods using
the env community source (two exact bindings) replay assertions retained
from before a disconnect through a real root NIP-42 session, the audio
deny-set check and signed NIP-98 requests: they are denied on both pods,
and a root session upgraded before the disconnect is refused by the
post-registration deny-set check. Postgres tests in `buzz-db` check that
archived and quiescing communities, and issuers missing from the
registry, never reach the snapshot or the live lookup. Process-level
`boot_lifecycle` tests spawn the real relay with `source=database`: an
over-cap or timed-out startup load exits non-zero with no health
listener bound, and a loadable snapshot lets readiness turn ready.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-19 10:32:18 -04:00
Will Pfleger
6286a54321 feat(relay): store per-community NIP-FI issuer bindings (#8372)
Stack: this PR → #8377 → #8399 → #8401

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

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

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

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

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

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

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

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

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

---------

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

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

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

### Validation

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


buzz-review-completed

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-16 14:06:53 -07:00
Brad Seiler
2d0f78c6e1 feat(auth): support explicit mesh JWKS transport (#8382)
## Summary

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

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

### Related issue

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

### Testing

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

Generated with Codex

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

---------

Signed-off-by: seiler <seiler@squareup.com>
Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz>
2026-10-16 14:26:08 -04:00
Logan Johnson
f148c0f22c feat(relay): add profile search over name and display_name only (#8301)
🤖
> **Review as a pair with block/buzz-app#1084** (the app change that
uses this endpoint). Either can merge first: the app falls back to the
old search on relays without this endpoint.

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

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

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

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

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

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

### Related issue
None found.

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

---------

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

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

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

### Related issue

N/A

### Testing

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

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

---------

Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:27:49 -07:00
Logan Johnson
d28f6b2f70 feat(relay): let channel-state filters opt in to system channels (#8182)
🤖
## Summary
After part 2, a client can read a system channel only if it already
knows the ID. This PR lets a client find system channels on purpose,
with an ordinary Nostr filter and no new filter key.

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

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

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

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

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

Part 3 of 3, stacked on part 2.

### Related issue
None found.

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

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

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

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

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

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

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

---------

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

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

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

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

Part 2 of 3, stacked on part 1.

### Related issue
None found.

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

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

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

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

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

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

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

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-15 08:40:15 -04:00
Logan Johnson
a3909acc67 feat(relay): match label and creator filters before the page limit (#8176)
🤖
## Summary
After parts 1 and 2, a client can ask for "channels with label X" or
"channels created by key K". But the relay applied these tag filters
after it read one page of the newest channel events. So a request such
as `{kinds:[39000], #t:["workspace"], limit:10}` could return fewer than
10 channels, or none, while matching channels existed. This PR moves the
label and creator match into the database query, before the page limit.

Part 3 of 3, stacked on part 2.

### Related issue
None found.

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

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

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

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

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

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

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

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-14 19:03:34 -04:00
Jarrod Sibbison
8599efb6a3 feat(relay): allow HTTP operator listener delivery URLs (#8287)
## Summary

Operator listener configuration currently rejects HTTP URLs, preventing
delivery to sibling services over internal HTTP connections. Accept HTTP
and HTTPS delivery URLs while retaining the existing pubkey,
duplicate-route, and URL restrictions.

Add configuration coverage for internal hostnames and custom ports,
verify signed delivery through the real HTTP client, and include the
listener suite in both unit-test runners. Shared NIP-98 signing and push
gateway code are unchanged.

### Related issue

Related: #7934 refactors operator listener delivery tests. No matching
open issue found.

### Testing

- `cargo test -p buzz-relay --lib operator_listener`: 13 passed,
including signed HTTP delivery and redirect rejection.
- `cargo fmt --all -- --check`, shell syntax, Justfile parsing, and diff
whitespace checks passed.
- `just test`: passed.
- `just ci`: stopped at mobile analysis with an existing undefined
`buzzDevPushAppProfile` in
`mobile/test/features/settings/notification_toggle_test.dart:81`.
Workspace and native desktop formatting/Clippy, desktop frontend checks,
and web checks passed. The mobile file is unchanged by this PR.

Locally verified with a spiked listener client

Signed-off-by: jsibbison-square <jsibbison@squareup.com>
2026-10-14 08:30:20 +11:00
Ian Oberst
85b7331784 fix(relay): partition audit observability follow-ups (#7994)
> [!IMPORTANT]
> **The alert rules changed in c3a314e71.** If you reviewed the alert
guidance earlier, please look again.
> - **The two range-based rules now apply only to live pods.** A range
selector such as `[30m]` keeps reading a terminated pod's samples until
they leave the window. So `BuzzPartitionAuditNotRunning` could fire for
about 10 minutes after normal pod turnover, and
`BuzzPartitionAuditFailing` could keep firing for up to an hour after
the pod was gone. Each rule now adds `and <counter>`, which requires the
counter to be exported right now:
>   ```yaml
>   expr: |
> sum without (outcome) (increase(buzz_partition_audit_runs_total[30m]))
== 0
>       and sum without (outcome) (buzz_partition_audit_runs_total)
>   ```
>   ```yaml
>   expr: |
>     increase(buzz_partition_audit_failures_total[1h]) >= 2
>       and buzz_partition_audit_failures_total
>   ```
> - **The audit counters start at 0.** When the relay starts, it exports
every `buzz_partition_audit_runs_total{table, outcome}` series and
`buzz_partition_audit_failures_total` at 0, before the first audit.
`increase()` ignores a new series' first sample. Before this change, it
could miss the first periodic failure, or read a table's first
`degraded` run as a stopped loop. This change is also what makes the
`and <counter>` gate work: the counters now exist for a relay's whole
life.
>
> The doc's old claim that pod turnover can't leave an alert firing was
true only of the instant-selector stale rule. The doc now explains the
difference.

## Why

These are the observability follow-ups deferred from #6515 ("fix(db):
audit partition catalog before creation"):

- **Timestamp-age alerts go silent** ([review
comment](https://github.com/block/buzz/pull/6515#discussion_r4127212141)).
Partition gauges are set only when a table audits successfully, and the
exporter evicts idle gauges after 2,700 s by default. After sustained
failures, `buzz_partition_audit_last_success_timestamp_seconds`
disappears. A table that has never audited successfully never has it. An
alert on `time() - last_success` then has no input.
- **Startup fallback failures are not counted** ([review
comment](https://github.com/block/buzz/pull/6515#discussion_r4127212158)).
When the startup fallback audit fails, it logs an error but does not
increment `buzz_partition_audit_failures_total`. Periodic failures do.
- **Repaired environments are always `degraded`**.
`PartitionTableAudit::degraded()` counts every legacy leaf, even one
wholly in the past. The `bb-block-staging` audit of the repaired catalog
reports `degraded` only because of `events_p2026_07_08_legacy`
`[2026-07-01, 2026-09-01)`. With this rule, every repaired environment
stays `degraded`, so nobody can alert on the outcome.
- **Partition config tests can see a poisoned env** ([review
comment](https://github.com/block/buzz/pull/6515#discussion_r4137416297)).
Two partition config tests take only `ENV_MUTEX`. A NIP-FI test running
in the same process can make them see its deliberately invalid
environment.

Partition health is still diagnostic only, through
`/_status.partition_catalog` and metrics. Nothing here affects
`/_readiness`. Handling concurrent creation (#6515 item 4) is out of
scope and stays with the catch-all advancement work.

## What

- **Success timestamp survives failures.** Each failed table audit
refreshes `buzz_partition_audit_last_success_timestamp_seconds` with
`increment(0.0)`, which leaves the value unchanged. It still resets the
exporter's idle tracking, the same mechanism as
`refresh_legacy_active_gauge_recency`. Sustained failures therefore keep
the last success exported and its age rising. A table that has never
succeeded exports `0`. A failed audit still never makes the last success
look fresh.
- **Alert guidance.** New `docs/partition-catalog-monitoring.md` covers
the metrics, how long each series lives, and the audit outcomes. It
notes that `runs_total` has no `unsafe` outcome. It gives three alert
rules:
- timestamp age, which also covers never-successful tables and sustained
failures;
- `sum without (outcome)
(increase(buzz_partition_audit_runs_total[30m])) == 0` for a stopped
audit loop. Counters are never idle-evicted, and this rule fires before
the stopped loop's timestamp is evicted, so there is no false resolve;
  - an aggregate failure-counter rule.

Both range-based rules also require their counter to be exported right
now (`and <counter>`), so a terminated pod's leftover samples can't
raise or hold an alert.
- **Zero counter baselines.** `try_install` exports each
`buzz_partition_audit_runs_total{table, outcome}` series (`ok`,
`degraded`, and `error` for both tables) and
`buzz_partition_audit_failures_total` at 0 before the startup audit, the
same way the auth series already start at 0. A failure in the startup
audit itself can still happen before the first scrape. The doc says so;
the stale rule covers that case.
- **Failure counter.** `buzz_partition_audit_failures_total` now counts
failed explicit audit attempts: the startup fallback audit and each
periodic audit. Both paths go through one helper,
`counted_partition_audit`. A failed `ensure_future_partitions` call,
such as a DDL error that the fallback then recovers, is not counted by
itself. A single attempt never counts twice.
- **Historical legacy leaves.** A legacy leaf is historical when its
finite upper bound is at or before the current month start. It is still
reported under `children` as `legacy_leaf`, but it no longer degrades
the table. A legacy leaf that still covers the current month or any
lookahead month stays degraded. All other degradation causes are
unchanged. The verdict now lives in `PartitionAuditReport::outcome()`,
which `buzz-admin partition-audit` uses. The JSON outcome names and exit
codes are the same as before.
- **Test hygiene.** Both partition config tests now use `env_guards()`.

## How it was tested

- `cargo fmt --all -- --check` and `cargo clippy -p buzz-db -p
buzz-relay -p buzz-admin --all-targets -- -D warnings`.
- New unit tests:
- The repaired incident layout with an injected clock: `degraded` in
August 2026 and `ok` on 2026-09-01T00:00Z, when the upper bound equals
the current month start, and on 2026-09-15. The legacy leaf is still in
the serialized report. The `outcome` label on the run counter follows
the verdict.
- A table test of the legacy-leaf predicate: the upper bound before, at,
and one second after the month start; lookahead and beyond-horizon
leaves; historical leaves with trigger drift; anomalous and `DEFAULT`
children; no audited month.
- Outcome precedence (error, then unsafe, then degraded) and the
`buzz-admin` exit-code mapping.
- Startup: a failed fallback counts once, a successful fallback after a
DDL failure counts nothing, and a successful ensure never runs the
fallback. Each periodic failure counts once. Against an unreachable
database, startup counts one aggregate failure while each table still
records two `error` runs.
- On the production exporter, never-successful audits leave run counters
and a `0` success timestamp for both tables.
- On the production exporter, all six run-counter series and the failure
counter read 0 before any audit. After one failed audit, `error` reads 1
and `ok`/`degraded` stay at 0. Removing the initialization fails this
test.
- PostgreSQL lane (`scripts/postgres-test-run.sh`) with new or extended
tests:
- The repaired layout is not degraded in September. The same catalog is
degraded in August.
- With a 1 s idle timeout, a failed audit leaves the success timestamp
unchanged, and sustained failures keep it exported past the idle
timeout. When audits stop, the timestamp is evicted but the run counters
remain. The next success exports a fresh timestamp.
- Mutation check: removing the failure-path refresh fails both alert
fixtures.
- These fixtures check the emitted series. They do not evaluate the
rules themselves, including the `and <counter>` gate. `promtool` isn't
available locally or in CI, and the doc points to `promtool test rules`
for that.
- `scripts/test-postgres-test-discovery.sh`.
- `config::` and `nip_fi_config::` tests run together in one process
five times.

## Related

This PR conflicts textually with #7993 (catch-all advancement) in
`crates/buzz-db/src/store/partition.rs` and
`crates/buzz-relay/src/main.rs`. Whichever PR merges second will be
rebased onto the other.

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

---------

Signed-off-by: Ian Oberst <ioberst@block.xyz>
Co-authored-by: Ian Oberst <ioberst@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:57:54 -07:00
klopez4212
5dc693aeaf Extract shared Huddle audio client for desktop consumers (#8129)
Huddle audio was embedded in the v0 desktop client, forcing other
clients to duplicate its protocol and codec behavior. This extracts
`buzz-audio-client` so v1 can depend on a pinned library revision
without building the v0 application.

The crate owns the bounded v2 audio handshake, roster parsing, wire
format, Opus sender, and per-peer NetEQ jitter buffer. The v0 human and
agent senders use it, and the desktop retains room admission,
identity/signing, TLS policy, device routing, mute, and teardown.
Receive decoding preserves v1's support for 120 ms Opus packets. No
relay protocol changes, speech models, or model downloads are included.
Docker builder stages include CMake because cargo-chef compiles the
workspace dependency graph.

Dependency security: Opus 0.4 replaces the unmaintained `audiopus_sys`
backend with `opusic-sys`. NetEQ is pinned exactly to 0.8.3 because its
published Rust sources never reference its declared ringbuf dependency.
RUSTSEC-2026-0293 is documented as unreachable for this audited source,
and a wrapper restriction rejects every other consumer of vulnerable
ringbuf versions. Independent review confirmed the scope;
re-audit/remove the exception when changing NetEQ. NetEQ 0.9.1 still
declares ringbuf 0.3, so a version bump alone does not remove it. Local
`cargo deny check` passes with this constrained exception.

Validation:
- All 21 shared-crate tests pass, including real loopback WebSocket
authentication/cancellation, real Opus-to-NetEQ playback, malformed
input, and 120 ms decoding. These tests are included in `just
test-unit`.
- Shared-crate, root workspace, and desktop Tauri Clippy pass; `just
desktop-tauri-check` passes.
- The desktop Tauri test workspace passes; native two-person
listening/device checks remain separate.
- All mandatory push checks pass at `d06bac5dc`, including the complete
Rust unit gate and desktop workspace checks. Existing agent timing and
relay log-capture tests failed under concurrent load, passed separately,
and passed in the full serial test run; no tests or hooks were skipped.
- One test-only fixture change allows cold Perl startup before the
escaped-process test's inner timeout (2 seconds instead of 300 ms). Its
independent 5-second watchdog, 300-second surviving pipe writer, and all
assertions remain intact. All eight process-helper tests pass;
independent review confirmed this still tests bounded drain completion.
- Dependency inspection confirms the library brings in no desktop
application, Tauri, identity store, relay server, or speech-model
crates.
- Independent source review found no correctness blockers.

Draft: a native two-person call, mute/leave/cancellation, and
microphone/speaker switching still need a runtime tryout and human
review. Full `just ci` was stopped during the unrelated mobile toolchain
download because of disk pressure; it is not a passing gate. Consumer
integration is tracked in block/buzz-app#489.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-13 16:24:27 -04:00
Will Pfleger
40c1c474e7 fix(relay): hide deletions of author-only events from other readers (#8197)
🤖 Opened by an agent (Hayt) for Will Pfleger.

The relay keeps `AUTHOR_ONLY_KINDS` (event reminders `30300`, push
leases `30350`, private managed agents `30179`) readable only by their
author, but a NIP-09 deletion request (`kind:5`) for one of those events
was readable by any community reader. The deletion carries no `h` tag,
so ingest stores it with its target's channel (none), and every read
gate classified it by its own kind, `5`. Another reader could read and
count it by author, by id, live, or over HTTP, learning the deleted ids,
how many, and when.

A deletion is now exactly as private as the event it deletes:

- **Classification by the deletion's own `k` tag.**
`buzz_core::kind::is_author_only_event_kind` treats a `kind:5` that
names an author-only kind in a `k` tag as author-only. Only the
canonical decimal spelling counts (`"30300"`, not `"030300"` or
`"+30300"`), so Rust classification, ingest and the admin SQL (which
matches the tag text) always agree. Because it reads the deletion, not a
target lookup, the deletion stays private after its target is
soft-deleted or gone. It is generic over `AUTHOR_ONLY_KINDS`, so a kind
added to the list later (for example `44300`) is covered automatically.
- **Every read surface.** The shared per-event gate
(`is_author_only_event`, used by WS REQ historical and lookup by id,
COUNT fallbacks, HTTP `/query`, `/count`, search hydration and the
thread window), live fan-out in `handlers/event.rs`, the COUNT fast-path
guard (`filter_can_match_author_only_kinds` now treats any filter that
can match `kind:5`, including unfiltered and id-only filters, as needing
per-event filtering), the channel-window aux closure in the bridge, and
admin event preview plus report list and detail (SQL suppression of
`kind:5` rows with a tag whose first two elements are `k` and an
author-only kind, matching the Rust classifier; soft-deleted rows
included).
- **Enforcement still reaches the author.** The admin report query takes
the private-deletion `k` list from its caller next to the hidden kinds.
HTTP-facing reads pass both; `admin_get_report_for_enforcement` passes
neither, so report-directed bans, timeouts and action recovery still
resolve the stored author of a reported private deletion.
- **Ingest agreement.** `validate_standard_deletion_event` rejects a
deletion of a stored (including soft-deleted) author-only event, by `e`
or by `a` coordinate, unless it carries the matching canonical
`["k","<kind>"]`. It also rejects an author-only `k` on a deletion of
any other kind, so a public deletion can't hide itself from the readers
who need it. A deletion whose target is itself a private deletion is
rejected with `invalid: cannot delete a private deletion request`: it
would otherwise be stored as a public event revealing the private
deletion's id and timing, and NIP-09 gives a deletion of a deletion no
effect. A deletion of a public deletion is unchanged. The `k` tag only
narrows who can read the request; it grants no one new deletion
authority.
- **Unauthorized deletions learn nothing about the target.** On every
delete-by-id path, no target-dependent error (its kind, the artifact and
`k`-marker errors, the artifact-removal-marker error, or where it lives)
is returned until every access check that could block the deletion has
passed. A sender who fails any of them gets `invalid: deletion target
not found or not deletable by you`, the same text and status as for a
missing target. Errors that depend only on the request, never on the
target, are unchanged: for example a `kind:9005` whose own `h` channel
is archived still gets `channel is archived`, for a missing target and a
real one alike. "May not delete" includes an author who has since been
removed from the target's private channel, an author whose token is
scoped to a different channel, and anyone deleting in an archived
channel. The single-target rule (exactly one `e` or `a` tag) now runs
before any target lookup, so every target faces the gates. For `kind:5`
with an `e` target, ingest decides authority (effective author or the
owning human of the authoring agent) first, then takes the channel from
that same lookup, soft-deleted targets included, so tombstones face the
same gates; an authorized re-deletion of a soft-deleted target now keeps
the target's stored channel instead of being treated as global, so
historical access checks, `#h` filtering and live routing all use that
channel: readers outside a private channel no longer see it, and that
channel's members and subscribers do. The token channel scope,
scoped-token global-event, membership and archived checks then reject
with the shared denial, all before `validate_standard_deletion_event`
returns its artifact and `k`-marker errors. Other kinds, and `kind:5`
with only `a` coordinates, keep their existing errors. For NIP-29
`kind:9005`, a target outside the `h` channel is denied like a missing
one, so the relay no longer returns "different channel" or "no channel";
`may_delete_channel_event` (the author while still a member or the
channel is open, a channel owner/admin, or the owning human of the
author agent) is then checked in the `h` channel before the
artifact-removal-marker error. No access restriction is removed and
nothing becomes newly deletable. An authorized sender who names the
wrong `h` channel for a `kind:9005` now gets the shared denial.

Whether kind:5 content is full-text indexed depends on how the database
was built. The desired-state schema (`schema/schema.sql`, which CI
applies) and databases created before migration `0008` index it; an
empty database initialized through `0008` gets a positive allowlist that
doesn't. Where it is indexed, the per-event gate on search hydration is
what keeps a private deletion out of other readers' search results.

No current client is affected by the new rejection. `buzz-app`'s signer
only allows `kind:5` for channel messages (with `h`) and managed-agent
`30177` coordinates, and neither `buzz-app` nor this repo's desktop or
CLI publishes deletions for `30300`, `30350` or `30179` today. NIP-ER
already requires `k=30300` on reminder deletions.

Coverage:
- `crates/buzz-test-client/tests/e2e_author_only_deletion.rs` (Backend
Integration job): a non-author gets nothing from WS REQ, WS COUNT, HTTP
`/query` and `/count` (by author, mixed kinds, and id), search, or live
delivery, while the author gets the deletion. Search runs WS and HTTP
against a private deletion and a public control deletion sharing a
reason word; with `BUZZ_TEST_SEARCH_INDEXES_DELETIONS=1` (set by the CI
step) the author must get exactly both and another reader exactly the
control; ingest rejects a missing, wrong, non-canonical, or
public-target marker, and an untagged deletion of an already
soft-deleted target.
- `e2e_author_only_deletion.rs` also sends one non-owner's deletions
over HTTP and WS for a missing, live, soft-deleted, private-channel and
global target, plus the `kind:9005` equivalents including a target in
another channel and one with no channel, and requires the identical
rejection text and HTTP status in every case; another member's deletion
of a private event never names its kind.
- `e2e_author_only_deletion.rs`
`removed_author_deletion_does_not_reveal_target`: an author removed from
a private channel gets the missing-target rejection over HTTP and WS for
`kind:5` and `kind:9005` (with `h` set to a channel they can write to)
against their retained message; the channel owner can still delete the
removed author's message, and the author can still delete in a channel
they can write to.
- `e2e_author_only_deletion.rs`
`archived_target_deletion_does_not_reveal_target`: over HTTP and WS,
`kind:5` against a message, the same message with a wrong `k`, and an
artifact in an archived channel, and `kind:9005` against the
archived-channel message with `h` set to an active channel, all match
the missing-target rejection; a `kind:9005` for an artifact-removal
marker in the archived channel matches a missing target with the same
`h`; in an active channel the owner still gets the `k`-mismatch and
artifact errors.
- `e2e_author_only_deletion.rs` `deletion_of_private_deletion_rejected`:
over HTTP and WS, the author's deletion of their private deletion, with
no `k` and with `k=5`, gets the rejection and stores nothing; a
non-author's gets the missing-target rejection; a `kind:9005` against it
gets the missing-target rejection; the private deletion stays hidden
from another reader in WS REQ and COUNT, HTTP `/query` and `/count`, and
live delivery; a deletion of a public deletion is still accepted.
- `e2e_author_only_deletion.rs` `soft_deleted_target_keeps_its_channel`:
an author removed from a private channel, and an author in an archived
channel, re-deleting their own soft-deleted message get the
missing-target rejection; a distinct successful re-deletion is readable
by a channel member and not by an outsider, by id and by `#h`, and is
delivered live to the member's `#h` subscription but not to the
outsider's.
- `e2e_author_only_deletion.rs`
`multi_target_deletion_error_ignores_targets`: deletions with two `e`
tags, or an `e` and an `a` tag, get the identical single-target error
whether the first target is missing, live, in another private channel, a
private reminder or an artifact.
-
`handlers::ingest::postgres_tests::scoped_token_deletion_rejection_does_not_reveal_target`:
a non-owner and the target's author, each with a token scoped to another
channel, get the same rejection for a missing, live, soft-deleted and
global target, and the author's `kind:9005` naming the token's channel
gets it for a missing target, a live target in another channel and a
global target; a token scoped to the target's channel still deletes, and
its re-deletion of the soft-deleted target is stored with that target's
channel.
- `thread_window::postgres_tests`: stored private deletions never reach
a non-signer through the thread-window or channel-window aux closures,
or by id once their target is gone.
- `api::admin::postgres_tests`: ban and stranded-ban recovery on a
reported private deletion reach the stored author while report detail
hides it; `admin_moderation` tests treat non-canonical markers as
public.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-13 14:45:59 -04:00
Tom Brow
5b907b838a fix(push): isolate push enqueue and stop workers on rollback (#8216)
Push enqueueing previously ran inside the message transaction, so a push
lock or database error could block or reject a message. Configured relay
writers now submit bounded best-effort work only after commit. A
dedicated connection and bounded queue isolate enqueue waits and
failures from message-write connections. Overload, errors, process loss,
or shutdown may drop a notification; the committed message remains
authoritative.

`BUZZ_PUSH_ENABLED=false` disables the application producer and workers.
Updated writer connections always bypass any surviving enqueue trigger.
The overlap migration updates an existing function without resurrecting
retired objects. Apply it before relying on isolation with a surviving
trigger, and replace every old enabled process and writer connection:
missing settings preserve legacy behavior during overlap.

SIGTERM/Ctrl-C cancels enqueue, matcher, and delivery work before
listener drain. Durable queues, claims, leases, preferences, and
delivery configuration remain intact. Claims expire normally; gateway
requests already accepted may still deliver. Reactivation requires an
explicit operator decision about retained work. There is no automatic
purge, startup interlock, or backfill of messages written while
disabled.

Built on merged [#8135](https://github.com/block/buzz/pull/8135),
including [#8178](https://github.com/block/buzz/pull/8178). Trigger
retirement [#8210](https://github.com/block/buzz/pull/8210) remains
open; this change is tested with the trigger present and after removal.
Operational details are in [the rollback and reactivation
guide](https://github.com/block/buzz/blob/relay-push-rollback/docs/push-gateway-deployment.md#relay-rollback-and-reactivation).

### Validation


[9d3d710](9d3d710dc1)
changes documentation and Rust comments only, with no executable or
assertion changes. Formatting and diff checks passed;
[12f1d1e](12f1d1e814)
additionally restores the reviewer-requested caveat that active lease
renewal may suppress a notification. This is also documentation-only;
Owner approval was confirmed on 12f1d1e.
[a4f602d](a4f602df49)
subsequently merges main to resolve a migration-count conflict after
main added migration 0058. Both 0058 and rollback migration 0059 are
retained; the focused embedded-migrator test, formatting, and applicable
desktop pre-push checks passed. Current-head [owner
approval](https://github.com/block/buzz/pull/8216#pullrequestreview-5474958334)
is confirmed. [CI attempt 2
passed](https://github.com/block/buzz/actions/runs/37985516452/attempts/2),
with required checks passing or appropriately skipped. The first attempt
failed only the unchanged replica-floor probe test with `MaskedActivity
{ masked: 1 }`; one failed-jobs retry passed without code changes. All
four push contract tests passed. The current-head [advisory security
review](https://github.com/block/buzz/actions/runs/37985514346/job/114006457769)
has remained in its review step since 20:18:51 UTC without a published
result for over 25 minutes. It is stalled under the advisory-review
policy, not a clean security review.

[Required CI checks passed or were
skipped](https://github.com/block/buzz/actions/runs/37964035783) on
[3164b25](3164b25cde).
The advisory security review was cancelled without a published result.
Codex's durable-retry suggestion was [rebutted and
resolved](https://github.com/block/buzz/pull/8216#discussion_r4232674980)
under the explicit best-effort notification contract. The [lease
activation-order
observation](https://github.com/block/buzz/pull/8216#discussion_r4233051799)
is correct. Under the clarified product requirement, either notification
outcome is acceptable during overlapping registration and message
arrival. Exact commit ordering is not required; deliberate historical
backfill remains prohibited. The bot thread is resolved against that
clarification; [Owner review accepted the clarified overlap
contract](https://github.com/block/buzz/pull/8216#pullrequestreview-5474720278)
on 9d3d710.

All 488 PostgreSQL tests passed. The final four-way producer contracts
passed on desired/migrated schemas, with/without triggers. Added
coverage verifies message commit under push-gate and queue-table locks,
recovery after enqueue failure, in-flight enqueue cancellation, the
lease timestamp filter (not production activation ordering),
serving-lease writers, rollback preservation, reconnects, and
reactivation without backfill. Queue saturation and all three relay
worker shutdown tests passed. Rust lint and all 17 source-policy checks
passed.

Full local CI/repository runs encountered the unchanged ACP
`keepalive_resets_idle_past_deadline` timing failure. Earlier desktop
subprocess timing tests also failed under parallel load and passed as an
isolated module. Under Tom's local pre-push exception, this update
excluded `rust-tests` and `desktop-tauri-checks`; remaining hooks stayed
enabled. Required CI validation remains necessary before merge. A
focused run competing with the full database suite hit wall-clock
deadlines; tests now use a short database lock timeout to distinguish
unwanted push dependencies, and their final isolated run passed.

A local process-replacement rehearsal on 3164b25 used the real relay
binary and a mock gateway holding a delivery request open. SIGTERM
closed that request in under 1 ms; the relay exited in 5.02 seconds.
Restarting the same binary with push disabled produced no further
gateway requests over 35 seconds, beyond the 30-second claim expiry. The
outbox row, lease, and its subscriptions remained byte-for-byte
unchanged. This used a seeded outbox and did not exercise APNs or a
mobile device.

Production writer inventory, shutdown deadline, and retained-queue
disposition still require operational validation before rollout. The PR
is marked ready for review. The overlap behavior is accepted by the
product requirement; owner review accepted that contract. No production
deployment has occurred.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-09 13:48:06 -07:00
Luke Tornquist
d34c9c2c9b ci: shorten the PR critical path (desktop lanes, smoke shards, parallel relay artifacts) (#8156)
## Summary

The PR critical path on main push [run
37635224099](https://github.com/block/buzz/actions/runs/37635224099)
(`e097c4299`) was about 42 minutes:

- **Desktop Core** took 41 min. It ran frontend checks, two clippy
graphs, the Tauri suite, the mesh-llm suite and every compiled-flag
state one after another. The compiled-flag step alone took 16m51.
- **Relay artifact producer → Desktop E2E Integration** took about 31
min. The producer built the relay binaries (5m07), the backend
integration archive (4m41) and the PostgreSQL archive (8m18) one after
another.
- **Desktop Smoke E2E** took 19–24 min per shard across 4 shards, with
82 min of tests in total.

This PR attacks all three.

### 1. Run each compiled-flag state once (`Justfile`)

| Previous step | Cost | Why it's redundant |
|---|---|---|
| Clean state: full `buzz_lib` suite (`--lib`) | 3m27 run |
`desktop-tauri-test` runs the same suite in the same compile state one
step earlier. When `BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY` is
unset, the expected value already defaults to `false` in non-owner-only
builds (`runtime/test_fixtures.rs`, `agents_tests.rs`). |
| Internal state: auto-connect and owner-only built separately | 1m22 +
1m06 + 56s across three recompiles | Internal release packaging always
sets both capabilities together, so building owner-only without
auto-connect tests a configuration that never ships. The extra 56s
happens because a bare `cargo test <filter>` builds every target, not
just the `--lib` harness. |
| Demo state: `cargo test --workspace` | ~30s more than `--lib` |
`buzz-terminal` and the `tests/*.rs` integration tests don't read any
`BUZZ_BUILD_*` variable or `buzz_lib`, so they ran the same binaries a
second time. |

A fourth cost was hidden: every recipe invocation spent ~1m20
recompiling `buzz-desktop` to the *same* unit hash `desktop-tauri-test`
had just built. `_ensure-sidecar-stubs` ran `touch` on every sidecar
stub on each run. `tauri-build` emits `rerun-if-changed` for each
`externalBin`, so the new mtimes reran `build.rs` and recompiled the
crate. Stubs are now created only when they're missing. This also speeds
up any local `desktop-tauri-*` recipe run back-to-back.

After this change:
- **Clean state:** runs only the two compiled-flag assertions
(`compiled_flag_matches_expected`, `compiled_policy_matches_expected`)
with `expected=false`. No recompile.
- **Internal state:** one compile with
`BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY=1` and
`BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1`. It runs both assertions with
`expected=true`, then the full `buzz_lib` suite. The suite now runs with
auto-connect enabled as well, which matches the shipped internal build
more closely than before.
- **Auto-connect-only state (assertions only):** auto-connect `true`,
owner-only `false`. The two capabilities are documented as independent.
Without this state, a `build.rs` block that read the other capability's
variable would pass, because every other state sets both or neither.
- **Demo state:** one compile with only `BUZZ_BUILD_DEMO_SLUG`. It
asserts the slug and asserts both capabilities stay `false`, then runs
the full `buzz_lib` suite.
- The recipe clears the three toggled `BUZZ_BUILD_*` capability
variables and any caller `BUZZ_TEST_EXPECTED_*` values up front. Each
state exports its own values in a subshell. Every invocation uses
`--lib`, so each state compiles one test harness.
- Each ignored-assertion run requires exactly one passing test per name
filter. Previously, a renamed or un-ignored assertion matched nothing
and passed silently.
- The 32-character demo-name rejection check is unchanged.

The full-suite reruns in the internal and demo states are kept on
purpose. They are the only coverage that the whole suite holds up in
those two shipped build shapes.

Estimate: the compiled-flag work goes from about 17 min to about 11.

### 2. Split Desktop Core into parallel, path-gated lanes
(`_ci-desktop.yml`)

- **Desktop JS:** `desktop-check`, `desktop-test` and `desktop-build`.
These compile no Rust, but some node:test cases read relay and DB crate
sources to pin shared constants (for example
`relayReconnectReplay.test.mjs`). So the job runs on desktop *and* Rust
changes, in parallel with the Tauri lanes. `lefthook.yml` notes this as
a deliberate local deviation.
- **Desktop Tauri (core | mesh | compiled-flags):** a matrix of parallel
Rust lanes.
- `core` and `compiled-flags` share the existing `desktop-core` Rust
cache key, so they start warm. `compiled-flags` restores it read-only.
- `mesh` keeps its own cache because `mesh-llm` pulls a different
dependency graph.
- **Smoke E2E and the Windows frontend build** compile no Rust and read
no crate sources, so they no longer run for Rust-only changes.
- **Smoke E2E goes from 4 to 10 shards.** Playwright shards by test
count, not duration. A simulation from the per-test durations in run
37635224099 reproduces the observed 4-shard split exactly. With 10
shards, the slowest shard runs about 11 min of tests.
- **The `Desktop` gate is stricter.** Each selected job must succeed and
each unselected job must be skipped, so a mis-gated job can't pass
silently. The required check context (`Desktop`) is unchanged.
`scripts/ci-required-gates.test.mjs` pins each job's `if:` to the gate's
selection expression, and checks which jobs a Rust-only, desktop-only,
Tauri, push, and empty selection pick.
- **Path-filter fixes:**
- The `desktop` filter now includes `_ci-desktop.yml`, `ci.yml` and
`Justfile` (which holds the desktop recipes), so edits to them run the
frontend lanes.
- The `rust` filter now matches `Justfile`. It still named the
pre-rename `justfile`, so a `Justfile`-only change selected no Rust
suites.
- `scripts/ci-selection.test.mjs` gains `Justfile` and desktop-workflow
scenarios, and the shared-workflow scenario now expects `desktop`.
- **Dead step removed:** the failure-only Playwright artifact upload in
the old Core job never had anything to upload, because that job never
ran Playwright.

### 3. Build relay artifacts in parallel (`_ci-relay.yml`)

- **The producer is now a 3-way matrix** (`relay-binaries`,
`backend-integration-tests`, `postgres-tests`).
- Each lane has its own exact-source cache entry and uploads
`desktop-e2e-relay-<artifact>`.
- Consumers download `desktop-e2e-relay-*` into `target/ci`, so their
paths are unchanged.
- The required `Desktop E2E Relay` context still reads the producer
job's aggregate result.
- **Rust cache saving:** all lanes restore the existing Rust cache key,
but only the widest lane (`postgres-tests`) saves it. Otherwise the
fastest lane could overwrite it with a narrower target dir.
- **Desktop E2E Integration goes from 2 to 3 shards.** Shard labels now
use `strategy.job-total`.

### 4. Bound degraded apt steps (`scripts/ci-apt-retry.sh`)

- With more shards, more runs hit the occasional degraded Ubuntu mirror.
On this PR, a Tauri lane ran `apt-get install` for 27 minutes: apt's
`Acquire` timeouts kept firing, but apt kept re-queuing the requests and
never failed. Separately, three Smoke shards stalled silently in
`playwright install-deps` (no output after `InRelease`) until the job
timeout. Healthy apt steps across four recent runs finished in at most
238s.
- Every apt-backed step in Desktop and Integration now runs through
`scripts/ci-apt-retry.sh`. The script writes the apt options to
`apt.conf.d`, so Playwright's own apt-get calls get them too. It kills
each attempt after 7 minutes (90s for `apt-get update`). Between
attempts it stops any `apt-get` or `dpkg` the attempt left running,
repairs dpkg under its own 60s deadline, and retries up to 3 times. The
stop matters for `playwright install-deps`, which runs `sudo sh -c
"apt-get update && apt-get install ..."`: sudo puts its command in a new
process group, so the deadline's signal stops `sh` and apt-get survives
holding the dpkg lock (seen on Smoke shard 8 of run 37696815557, and
reproduced with real sudo).
- Downloaded `.deb`s are kept between attempts, so a retry resumes where
the last one stopped. Against a mirror that stays degraded, this bounds
each wrapped command at about 25 minutes rather than recovering it. The
Tauri lanes wrap two commands, `apt-get update` (about 8.5 minutes worst
case) and `apt-get install` (about 25), so about 34 minutes together.
Job timeouts are sized to fit that bound plus the job's own work: Tauri
55m (lanes take up to ~15m), Integration 40m, and Smoke 45m. A degraded
mirror therefore ends with the wrapper's error instead of a silent job
timeout.
- **The retry has already fired on this PR.** In [run
37703795342](https://github.com/block/buzz/actions/runs/37703795342),
`playwright install-deps` on Integration shard 1/3 hit the 420s attempt
deadline. The second attempt started about 1.4s later, downloaded and
configured the real packages, and succeeded. The step took 526s (8m46),
and that shard took 17m24 while the other two took about 5m. Healthy
installs have taken at most 238s, so 420s leaves headroom, and a slow
mirror costs one attempt instead of the job.
- Between attempts the script stops **every** `apt-get`/`dpkg` on the
machine, not only the ones it started. That's intentional: on a hosted
runner any other one would hold the same dpkg lock. It assumes an
ephemeral runner, and the script says not to use it on a self-hosted
runner or a developer machine.
- `scripts/ci-apt-retry.test.mjs` covers the following with a fake
`sudo`: success, retry, the attempt budget, a stalled attempt, a stalled
dpkg repair, an apt-get that escapes the attempt's process group (on a
retry and on the final attempt), and usage. It needs Linux (GNU
`timeout`, `flock`, `setsid`, procps), so it skips on other platforms.
CI runs it on Ubuntu.

### What got simpler

- **Compiled-flag recipe:** two full-suite runs instead of three, six
cargo invocations instead of eight, and sidecar stubs no longer
invalidate the desktop build cache.
- **Path selection:** each desktop job declares only the paths that
actually affect it. The stale `justfile` filter and a dead artifact step
are gone.
- **Apt options:** set once in `apt.conf.d` by one script, instead of as
repeated `-o Acquire...` flags on each apt call.
- **Shard counts:** the shard count is written once per matrix, not
repeated in names and commands.

### Cost

- **More runners:** a full run uses more runner jobs. Smoke E2E goes
from 4 to 10 jobs, Desktop Core from 1 to 4, the relay producer from 1
to 3, and Integration from 2 to 3.
- **Fewer runners on single-area PRs:** desktop-only PRs skip the Rust
lanes, and Rust-only PRs skip Smoke E2E and the Windows build.
- **One extra Rust cache entry:** `desktop-mesh`.

### Related issue

None found.

### Testing

Blox, compiled-flag recipe at `77b9874b`:
- [x] Recipe passes with output redirected to a file. The log keeps all
4 state headers, the ignored assertions ran 2/2/2/3, and both full
suites passed (`3423 passed`).
- [x] A second back-to-back run shows no clean-state `Compiling
buzz-desktop`.
- [x] Mutation tests all fail as required:
  - `build.rs` auto-connect reads the owner-only var: rc=101.
  - The reverse swap: rc=101.
  - Rename `compiled_policy_matches_expected`: rc=1.
  - Wrong owner-only expectation: rc=101.
- [ ] `just ci`

Workflows:
- [x] `actionlint` on `_ci-desktop.yml`, `_ci-relay.yml` and `ci.yml`:
clean.
- [x] `scripts/test-ci-required-context-isolation.sh`: passes.
- [x] GitHub Actions: runs
[37701003713](https://github.com/block/buzz/actions/runs/37701003713)
and
[37703795342](https://github.com/block/buzz/actions/runs/37703795342)
took 27m09 and 28m51. Before this PR, PR runs had a median of 40.0m
(n=21) and main pushes took about 42m. These are observations across
mixed runner images, not a measured causal speedup.
- [x] Selection, gate and apt suites (74/74 on Linux), `actionlint` and
`bash -n` on Blox at tree `db9725fe`.

---------

Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-10-08 11:33:37 -04:00
Luke Tornquist
f8bf2c529c refactor(db): type-level admitted event-write transactions (AdmittedTx) (#8130) 2026-10-08 08:47:07 -04:00
Luke Tornquist
1972b7d256 feat(admin): add community archive commands (#7107) 2026-10-07 18:31:00 -04:00
Will Pfleger
032198ce43 feat(relay): add community reads to the admin API (#8011)
The in-app Admin Console needs to work on any community the relay
serves, not only the one the desktop is connected to. This adds four
read-only routes under `/api/admin/v1` that the stacked desktop PR
builds on, puts every admin route under one explicit access rule, and
binds every admin request's signature to its real method, which fixes
signed `HEAD` on reads.

## Access rule

| Mode | Moderation reads | Writes | Staffing (`/operators`) |
|---|---|---|---|
| `nip98` (default) | signed, any staff role | signed, role-checked |
signed Operator |
| `disabled` | served to anyone who can reach the relay | `403` | `403`
|

"Moderation reads" means every read: reports, feedback and attachments,
restrictions, and the four community routes below. `nip98` behavior is
unchanged, and the Operator API (`/operator/*`) is untouched.

**Existing `BUZZ_ADMIN_AUTH=disabled` deployments start serving the four
new reads** (community directory, member search, member lookup without
staff status, and stored events by ID) with no config change. The
startup `WARN`, `docs/admin/README.md`, `.env.example`,
`deploy/compose/.env.example` and the `just admin` comment list exactly
what disabled mode exposes, say it should be reachable only by people
allowed to see all moderation data in every community, and say the whole
relay port must be private: the admin API is on the relay's main
listener and its hostname check uses the caller's `Host` header. `just
admin` binds the relay to `127.0.0.1` unless `BUZZ_ADMIN_AUTH` is
exactly `nip98`, so an unset, padded or misspelled value stays local.

In code, `authorize()` returns `AdminAccess`, either `NetworkTrusted`
(disabled mode, no identity) or `Staff(AdminPrincipal)` (`nip98`, any
rostered Operator or Moderator). Each route makes one check: **view**
(`authorize()` succeeding), **act** (`AdminAccess::act()`, must be
`Staff`, then the existing role checks) or **operator**
(`AdminAccess::operator()`, must be an Operator). Act and operator
refuse `NetworkTrusted` with `403 this endpoint requires
BUZZ_ADMIN_AUTH=nip98`; no principal is ever created for an anonymous
caller. `every_route_makes_its_check_in_both_auth_modes` lists every
mounted admin route with its check and asserts the result in both modes,
and `every_route_is_listed` fails when `router()` mounts a route that
list does not name.

## Routes

All four live in `api/admin/reads.rs`; `mod.rs` only registers them.
Their queries reject unknown fields, like `ReportQuery`. The three
`?communityHost=` routes share `CommunityQuery`, which now rejects
unknown fields too, so the existing unban, untimeout and direct-action
routes that use it do the same; their desktop callers send only
`communityHost`. Every admin route that takes `limit` validates it with
the module's shared `limit()` helper, so an out-of-range value is `400
invalid_limit`.

| Route | Returns |
|---|---|
| `GET /communities?q=&cursor=&limit=` | `{items: [{id, host,
icon\|null}], nextCursor\|null}` |
| `GET /members/search?communityHost=&q=&limit=` | `{items: [{pubkey,
displayName\|null, nip05\|null, avatarUrl\|null}]}` |
| `GET /members/{pubkey}?communityHost=` | `{pubkey, profile:
{displayName, nip05, avatarUrl, about}\|null, role:
"owner"\|"admin"\|"member"\|null, banned, mutedUntil\|null, isStaff:
bool\|null}` |
| `GET /events/{id}?communityHost=` | `{id, authorPubkey, kind, content,
createdAt, deletedAt\|null, channelId\|null}` |

- **One community per read.** Community-scoped routes bind
`communityHost` through the tenant binder (`400 unknown_community_host`)
and read only that community's `users`, `relay_members`,
`community_bans` and `events` rows.
- **Directory.** It uses the binder's liveness rule (not archived, not
deleted, `deletion_state = 'active'`), so every listed host can be
opened. `q` is an escaped, case-insensitive host prefix of at most 255
characters (`400 invalid_query`). Results are ordered and paged by
`lower(host)`, which is unique, so the opaque base64url cursor carries
only the last host; a malformed cursor is `400 invalid_cursor`. `limit`
is 1 to 100 with a default of 50.
- **Member search.** It reuses `search_users`, which already escapes,
binds and scopes, with no second escaping layer. `q` is trimmed and must
be 1 to 100 characters (`400 invalid_query`), and `limit` is 1 to 50
(default 20). It searches community profiles, so former members appear.
- **Member lookup.** `isStaff` uses the same roster lookup as the
direct-action staff guard and is returned to signed staff only: it is
`null` in disabled mode, where the roster is never read. A roster lookup
failure is a 500, never "not staff".
- **Event preview.** It reads the event by `(community_id, id)` and
never returns a private kind (see below). A malformed id, an id stored
only in another community and a private kind are all `404
event_not_found`, the same answer as an absent event.

## Private events

Admin reads never reveal events the relay keeps private.
`admin_hidden_kinds()` combines the four private kind sets in
`buzz_core::kind`: `AUTHOR_ONLY_KINDS`, `RESULT_GATED_KINDS`,
`P_GATED_KINDS` and `SHARED_GATED_KINDS`. The whole shared-gated set is
hidden, including events tagged `["shared","true"]`; the shared tag is
never evaluated. Every HTTP response applies it the way it treats a
missing event: event preview is `404 event_not_found`, report detail
returns the report with `message: null` and `targetAuthorPubkey: null`,
and the report list returns a null target author.

Moderators can still act on these events, they just cannot read them.
Report detail is one query with the hidden-kind list as a parameter,
exposed two ways: `admin_get_report` hides private kinds and backs every
HTTP response (GET detail, the resolve response, reopen, cancel and the
reporter-notice worker), while `admin_get_report_for_enforcement` reads
the target unfiltered and is used only by the resolve handler's
enforcement load (the staff-target check and ban, timeout and kick
target derivation) and by the recovery worker for interrupted actions.
Its result is never serialized, so a ban from a report on a private
persona bans the real author while GET still shows nothing.

## Method binding

Axum serves `HEAD` through `GET` handlers, but the read handlers
authorized with a literal `"GET"`, so a correctly signed `HEAD` was
rejected. Admin reads now authorize through `authorize_read` and writes
through `authorize_write`, both of which use the request's real method
and full target, so no handler hardcodes a method string. A database
error behind any admin route is now logged before it becomes a 500.

## Directory query plan

`idx_communities_host` is on `lower(host)` in the default collation, so
the prefix `LIKE` is a filter, not an index condition. The plan walks
the index in order and stops once it has `limit` matches. A common
prefix is cheap (on local Postgres 16 with 21,359 communities, a first
page skipped 2,728 index rows in 3.9 ms), but a prefix with few or no
matches walks every index entry, so its cost grows with the number of
communities. A `COLLATE "C"` index would turn the prefix into an index
range; it is left out because the admin directory is low-traffic and the
cost is small at current scale.

The cursor condition is `lower(host) > COALESCE($2, '')`, with no `OR`,
so it remains an index condition under a generic plan:

```text
->  Bitmap Index Scan on idx_communities_host
      Index Cond: (lower((host)::text) > COALESCE($2, ''::text))
```

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-07 16:07:38 -04:00
Luke Tornquist
e097c42996 feat(relay): time post-metrics-bind startup steps (#8132)
## Summary

Relay pods on large deployments can take over 5 minutes to open their
health port, and right now nothing shows which startup step is slow.
Startup timing ends at `metrics_bind`, and the slow work all happens
after it.

This PR times every startup step from Postgres connect through public
listener bind. It does not change startup behavior.

- Each step logs `Startup phase started` and `Startup phase finished`,
with `phase`, `elapsed_ms`, and `status` (`succeeded` / `degraded` /
`failed`, the same values `buzz_process_lifecycle` records use).
- Gauge `buzz_startup_phase_current{phase}` is `1` while a step runs and
`0` after it ends. A pod that is still starting shows exactly which step
it is on.
- Gauge `buzz_startup_phase_seconds{phase}` records each finished step's
duration.
- The startup NIP-43 membership reconcile logs `NIP-43 startup
reconciliation progress` every 5,000 communities, with `done`, `total`,
`republished`, `failed`, and `elapsed_ms`.

These are gauges rather than counters on purpose. Each value is written
once per boot, and a monotonic-counter scrape drops a series' first
sample, so a one-shot counter never shows up.

## Contract change

`deploy/charts/buzz/README.md` ("Early-startup telemetry contract") said
the early lifecycle phases are log-only. That still holds: `crypto_init`
through `metrics_bind` are unchanged and emit no metrics.

The README now also documents the post-bind steps. The guard in
`boot_lifecycle.rs` now allows exactly the two `buzz_startup_phase_*`
families. It still rejects any other startup, boot, or lifecycle family,
and it also rejects any exported label value that names an early phase.

## Design

- `crates/buzz-relay/src/startup_steps.rs` adds a closed `StartupStep`
vocabulary (18 steps) and a `StepTimer` guard.
- A step ends exactly once:
  - `finish()` → `succeeded`
  - `degrade()` then `finish()` → `degraded` (a non-fatal error branch)
- dropped without finishing → `failed` (covers `?` early returns and
panics)
- The NIP-43 and large-roster reconciles now return `ReconcileSummary {
repaired, failed }`. A sweep where any item fails ends its step
`degraded`, and the final log line includes the `failed` count. NIP-FI
warm ends `degraded` when any issuer did not warm. The deprecated
`reconcile_nip43_membership_snapshots` wrapper still returns `repaired`.
- Steps that are disabled by config (migrate, audit connect, NIP-FI
warm, git probe, NIP-43 reconcile) are not timed, so their series never
appear.
- The gauges are set once, so they age out after the existing gauge idle
timeout (2700 s with default intervals; logged as `idle_timeout_secs`).
A step that runs longer than that loses its `_current` series. The
README says so.
- Progress logging runs only for the `Bootstrap` reconcile. The 60 s
maintenance sweep stays quiet.

## What got simpler

Little got simpler in the code: this mostly adds instrumentation. One CI
gap closed along the way: the 54 `config` tests and the 9 non-ignored
`boot_lifecycle` tests, which ran in no CI lane, now run in `test-unit`.
The instrumentation is scoped as small as I could make it, and it is the
evidence needed for the follow-up that should delete code: moving the
per-community NIP-43 reconcile off the pre-bind path, after which the
900 s startup-probe override can go back to the chart default.

## Testing

- `startup_steps` unit tests (`DebuggingRecorder`):
  - a running step is current and has no duration
  - a finished step clears current and records its elapsed time
  - dropped and degraded steps clear current and record a duration
- log capture: `finish`, `degrade`+`finish`, drop, and panic each emit
exactly one `Startup phase finished` record with the right `status`.
Mutating `degrade()` to a no-op and removing the ends-once guard makes
these tests fail.
- the vocabulary is unique and shares no names with the early lifecycle
phases
- Tests that capture logs or check callsite interest now share
`test_support::tracing_dispatch_lock()`. These are the `startup_steps`
helpers, two `telemetry` tests, and the `config` and `bridge`
log-capture helpers. tracing-core caches each callsite's interest across
the whole process. Without the lock, the new panic test lost its record
in 60 of 150 parallel runs of `telemetry:: startup_steps:: config::`.
With the lock it failed 0 of 300. Removing the lock from `gauges()`
brings the failures back (53 of 100 runs).
- `nip43_progress_logs_every_interval_inside_the_startup_sweep_only`
checks the cadence at 79,942 communities (15 progress lines, none at the
start or end) and table-tests that the maintenance sweep never logs
progress.
-
`boot_lifecycle::successful_main_emits_complete_lifecycle_and_reports_current_startup_step`
boots the real relay binary against a TCP peer that never speaks
Postgres. It asserts that `/metrics` shows
`buzz_startup_phase_current{phase="db_connect"} 1` and no finished-step
durations, and that no early phase leaks into metrics.

- **CI routing.** `just test-unit` picks relay tests by hand-maintained
filter. Before this PR, CI's filters selected none of the
`startup_steps`, `telemetry`, `config` or non-ignored `boot_lifecycle`
tests above; the NIP-43 cadence test is picked up by the existing
`handlers::side_effects::tests::` clause. Its infra-free relay stanza
now selects `startup_steps::tests::`, `telemetry::tests::` and
`config::tests::` (anchored so `nip_fi_config` is not selected twice),
and a new `cargo nextest run -p buzz-relay --test boot_lifecycle` line
runs the non-ignored `boot_lifecycle` tests (they spawn the real binary
and need no Postgres or Redis; the Postgres cases stay `#[ignore]`d).
`scripts/run-tests.sh` mirrors both, running `telemetry` and `config`
single-threaded because they mutate process env.

### Manual check

1. `just relay` (or run `buzz-relay` against local Postgres and Redis).
2. Once the relay is up: `curl -s localhost:9102/metrics | grep
buzz_startup_phase`. Every step that ran has a `_seconds` value, and
every `_current` value is 0.
3. In the relay logs, `"Startup phase finished"` lines show each step's
`elapsed_ms`.

---------

Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-10-07 10:15:43 -04:00
Luke Tornquist
d0fd96fca6 BUZZ-175: Route serving event writes through the tenant lock (#7828)
## Summary

Route every serving event write through one community-admitted
transaction entry point. `Db::begin_event_write_transaction(community)`
is now the only `Db` constructor for event-write transactions: it takes
the shared community admission lock before returning, so callers that
use it cannot take domain or row locks ahead of tenant admission, and a
quiescing community rejects the write at entry. Database triggers and
commit-time fences remain the authoritative backstop.

This migrates normal, replaceable, roster, relay membership, reaction,
reminder, admin-delete, push-owned, artifact-accept, huddle-join
(48101), workflow-deletion, and command-executor event writes. Coupled
mention writes stay in the same transaction; pool-level
`insert_mentions` now opens its own admitted transaction.

**Review fixes (since `950d5f6`):**
- The unguarded event-write constructor is gone. Removed with it: the
deprecated `begin_transaction` alias, the test-only replica-floor
constructor, and `begin_community_write_transaction` (now identical to
the single constructor). Artifact accept, the huddle join, and both
workflow-deletion paths now admit before any domain or row lock;
`persist_command_event` no longer hand-rolls `guard_transaction`;
read-only `query_artifacts` no longer opens an event-write transaction.
- `insert_channel_head_checked` is now `insert_canvas_head_checked`: it
rejects non-canvas kinds before opening a transaction and always takes
the coordinate lock.
- Source policy now also scans `buzz-relay`: any function that hands a
transaction to an event-write helper must have opened it through the
admitted constructor. The scan drops each `#[cfg(test)]` item instead of
stopping at the first one: it tracks brace depth and ends the item on a
line ending in `;` or `}` (ignoring a trailing `//` comment, even after
a string that contains `//`), with a column-0 `}` as fallback. Fixtures
pin a unit struct, a multi-line static, a `const { … }` block, a struct
with fields, a module, and one-line items with trailing comments,
including after `"https://…"` and `"ws://…"` strings; a raw opener after
each must still be flagged. Known limit: braces inside string or char
literals or comments are still counted, and only `//` trailing comments
are recognized, so a test-only item with unbalanced braces in a literal
or comment, or with a trailing `/* */`, would hide what follows; none
exists today. The scan checks that admission is present; the per-path
PostgreSQL tests pin that it comes before domain locks.
- `just test-unit` (and the non-nextest fallback in
`scripts/run-tests.sh`) now runs `buzz-db --test observability_source`.
Before this, no CI job ran these source-policy tests: the unit lane ran
`buzz-db --lib` only and the PostgreSQL lane runs only ignored tests.
- Renamed or removed since `950d5f6`: `begin_transaction`,
`begin_community_write_transaction`, and the replica-floor test
constructor are gone; `insert_channel_head_checked` →
`insert_canvas_head_checked`; test helper `quiesce_community_for_tests`
now delegates to `main`'s `set_deletion_state`, which reads the real
`deletion_fence_generation` instead of hard-coding `'0'`;
`runtime::insert_mentions` is pinned to the admitted constructor by
`pool_level_insert_mentions_opens_the_tenant_local_chokepoint`.

**What got simpler:** four transaction constructors collapse to one, and
the hand-rolled admission guard is deleted. The compiler rejects callers
of the removed constructors, but a transaction opened from `Db::pool()`
can still reach the public `*_in_transaction` helpers; only the
source-policy scan and the database fences catch that until BUZZ-254.

**Behavior note:** #7932 made `insert_event` skip the transaction for
kinds that are not listener mentions. Every serving event write now has
to run in a transaction that holds the community admission lock, so
`insert_event` always opens the admitted transaction again. `main`'s
`soft_delete_event_and_update_thread` / `_in_tx` split and its
removal-marker guard are kept; the outer function now opens the admitted
transaction.

### Related issue

[BUZZ-175](https://linear.app/squareup/issue/BUZZ-175). Caller-migration
stage following #7706 (merged); rebased onto `main` at `5173fad66`.
Follow-up: [BUZZ-254](https://linear.app/squareup/issue/BUZZ-254) makes
event-write helpers require a type-level admitted transaction.

**Known debt, out of scope:** `create_channel`, `update_channel`, and
`insert_thread_metadata` write channel and thread rows (not events)
outside the admitted constructor.

### Testing

At `efe4a94623dd69973780a3c39f16dd972cf7cb76` on a cloud workstation:
fmt; `buzz-db` clippy (all targets, `-D warnings`);
`observability_source` 14/14; `just --dry-run test-unit` includes the
new step. Reverting just the end-of-item check to an earlier rule makes
a trailing-comment fixture fail.

At `eaf190ae59d1595741c3d19dc4c23ce492b5f4d4` (the commit after it only
changes this test file, `Justfile`, and `scripts/run-tests.sh`): full
GitHub CI green; on the workstation, clippy for `buzz-db` and
`buzz-relay`, ordinary `buzz-db` 154/154, `just test-unit` green, and
the PostgreSQL lane 805 of 806 (exception below). An end-to-end run
against a local relay and the built desktop app covered artifact accept,
huddle join, and workflow deletion for both a healthy and a quiescing
community, plus p-tag reaction rollback and replay of the same signed
event.

New tests: one fenced-community test per migrated path (artifact accept,
huddle join, both workflow deletions) puts the community into
`quiescing`, holds the lock the path takes next, and asserts rejection
at entry with nothing stored; a DB-free test pins the non-canvas
rejection.

Exceptions:
-
`runtime::postgres_tests::writer_pool_rejects_non_read_committed_database_default`
hangs on that workstation's local Postgres 16 at both this head and an
earlier `main` base, `f0eb5575f` (2/2 runs each). The test, `Db::new`,
and the pool setup are unchanged by this PR, and the test passes in
GitHub CI. Live traces put the hang in the client, not the database: the
1 s acquire timeout stops firing after sqlx's `after_connect` rejection
path under tokio's `current_thread` runtime. No database lock waits were
observed.
- Two `observability_source` assertions were already failing on `main`,
unnoticed because no CI job ran that test binary: a stale
`fetch_optional` pin after #8005, and lease-seam attribution that now
lives in the `bounded_serving_lease_sql` helper. Both are corrected
here, and the binary now runs in `just test-unit`.

Generated with Codex

---------

Signed-off-by: tornquist <tornquist@squareup.com>
Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-10-06 10:24:41 -04:00
Will Pfleger
df3f28a2c4 feat(relay): add NIP-FI shadow mode for HTTP, WebSocket and admin (#8034)
Stack: this PR → #8062

🤖 Follows #8028 (merged).

Adds `BUZZ_NIP_FI_MODE=shadow`. The relay loads and validates the full
enforce configuration (issuers, communities, lifetimes, command
issuers), evaluates NIP-FI evidence wherever enforce would decide, and
records what enforce would have done, while every request, upgrade and
session behaves exactly as in `off`. The one approved exception is admin
disconnect: shadow verifies the command and returns `200`, where Off has
no verifier and returns `503`. It lets an operator measure the
would-deny rate, including Hosts missing from `BUZZ_NIP_FI_COMMUNITIES`,
before switching to `enforce`.

- **Mode predicates.** `NipFiMode` gains `is_off`, `restricts`,
`evaluates`, `enforces`, `denies_unconditionally` and `observes_only`,
built on two exhaustive `match` bases (`restricts`, `evaluates`);
`is_off` is a `matches!` and the rest derive from the bases, so a new
mode cannot silently fall into one side. Every call site uses them,
including the Git membership-lookup failure from #8005, which uses
`restricts()` so shadow keeps Off's body. A test scans each crate's
`src/` and fails on any `NipFiMode::<variant>` in production code
outside the predicate definitions and the env parser, which catches
`==`, `matches!` on any number of lines, `if let` and or-patterns.
- **Startup.** Shadow goes through enforce's path, so every enforce
startup refusal applies, including the required connection lifetime.
Missing-setting errors name the configured mode through one formatter
(`mode_requires`); the enforce text is byte-identical. Shadow builds the
assertion verifier and claims command replays under its own Redis
prefix, both through the production `AppState::new`.
- **HTTP and Blossom.** `admit_nip_fi_http` keeps Off's result in shadow
and additionally replays enforce in enforce's order: first the router
guard's steps (community, then assertion), then the handler's steps
(NIP-98, pairing, deny set) on the same NIP-98 proof, through
`evaluate_enforce_steps`, which enforce shares. A Host mapped in config
but missing from the communities table, with a valid assertion, records
an admit, because enforce's guard passes and its 404 is not a NIP-FI
denial. A dev-mode `X-Pubkey` identity is marked unsigned and is never
an enforce proof, so shadow records it as a `nip98` would-deny. On
bridge and Blossom routes, the strict proof check (payload tag, Blossom
`Strict`) runs as a separate pure check that consumes no replay entry
and records on its own series, so it never counts as an admission
verdict. Shadow records exactly one verdict for every identity,
community or credential refusal enforce would make, with Off's status,
body and challenge unchanged: bridge, workflows, Blossom, Git transport
and settings, GIF search and share, and invite minting record the
`community` would-deny when the Host is unmapped. Git's missing or
malformed credentials still reject before any database work, and shadow
records the enforce verdict for that failed proof. The router guard
stays transparent in shadow.
- **WebSocket, root and audio.** The upgrade check evaluates the
attached assertion once. An upgrade enforce would refuse records that
would-deny; otherwise the assertion goes into a shadow-only session
object and the upgrade returns as in Off. The assertion never reaches
the connection, the identity registries, the admission gate, terminal
frames or cancellation. Each session records at most one admission
verdict. **Pairing** is recorded on both ingresses right after a valid
NIP-42 proof, before ordinary relay policy, where enforce pairs: a key
mismatch or claimless assertion is a `pairing` denial. **The deny-set
check** runs where enforce runs it, after registration: a hit is a
`deny_set` denial, and a clean read keeps the session pending and
registered. **The admit** is recorded only when the connection is
actually admitted: on root at the AUTH commit, after ordinary policy; on
audio when the participant join transaction commits, after relay
membership, channel membership, the final ban and membership decision,
mesh routing and the join's own checks, and before publication. A NIP-42
failure, which comes before pairing, and any refusal between pairing and
the admit (ordinary policy, mesh, or a join refusal inside the join
transaction) retires the observer when the refusal is decided, before
any refusal frame, rollback or cleanup is awaited, so it leaves no
record even if the deadline passes during that cleanup. A record-only
deadline task, armed at upgrade with enforce's
`compute_session_deadline`, records `expired` when enforce would have
closed an admitted session. If the deadline passes while the session is
still pending, that is its single admission verdict, a denial with
`stage=deadline`, and a later AUTH records nothing. A deny for the key
(admin or cross-pod) records `revoked` on an admitted session; if it
arrives after the deny-set check but before the admit, including a deny
refused for capacity, the session keeps a pending-revocation mark and
records the admit followed by exactly one `revoked` end, matching
enforce's phase-independent close, and the deadline cannot take that
end. The phase, the mark and retirement change together under one
session lock. Each session records at most one end. The session holds
the socket's cancellation token for recording only: once the socket is
cancelled, no transition records anything, even while connection
references remain (a cancel fence; the token never feeds a close
action). Upgrade would-denies carry the ingress route, `ws` or `audio`.
- **Admin disconnect.** Shadow verifies the command and records the deny
entry, so later admissions record would-denies, but closes no session;
the response stays `{"disconnected": true}`. Shadow publishes cross-pod
on its own channel, `buzz:nip-fi:shadow-disconnect`, which only shadow
pods subscribe to, so no enforce pod (including older builds that know
only `buzz:nip-fi:disconnect`) acts on a shadow command. Shadow pods
also keep listening on `buzz:nip-fi:disconnect`, so their deny record
includes enforce's real disconnects. Shadow claims command replays under
`buzz:nip-fi:shadow-command:{hash}`: replays are still rejected within
shadow, and a shadow accept never uses up the enforce claim. Enforce and
Off keep the same channel, keys and close behavior.
- **NIP-11.** Shadow serves the same document as enforce, including
`limitation.federated_identity`, because clients only attach evidence to
a relay that advertises it.

## Recording

| Series | Labels | One increment per |
|---|---|---|
| `buzz_nip_fi_shadow_total` | `route` (`http`, `ws`, `audio`), `stage`,
`outcome`, `community` | decision enforce would make: an HTTP admission,
a refused upgrade, a session's NIP-FI AUTH decision, or its deadline
passing before AUTH (`stage=deadline`) |
| `buzz_nip_fi_shadow_strict_proof_total` | `route` (`bridge`,
`blossom`), `outcome` (`pass`, `rejected`), `community` | strict NIP-98
side check |
| `buzz_nip_fi_shadow_session_end_total` | `route` (`ws`, `audio`),
`reason` (`expired`, `revoked`), `community` | admitted session enforce
would have ended (admitted sessions only) |
| `buzz_nip_fi_shadow_disconnect_total` | `route` (`admin`,
`cross_pod`), `outcome` | disconnect-path event, in place of the real
disconnect, lag, capacity and poison counters |

`community` is the configured canonical URI or `unmapped`, never the raw
`Host`; an unmapped or empty Host records `stage=community,
outcome=unavailable`. A shadow pod never moves an enforce disconnect or
failure counter. Strict-proof rates use their own `pass + rejected`
total, and session-end rates divide by the `admit` count for the same
route. There is no per-verdict `info` log; a would-deny writes a `debug`
line with no token, claim or issuer. The pre-existing enforce `debug`
line for the proven NIP-98 key is shared and also fires in shadow. Admin
commands rejected for a bad header, body or proof, a replay, or a
dependency error record no shadow verdict; only capacity rejection is
counted.

## Not observed

- Handler-side path-validation exits: an invalid Git owner or repo name
on default-branch, a media path that is not a valid hash, and a Git pack
URL with a query string record nothing from the handler. The router
guard still records what enforce would refuse at the guard (for example
a missing assertion), because enforce refuses those requests before it
validates the path.
- A refusal that is not a NIP-FI decision, such as a tenant-binding 404
behind a passing guard, or an ordinary WebSocket refusal after pairing
(a ban, membership or join refusal before admission).

## Follow-ups

- A bounded `route` label per HTTP surface (bridge, Blossom, Git, …) on
`buzz_nip_fi_shadow_total`; today every HTTP verdict carries
`route=http`.
- One shared helper for the ten copied `observe_unbound` recording
hooks, stacked on this PR.

The shadow recorder and session suites and the root pairing witness run
in `just test-unit` and the `scripts/run-tests.sh` fallback; the
`AppState::new` shadow witness, which exercises the command verifier
against real Redis, runs in the `external_infra_redis` lane. NIP-FI env
tests recover a poisoned env lock so one panic cannot cascade.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Signed-off-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-02 18:32:17 -04:00
Alex Rosenzweig
4f51b9e101 fix(mcp): support Goose discovery handshake (#8037)
## Summary

Goose 1.52 starts MCP connections with `server/discover`. `buzz-dev-mcp`
rejects that request with `ExpectedInitializeRequest`, preventing Goose
from loading its Buzz tools.

Upgrade the tool server to rmcp 3.4.1 and adopt the renamed SDK types.
Subprocess tests exercise discovery and legacy initialization, tool
listing, file reads/edits, invalid arguments, connection survival, and
EOF shutdown. Include the server in both Rust unit-test runners and make
the cancellation regression approve its shell call before testing
cancellation.

The native agent retains its independent rmcp 1.x client. Cargo also
unifies the existing 3.x dependency used by the mesh smoke harness.

## Validation

- Normal `git push` passed all hooks, including Rust tests, Tauri
checks, and repository policy checks.
- `just ci` passed on the final stack: repository checks, Rust and Tauri
tests/lint, desktop tests/build, web build, and mobile checks/tests.
- MCP: 78 existing tests and two subprocess lifecycle tests passed; the
discovery regression fails with the old SDK.
- All 54 native-agent regressions passed on the final stack, including
cancellation of a real MCP shell process.
- Previously exercised unmodified Goose 1.52 and 1.47 with Sandpit and
the real tool server. Allowed workspace operations and permitted network
traffic succeeded; disallowed file and network operations were denied.
- Live staging desktop check with Goose 1.52: the reply appeared in
Buzz, an allowed request returned HTTP 200, and a disallowed request
returned CONNECT 403.

## Stack

1. **#8042 → `main`**: deterministic handoff regression.
2. **This PR → #8042**: Goose MCP discovery compatibility.

Merge the lower PR first, then retarget this PR to `main`. This diff
contains only the compatibility change and its tests; the relay build
repair is already on `main` in #8036.

---------

Signed-off-by: Alex Rosenzweig <arosenzweig@squareup.com>
2026-10-02 11:24:25 -05:00
Will Pfleger
a38707d35c fix(relay): close community ban gaps across audio, agents and HTTP (#8005)
🤖 Community bans had gaps. Audio sockets never checked them: the audio
join path didn't look up ban state, and a ban closed only the member's
root sockets, so a banned member could join a huddle or stay in one. An
agent's access comes from its owner, but the ban and timeout checks only
looked at the pubkey that signed the event. Several write paths returned
before those checks ran, admission could miss a ban that landed
mid-AUTH, and removing or losing a member left live sockets open. This
PR closes those gaps.

## Audio

- `handlers/auth.rs`: the root socket's ban lookup, including the NIP-OA
owner cascade and fail-closed DB handling, is `community_ban_outcome`.
Root auth and audio join both call it, so the two checks can't drift
apart.
- `audio/handler.rs`: audio join runs the ban check after the write-free
channel membership check and before any huddle lease or durable write. A
ban is refused with `AuthorizationDenied` and a DB error with
`AuthorizationUnavailable`. With no FI assertion, the refusal is the
root route's text (`blocked: you are banned from this community` /
`error: internal error checking restriction state`).
- `state.rs`: `CommunityConnectionControl` holds the authenticated
pubkey. `CommunityConnectionRegistry::disconnect_pubkey` closes bound
audio sockets for one pubkey in one community with a 1008 `access
revoked` close. `AppState::disconnect_pubkey_local` closes everything a
pubkey has open in a community on this pod (root and audio); the
clusterwide disconnect and the conn-control Redis subscriber in
`main.rs` both use it, so remote pods close audio sockets too. Root
sockets are still closed through `ConnectionManager`, so nothing is
closed twice. A socket that proved a NIP-FI identity instead gets the
standard NIP-FI denial (the restricted frame, then 1008 `authorization
denied`), and a ban never overwrites a close reason already chosen.

## Behavior

- **Owner restrictions carry to agents.** `restriction_state` folds in
the restriction row of the agent's owner (`users.agent_owner_pubkey`) at
read time. Nothing is copied onto the agent, so unbanning or
un-timing-out the owner restores the agent immediately. Every hot-path
caller (AUTH, ingest, audio, HTTP) picks this up.
- **Admission is fenced against a concurrent ban.** Root AUTH and audio
admission bind the proven pubkey to the socket first, then make the
final ban and relay-membership decision
(`handlers::auth::final_admission_denial`), then refuse if a disconnect
already cancelled the socket. A ban or removal whose disconnect ran
before the bind is visible to those fresh reads; one that runs after it
finds and closes the bound socket. Both reads come from the writer,
never the bounded-stale replica, so a removal that committed before the
bind cannot be undone by a lagging replica. Both fail closed, and the
check covers a delegated agent's owner. Each admitted socket records the
agent's owner, from the NIP-OA tag or the stored link.
- **Agents are always linked to their owner before admission.** Root
AUTH and audio both record `users.agent_owner_pubkey` for a NIP-OA agent
before admitting it, and refuse admission (`error: internal error
recording agent owner`) if that write fails, because revoking the owner
finds the agent's sockets through that link.
- **Revoking live access covers agents without a database read.**
`AppState::revoke_live_access` sends one clusterwide disconnect that
closes, on every pod, each root and audio socket in the community whose
principal **or recorded owner** is the target. An agent socket admitted
before its owner link existed carries no recorded owner, so recording an
owner for the first time (`materialize_nip_oa_owner`, the single writer
used by root AUTH, audio admission and HTTP NIP-OA) closes that agent's
ownerless root and audio sockets clusterwide; they reconnect with the
owner attached. That disconnect skips owner-bound sockets. Admission
with a NIP-OA owner records it on the socket before binding the pubkey,
so the socket survives its own link; admission without one binds the
pubkey first and then reads the stored owner, so a concurrent link
either closes the socket or is seen by the read. A
`users.agent_owner_pubkey` sweep still runs as a secondary path; if its
lookup fails, the error is returned and there is no retry task. The
kind-9040 ban, the report-action ban (left non-terminal so recovery
re-runs it), relay-admin removal (9031), and NIP-43 self-leave all use
it and report an incomplete revoke instead of claiming success. Each
revokes right after its change commits; the ban no longer waits on its
audit insert, whose error is still returned.
- **NIP-43 self-leave works over WebSocket.** Kind 28936 is in the
ephemeral range, so `handle_event` used to fan it out and answer `OK
true` without removing membership. It is now routed to ingest; no other
ephemeral kind changes.
- **The write gate runs before every kind-specific branch.**
`enforce_write_restriction` runs ahead of command routing (DM open, DM
add member, DM hide, workflow definitions and triggers, approvals),
product feedback, reports, and moderation or relay-admin commands.
Ephemeral events and agent observer frames return before ingest in
`handle_event`, so they go through the same gate there.
  - A ban exempts nothing.
- A timeout admits only reports (kind 1984), unban (9041) and untimeout
(9043). A timed-out admin cannot send relay-admin commands (9030–9033)
until the timeout ends.
- **HTTP refuses banned principals.** `enforce_relay_membership` runs a
ban check after the membership decision, including on open relays: 403
`blocked: you are banned from this community` for a ban, 503 for a
failed lookup. Media upload, media GET/HEAD and Git transport keep that
503 instead of reporting it as "not a relay member": in NIP-FI Enforce
mode they send the canonical `AuthorizationUnavailable` response
(`text/plain; charset=utf-8`, `authorization unavailable\n`), and in Off
mode each keeps its legacy unavailable response. Media storage failures
are not reclassified. Git transport checks membership and then runs its
own ban check once per request. The NIP-OA owner is resolved from
`x-auth-tag` when present. HTTP checks **bans only**: a timeout blocks
writes, HTTP writes already reach the ingest gate, and reading stays
allowed under a timeout.

Unchanged by design: a lost Redis fan-out can leave another pod's socket
open until it reconnects, and there is no recurring ban recheck on open
sockets.

## Path table

| Path | Category | Ban check |
|---|---|---|
| Root WebSocket AUTH and audio admission, including NIP-OA owner
binding | community admission | final fenced check after bind; owner
link required |
| `POST /events`, `/query`, `/count` (`bridge.rs`) | community HTTP |
existing `enforce_relay_membership` caller |
| GIF search and `POST /gifs/share` (`gifs.rs`), media upload and fetch
(`media.rs`), workflows (`workflows.rs`) | community HTTP | existing
caller; media keeps 503 on lookup failure |
| Git transport (`git/transport.rs`), git settings (`git/settings.rs`) |
community HTTP | existing caller; transport keeps 503 on lookup failure
|
| `GET /moderation/reports`, `/moderation/audit`,
`/moderation/restricted` | community HTTP | **newly covered** |
| `POST /api/invites` (invite mint) | community HTTP | **newly covered**
|
| NIP-43 self-leave (WebSocket and `POST /events`, both via ingest) |
roster change | revokes live access like admin removal |
| Invite claim, accept-policy, `/api/join-policy*` | pre-membership
invite and policy flows | none; ban state is checked when the caller
authenticates |
| `/`, `/info`, `/.well-known/nostr.json`, `/health`, `/_liveness`,
`/_readiness` | public metadata and health | none |
| `/hooks/{id}` | secret-authenticated webhooks | none; no community
pubkey |
| `/operator/*`, `/api/admin/v1`, `/internal/git/policy` | operator,
admin and internal routes | none; separate auth plane |
| `/_mesh/demo/echo` | demo only (mesh plus `mesh_demo_echo`) | none |

## Known limits

- Cross-pod closure depends on the Redis fan-out; the tests prove the
pod-local close by principal and recorded owner.
- Live delivery to existing subscriptions depends on the disconnect.
Events are not rechecked one by one.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-01 23:24:56 +00:00
Will Pfleger
9b083957f6 refactor(buzz-relay): share NIP-FI assertion evaluation across adapters (#7990)
🤖 Stack: #7990 → #8028

The three production NIP-FI assertion-verification sites (the HTTP
assertion guard in `router.rs`, `check_nip_fi_at_upgrade` in
`nip_fi_upgrade.rs`, and `admit_nip_fi_http` in `nip_fi_http.rs`) each
carried their own extract → verify → map-to-denial sequence, and HTTP
admission and `enforce_nip_fi_key_pairing` each carried their own
key-equality check. The admin disconnect route (`POST
/api/nip-fi/disconnect`) kept a second copy of the header parser and
hand-typed its denial bodies. Community binding and shadow mode both
need to change exactly those decisions, so they now live in one place.

**No behavior change.** Every denial class, status code, body, header
and log line is preserved, including `deny_protected` (503), the
startup-race 503, transport-before-verifier precedence,
NIP-98-before-assertion ordering, and guard + handler double
verification.

- New relay-private `nip_fi_core` owns `extract_bearer_token` and
`http_denial` (moved from `nip_fi_http`), `evaluate_attached_assertion`
(the single `verify_assertion` call for all three adapters, returning a
typed `AssertionRejection`), and `asserted_key_matches` (the pairing
predicate; a claimless assertion never matches). `http_denial` and the
disconnect route's plain-text renderer fall back to the bare denial
status instead of panicking or answering an empty 200; both fallbacks
are unreachable because every status and header value is a constant.
- Adapters keep their own mode short-circuits, exemptions, ordering,
metrics and `code()` debug logs; the guard still logs nothing.
- The disconnect route parses its header with `extract_bearer_token`,
renders header failures with `http_denial`, and renders every other
rejection (malformed body, bad pubkey, no command verifier, every
`CommandError`) through `CommandError::http_status()`/`response_body()`.
Its check order (header → body → pubkey → verifier present → verify),
`DenySetFull` warning and metric, success body and cross-pod publish are
unchanged. The old Unicode whitespace check and the shared ASCII one
agree on every reachable input, because `HeaderValue::to_str` only
admits visible ASCII and tab.
- The 12 HTTP admission callers, 3 WS upgrade placements, deny-map
checks, expected-URL constructors and the command/admin proof path are
untouched.
- Characterization tests land before each refactor, green against the
unmodified code, and pass unchanged after it. A routed test counts both
verifications on one request (guard, then handler admission) and shows
admission's own failure decides the response. Disconnect-route tests pin
exact status, content type, challenge and body for every rejection, the
observable check order, and that only `DenySetFull` increments
`buzz_nip_fi_disconnect_capacity_rejections_total`. The test modules
share one counting `ScriptedVerifier` from `nip_fi_core::tests`, and the
session tests build connections through `connection::tests::test_conn`.
`nip_fi_core::tests::` is added to the unit selectors in `Justfile` and
`scripts/run-tests.sh`.

🤖 Implemented by Hayt.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-01 18:52:48 -04:00
Will Pfleger
24c1e702a6 fix(relay): gate owner-only kinds in shared fan-out access filter (#8006)
DM visibility snapshots (kind:30622) and agent turn metrics (kind:44200)
must reach only their owner, meaning the pubkey in their `p` tag.
History reads and same-pod live delivery already enforced this. Events
arriving from another pod over Redis went only through
`filter_fanout_by_access`, which had no owner check. So a subscription
on another pod could still match one of these events, for example a
subscription that names only event IDs and no kind.

The owner check now lives in `filter_fanout_by_access`
(`crates/buzz-relay/src/handlers/event.rs`), which both same-pod
dispatch and Redis delivery go through. It reuses the read path's
predicate, `buzz_core::filter::reader_authorized_for_event`. The
separate copy inside same-pod dispatch is removed, so there is one check
instead of two.

Behavior changes:
- An owner-only event with no `p` tag now reaches no one live. Before,
on the same pod, it reached every matching subscriber.
- Any `p` tag now counts as the owner, matching history reads. The old
same-pod check used only the first `p` tag.

Tests:
- `owner_only_kinds_keep_only_the_owner` covers the shared filter.
- `pubsub_owner_only_kinds_reach_only_the_owner` covers Redis delivery
end to end, for both kinds. It uses subscriptions that name only event
IDs, and includes an event with no `p` tag.
- `dispatch_owner_only_kinds_reach_only_the_owner` covers same-pod
dispatch the same way, checking the `EVENT` frames actually queued.
- Each test fails with its filter call removed and passes with it.

Hook note: this branch was pushed with `LEFTHOOK=0`, so the pre-push
hook did not run on it. Earlier push attempts failed under heavy machine
load, in crates this diff doesn't touch: the `buzz-acp` timing tests
`idle_resets_on_stdout_activity` and
`keepalive_resets_idle_past_deadline`, then `rust-tests` and
`desktop-tauri-test` (`buzz-desktop`). CI is the gate for the full
suite.

Also fixes a timing flake in #7977's
`deny_set_full_leaves_command_retryable` test: its filler deny entry
could expire before the full-set request, so the test now gives it an
`until` offset of 2 seconds.

🤖 Implemented by Duncan (agent).

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-01 18:40:17 -04:00
Will Pfleger
16839a0773 feat(nip-fi): admin disconnect/deny API with in-memory deny-until-TTL map (S4) (#7977)
This adds the NIP-FI S4 admin command surface. An authorized issuer
principal can deny an `(issuer, pubkey)` pair until a TTL expires. When
that happens, the relay records the entry in a bounded in-memory deny
map, closes matching live sockets on every pod, and refuses new
admissions while the entry is live.

Supersedes #7265

## Breaking

In `BUZZ_NIP_FI_MODE=enforce`, every entry in `BUZZ_NIP_FI_ISSUERS` now
has to carry `maximum_command_age_seconds` (1–60) and a non-empty
`authorized_principals` list, and startup fails if any issuer is missing
either one. That means an S3-only enforce config that boots on `main`
won't boot with this change. No deployment sets `BUZZ_NIP_FI_*` yet. The
per-issuer field table and a working example are in the
`nip_fi_config.rs` module docs.

## What changes

- **`buzz-auth`**:
- `nip_fi/command.rs` verifies signed admin commands. It checks issuer
policy, command age, the authorized principal (exact, case-sensitive
`sub` match), and a `jti` of at most 512 bytes.
- `nip_fi/deny_map.rs` is a per-issuer, capacity-bounded deny-until-TTL
map on top of `DashMap`. Expired entries and `jti` reservations are only
swept when a write would otherwise hit capacity. The sweep is O(n) and
holds the lock every admission check takes, so this keeps it off the
admission path. Reads compare against `now`, so an expired entry that
hasn't been swept yet never counts as live.
- **Cross-pod command replay fence**: `nip_fi/command_replay.rs` adds a
`CommandReplayGuard` trait, and `buzz-pubsub` implements it in Redis
(`RedisCommandReplayGuard`), following the NIP-98 replay guard. Once a
command is authenticated, the verifier takes an atomic `SET
buzz:nip-fi:command:{sha256(len(iss)‖iss‖len(jti)‖jti)} 1 NX EX <ttl>`
claim, with the TTL set to the command's remaining validity rounded up
to whole seconds plus skew, clamped like NIP-98, so the claim outlives
acceptance by a verifier whose clock trails by the full skew. Before the
claim, the verifier reserves a pending `jti` and deny-entry slot in this
pod's deny map under the issuer lock, so a full deny set returns `503
deny set full` without touching Redis and the same signed command can be
retried. The claim is taken only after the signature and claims verify,
so a forged token cannot burn a real `jti`. A `jti` already claimed on
any pod returns `403`. A Redis error fails closed with `503
authorization unavailable`: no deny entry, no session close, no publish.
A replayed claim, a Redis error, or a cancelled request drops the
reservation. After a successful claim the reservation commits to the
`jti` record and deny entry, which cannot fail on capacity. A claim can
still remain with no deny entry if Redis applied `SET` but the reply was
lost, or the pod stops between claim and commit; that fails closed, and
a retry gets `403` until the TTL expires.
- **`api/nip_fi.rs`**: the admin disconnect/deny endpoint. It verifies
the command, inserts the deny entry, disconnects locally, and publishes
over `buzz-pubsub` `conn_control` so every other pod runs the same
disconnect. A receiving pod clamps `until` to its own ceiling instead of
rejecting it. Otherwise a pod whose clock trails the origin would drop a
command the origin already answered with 200, and the target's sockets
on that pod would stay open. Malformed pubkeys, unknown issuers, and
unrepresentable timestamps are still rejected.
- **Admission and live enforcement**:
- WS upgrade (`router.rs`) and HTTP (`nip_fi_http.rs`) refuse keys that
have a live entry with `403 authorization_denied`.
- Root NIP-42 auth and audio auth register the proven identity (pubkey +
admitting issuer) first, and only then check the deny map. With that
order, a concurrent disconnect either finds the socket in its close scan
or the check finds the entry. `OK(true)` isn't sent if a concurrent
disconnect already cancelled the session.
- `ConnectionManager::disconnect_nip_fi` and
`CommunityConnectionRegistry::disconnect_nip_fi` are scoped to the
issuer.
- **One wire form for `authorization_denied`** (FI-TRACE-DENIAL-ORACLE):
every root and audio WebSocket `authorization_denied` denial now goes
through one first-writer-wins transition on
`CommunityConnectionControl`. That covers the S4 deny-set hit and admin
disconnect, and also the existing S3 key-mismatch, lease-expiry, and
membership refusals. The transition sets the `AuthorizationDenied`
reason only if nothing else has set a reason, and only the winner
enqueues the denial frame. So every one of these denials is the class's
denial frame followed by `Close(1008, "authorization denied")`, and a
concurrent registry scan can't queue a second one. Before this, S3 key
mismatch and expiry closed with `Close(None)`, which made them
distinguishable from a deny-set hit on the wire. `evidence_rejected` and
`authorization_unavailable` keep the bare close.
- **Config**: see Breaking. `deny_set_capacity` is optional and defaults
to 50000. Setting any command field without a command age fails closed.
- **Boot** (`main.rs`): installs the command verifier and deny map. It
subscribes to cross-pod disconnects before the Redis subscriber starts,
so disconnects relayed during boot are buffered instead of dropped.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-01 19:40:09 +00:00
Will Pfleger
8519db1532 feat(buzz-relay): NIP-FI stateless enforcement (S3) — upgrade gate, NIP-42 pairing, session lifetime, JWKS warm (#7224)
## What this PR does

Implements NIP-FI stateless enforcement at the relay WebSocket upgrade
point. Every connection attempt on a NIP-FI–enabled relay must carry a
valid federated identity assertion signed for the connecting Nostr
pubkey; the relay validates it before the first frame is read, pairs it
with NIP-42 AUTH, and enforces a bounded session lifetime without
persistent state.

### Core enforcement seam

- **Assertion at upgrade** (`nip_fi_upgrade.rs`): identity header is
parsed with the same `extract_bearer_token` and denied with the same
`http_denial` as HTTP ingress (`nip_fi_http.rs`), JWT verified
(RS256/ES256/EdDSA), expiry checked, and the Nostr pubkey embedded in
the sub claim must match the NIP-42 AUTH pubkey that arrives in the
socket within the AUTH timeout. Denial returns exact byte contracts —
`401 + WWW-Authenticate: Nostr + "authentication required\n"` for
missing evidence, `403 + "evidence rejected\n"` for malformed or invalid
evidence. The active HTTP/1.1 gate runs before `bind_community`, so
denied upgrades pay zero DB cost.
- **NIP-42 pairing** (`nip_fi_gate.rs`, `audio/handler.rs`): after the
assertion is validated at upgrade, a `SessionAdmissionGate` is created
with the assertion's expiry deadline and a `CancellationToken`. The gate
is passed to every effect-acquiring path so no new effect permit is
granted after the session expires. AUTH is verified against the
assertion pubkey; a mismatched AUTH key receives `NOTICE restricted:
authorization denied` and a socket close. Every other post-upgrade AUTH
denial on an FI connection uses the same terminal shape — the canonical
spec NOTICE on the terminal queue, then close — so frame type and close
behaviour reveal nothing about private policy: ban, allowlist, or
relay-membership denial → `restricted: authorization denied`;
ban/allowlist/membership lookup failure or an internal verifier failure
→ `restricted: authorization unavailable`; an invalid NIP-42 proof →
`restricted: evidence rejected`. Connections without an assertion keep
today's `OK false` replies.
- **Session lifetime** (`nip_fi_session.rs`): three independent
deadlines bound each session — token `exp`, max assertion age from
config, and relay max connection lifetime. The first to fire cancels the
gate and sends the terminal denied frame.
- **Effect admission at expiry** (`audio/join.rs`, `handlers/req.rs`):
no new effect permit is granted after expiry. Two paths carry calculated
budgets. The audio **lease resolver** holds its permit for at most
`worst_case::PERMIT_HOLD` (14.5 s), and cancellation **during that
resolver** reaches socket close within `worst_case::EXIT` (25 s:
resolver, release of the won lease, terminal flush). Redis checkout has
one total deadline and directory scripts are single-round-trip `EVAL`,
which is what makes those budgets hold. When post-write fence
verification fails after a landed CAS, the acquire keeps the reply and
attempts an exact owner+generation cleanup on the same connection
without a serving-write lease; if that cleanup fails or its reply is
uncertain, the lease expires by its 30 s TTL. Neither number bounds
audio close in general: other admission and teardown waits (for example
the channel lookup after the resolver) and the known limitations below
have no client-side deadline. REQ liveness, search and history reads
race gate cancellation: a lost race drops the read, releases the permit,
retires the claim through `close_if_owner`, and sends no further EVENT
or EOSE. A history statement timeout ends the read with a timed-out
outcome, and its owner-checked retirement (`CLOSED`, map, index and
topic release) runs after the race with the permit dropped, so
cancellation cannot drop it halfway. Subscription registration stays one
uncancellable unit, and the permit is still taken before the claim.
- **JWKS recovery** (`main.rs`): builds on main's
`warm_nip_fi_jwks_snapshots` / `nip_fi_jwks_refresh_loop`. Issuers warm
concurrently and the first refresh is seeded from the warm result (one
interval for a warm issuer, 5 s for a cold one). A refresh that finds no
live snapshot retries at the fast cadence (5 s after a warm issuer dies,
doubling to a 300 s cap) instead of waiting a full interval, and
`run_jwks_refresh_supervisor` restarts the loop after a panic (1 s → 60
s backoff); On shutdown it is cancelled and awaited before the drain
backstop is disarmed; cancel drops an in-flight fetch and starts no
further issuer, so the join stays inside the drain bound. Logs carry
`issuer_index` only.

### Known limitations

- **Known limitations: bounded only by the dependency.** No new effect
is admitted after expiry. Two awaits that are already admitted have no
client-side bound, so if a dependency stalls at expiry, teardown waits
for it:
- **Audio join commit:** its effect permit spans `tx.commit()`, room
publication and the `event_mentions` insert. During a DB or Redis stall,
audio cleanup and socket close wait for the dependency.
- **REQ subscription setup:** the first subscriber to a topic sends
`Subscribe` to the pubsub task through a bounded channel (capacity
4096). The send blocks only if that queue is full, which happens when
the pubsub task isn't keeping up (for example during reconnect backoff
or a burst). Registration holds the connection's `conn.subscriptions`
lifecycle lock across the send, so a blocked send also blocks other
claims on that connection, including search, and any cleanup waiting on
that lock. The send itself predates this PR; holding the REQ permit
across it is new here.

### Archive/join serialization

`commit_participant_join` now takes a row-level write lock (`SELECT ...
FOR NO KEY UPDATE`) on the channel row at step 3. This serializes all
join commits against concurrent `archive_channel` calls — the archive
UPDATE (`archived_at` non-key column) blocks on the row lock, closing
the READ COMMITTED race on both the `Existing` and `AutoAddRequired`
paths.

### HTTP/2 extended-CONNECT gate (latent path)

The root WebSocket handler uses a belt-and-suspenders approach. The
HTTP/1.1 path (currently the only live WebSocket shape — workspace Axum
does not enable `http2`) is gated pre-extractor via the `Upgrade:
websocket` + `Connection: Upgrade` header predicate. A second gate point
inside the `Ok(ws)` arm is structural future-proofing: if `http2` is
ever enabled, any h2 extended-CONNECT shape the extractor accepts but
the h1 predicate misses is caught there. The active HTTP/1.1 fire-point
sits before `bind_community` (zero DB cost on denial); the latent h2
fire-point runs after it.

### Mixed-version huddle control (rolling deploy)

Owners advertise the `huddle-commit-phase` mesh capability. The ingress
selects the mode after the owner stream opens: it sends the appended
`RegisterPeerCommitPhase` variant, and later `CommitConfirmed`, only
when the owner's exact runtime gossip record carries that flag at that
point, and latches the result for the session. A successful open implies
the owner's record is present, so a record that lands during acquisition
is not missed. Otherwise it sends plain `RegisterPeer`, which every
owner decodes. A new owner publishes a plain `RegisterPeer` once at
registration (pre-commit-phase behavior) and holds a
`RegisterPeerCommitPhase` peer until its confirm; duplicate or legacy
confirms publish nothing. Existing wire variant indices are unchanged.

### Off-mode compatibility

When `BUZZ_NIP_FI_MODE=off` (or unset), the relay reads no identity
header and installs no upgrade gate, NIP-42 pairing check, or session
deadline. Existing NIP-42 AUTH flows are unchanged. Huddle
commit-before-publish is not FI-gated and applies in every mode, so
huddle join publication timing differs from main even in Off mode.
External connection cancellation (socket close, archival, revoke) now
stops an in-flight liveness, search or history read at its next await;
an uncancelled connection's frames and payload bytes are unchanged.

## Test coverage

Every test this PR adds, or whose assertions it changes, runs in exactly
one CI lane: infra-free tests in `just test-unit`, Postgres-backed tests
in `postgres_tests` modules under the PostgreSQL lane. The `buzz-relay`
bin tests run whole in `just test-unit`. `scripts/run-tests.sh` mirrors
the effective relay unit selection for machines without nextest, except
main's pre-existing `api::admin::` module. Every `buzz-relay` test
fixture reads `Config` through `Config::for_test()`, which holds
`NIP_FI_ENV_LOCK`, so a single-process `cargo test` run cannot observe a
half-written FI environment
(`fixture_config_read_waits_for_fi_env_lock`). The stub pools in the
router and AUTH fixtures use a 100ms acquire timeout, which shortens a
database fallthrough rather than removing it.

- **Upgrade/router gate** (`router::tests`): Enforce-mode `nip_fi_*`
gate tests for h1 WS upgrade, NIP-11 content negotiation, and audio
ingress. Off-mode witnesses `nip_fi_off_root_passes_without_header`,
`nip_fi_off_root_ignores_malformed_header`,
`nip_fi_off_audio_passes_without_header`,
`nip_fi_off_audio_ignores_malformed_header` prove the gate is bypassed
(status outside 401/403/503, and no-header status equals
malformed-header status); they do not prove a successful upgrade.
- **NIP-FI units**: `nip_fi_config`, `nip_fi_upgrade`, `nip_fi_gate`,
`nip_fi_session`, covering assertion contracts, NIP-42 pairing, session
lifetime, and gate behavior.
- **Effect barrier at cancellation** (`postgres_tests`, PostgreSQL
lane):
`w3_b2_req_barrier_expiry_mid_flight_blocks_subscription_registration`,
`w4_b2_count_barrier_expiry_mid_flight_blocks_count_query`.
- **AUTH denial uniformity** (`handlers::auth::tests`): each
production-handler witness asserts the exact terminal frame, no denial
on the data or ctrl channel, and cancellation, plus the unchanged
Off-mode `OK false`. Unit:
`fi_invalid_nip42_proof_emits_terminal_evidence_rejected`,
`fi_ban_check_error_emits_terminal_authorization_unavailable`,
`handle_auth_pairing_mismatch_runs_full_root_denial_path`, and
`nip42_denial_class_separates_internal_failure_from_bad_evidence`
(internal verifier failure → `authorization unavailable`; each
client-evidence NIP-42 failure → `evidence rejected`). PostgreSQL lane:
`fi_ban_denial_emits_terminal_authorization_denied` (real
`community_bans` row; Off mode keeps `OK false` on ctrl, then close),
`fi_relay_membership_denial_emits_terminal_authorization_denied`,
`fi_allowlist_check_error_emits_terminal_authorization_unavailable`,
`fi_relay_membership_check_error_emits_terminal_authorization_unavailable`,
`fix_4a_allowlist_denial_with_fi_assertion_emits_canonical_restricted_frame`.
- **Bounded audio exits** (`audio::handler::tests`, `just test-unit`):
`check_cancel_exit_with_never_ready_sink_returns_within_flush_budget`
drives the production `check_cancel!` fence with a queued FI denial into
a never-ready sink and requires it to return within
`WS_TERMINAL_FLUSH_TIMEOUT`;
`challenge_send_to_never_ready_sink_exits_within_flush_budget_on_cancel`
requires the NIP-42 challenge send to yield to cancellation and exit
within the same budget; every pre-`send_loop` audio exit write goes
through the same `connection::send_exit_frames_bounded` helper.
- **Audio NIP-42 classification** (`audio::handler::tests`, `just
test-unit`):
`audio_bad_nip42_proof_with_fi_emits_one_evidence_rejected_then_cancels`
requires exactly one canonical `restricted: evidence rejected` frame and
cancellation; `audio_bad_nip42_proof_off_mode_keeps_auth_failed_frame`
pins the Off-mode frame.
- **Effect permit before pre-auth writes** (PostgreSQL lane):
`handlers::auth::tests::postgres_tests::expiry_before_nip_oa_materialize_writes_no_rows_and_commits_no_auth`
expires the session just before NIP-OA materialize and asserts no
`users` or agent-owner row and no committed auth.
- **Community admission** (`just test-unit`):
`state::tests::on_not_run_runs_once_on_each_deny_arm_and_never_on_admit`.
- **Auth metrics contract**:
`connection::tests::auth_lifecycle_reconciles_every_terminal_and_never_leaks_gauge`
records every terminal including `PairingMismatch`;
`metrics::contract_tests::*` derive the series count from
`AuthOutcome::ALL`.
- **JWKS** (`main.rs`): `hard_dead_warm_issuer_resets_to_fast_cadence`
and `jwks_refresh_success_waits_interval_failure_uses_fast_backoff`
drive the real loop with paused Tokio time; `cold_issuer_*` cover the
backoff helper;
`f1_supervisor_loop_drives_recovery_and_restores_admission` runs
`run_jwks_refresh_supervisor` over a real `ProductionJwksSource` through
injected panic → restart → hard-dead → 5 s retry → recovery, then checks
`key_set` admission and that the first post-restart fetch follows the
cold cadence (every issuer restarts cold);
`jwks_refresh_cancel_drops_in_flight_fetch_and_skips_remaining_issuers`
cancels during a pending fetch with a second issuer due.
- **Bounded lease acquire** (`audio::handler::tests::postgres_tests`,
PostgreSQL lane):
`failed_post_cas_verification_is_fenced_and_discards_the_won_lease`
requires a won-then-unverified lease to return fenced and be deleted by
exact owner+generation;
`stalled_lease_sql_and_stalled_exit_release_close_within_exit_bound`
stalls lease SQL and the exit release and requires socket close within
`worst_case::EXIT`. `audio::join::tests` pins `PERMIT_HOLD` at 14.5 s
and `EXIT` at 25 s.
- **REQ/search cancellation** (`handlers::req::tests`, `just
test-unit`):
`expiry_during_stalled_history_read_releases_permit_and_claim` and
`expiry_during_stalled_search_read_releases_permit_and_claim` stall the
read inside production `handle_req`, expire the gate, and require
`expire()` to return within 2 s, an empty subscription map, and no EVENT
or EOSE.
- **History timeout retirement**
(`handlers::req::tests::postgres_tests`, PostgreSQL lane):
`history_timeout_retirement_survives_expiry` drives a two-channel
history REQ into a real statement timeout, pauses retirement after the
index removal and before the topic release, expires the gate, then
resumes. It requires the map and index entries gone and both channel
topic refcounts at zero.
- **Archive/join serialization** (PostgreSQL lane):
`f2a_archived_channel_rejects_existing_join`,
`f2b_join_for_update_blocks_concurrent_archive_existing_path`,
`f2c_join_for_update_blocks_concurrent_archive_auto_add_path`.
- **Huddle room and handler**: `audio::room::tests` pending-peer tests,
including `roster_revisions_are_ordered_and_snapshot_is_authoritative`;
`fix_b_w1_w3_handler_pre_commit_cancel_emits_no_delta`,
`fix_b_w2_success_emits_exactly_one_delta_with_monotone_revision`.
PostgreSQL lane: `b1_cross_pod_bootstrap_precedes_buffered_owner_delta`,
`commit_confirm_timeout_at_seam_triggers_teardown_on_stalled_owner_stream`,
`confirm_failure_combined_outcome_no_orphan_join_exactly_one_48102`,
`confirm_send_pending_expiry_terminates_client_with_denial_within_1s`,
`c1_handler_ingress_sends_only_base_frames_to_legacy_owner`,
`f7a_cross_pod_joined_payload_includes_owner_pod_peers`.
- **Mixed-version control** (`audio::join::tests`):
`shared_control_variants_are_byte_identical_to_base_wire` (against
frozen `audio::join::base_wire`),
`c1_new_ingress_speaks_base_wire_to_legacy_owner`,
`c2_base_ingress_register_publishes_once_on_new_owner`,
`c3_capable_owner_record_selects_commit_phase_hold_back`,
`capability_record_installed_during_open_selects_commit_phase`,
`duplicate_or_legacy_commit_confirmed_is_a_no_op`,
`fix_b_commit_confirmed_arm_commits_peer_and_broadcasts_joined`,
`pending_remote_stream_close_emits_no_joined_no_left`.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-09-29 14:27:40 -04:00
Will Pfleger
9b1c4f98e2 🤖 perf: bound long-thread aux reads and make query deadlines terminal (#7854)
Long threads (over ~100 replies) time out in desktop. The aux hop that
loads reactions and other e-tag references builds its filter as one
`tags @> …` term per id, joined with `OR`. Past about 100 ids the
planner switches to walking each partition's primary key backward,
expecting to stop early. When few rows actually match, it scans the
whole community. With a dirty GIN pending list, a 188-reply thread never
finishes.

### What changes

- **Set-based e-tag reads (`buzz-db`).** The aux e-tag query now puts
the id set behind a `MATERIALIZED` CTE and matches with `ANY`. Each
e-tag read also runs under a transaction-local `statement_timeout` of 20
s.
- **Terminal timeout contract (`buzz-relay`).** When a read hits the
deadline, the client gets a terminal timeout (`CLOSED` for REQ, the
timeout error for COUNT and search), not a retryable failure.
- **Subscription lifecycle (`buzz-relay`).** The per-connection
subscription map lock is now the lifecycle lock. Claims, replacement,
client `CLOSE`, owner-checked timeout and search cleanup, revoke, and
connection teardown all change the map, the registry, topic refcounts
and terminal frames while holding it. As a result, a timed-out
subscription's `CLOSED` goes out before any replacement with the same id
can claim it, and claims made after teardown are refused.
- **Dropped terminal frame disconnects (`buzz-relay`).** If the `CLOSED`
for a retired subscription (owner-checked timeout or channel eviction)
can't be queued, the relay cancels the connection instead of leaving the
client holding a subscription that will never receive fan-out. A
client-initiated `CLOSE` ack is exempt.
- **COUNT under the same deadline (`buzz-db`).** The fast COUNT path
runs e-tag counts under the same transaction-local 20 s cap as SELECT,
so WS COUNT and HTTP `/count` produce the terminal timeout too.
- **Replica reads surface the deadline (`buzz-db`).** When a
replica-routed read is cancelled by `statement_timeout` (SQLSTATE
`57014`), the error is returned instead of re-running the query on the
writer, which would run the same slow statement. Other replica errors
still fall back to the writer.
`PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md` records the exception.
- **Deadline handling (desktop).** Only the relay's `query timed out`
counts as a query deadline. A client-side `request timed out` stays
retryable. For one-shot reads a deadline is terminal: the global query
retry (budget 1), thread-replies retry and reaction hydration don't
re-send the same slow query. Reaction ids held back by a deadline are
released when the channel is opened again, so reopening retries them
while same-channel re-renders don't. Live subscriptions, in both the
renderer and the native archive-sync socket, keep treating a deadline
`CLOSED` as transient: they back off and resubscribe with the same
filter instead of being dropped.
- **Deadline handling (mobile).** Mobile recognizes the deadline on both
transports: HTTP `503 {"error":"query timed out"}` and the WS `CLOSED`
reason.
- The root `ProviderScope` retry policy returns no retry for deadline
errors and keeps Riverpod's default backoff for everything else.
  - Background thread-summary recounts don't retry a deadline.
- Forum posts polling pauses while the last load settled on a deadline;
reopening the forum retries.
- A channel's newest-window load surfaces a deadline instead of falling
back to legacy WS history, and the channel list's batched queries return
"unavailable" on a deadline instead of fanning out to per-filter
fallbacks. An unavailable batch is not treated as empty. Known
last-message timestamps and unread state are kept, and unread catch-up
moves `lastMessageAt` forward (never backward) the same way live events
do, so a cold start with no known timestamp still gets one.
- A live subscription that gets a deadline `CLOSED` backs off and
resubscribes with the same filter. History requests still fail on it.
- Settled load failures show a Retry control, matching desktop's
thread-replies error card: a shared `LoadErrorView` on the channel
timeline, forum thread and forum posts list, and an icon button beside
the thread replies summary. While the thread query has no value, the
thread page shows the route snapshot plus live, cached and optimistic
replies, with the loading or error status still visible.
- Other re-sends (reconnect, foreground resume, live events, the unread
backstop, scroll) are not suppressed; each is bounded by the relay's 20
s statement deadline.
- **Long-thread seeder.** `just seed-long-thread [replies]` posts one
thread (default 187 replies) with some reactions to the local dev relay,
for reproducing long threads in the apps. It uses a throwaway key,
refuses non-loopback relays and ignores ambient `BUZZ_*` credentials.
Writes are paced to fit the stock relay rate limit.

**Lifecycle concurrency.** The lock serializes one connection's
subscription changes. Other connections can still be delayed by pressure
on the shared pubsub command queue, and by serial eviction loops waiting
behind a blocked connection. This PR does not claim strict isolation
between connections.

**Recovery vs. load.** Clients stop automatic retries of the same
one-shot read, but they don't remember deadlines across other triggers:
reconnect, resume, live events and the unread backstop can each send the
read again later, bounded by the 20 s statement deadline. A live
subscription that hits the deadline resubscribes with its original
filter under the existing 1–30 s backoff, and against a filter that
keeps timing out it retries at the 30 s cap indefinitely, so the worst
case is one bounded query per subscription every 30 s. `since` is
deliberately not advanced to now on resubscribe, because that would skip
events missed while the subscription was closed. Unchanged from `main`:
when mobile's channel history sync fails with cached messages available,
the cached messages are shown without the error or Retry control.


### Benchmark

Local reproduction on an isolated bench stack (not committed):
- PG16, migrations applied, one 188-reply thread plus 5M background
events in the same community.
- Each request replays the desktop thread-replies filter through the
relay, with `include_aux` on. Times are total ms for 3 runs, with a 60 s
client cap.

GIN pending-list state is controlled with `VACUUM` (clean) or by
refilling the pending list (dirty), and recorded per partition with
`pgstatginindex`. Dirty runs held 402 pending pages on every partition's
tags index at the start and end of each matrix. Clean runs held 0–1
pages (relay startup writes).

| build | GIN pending state | limit 10 | 50 | 100 | 200 (188 ids) |
|---|---|---|---|---|---|
| `main` | clean | 26 / 16 / 13 | 56 / 51 / 52 | 92 / 93 / 95 | 662 /
639 / 641 |
| `main` | dirty | 315 / 298 / 327 | 22141 / 15888 / 16769 | 28992 /
27819 / 28080 | timeout ×3 (>60 s) |
| this PR | clean | 35 / 21 / 14 | 57 / 49 / 48 | 82 / 80 / 75 | 153 /
150 / 135 |
| this PR | dirty | 290 / 254 / 251 | 1257 / 1204 / 1206 | 2432 / 2378 /
2382 | 4333 / 4706 / 5104 |

Baseline `origin/main` `b37e47721`; this PR at `5f2650f5f`, whose e-tag
read builders are identical to the current head. At head `1ad81d868`
against `origin/main` `15d44dcce`, limit 200 measured 145–179 ms clean
(`main` 683–715) and 4.41–4.51 s dirty (`main` timeout ×3).

Every completed cell returns the same signed event set: 209 events, 187
replies at limit 200. With `include_aux` off, every cell is ≤66 ms. The
thread-window loader (`select_aux`) is unchanged from `main`, and its
timings and captured plans match `main`'s.

Actual aux-hop statements captured from each relay binary with
`auto_explain` (analyze + buffers), execution ms:

| build | clean, 100 ids | clean, 188 ids | dirty, 100 ids | dirty, 188
ids |
|---|---|---|---|---|
| `main` (N-way `OR`) | 40 | 298 | 28785 | 62892 |
| this PR (`MATERIALIZED` fence + `ANY`) | 23 | 45 | 1992 | 3539 |

**What the fence changes**
- On `main`, the planner combines `ORDER BY created_at … LIMIT` with an
overestimated match count and walks each partition's primary key
backward, expecting to stop early. With few real matches it scans the
whole community: the dirty 188-id statement touches ~4.9M shared buffers
on `main` vs ~0.6M with the fence.
- The fence prevents that early-stop plan. Across the measured cases
(1–188 ids, both pending states), the planner used the GIN tags index.
The fence doesn't force GIN in general; a broad enough filter could
still get a different plan.

**Tradeoff:** the fence materializes every match before sorting. In this
bench small limits stayed within run-to-run noise of `main`. A single
hot id (5,000 reactions, limit 100) measured 30–34 ms unfenced vs 35 ms
fenced.

**Deadline:** each e-tag read (SELECT and COUNT) runs under a
transaction-local `statement_timeout` of 20 s. It only tightens: a
shorter operator timeout (`BUZZ_DB_STATEMENT_TIMEOUT_MS`) is kept. It's
a per-statement limit, not a per-request one, so a request that runs
several pages or hops can take longer than 20 s in total. The benchmark
does not force the deadline.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-09-28 16:36:40 -04:00
Luke Tornquist
b37e477211 Make relay readiness process-local (#7341)
## Summary

Make Kubernetes readiness depend only on the relay process lifecycle, so
a shared Postgres or Redis slowdown cannot withdraw every pod at once.

Move dependency checks to one fixed-cadence loop per pod. The loop
permits only one evaluation at a time, emits metrics after each sample,
and caches a timestamped report. `/_status` reads that cache without
calling Postgres, Redis, or the deletion catalog. It reports
`not_yet_sampled`, `fresh`, or `stale`. Each completed sample also
writes a Unix timestamp gauge, so Datadog can compute sample age at
query time.

Fail closed when the community lifecycle lookup errors. An inconclusive
tenant-state lookup no longer admits a socket to AUTH or REQ handling.

### Related issue

None found. This addresses the elevated relay HTTP 500 and reconnect
incident investigated on 2026-09-03.

### Tradeoff

The readiness gauge now reports the latest private-probe observation,
not the shutdown transition itself. After shutdown starts, a scrape can
still see `1` until the next readiness probe refreshes the gauge to `0`.
Kubernetes routing is unaffected because it uses the readiness response
from the authoritative process state.

A terminal `0` was never guaranteed: even a transition-owned gauge could
update in process and then exit before Prometheus scraped it. If no
scrape occurs before exit, both designs miss the terminal sample. The
new design adds at most the interval until the next readiness probe when
a scrape occurs during shutdown.

Dependency status is now sampled every 30 seconds rather than on demand.
A slow evaluation delays the next sample instead of stacking more work.
The status payload exposes the cached report age. The completion
timestamp stays unchanged when sampling stops, so `time() - timestamp`
crosses the stale threshold without a server aging loop. Outcome
counters and duration histograms remain cumulative and cannot provide
no-data detection.

### Testing

Gimli exercised exact SHA `edff1ec4a5362e132e367bf7449d2b0a68f1e9a9` on
an isolated Blox stack. Repeated `/_status` bursts caused no dependency
calls, metrics advanced without status traffic, stale status remained
visible, and lifecycle lookup failures admitted no AUTH or REQ handling.
Follow-up verification at `fdbb394ed598c94cde85ed01cb11e9de0ee8f00d`
confirmed sampler-owned outcome and duration emission without status
traffic and bound the real startup seam. The final timestamp-gauge
change is covered by targeted red/green tests and the same boot
regression at `c0f36e22b527c2fd6a63a548d036d006ffa17171`.

### Complexity

Readiness remains one process-local sample per probe. Dependency I/O now
has one owner and one execution path; request volume cannot increase
dependency-check load. One completion timestamp replaces the prior
server-side age update path; Datadog computes age without another loop.
Community admission also returns to one fail-closed rule for inactive
and unknown tenant state.

Generated with [Claude Code](https://claude.ai/code)

---------

Signed-off-by: tornquist <tornquist@squareup.com>
Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-09-28 14:02:12 -04:00
Will Pfleger
6410e685a8 feat(relay): enforce NIP-FI assertion+NIP-98 pairing on HTTP ingress (#7264)
Implements NIP-FI HTTP ingress enforcement per the merged spec (PR
#7254). Every protected HTTP surface in enforce mode requires a valid
`Nostr-Federated-Identity: Bearer <JWS>` assertion alongside its NIP-98
authorization, bound to the same key. The NIP-98 pubkey (proven actor)
must equal the assertion's `nostr_pubkey`; absent, mismatched, or
unverifiable assertions are denied fail-closed with byte-exact
text/plain responses per the spec's rejection table.

This is S5 of the `buzz-enterprise-identity` program. WS enforcement
(S3) is separate.

## What changed

**New `nip_fi_http` module:** `admit_nip_fi_http()` covers the full
admission sequence — `Nostr-Federated-Identity` extraction, offline JWKS
verification, NIP-98 key pairing [FI-INV-05], issuer-qualified deny-map
check via `(iss, pubkey, now)` [FI-INV-14], and byte-exact denial for
all four `DenialClass` variants. `admit_nip_fi_http_on_state()` is the
single call-site wrapper for all surfaces [FI-TRACE-AUTHORITY-UNIFORM].

**Protected surfaces** (all gated via the uniform authority):
- HTTP bridge: `POST /events`, `/query`, `/count`
- GIF proxy: `POST /gifs/search`, `/gifs/share`
- Workflow reads: `GET /workflows/{id}/runs`, `GET
/workflows/{id}/runs/{id}/approvals`
- Moderation reads: `GET /moderation/reports`, `/moderation/audit`,
`/moderation/restricted`
- Media/Blossom: `PUT /upload`, `/media/upload`
- Git smart-HTTP: `info/refs`, `upload-pack`, `receive-pack`
- Git settings control plane: `GET`/`POST
/git/:owner/:repo/default-branch` (now routed through
`admit_nip_fi_http_on_state` for full assertion key-pairing)
- Invites: `POST /api/invites` (mint only; claim exempt as
pre-membership enrollment door)

**NIP-98 strictness in NIP-FI mode:**
- When NIP-FI is not Off, bridge POST handlers force `require_auth_token
= true` — the `X-Pubkey` dev-mode fallback cannot satisfy the pairing
requirement [NIP-FI.md:547-567]
- POST bodies on `/events`, `/query`, `/count` require a matching
`payload` tag in enforce mode [NIP-FI.md:579-597]
- A payload tag present with no content (one-element `["payload"]` or
empty string) is rejected as a structurally invalid claim — NIP-98
requires the content to be a valid 64-char lowercase hex SHA-256 digest.
Malformed payload diagnostics use a static error message (no
attacker-controlled content in logs).
- Off mode preserves pre-NIP-FI behavior per [FI-INV-15]

**Off-mode compatibility changes:** Admission now runs before request
validation on every protected surface, so Off mode differs from
pre-NIP-FI behavior in these ways:
- **GIF routes:** NIP-98 authentication happens before the upstream
provider lookup. An unauthenticated request on a tenant with no GIF
provider now returns 401 instead of 404. The 401 goes through the legacy
`api_error()` path, so the body is JSON `{"error":"..."}` with no
`WWW-Authenticate` header.
- **Workflow runs** (`GET /workflows/{id}/runs`): the query string is
parsed after admission. A request with a missing credential and a
malformed query (for example `?limit=abc`) now returns 401 instead of
400.
- **Authorized malformed query:** once admitted, a malformed query
returns a JSON 400 `{"error":"invalid query: ..."}` from
`parse_query_or_400` instead of axum's `text/plain` `Query` rejection.
- **Moderation reads** (`/moderation/reports`, `/moderation/audit`):
same extraction change as workflow runs. Admission comes first, then a
JSON 400 for a malformed query.
- **Malformed `payload` tag:** shared NIP-98 verification now rejects a
malformed `payload` tag in every mode, including on bodyless requests.
That means a one-element `["payload"]`, an empty digest, or anything
other than 64 lowercase hex characters. Before this change, a bodyless
request ignored the tag.

**Auth header cardinality:** Enforce mode rejects requests with more
than one `Authorization` header with 403 `evidence rejected\n`.
DenyProtected returns 503 `authorization unavailable\n` before this
check (and before NIP-98 or assertion verification) runs. The
cardinality gate is enforced at the NIP-FI admission boundary
(`admit_nip_fi_http`), not via legacy auth boolean flags, so it applies
uniformly to all Enforce-mode surfaces including Git. Off mode passes
through the first header value unchanged, preserving pre-NIP-FI behavior
[FI-INV-15].

**Denial-class remapping:** In Enforce mode, NIP-98
extraction/verification failures from the closure are mapped to the
appropriate `DenialClass`: `MissingEvidence` when no `Authorization`
header is present, `EvidenceRejected` when the header is present but
invalid. Off mode propagates legacy responses unchanged [FI-INV-15]; for
example, the Git missing-Authorization and wrong-scheme 401s keep their
legacy bytes with no `content-type` header.

**JWKS refresh scheduling:** The refresh timer's `*last` checkpoint is
set after the fetch completes, not from the pre-fetch timestamp,
preventing interval drift under network latency. The loop is extracted
to `nip_fi_jwks_refresh_loop` for unit-testability.

**Log hygiene [NIP-FI.md:777-779]:** Issuer identity is never written to
logs or error messages. All startup/refresh diagnostics use an ordinal
index (`issuer_index = N`). Config parse errors for malformed issuers
use classify()/line/column codes rather than raw config values.

**Admin SPA document routes:** `/reports`, `/reports/{id}`, and
`/feedback` are exempt from the NIP-FI assertion guard only on the admin
host (host-qualified via `is_admin_host`). These paths are not added to
the broad `NIP_FI_EXEMPT_PREFIXES` list — tenant hosts remain fully
protected.

**Deny-map seam (`HttpDenyMap` trait):** signature is `(issuer, pubkey,
now)` matching `NipFiDenyMap::is_denied` from S4 (PR #7265) so
integration is a one-liner. Stub is `AlwaysAdmitStubDenyMap` — named to
accurately describe stub-phase semantics (fail-open intentionally
pending S4).

**Route inventory:** every authenticated HTTP route is classified as
Protected or Exempt with justification. Documented in the F4 seam-test
comment in `bridge.rs`.

**Outer-guard tests:** `#[ignore = "requires Postgres"]` tests in
`bridge.rs` drive the full router (`build_router`) in Enforce mode with
valid NIP-98 but no `Nostr-Federated-Identity` header. These exercise
the **outer `nip_fi_assertion_guard` middleware** in `router.rs` — the
layer that fires before any handler runs. They prove the guard's exact
response bytes (401 `authentication required\n`, `text/plain;
charset=utf-8`, `WWW-Authenticate: Nostr`) for each protected route.
Per-handler `admit_nip_fi_http_on_state` wiring is proven separately by
the cardinality test and the media/settings/git handler-level suites.

## Seams

**S3 seam:** `nip_fi_config.rs` is duplicated (S3 not landed;
session-deadline parts stripped). Removed when S5 rebases onto S3.

**S4 seam:** `AlwaysAdmitStubDenyMap` until S4 provides the real map.
Integration is one line at the `admit_nip_fi_http_on_state` wrapper.

## Evidence

At head `ee338ea9c` (branch `hayt/nip-fi-http-enforcement`), hosted CI
run
[36167450853](https://github.com/block/buzz/actions/runs/36167450853):
- **Rust / Unit Tests** ([job
108179338458](https://github.com/block/buzz/actions/runs/36167450853/job/108179338458)):
passed. The `buzz-relay` `test-unit` step ran 171 tests with 171 passes.
Its PASS lines include all 57 non-ignored tests this PR adds to the
relay: `nip_fi_http::tests` (25), `nip_fi_config::tests` (8),
`router::tests` (8), `api::parse_query_tests` (5),
`api::git::transport::off_mode_precedence_tests` (5),
`tests::jwks_refresh_*` (3), and `composition_tests` (3). The same job
ran the `buzz-auth` suite, which includes
`nip_fi::jwks::tests::key_set_serves_live_snapshot_under_reader_and_refresh_overlap`.
- **Rust / Rust Lint**: passed.
- **Relay and PostgreSQL / Backend Integration (relay e2e)**: passed.
- **PostgreSQL Domain / PostgreSQL Tests** ([job
108183303436](https://github.com/block/buzz/actions/runs/36167450853/job/108183303436)):
passed. 542 tests run, 542 passed.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
Signed-off-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-09-25 21:01:31 +00:00
Will Pfleger
b65cff31a4 fix(ci): run the admin disabled-mode DB test in the PostgreSQL lane (#7900)
`api::admin::postgres_tests::disabled_mode_allows_unauthenticated_requests_on_the_admin_host`
falls through to a DB-backed read route but was not `#[ignore]`d. The
`test-unit` exclusions meant to skip it still named
`api::admin::tests::…`, which stopped matching when #6730 moved the
admin tests into `mod postgres_tests`. As a result the test ran in the
infra-free unit job and only passed after waiting out the sqlx acquire
timeout (~150s in hosted CI), while the ignored-only PostgreSQL lane
never ran it against a real database.

- The test is now `#[ignore = "requires PostgreSQL"]`, like its
DB-backed peers, so the PostgreSQL lane runs it.
- The two dead exclusions are removed, and the `api::admin` clause is
just `test(/^api::admin::/)`.
`nip98_mode_unrostered_signer_does_not_consume_a_replay_slot` was
already ignored and runs in the PostgreSQL lane.
- The `Justfile` comment now states the convention (DB-backed admin
tests are ignored; non-ignored ones reject before touching the DB),
drops the stale reference to a ci.yml step that no longer exists, and
keeps the timeout-trap warning.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-09-25 19:03:21 +00:00
Will Pfleger
2013148852 🤖 fix(justfile): point just staging at the current staging relay (#7881)
The staging recipe pointed at the retired
`sprout-oss.stage.blox.sqprod.co` relay.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-09-24 16:06:27 -07:00
Luke Tornquist
8f6b66f9ff Select one feature flag provider at compile time (#7677)
## Why

Buzz server components need one typed feature-flag contract without
forcing the open-source build to depend on LaunchDarkly. A staging or
production relay must not be able to choose the wrong provider through
runtime configuration.

## What

- Adds typed boolean and signed `i64` flags with static, environment,
and compile-gated LaunchDarkly evaluators.
- Defines an executable relay artifact contract that compiles exactly
one provider.
- Rejects zero or multiple relay provider features at compile time.
- Runs the LaunchDarkly feature tests and the provider compile matrix in
the unit CI lane and fallback test script.

## How

`buzz-feature-flags` stays provider-neutral. The future relay
composition root will expose one build path per artifact feature:
`static-feature-flags`, `environment-feature-flags`, or
`launchdarkly-feature-flags`. Runtime configuration carries only
settings for the selected provider. It never carries a provider name,
fallback, or precedence rule.

The crate still has no relay consumer. A locked compile fixture proves
each singleton builds and every zero or multi-provider combination fails
until the relay adopts this contract.

This removes the proposed runtime `ProviderMode` and leaves one
provider, one construction path, and one evaluator per artifact.

## Risk

This PR still has no relay or app consumer, so it changes no
user-visible behavior. Risk is limited to the new feature-flag crate,
its compile contract, and its unit-test lane.

## Testing

An external Rust consumer at `30cf968bfe85e82980fda7e63f5bebe649ca939f`
exercised six environment-evaluator scenarios and 59 assertions. The
compile-time correction adds no live consumer to exercise manually.

## Bigger picture

When `buzz-relay` adopts this crate, its build manifests must map each
distribution to one provider feature. That consumer PR must verify live
Relay Proxy reachability, secret ingestion, lifecycle ownership, and
user-visible behavior.

Generated with Codex

---------

Signed-off-by: tornquist <tornquist@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 15:31:08 -04:00
Mic Neale
4544ba1713 chore(mesh): upgrade MeshLLM to 0.76.2 (#7559)
## Summary

Upgrade Buzz from MeshLLM `v0.76.0-rc9` to the latest stable [v0.76.2
release](https://github.com/Mesh-LLM/mesh-llm/releases/tag/v0.76.2) (via
v0.76.1).

- Update all eight Mesh git dependencies across desktop and relay smoke
examples; both lockfiles resolve all 47 Mesh packages to
`a0c1e66b0ac037dd56544b9e2d94969ea43d694f` (tag `v0.76.2`). All non-Mesh
lock records are unchanged by the 0.76.1 → 0.76.2 bump.
- Match Mesh's `ed25519-dalek = 3.0.0` and `rusqlite = 0.40`
dependencies.
- Explicitly enable rusqlite's `fallible_uint` feature, preserving
Buzz's existing `u64` SQL conversions when **mesh is disabled**.
Mesh-enabled builds previously supplied this transitively, so testing
both configurations matters.
- Temporarily pin **only** the optional `sqlx-sqlite` package to the
merged upstream compatibility fix
[transact-rs/sqlx#4402](https://github.com/transact-rs/sqlx/pull/4402),
revision `94aafe3a68884d923b0798a767c8d7f6cfda89d2`. SQLx 0.9.0 caps
`libsqlite3-sys` below 0.38, while Mesh 0.76 requires 0.38. Cargo
resolves the conflict even with SQLite disabled. The fix raises that
upper bound; remove the patch once released. Buzz's active PostgreSQL
driver stays on crates.io SQLx 0.9.0.
- Preserve the explicit Linux keyring 3 `crypto-rust` feature so Secret
Service sessions retain DH encryption independently of Mesh keyring 4.
- Preserve the dedicated examples-only `buzz-mesh-smoke` crate and
lifecycle workflow wiring, keeping Mesh/native-runtime dev dependencies
out of relay CI builds.
- v0.76.1 → v0.76.2 upstream delta: self-updater fix for composed
product-v2 bundles (Mesh-LLM/mesh-llm#1844) plus release plumbing — no
API changes affecting Buzz.
- Merged `origin/main` (`7c789dee0`) without rewriting shared PR
history.

### Validation at `ae4adba15` (local macOS ARM64, Hermit toolchain)

- **Passed:** `cargo test --locked -p buzz-mesh-smoke --all-targets`
(all six example harnesses compile; no live harness execution).
- **Passed:** desktop Tauri workspace tests, both default and
`--features mesh-llm` graphs (3,296 tests with mesh enabled, 0
failures).
- **Passed:** `just desktop-tauri-clippy` (both feature graphs, `-D
warnings`), root workspace clippy `-D warnings`, `cargo fmt --all
--check`.
- **Passed:** `--locked` metadata resolution for both workspaces;
lockfile diff audited to touch only Mesh package records.
- **Passed:** full pre-push hook suite on the final push (branch-skew,
rust-tests, desktop-tauri-checks, file-size-check) — no hooks bypassed.
- **Not run:** live Mesh inference/admission smoke or installed-app UI
exercise. CI lifecycle smoke coverage applies.

Originating Buzz conversation:
buzz://message?channel=dffbc117-845a-4c02-bc3f-eaeeefc4deaa&id=670f9c262e1ec0a4df5600b3173a0c5de2abe17e08ba9ccfdd8eff109fadf1a8

---------

Signed-off-by: Thinker <75d8a808fa21bb8d1812e080cf471db601c6e8e6a62dcd516d37531e83a7bb77@meshllm.communities.buzz.xyz>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Thinker <75d8a808fa21bb8d1812e080cf471db601c6e8e6a62dcd516d37531e83a7bb77@meshllm.communities.buzz.xyz>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Galadriel <b81c841ed20a8e17caf4e6fac2f6f3bc61d304c84b8394720f6d00f37538c47e@meshllm.communities.buzz.xyz>
Co-authored-by: Gu <gu@buzz>
2026-09-24 07:32:07 -06:00
Will Pfleger
b6a26556ec feat(canvas): add version history with atomic restore (#6780)
🤖 Adds append-only channel canvas history, restore, and
relay-authoritative optimistic concurrency so stale edits cannot
silently replace a newer canvas.

## What this adds

- **Desktop history and restore** — browse revisions with author,
timestamp, preview, and diff; restore an older revision as a new signed
head after explicit confirmation. Persisted empty canvases remain
reachable, mutation outcomes are announced accessibly, and focus moves
to a stable destination after save or restore.
- **CLI history and restore** — `buzz canvas history --channel …`, `buzz
canvas get --channel … --revision <id>`, and `buzz canvas restore
--channel … --revision <id>` use keyset pagination over the retained
revision stream.
- **SDK canvas builders** — optional `expected-revision` tags, bounded
ancestry classification, and shared timestamp discipline keep
first-party writes ahead of the head they read without propagating
poisoned future timestamps.

## Authoritative concurrency contract

Tagged canvas writes use compare-and-swap semantics in the relay and
database:

- `expected-revision=none` succeeds only when no live canvas exists.
- An event ID succeeds only when it names the canonical live head.
- A byte-identical replay succeeds only when that event is already the
canonical live head.
- A successful candidate must sort strictly ahead of the current head
under `created_at DESC, id ASC`.
- Malformed preconditions and stale or non-advancing writes are rejected
before event rows, mentions, or fan-out are committed.

The database serializes tagged writes, unconditional kind `40100`
writes, and canvas soft deletion on the same `(community, kind,
channel)` advisory key. Untagged `buzz canvas set` remains an
unconditional write, but participates in that serialization boundary so
it cannot race a tagged save or deletion. Deletion classification is
derived inside the datastore transaction; unrelated event kinds do not
take the canvas lock.

## Read and timestamp consistency

Precondition and post-write ancestry reads request strong consistency
from `/query`, pinning them to the writer when replica routing is
enabled. Display-only history remains replica-eligible.

First-party canvas writers stamp `created_at = max(now, head + 1)` with
a conservative client skew limit. The relay applies a canvas-specific
future-timestamp ceiling before persistence, preventing an accepted
boundary head from making subsequent first-party writes invalid while
preserving the broader ingest limit for other event kinds.

## Durable outcomes and recovery

After an accepted publish, a failed verification read is reported as
durable success with `verified: false` rather than a failed save.
Desktop and CLI preserve the new event ID and direct users to History
when verification is unavailable. A successful read walks the bounded,
cycle-guarded `expected-revision` ancestry chain so transitive
descendants are not misclassified as supersession. Canvas caches
invalidate for every settled mutation outcome, including a retained but
superseded revision.

The 409 reconciliation path in `cmd_restore_canvas` now separately
establishes persistence evidence from ancestry classification.
`canvas_write_survived` checks reachability only; a secondary
writer-pinned IDs lookup distinguishes "A absent" from "A present but
superseded by a legacy write." The four-way outcome (survived /
persisted-but-superseded / genuinely-absent / read-fail) is now correct
for all lost-response shapes including unconditional legacy writes.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
2026-09-23 11:03:54 -04:00
Tom Brow
7e96f91229 feat(mobile-infra): support development App Attest (#7744)
Development-signed iOS apps cannot enroll with the current gateway
because their Apple App Attest AAGUID is rejected.

Add an explicit personal development build feature and environment
setting so a development gateway can use a developer's own application
identity and sandbox APNs credentials.

Ordinary gateway builds remain production-only. Special builds also
default to production, enforce exactly the selected signed AAGUID, and
retain certificate-chain, pinned-root, nonce, app-ID, key,
credential-ID, and counter verification.

Document the matching mobile signing configuration and expose the
feature through an optional Docker build argument.

### Validation

The development image deployed successfully to a personal Railway stack;
private readiness, enrollment challenges, authenticated relay access,
and APNs sandbox TLS/HTTP2 connectivity passed.

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-22 22:53:46 +00:00
Jordan Mecom
7881447c19 fix(audit): frame hash inputs with TLV (#7492)
Hash new audit entries from a TLV buffer built with one-byte field tags,
big-endian u64 byte lengths, and raw values. Absent optional fields are
omitted; present empty values retain their tag and zero length. This
prevents bytes from moving between fields without restricting object IDs
or inferring boundaries from field contents.

Store an explicit `hash_version`. Migration 0048 labels existing rows as
version 1, and new writers use version 2. Historical hashes are
unchanged and mixed-version chains verify. The legacy encoding retains
its original ambiguity; TLV protects new entries. Apply the migration
before deploying the updated writers and verifiers.

Tests cover both boundary collisions, absent versus empty fields, stable
digests, unknown versions, mixed-version chains, and stored-row
tampering. All 16 audit and 128 database unit tests passed. The six
PostgreSQL audit tests passed against both migrated and fresh schemas,
and a populated-row migration check preserved the historical hash. Full
`just ci` passed.

Related audit fixes: #4216 and #4249; issue #4173.

Signed-off-by: Jordan Mecom <jm@squareup.com>
2026-09-22 11:19:11 -07:00
ravarora2
a929532a04 fix(admin): allow cold storage worker DB startup (#7770)
## Summary

The standalone S3 accounting worker can exit before its scan begins
because database startup inherits the relay’s three-second connection
budget. A cold DNS probe took about five seconds.

Give `buzz-admin storage-snapshot` its own pool: one connection, no
minimum idle connections, and a 30-second acquisition budget. Retry
startup timeouts and transport failures at most twice, after two and
five seconds. Total startup wait is bounded to 97 seconds.
Configuration, authentication, TLS, and protocol errors fail
immediately.

Log each startup attempt’s elapsed time and error category without
connection credentials. Relay defaults stay unchanged. Retries stop
before lock acquisition; the same lock-owning database session still
publishes the completed snapshot.

### Related issue

Follow-up to #7543. No duplicate startup fix found.

### Testing

Archived-command check: built a nextest archive, temporarily moved
`target/debug/buzz-admin` out of the checkout, and ran
`scripts/postgres-test-run.sh --archive-file <archive>`. Both tests
failed before the path fix and passed afterward. The tests use nextest’s
relocated executable path, with the original Cargo path retained for
local runs.

Manually restored the old startup call and ran
`scripts/postgres-test-run.sh -p buzz-admin --test
postgres_storage_snapshot -E 'test(storage_snapshot_retries_then)'`. It
failed before reaching S3; restoring the fix passed. This confirms the
test exercises the actual worker command.

Local `just ci` passed all stages before mobile tests, which stopped at
SDK discovery in the `objective_c` native hook. The default Xcode tools
report an unaccepted license. Two existing PostgreSQL tests hit
connection timeouts in the parallel run and passed individually.

After merge, build a worker image and repeat the cold-pod staging smoke
test through snapshot publication before changing relay metrics mode.

Generated with Codex

---------

Signed-off-by: Ravneet Arora <rarora@squareup.com>
2026-09-22 13:32:20 -04:00
ravarora2
6b1e45d2f6 Isolate S3 storage metrics from the relay (#7543)
## Why

**This PR moves the S3 storage scan out of the serving relay and into a
separate batch job.** The job saves a complete result in PostgreSQL. The
relay reads that result, exports the existing storage totals, and
reports the snapshot's freshness and database-read health.

```text
Today:         relay -> scan S3 -> calculate totals -> Datadog metrics
After rollout: CronJob -> scan S3 -> PostgreSQL snapshot -> relay -> Datadog metrics
```

The code in this PR is the new pipeline. It adds the worker, database
snapshot, relay read mode, and Helm CronJob template. It does **not**
enable the pipeline in production. The chart keeps the CronJob disabled,
and the relay keeps its current inline mode by default.
[squareup/builderbot-platform-core-infrastructure#244](https://github.com/squareup/builderbot-platform-core-infrastructure/pull/244)
will set the production limits, enable the job, and switch the relay to
the saved snapshot after this PR merges.

The relay currently lists the media bucket once an hour and calculates
storage totals inside the serving process. The production bucket now
exceeds the relay's one million object cap, so each sweep fails and the
last good per-community values remain stale. The Datadog dashboard can
no longer show current storage use by community.

Raising that cap inside the serving relay would move a larger,
memory-heavy scan into the process that owns live WebSocket connections.
The scan retains state for unique blob hashes and community bindings, so
its memory use grows with the bucket. A large scan could raise relay
memory use, trigger restarts, and drop user connections.

The goal is to restore current metrics without putting a
production-scale bucket scan in the relay's failure domain. No media
objects move or change. Only the process that calculates the totals and
the handoff to the relay change.

## What

- Add `buzz-admin storage-snapshot`, a run-once worker that scans S3 and
saves one complete accounting snapshot in PostgreSQL.
- Add `external` relay mode to read the saved snapshot. Keep the current
`inline` mode as the default, and add `off` as a fail-closed option.
- Add an opt-in Helm CronJob for the worker. The 10 million object cap
exists only on this Job.
- Keep the current fleet and per-community gauges, and add snapshot age,
generation duration, configured cap, cap utilization, and database-load
health gauges.
- Add an additive migration for the latest complete snapshot and its run
metadata.

## Overall plan

1. Merge this PR to add the worker, database handoff, relay modes, and
disabled-by-default chart support. This step does not change production
behavior.
2. Publish the relay image and Helm chart `0.1.9` from `main`.
3. Update
[squareup/builderbot-platform-core-infrastructure#244](https://github.com/squareup/builderbot-platform-core-infrastructure/pull/244)
to pin those final artifacts and rerender its manifests.
4. Use the infrastructure PR to deploy the CronJob with a 10 million
object cap, a one-hour deadline, and a 20 GiB memory limit.
5. Start one manual Job during a staffed window and verify its snapshot,
duration, memory use, relay stability, and Datadog output.
6. Keep the daily schedule and `external` relay mode only after that
gate passes, then add alerts for stale snapshots, snapshot-load failure,
failed jobs, cap pressure, duration, and worker memory.

The code and infrastructure remain separate so this PR can ship the
mechanism without silently enabling a production-scale scan. The
infrastructure PR owns the production settings and rollout gate.

## How

### Complete snapshot handoff

The worker takes a deployment-wide PostgreSQL advisory lock before it
lists S3. This prevents scheduled and manual workers from overlapping,
even if Kubernetes starts both.

The final database replacement runs through that same lock-owning
session. If the session dies during the scan, publication fails instead
of letting the stale worker overwrite its successor. A worker that no
longer owns the lock cannot publish through another pooled connection.

The worker reads S3 in pages of 1,000 objects and folds each page into
physical totals, logical per-community usage, and anomaly counts. It
stops before folding a page that would exceed `--max-objects` and emits
structured progress every 100,000 objects.

The worker replaces the singleton database row only after the full scan
succeeds. A listing error, malformed page, cap breach, serialization
error, lost lock session, or database error cannot publish a partial
snapshot. The last completed snapshot stays available. Each stored
result includes its completion time, duration, cap, and image or source
revision.

PostgreSQL is the handoff between the batch worker and the relay. The
worker writes one complete deployment-wide snapshot, and the relay reads
that snapshot on its normal leader metrics tick. No new network service
or worker endpoint is required.

### Relay behavior

`BUZZ_STORAGE_METRICS=inline` keeps the existing in-process sweep and
its one million object default. `external` loads the newest completed
worker snapshot on each leader metrics tick. `off` emits no
storage-family metrics. An unknown value fails closed to `off`.

External mode republishes the existing fleet, per-community, and anomaly
metric names, so those current queries remain valid. Snapshot handoff
health uses a separate family: `buzz_storage_snapshot_age_seconds`,
`buzz_storage_snapshot_duration_seconds`,
`buzz_storage_snapshot_max_objects`,
`buzz_storage_snapshot_cap_utilization`, and
`buzz_storage_snapshot_load_ok`.

The `buzz_storage_sweep_*` gauges remain inline-only because they
describe a scan attempted by the relay process. External mode does not
synthesize `sweep_ok=1` from the last successful database row: an old
row proves only that an earlier worker completed. Kubernetes CronJob/Job
status is authoritative for the latest worker result, including non-zero
exit, deadline expiry, and OOM termination.

The relay keeps the last complete snapshot available when a worker run
fails. Snapshot age shows that no replacement arrived on schedule, while
`buzz_storage_snapshot_load_ok` distinguishes staleness from a missing
row, database read failure, or invalid payload. If a community
disappears, changes host, or leaves the configured emission scope, the
relay zeroes the old labeled series instead of leaving stale values
behind.

Per-community bytes are logical referenced media usage. If two
communities reference the same blob, each community receives the blob's
bytes. Fleet physical totals still count each S3 object once. This PR
preserves those existing metric semantics.

### Deployment boundary

The CronJob is disabled by default. When enabled, it uses
`concurrencyPolicy: Forbid`, no retry, a configurable active deadline,
bounded history, explicit CPU and memory resources, a read-only root
filesystem, and no Istio sidecar by default.

The worker receives only `DATABASE_URL`, S3 settings, optional S3
credential references, and the code revision. It does not receive the
relay private key, Git hook HMAC secret, Redis URL, or the full relay
secret as environment variables. Operators may reuse the Buzz service
account or select a dedicated one.

### Non-goals

- This PR does not enable the worker or `external` mode in production.
- This PR does not change media uploads, object ownership, retention, or
deletion.
- This PR does not change the names or meaning of the existing fleet,
per-community, or anomaly storage gauges.
- This PR does not add a lease service or allow multiple workers to
publish concurrently.

## Risk

The application and chart defaults do not enable the new worker or raise
the serving relay's cap. The database change is an additive
deployment-global singleton table. The relay's default remains `inline`,
so merging this PR alone does not change the active metrics path.

The main rollout risk is worker memory use because the fold retains
state for distinct blob hashes and community bindings. The first
production-sized run must confirm peak memory and duration before the
schedule is trusted. The 10 million cap and one-hour deadline bound the
first deployment, but they do not replace measurement.

External mode also needs stale-snapshot, snapshot-load, and job-failure
monitors because the relay intentionally keeps publishing the last
complete result after a failed job or read error. This behavior avoids
gaps and partial data, while snapshot age and load health tell operators
when the result or PostgreSQL handoff needs attention. Job status
remains necessary for immediate failures and termination reasons that a
killed process cannot record itself.

## Testing

No production S3 scan was run from this PR.

Automated regressions verify that external mode keeps stale last-good
totals visible without emitting inline attempt-health gauges, reports
database-load failure while retaining the cache, and leaves inline
telemetry unchanged. Mutation checks also prove those tests fail if
external mode is routed through inline emission or a failed load is
reported as healthy.

After deployment, create one manual Job from the CronJob during a
staffed window. Require a completed Job, exactly one new snapshot, fewer
objects than the configured cap, acceptable duration and peak memory, no
relay restart regression, restored Datadog points, and no OpenMetrics
sample truncation.

## Next steps

1. Complete review and merge this PR.
2. Publish chart `0.1.9` and the final main-branch relay image.
3. Replace the provisional pins in
[squareup/builderbot-platform-core-infrastructure#244](https://github.com/squareup/builderbot-platform-core-infrastructure/pull/244),
rerender, and require clean CI.
4. Run the staffed manual Job gate and record object count, peak memory,
duration, snapshot revision, and relay stability.
5. Confirm the relay reads the new snapshot in `external` mode and the
existing `block.buzz_relay.*` storage series update again.
6. Enable the daily 03:17 UTC schedule and add dashboard panels and
monitors for snapshot age, snapshot-load health, Job failures, cap
pressure, duration, and worker memory.

If production must roll back to an older relay image, set
`BUZZ_STORAGE_METRICS=off` first. Older images do not understand
`external` and would resume the failing inline scan.

## Bigger picture

This is the application half of the storage-metrics repair. The paired
infrastructure PR owns the production resource limits, schedule,
artifact pins, secret boundary, and monitored rollout. Keeping those
changes separate lets this PR remain safe by default while still
providing a complete path to current per-community S3 totals.

Related issue: N/A. No matching issue or pull request exists in
`block/buzz`.

Generated with Codex

---------

Signed-off-by: Ravneet Arora <rarora@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 10:34:31 -04:00
Tom Brow
76f7e5ef0c feat(push): make gateway origin configurable (#7261)
Make the push gateway origin configurable across the relay, gateway,
mobile client, and deployment chart.

This is a fresh deployment. The gateway has only run on personal
development infrastructure, and push has no existing enabled users.
Legacy migration, configured-to-unconfigured mobile transitions, and
rolling upgrades from pre-launch gateway binaries are not supported.

Populated pre-launch databases are rejected with an informative error
before migrations run; their data is untouched. Mobile builds may omit
the gateway URL, leaving push unavailable. Once push is enabled,
subsequent builds must retain that configuration.

### Validation

- All applicable CI checks pass at
`5f546cfaf403f0386f146d213adfb4392f4ec29c`, including Rust,
mobile/Swift, desktop/integration, security, image builds, and Helm
checks. An unrelated agent cancellation test passed on retry.
- PostgreSQL CI passed 384 tests, including the production-entry-point
regression for refusing populated pre-launch databases before
migrations, fresh initialization, and subsequent initialized
deployments.
- Focused local validation passed all 10 gateway PostgreSQL tests under
the CI discovery filter and all 3 unconfigured-mobile tests.
Repository-wide PostgreSQL discovery validation and the complete
pre-push hooks passed.
- Physical-device APNs/App Attest behavior and deployment to a live
cluster were not verified in this work.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
2026-09-15 14:56:45 -07:00
Mic Neale
44316ff72f feat(mesh): upgrade to mesh-llm 0.76.0-rc8 and recommend Qwen3.8 27B (#6189)
Upgrades Buzz's mesh-llm dependencies through the released `v0.76.0-rc8`
tag (`2040765d`), including the Qwen3.8 curated recommendation and rc8's
scheduler/runtime improvements.

**Scope note:** the earlier open-relay/unenforced-admission mode has
been removed from this PR at Mic's direction — it is not a product mode
we want. Mesh admission remains roster/allowlist driven, exactly as on
`main`: on a relay with no NIP-43 membership snapshot the mesh runs
self-only. No NIP-11 mode probing, no mode-transition restarts. A future
perimeter/admission strategy for open relays will be designed
separately.

This PR also:
- seeds `BUZZ_AGENT_LLM_TIMEOUT_SECS=660` for mesh agents, above
MeshLLM's 600-second backend timeout;
- makes `desktop-tauri-clippy` lint both default and `mesh-llm` cfg
graphs;
- runs the feature-enabled desktop test suite in CI;
- recommends Qwen3.8 27B Q4_K_M for 64 GB-and-larger machines, then
ladders down through Gemma 4 E4B and Qwen 9B for smaller machines;
- keeps stored shared-compute `auto` translated to MeshLLM's supported
wire model `mesh`.

RC8 verification:
- `just ci` passed locally at
`92ecc7ec933bdd4df804cc9f28a2b51efa5313c5`.
- Pre-push differential gates passed, including both desktop Tauri cfg
graphs and package tests.
- A prior isolated runtime smoke used the RC8 binary's OpenAI endpoint
for a Buzz-shaped system/user/tool/tool-result/final-response loop; all
assertions passed and the isolated process was shut down.

Perf previously measured on M5 Metal, Qwen3.8-27B-Q4_K_M: TTFT 0.22–0.32
s, ~25 tok/s streaming; agent-shaped turns ~1 s to first token after the
first (prefix cache).

---------

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
Signed-off-by: Alessandro Joabar <sandro@squareup.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
Co-authored-by: Mic Neale <305999590+micspiral@users.noreply.github.com>
Co-authored-by: Alessandro Joabar <sandro@squareup.com>
2026-09-08 07:30:45 -07:00
Jordan Mecom
e09f715c9d Verify ACP relay events before prompt routing (#7010)
## What changed

Verify every Nostr EVENT received by buzz-acp immediately after
deserialization. Events with an invalid NIP-01 ID or Schnorr signature
are dropped before subscription routing, deduplication, replay-watermark
updates, membership handling, or either harness queue.

## Safety

Signature verification runs on the blocking pool so cryptographic work
does not block the relay task. A verification failure drops only that
event and keeps the connection available for subsequent valid traffic.
The existing observer-control verification remains as defense in depth.

Regression tests cover valid delivery; changed content, ID, signature,
pubkey, tags, and timestamp; a forged owner shutdown command with a
recomputed ID; forged membership notifications; and forged
observer-control events.

## Testing

cargo test -p buzz-acp

cargo clippy -p buzz-acp --all-targets -- -D warnings

just ci

---------

Signed-off-by: Jordan Mecom <jm@squareup.com>
2026-09-04 11:19:03 -07:00
Luke Tornquist
cd02b693aa refactor(relay): extract NIP-29 membership authorization (#7285)
## Why

NIP-29 membership authorization for kinds 9000, 9001, and 9022 was
embedded in a large async handler, requiring Postgres and Redis to test
and repeating the last-owner rule across five call sites. This addresses
[TheSentinel454/buzz#24](https://github.com/TheSentinel454/buzz/issues/24)
without adding the broader state/repository abstraction suggested there
because HTTP and WebSocket writes already share ingestion.

## What

- Extract pure, typed membership authorization decisions while leaving
database reads and mutations in `validate_admin_event`.
- Preserve existing client-visible errors and independent database
last-owner safeguards.
- Collapse five last-owner policy restatements into one predicate and
share the identical self-departure policy.
- Include the relay decision modules in `just test-unit`, so these tests
execute in CI.

## Risk Assessment

Medium-low. This touches production relay authorization, but
intentionally preserves wire behavior and database defense in depth;
exhaustive decision tables and relay-backed tests cover the affected
paths.

## Simplification

This removes repeated policy from the orchestration path and makes the
rule set directly testable without introducing a repository trait or
second transport path.

## Verification

Verified at `15255a090797f85874921120003c645962fefaed`:

- `just test-unit` — all 10 package summaries passed. An initial run hit
two unrelated timing-sensitive `buzz-acp` failures; the complete retry
passed 905/905 in that package.
- `cargo fmt --all -- --check`
- `cargo clippy --workspace --all-targets -- -D warnings`
- `just file-size-check` — 10/10 policy tests passed.
- `just security-review-check` — 13/13 tests passed.
- Push preflight — `push-head-scope`, `branch-skew`, `file-size-check`,
`rust-tests`, and `desktop-tauri-checks` passed.
- Blox Postgres relay lane — 84/84 at the byte-identical source patch;
full live `e2e_relay` behavior matched the clean base (45 pass and the
same pre-existing kind:9002 failure on each).

Generated with Claude Code

---------

Signed-off-by: tornquist <tornquist@squareup.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-04 10:46:55 -04:00
Will Pfleger
5aed49b505 feat(buzz-agent): add DatabricksAuthCoordinator single-flight OAuth (#5545)
## What

Consolidates all Databricks OAuth acquisition behind one coordinator on
`PkceOAuthTokenSource`. Every entry point — the four `TokenSource`
methods (`bearer`, `bearer_no_browser`, `refresh_now`,
`interactive_login`) plus the public `acquire_with_intent` — routes
through a single `acquire()`/`acquire_locked()` core that owns browser
and cooldown policy.

Before this, acquisition logic was scattered across those methods with
no coordination: concurrent callers (Desktop discovery, the saved-agent
model picker, managed-runtime inference) could each pop their own
browser, and a just-denied attempt would immediately re-prompt on the
next passive read.

## How

- **Intent policy.** `AuthIntent::{Auto, UserInitiated, Headless}`
decides whether a caller may open a browser and whether it honors the
cooldown. `Headless` never browses; `Auto` browses but honors an
unexpired cooldown; `UserInitiated` browses and bypasses+clears the
cooldown.
- **Two-layer single-flight per cache key.** An in-process registry
(`INFLIGHT`) coalesces same-key, same-intent callers onto one leader's
attempt before the file lock. The slot key is `(lock_path, AuthIntent)`,
so a `UserInitiated` sign-in never inherits an `Auto` leader's result. A
joined result is revalidated against the waiter's own contract. Across
processes, callers serialize on a `flock`-based advisory lock and share
success through the on-disk cache. RAII `Drop` releases both lock and
leader slot.
- **Joiner credential-state reconciliation.** `SlotPublish` carries the
full `CachedToken` on success. Each joiner reconciles its own
independent `state` cell under `state.lock().await` before returning:
adopt when absent, expired, or matching the joiner's rejected
credential; preserve any distinct newer usable credential. On a matching
shared failure, neutralize the joiner's in-memory rejected entry under
lock via `expire_rejected_memory` — durable disk mutation is reserved
for `acquire_locked` under the cross-process file lock. Without this, a
joining source's state remains stale or empty and subsequent plain
`bearer()` calls resurface the rejected or absent credential.
- **Validate-before-persist boundary.** `finish()` is the
candidate-token persistence boundary for refresh and browser results.
Before a token is written to cache or the cooldown is cleared, a bearer
equal to the caller's rejected bytes yields a typed failure.
- **Token neutralization.** When `acquire_locked` enters with `rejected
= Some(bytes)`, it calls `expire_rejected()` under the state lock before
any cache check.
- **Cross-process failure single-flight.** An `AttemptRecord` sidecar
records a monotonically-increasing generation, intent, result code, and
SHA-256 digest of the completing caller's rejected token. Adoption is
temporal (pre-queue snapshot predates current generation) and
digest-matched.
- **Typed outcomes.** `AuthError` with stable `code()`/`from_code()`
replaces display-text matching.
- **Durable cooldown sidecar.** Every failed browser attempt is recorded
next to the cache key. 5-minute expiry.
- **Windows disk persistence disabled.** On non-Unix platforms,
`persist()` is a no-op. Lock, cooldown, and attempt sidecars are active
on all platforms. Tests that seed or assert on the on-disk token cache
are `#[cfg(unix)]`-gated.
- **Injected browser opener** invoked while the localhost callback
listener is live.

## Tests

- `crates/buzz-agent/tests/databricks_auth_coordinator.rs`:
browser/cooldown/classification acceptance matrix with a scripted
`BrowserOpener` and stub OIDC provider. P1 regressions exercise the full
`finish()` → `acquire_locked()` → `acquire_leader()` →
`LeaderGuard::complete()` → joiner wiring:
- `test_inprocess_joiner_reconciles_stale_state_after_shared_success`
(Unix): two real sources both loaded locally-fresh-but-rejected X; after
shared success Y, subsequent plain `bearer()` on both returns Y, not X.
-
`test_inprocess_joiner_neutralizes_rejected_on_matching_shared_failure`
(Unix): B's matching rejected X is force-expired in memory after shared
`RefreshRejected`; subsequent read cannot return X.
- `test_inprocess_joiner_populates_empty_state_no_second_acquisition`
(non-Unix): empty A/B join a browser success; B's subsequent headless
read returns Y without a second browser (no disk fallback on non-Unix
exposes the regression).
- `test_crossprocess_userinitiated_waiter_adopts_predecessor_denial`
(snapshot-marker barrier replacing an earlier sleep for deterministic
generation ordering).
- `auth.rs` in-crate tests: lock-primitive edges, disk-recheck on shared
failure (`#[cfg(unix)]`), and:
- `test_joiner_reconciliation_blocked_until_state_lock_released`:
deterministic direct-poll proof that awaited reconciliation requires
`state.lock().await`. The test task holds B's state mutex and manually
polls a pinned real `acquire()` future — Poll 2 (slot published, mutex
still held) must return `Pending` because `lock().await` blocks; with
`try_lock` instead, Poll 2 returns `Ready`, failing the assertion.
- `test_joiner_preserve_distinct_newer_credential`: deterministic direct
polling parks B at `slot.wait()`, then writes Z directly into B's state
in the same task, then publishes Y and awaits completion. B must return
Y but leave state == Z. Mutation check: unconditional adoption
overwrites Z with Y, failing the state assertion.
- `test_joiner_shared_failure_recovers_disk_replacement` (Unix): the
matching-failure joiner enters the recovery branch — after
`expire_rejected_memory` (in-memory, empty state no-op) it reads a
sibling-written disk replacement via `usable_from_disk` and returns it.
Mutation check: removing the `usable_from_disk` recovery branch returns
`Err(RefreshRejected)`.
- `test_joiner_failure_does_not_write_disk` (Unix): byte-for-byte
disk-invariance regression — a matching-failure joiner calls
`expire_rejected_memory` and must not touch the on-disk cache. An
independent process C may write a valid replacement between A's failure
and B's reconciliation; this guard ensures B's unfenced in-memory
neutralization cannot overwrite C's concurrent disk write. Mutation
check: reverting to `expire_rejected` rewrites the file (`expires_at =
0`), changing the bytes and failing the assertion.
- `test_lock_timeout_leaves_cooldown_sidecar_byte_for_byte_untouched`: a
waiter past its deadline returns `LockTimeout` before entering
`acquire_locked`; the cooldown sidecar bytes are unchanged. Documents a
pre-lock limitation: `LockTimeout` callers do not neutralize state or
sidecars.

## Scope / follow-ups

- **Runtime 401 handling is deferred.** This PR owns acquisition
single-flight and policy.
- **Desktop wiring is Phase 2** (not in this PR's boundary). Confined to
`crates/buzz-agent/`.
- **Windows DACL** is a follow-up once the `windows-sys` binding is
available.

## Stack

Built on [#5534](https://github.com/block/buzz/pull/5534)
(`hayt/databricks-oauth-cache-hardening`), now merged. Retargeted to
`main`.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-09-01 15:59:21 -04:00
Logan Johnson
4a9de1a3a1 feat(desktop): add isolated named demo builds (#6407)
🤖 I’m Larry, updating this description on Logan’s behalf.

## Summary

Build named macOS demo apps without Finder automation or collisions with
installed Buzz. `just desktop-demo-build "PR 6407 Demo"` produces a
matching app and DMG, with a fresh build identity even when the same
display name is reused.

- The headless DMG packager uses `hdiutil`; optional Finder styling is
bounded. The existing production release recipe is unchanged.
- Each demo has independent app data, keychain, nest, CLI name,
voice-model storage, repository discovery, and agent OAuth/config
storage. Reset preserves production and sibling-demo state, and retains
retry intent when credential removal or root resolution fails.
- Native links accept only the active build’s registered scheme, then
translate validated entity links into the frontend’s canonical `buzz:`
format.
- The recipe builds all six executable sidecars. Display names are
capped at 31 ASCII characters so the generated identity fits Rust’s
build-time limit.

**Open delivery requirement:** downloaded demos must run without a
Gatekeeper security override. The current recipe is ad-hoc signed and
unnotarized; it does **not** satisfy this requirement. Trusted
branch-demo signing/distribution remains blocked on establishing an
approved signing path. This PR is not being presented as complete
download-and-run delivery.

### Related issue

N/A — reported in the Buzz DMG-packaging workstream.

### Testing

At `11ce21ff97cb387ad676e7caa65b00964097d0bb`, macOS Blox passed the
Tauri workspace suite and compiled-flags gate (including the full
named-demo state; each library pass: 2,992 passed, 19 ignored), Tauri
all-target clippy, the full `buzz-agent` package suite, and frontend
lint/typecheck plus 5,733 tests. Regression coverage includes
cold-start/running entity-link handling, wrong-build rejection, OAuth
deletion failure and retry, unresolved credential roots, and
production/sibling preservation.

At the same head, an extra full named-demo/mesh-enabled run had 3,092
passing tests and one failure: a pre-existing shared-compute `auto`
versus `mesh` expectation, also reproduced on the old published head
`a77b25eca`. The ordinary and demo-state matrix above passes; this is
not an all-features-green claim. Live macOS Launch Services delivery
remains unverified.

GitHub CI completed with 30 successful checks and 9 skipped. The
exact-range security review has not run; its authorization notice
remains open. CI success does not establish trusted signing or
downloaded-app launch.

Earlier demo artifacts established matching app/DMG names, side-by-side
launch, and six non-empty executable arm64 sidecars. These screenshots
show an earlier artifact, not a new build of the final repair commit.
Signature-integrity checks are not Gatekeeper/notarization evidence.

<img width="1032" height="548" alt="Buzz PR 6407 Demo disk image
containing the matching app"
src="https://github.com/user-attachments/assets/bca0277e-db03-4308-b280-fcad55e6d601"
/>

<img width="1186" height="821" alt="Buzz PR 6407 Demo running alongside
other Buzz installations"
src="https://github.com/user-attachments/assets/b4bf4ae5-c341-4e15-8090-9d2ea7c623b6"
/>

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
Co-authored-by: Other Brother Darryl <cee32d92756729ee0c097c5661b879c6199931cd25315c8cf398dcbf0f155cf1@buzz.block.builderlab.xyz>
Co-authored-by: Larry <loganj+sandbox-larry@squareup.com>
Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
2026-08-31 21:14:19 -04:00
Wes
93237b4a74 fix(acp): wake agents from workflow messages (#6953)
> Pinky, an AI agent, is opening this PR on Wes's behalf.

## Summary

Workflow-generated messages can contain a valid agent mention but still
fail the ACP inbound author gate because the relay signs the event. This
keeps the existing wake policy and gives ACP a narrowly verified
effective author:

- preserve the workflow owner's existing `p` tag and all
rendered-mention `p` tags
- add explicit `["buzz:workflow-owner", <owner hex>]` provenance to
relay-generated workflow messages
- add `["buzz:workflow-mention", <agent hex>]` authority only for
mentions resolved from the stored, unrendered workflow step template
- accept that owner only for a verified kind-9 event signed by the
relay's current NIP-11 `self` key, with unique canonical workflow
metadata and an explicit workflow mention for the receiving agent
- route the verified owner through the existing author and in-flight
mode policies in both normal and setup listeners
- refresh relay identity after reconnects, retaining the last verified
key on transient fetch errors while treating a successful response
without `self` as definitive removal

Malformed, duplicate, forged, tampered, wrong-kind, and wrong-relay
attribution all fail closed to the raw event signer. `respond-to=nobody`
remains absolute. Old/mixed-version messages without the explicit
provenance retain their current fail-closed behavior.

## Trust boundary

The workflow owner means **“scheduled by,” not “authored every rendered
word.”** Trigger-controlled substitutions may still produce ordinary `p`
mention routing for compatibility, but they cannot mint
`buzz:workflow-mention` authority. Only a target named in the durable
owner-authored step template can receive that authority.

The author gate is not bypassed: after relay signature/provenance
verification, the effective owner is evaluated under the same
`owner-only`, `allowlist`, DM, and `nobody` policies used for ordinary
messages. Owner control commands continue to use the raw event signer.

## Why this PR

This is the focused immediate fix for waking an **online** agent from a
stored workflow mention. Earlier attempts were not a finished mergeable
fix and had materially different or incomplete trust designs. Larry's
larger draft stack addresses durable delivery across restarts; that
remains valuable future work and can supersede this effective-author
path when it lands.

## Validation

At exact clean commit `fe5b55619fe44176343eefb4cb7fe180df45a7d8`:

- `buzz-relay workflow_sink`: 25/25 passed, including all four ignored
PostgreSQL cases
- `buzz-acp --lib`: 845/845 passed
- `buzz-workflow --lib`: 169/169 passed (2 unrelated PostgreSQL tests
ignored)
- warnings-denied Clippy passed for the changed Rust packages
- `cargo fmt --all -- --check` passed
- `git diff --check` passed
- repository pre-push gates passed, including branch-scoped Rust tests
- CI now selects the ACP library tests and the relay's pure + PostgreSQL
workflow-sink tests so these guards cannot silently remain unexecuted

The production event-to-author gate is shared by normal and setup
listeners and has biting regression tests for accepted explicit
attribution, legacy owner-`p` rejection, and forged-attribution
rejection.

## Exact-head local relay + ACP proof

Following the release-binary/local-relay shape in `TESTING.md`, the
exact commit above passed a fresh isolated real-process matrix using:

- a freshly recreated Postgres database with migrations
- isolated Redis
- exact-head release `buzz-relay`, `buzz`, `buzz-admin`, and `buzz-acp`
binaries
- newly provisioned owner, channel, and bot member through the CLI
- workflow creation and triggering through the running relay
- a deterministic ACP protocol subprocess capturing actual
`session/prompt` dispatches
- a NIP-11 `self` value verified against the running relay signer

Cases:

1. A stored explicit workflow mention woke an `owner-only` agent exactly
once.
2. A workflow message without an agent mention did not wake it.
3. A non-relay signer forging every workflow authority tag did not wake
it.
4. Trigger-controlled `{{trigger.text}}` containing `@Wake Agent`
retained ordinary `p` routing but received no authority-bearing
workflow-mention tag and did not wake the agent.
5. `respond-to=nobody` remained absolute for a valid relay-authenticated
workflow mention.

The deterministic ACP subprocess isolates and directly proves relay →
ACP authorization and prompt dispatch without depending on external
model behavior.

## Deployment and residual risk

Relay and ACP changes must be deployed together for the new wake
behavior; mixed versions fail closed. Production paired-deployment proof
remains distinct from the successful local integration run. Setup-mode
behavior has automated coverage but was not a separate case in the
five-case local matrix. Relay-key rotation is observed at ACP
startup/reconnect; transient NIP-11 errors retain the last verified key,
an intentional availability tradeoff documented in code.

---------

Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Co-authored-by: LioLionel <62820906+LioLionel@users.noreply.github.com>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Co-authored-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz>
2026-08-31 16:24:09 -04:00
Will Pfleger
17ecf0b8c9 fix(ci): salvage Codex review output on PTY-shutdown hang (#7042)
Codex CLI can leave a PTY descendant holding the action's inherited
stdio after the turn completes. The `runCodexExec.ts` wrapper waits on a
`close` event that never fires, so the `Review pull request` step hangs
until the job timeout kills it — discarding the finished review the CLI
already wrote to disk.

The CLI writes the completed review to the `--output-last-message` file
(exposed as `output-file`) **before** the hang. This PR adds a salvage
step that recovers it, and sets the step and job timeouts to preserve
the full 30-minute Codex execution budget.

**Changes (`codex-security-review.yml`):**

- Add `output-file: ${{ runner.temp }}/codex-review.json` to the `Review
pull request` step so the CLI writes the result before the hang.
(`runner` context is valid in `steps.with`; not in `jobs.env`.)
- Add `timeout-minutes: 30` and `continue-on-error: true` to the Codex
step — a hang now costs ≤30 minutes instead of 40, and the salvage step
still runs.
- Set job `timeout-minutes: 40` to give setup, step cancellation, and
salvage sufficient headroom without colliding with the Codex execution
budget. The original 30-minute job timeout was too narrow: evidence from
run
[33114428326](https://github.com/block/buzz/actions/runs/33114428326/job/98665369165)
shows completed output appearing 28m46s after step start, meaning a
20-minute step timeout could kill a legitimate review before the salvage
file exists.
- Add a `Salvage review output` step with `if: always()`: prefers
`steps.run_codex.outputs.final-message` on a clean exit; falls back to
the output file when the step timed out. The output file path is set in
the step's own `env` block (`CODEX_OUTPUT_FILE: ${{ runner.temp
}}/codex-review.json`), where `runner` is valid. Validates shape
(non-empty JSON object, has `overall_risk`); fails the job hard if
neither source is present.
- Wire the job `outputs.review_json` to
`steps.salvage.outputs.review_json`.

**Changes (`Justfile`, `ci.yml`):**

- Add `actionlint .github/workflows/codex-security-review.yml` to
`security-review-check` so expression-validity errors are caught
locally.
- Provision `actionlint` via Hermit (pinned v1.7.12) rather than a
one-off `Install actionlint` curl step, so the same binary is used
locally and in CI.

**Security posture is unchanged:** the salvage step reads the action's
own output and a file written to `runner.temp` — neither is
PR-controlled. Credential-stripping env block on the Codex step is
untouched.


Note this is a temporary workaround until
https://github.com/openai/codex-action/issues/169 is addressed

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-31 13:31:22 -04:00