Commit Graph

27 Commits

Author SHA1 Message Date
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
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
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
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
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
2ce8df8533 fix(models): curate Databricks alias-aware labels for 5 missing endpoints (#6360)
Curates Databricks model labels through prefix-stripping aliasing and
materializes exact records for five endpoints missing from the
Databricks catalog.

**Alias-aware label resolution** (Databricks v2)
- Strip workspace-specific prefixes (`goose-`, `team-x-`, etc.) before
exact-record lookup so any prefixed alias resolves to its canonical
label without enumerating every alias variant.
- Return `None` for uncurated ids instead of falling back to a raw
string; callers control the fallback display.

**New exact records — label-only (Anthropic Messages route, axes from
family rules)**
- `databricks-claude-fable-5` → "Claude Fable 5" (materialized from
`anthropic-adaptive-xhigh-fable-5`)
- `databricks-claude-opus-4-8` → "Claude Opus 4.8" (materialized from
`anthropic-adaptive-xhigh-opus-4-8`)
- `databricks-claude-opus-5` → "Claude Opus 5" (materialized from
`anthropic-adaptive-xhigh-opus-5`)
- `databricks-claude-sonnet-5` → "Claude Sonnet 5" (materialized from
`anthropic-adaptive-xhigh-sonnet-5`)

**New capability-bearing record — Kimi K3 (MLflow Chat route)**
- `databricks-kimi-k3` → "Kimi K3" with axes from models.dev Moonshot
catalog: reasoning toggle + effort `[low, high, max]`, no default;
`_reconciliation_doc` records the source and reconciliation policy.

**Corpus and tests**
- 6 new normative-corpus vectors (canonical + one `goose-*` alias per
new id); executable vector count updated to 113 in both Rust and TS
gates.
- Rust and TS label tests extended to cover all 3 new canonical ids and
their aliases via the prefix stripper.
- Sentinel variable fix in discovery-provider test to prevent false
failure in Databricks dev environments where `BUZZ_AGENT_PROVIDER` is
set.

---------

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-08-20 13:46:10 -04:00
Will Pfleger
1b7e5ac1be feat(model-capabilities): drive model capabilities and labels from one manifest (#5597)
## Summary

Centralizes model capability knowledge — thinking mode, supported effort
levels, wire routes, and human-readable labels — into a single manifest,
`scripts/model-capabilities.json`. Rust and TypeScript each get a small
interpreter that reads the same manifest, replacing hand-maintained
tables scattered across both languages that had already drifted apart. A
capability change is now a data edit, not parallel edits to two code
paths. Supersedes the codegen approach explored in #3603.

A cross-language contract keeps the two interpreters honest:
`scripts/normative-corpus.json` is a golden snapshot generated from the
Rust resolver (103 vectors covering all six capability axes) and
replayed natively in TS. CI fails if either language disagrees with the
corpus or the corpus drifts from the resolver. Regenerate with `just
regen-model-corpus`.

## Behavior changes

- **Effort dropdown for `openai-compat` providers** no longer offers
`max`. The request path always clamped `max` to `xhigh` on the wire, so
the UI stops offering a value that was silently rewritten. UI-only,
wire-identical.
- **Databricks v2 routing (wire-visible):** uncurated endpoint names
carrying a bare Claude code-name segment (e.g. `goose-opus-5`) now route
to the MLflow chat wire instead of Anthropic Messages — they lose
Anthropic prompt caching but still succeed on a valid OpenAI-compatible
wire. Curated `databricks-claude-*` records and any name starting with
`claude` are unchanged. A handful of other uncurated/adversarial name
shapes similarly fall back to MLflow chat instead of pattern-matched
routes; every curated model resolves identically to before, all axes.
- **Curated model labels on the real discovery path.** The Databricks
API returns no display name, so discovery emits the raw endpoint id as
the model `name` (`{id, name: id}`) on every path. `ModelEntry.name` is
now curated at all four construction seams in `buzz-agent` — v2
discovery, v1 parse, the auth-empty default catalog, and the
configured-model fallback — via a read-only `databricks_registry_label`
lookup over the manifest's `databricks_v2` exact records; `id` stays the
raw wire/config value. A known id renders its curated label
(`databricks-gpt-5-5` → `GPT-5.5`), an unknown id passes through
unchanged, and the default-catalog row reads `GPT-5.5 (default
catalog)`. As a defense against older `buzz-agent` binaries and any
harness that echoes ids, `resolveModelLabel` treats a discovered name
equal to the trimmed id as absent and falls through to the registry
tier; a genuinely distinct name (including the suffixed default-catalog
label) still wins.

## Cleanup

Deletes the duplicated capability tables and their tests: the
`config.rs` gpt5 matchers, effort tables, and clamp logic; the legacy
segment-based Databricks v2 route classifier in `llm.rs`; and the TS
hand tables plus `effortTable.fixture.json`. All are replaced by
manifest lookups through the shared resolver — no line of capability
data exists in two places.

---------

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-08-17 11:23:28 -04:00
Tyler
6530b58a61 feat(k8s): Kubernetes backend plugin + desktop deploy path (#4289)
# Kubernetes backend plugin (crates/buzz-backend-kubernetes) + desktop
deploy path

Implements docs/remote-agents.md (merged @ 28ae6cd21) as ONE PR: the
provider
binary, the desktop changes that make it work, the harness inactivity
reaper,
the Sprig image, and the conformance/live-test suites.

Channel: buzz-remote-agents (29414326-dba7-402d-b384-b1b34d63a2e6),
thread c42b70ef.

## What's here (by lane)
- **crates/buzz-backend-kubernetes** (Dawn): stdin/stdout JSON provider,
info +
  deploy; pure classify.rs (one match arm per spec state-machine row);
reconcile/GC with ownership-marker gate + same-clock orphan check;
per-attempt
immutable Secrets; three-tier env with clear-then-write authoritative
tier.
- **Desktop** (Mari): KD3 launch block from resolved descriptor, KD5
pre-secret
negotiation gate (resolve-once → stage-and-digest → info → protocol gate
→
deploy), KD1 Windows extension strip, bundling (externalBin + Justfile +
release/canary workflows + stub loops), tauri.windows.conf.json platform
  override (Decision B: no Windows artifact).
- **buzz-acp** (Max): KD4 BUZZ_ACP_EXIT_AFTER_INACTIVITY reaper
(pool-independent;
reset only at accepted dispatch; in-flight turn/heartbeat defers, never
resets);
BUZZ_ACP_EXIT_AFTER_INACTIVITY + BUZZ_ACP_NO_PRESENCE reserved. KD8 fix.
- **Image + tests** (Perci): Dockerfile.sprig (digest-pinned bases, exec
buzz-acp
PID 1, relay-scoped credential config), image contract script, provider
conformance suites (golden wire fixtures shared with desktop tests),
live-local
  runbook (namespace-scoped, shared-cluster safe).
- **Docs** (Sami, first commit): citation re-pin c1bca1b56 → 28ae6cd21
(44/49
were already byte-exact; 3 offsets fixed) + I3 presence-bound correction
(below).

## Named spec deviations (deliberate, each with rationale)
1. **No baked default image yet.** ghcr.io/block/buzz-sprig is
unpublished
(verified: anonymous pull 403 vs control 200). Omitted `image` returns
an
   in-band field-required error instead of a default.
2. **Image override STRICTER than spec §Image:** digest-only
(`name@sha256:<64hex>`); ALL tags rejected; `name:tag@digest`
normalized.
With no baked default the override is the only path, so tag-acceptance
would
make mutability the v1 norm. Strictness is reversible; a moved tag under
an
nsec is not. Baked digest default + tag re-acceptance = follow-up with
image
   publish.
3. **imagePullSecrets not in schema (v1).** Explicit user images may
rely on
namespace-preprovisioned pull credentials — the substrate boundary.
Field
added only if the publish decision proves it necessary. 9-field budget
intact.
4. **Decision A closed: writable empty workspace.** Nest projection =
named
   follow-up; no image-side scaffolding.
5. **Decision D overridden by Tyler (event b55398d8):** provider ships
bundled
with the desktop like buzz-acp/buzz-agent; spec §Distribution's separate
   release workflow deleted for v1.
6. **I3/vision presence bound corrected 90s → 180s.** PRESENCE_TTL_SECS
moved in
#3783 during this spec's base→merge window; the number was inherited,
not
chosen. Spec :206/:216/:928 + inline quote + VISION_REMOTE_AGENTS.md:59
corrected. ← Tyler: the vision is your document; this edit is flagged
for
   your explicit eyes.
7. **Spec citations are pinned to 28ae6cd21** (main at spec merge) and
resolve
   there, not at this PR's head — this PR's own lanes move
crates/buzz-acp/src/lib.rs by ~100 lines (19 citations across
KD4/KD6/KD7/
§Stop/§Launch data). Known Defects rows fixed BY this PR retire on
merge;
   the section documents main as of the pin.
8. **KD7 grace tension declared:** pod terminationGracePeriodSeconds=60
vs
KD7's measured ~87s shutdown tail at parallelism 10 (~197s at cap 32).
   KD7 is ruled out of scope, so L1-3's "enough grace for full graceful
shutdown" is NOT met at default config — deliberate, resolved by the KD7
   follow-up, not silently.

## Question for Tyler
Will ghcr.io/block/buzz-sprig publish PUBLIC? If private-by-policy,
§Image needs
an imagePullSecrets story before the baked-default follow-up can land.

## Out of scope (named follow-ups)
KD6 exit-code contract + KD7 shutdown budget (gate OnFailure), OnFailure
restart
policy, Windows provider binary, PVCs/nest projection, mesh
deployability,
sprig image publish workflow + baked multi-arch digest default.

## Reproduce locally (four traps that cost us real time)

**1. Git hooks inherit the invoking shell's PATH — pin the shell, not
just your
verification commands.** `rust-toolchain.toml` pins `1.95.0`, but the
rustup shim
that honors that pin lives in `~/.cargo/bin`. If Homebrew's cargo is
earlier on
PATH, `cargo` in this repo is 1.89.0, which cannot build the workspace
at all:

```
$ /opt/homebrew/bin/cargo check -p buzz-db
error: rustc 1.89.0 is not supported by the following packages:
  sqlx@0.9.0 requires rustc 1.94.0
  ...                                                    # exit 101
```

Verifying with `PATH="$HOME/.cargo/bin:$PATH" cargo test` does *not*
protect the
push: lefthook's `pre-push` → `just test-unit` re-resolves `cargo` from
the
shell's own PATH, so a green local run is followed by a hook failure on
a crate
you never touched. Export the PATH for the whole shell, not per-command.
This
bit twice.

**2. Line-scope your mutations, or the mutation edits its own
detector.** When
mutation-testing the respond-to guard, a whole-file `sed` on the mode
literal
touches 5 sites — the guard *and* the fixtures/assertions that test it.
The
mutation and its detector move together and the suite stays green, which
reads
as "this code is dead" when it actually means "you deleted the
experiment":

```
# WRONG — 5 sites, guard and tests mutate together
$ sed -i '' 's/"allowlist"/"allowlist-DISABLED"/g' src/env.rs
test result: ok. 145 passed; 0 failed          # false survivor

# RIGHT — 1 site, anchored to the guard's own definition line
$ sed -i '' '/^const RESPOND_TO_ALLOWLIST/s/"allowlist"/"allowlist-DISABLED"/' src/env.rs
failures:
    env::tests::allowlist_mode_with_an_empty_list_is_refused
    env::tests::an_allowlist_entry_that_is_not_64_hex_is_refused
test result: FAILED. 143 passed; 2 failed      # real kill
```

Restore by copying a pristine file back and confirming `git diff --stat`
is
empty, not by re-running an inverse `sed`.

**3. A completeness guard is not a correctness guard.** The shared wire
fixture
`tests/fixtures/provider-wire/deploy-full-launch.request.json` passed
every test
we had while containing four classes of invented data (wrong
`respond_to`
encoding, an env key no emitter writes, allowlist entries that fail the
harness's own 64-hex rule, a `launch.env` key from no descriptor layer).
The
provider's tests could not have caught this: its types are deliberately
indifferent to these values (`Option<String>`, `Vec<String>`, arbitrary
map), so
"the provider parses it" was never evidence that the desktop emits it.
The fix
was not a stronger provider assertion but a rule about provenance —
"recorded"
means executed-and-transcribed, and the desktop's whole-object equality
test is
the only enforcement that can exist. See the fixture README.

**4. Every drift this arc was a value that agreed with itself.** Five
invented
values were found, and not one was caught by an assertion failing — each
was
caught by someone asking where a value came from. A named constant
referenced
symbolically on both the fixture and assertion side. A `sed` that
mutated its
own detector. Six probe rows that all died at the same unrelated error.
A
descriptor struct literal compared against a fixture built from that
literal
(`launch.args: ["run","--session"]`, which the resolver actually returns
as
`["acp"]`). The general defense is not more assertions but provenance: a
stub is
a control that varies nothing, and the more faithful it looks the better
it
hides. Ask what executed, not what passed.

*Fixture-test determinism caveat (post-verification, Quinn + Dawn).* The
desktop's whole-object fixture test calls the real resolver, which
consults a
process-global harness registry whose own docs require
`registry_test_lock`
for any test touching it. The fixture test holds no lock and is
nonetheless
deterministic — but by containment, not by ordering. Measured, not
derived:
planting a definition with `id: "goose"` directly into the registry
(bypassing
the loader) changes the resolved descriptor from `args: ["acp"]` to
`args: ["--poisoned"]`, so `resolve_effective_harness_descriptor`
**does**
reach the registry for this id — it does not short-circuit on the
builtin
table first. Two controls discriminate: an empty registry and a registry
poisoned under a *different* id both return `["acp"]`. What actually
protects
the test is that the registry has exactly one writer
(`update_loaded_harness_registry`, reached only via
`warm_harness_registry_from_dir`) — but that writer concatenates **two**
sources of unequal strength (`custom_harnesses.rs:319-326`). Custom
files
pass through `load_custom_harnesses`, whose `check_id_collision` rejects
the
reserved builtin id `goose` case-insensitively at the loader — and that
leg
is tested (`load_applies_id_collision_check` writes a real `goose.json`
and
asserts the loader drops it). Preset definitions
(`preset_harness_definitions`, `presets.rs:177-193`) are a bare `.map`
over
`PRESET_HARNESSES` with **no collision check** — exhaustive call-site
enumeration at `60007fda4` finds four production `check_id_collision`
sites,
none on the preset path. That leg holds only because `goose` is not in
the
preset table today (intersection of TIER1 and preset ids is empty) —
executed, not just read: adding a preset with `id: "goose"`,
`args: ["--poisoned"]` and warming via the normal preset-only path
(`warm_harness_registry_from_dir(None)`, no custom dir, no direct
writer)
flips the fixture's emitted `launch.args` from `["acp"]` to
`["--poisoned"]`
at `60007fda4`, command/env/policy_env unchanged. So: no test in the
suite
can put a `goose` entry in the registry
via the custom path, and no preset currently carries one, so no
interleaving
can perturb this fixture — containment with one checked leg and one
coincidental one. A future fixture built on a **non-builtin** runtime id
has
no containment at all — it would be order-dependent against whatever
registry-writing test ran last and must take the lock.

*Late instance, found while reviewing the mode guard.* The guard
exact-matches
`respond_to` untrimmed and case-sensitively, which is only correct if
clap's
`ValueEnum` derive is case-sensitive. `config.rs` gives two answers: the
derive
at `:448-453` carries no `ignore_case`, while the crate's own tests call
`RespondTo::from_str(s, true)` — `ignore_case = true`. Reading the
source
supports either. Measured on the built binary instead: `owner-only`
starts,
`OWNER-ONLY` / `Owner-Only` / `ALLOWLIST` / `NOBODY` all exit rc=2
`invalid
value`. Case-sensitive at the CLI, so the guard is right — and right for
a
reason the source does not state. The `from_str(_, true)` tests exercise
a
different surface and are not evidence about the CLI.

*Corollary, and the sharper half.* When a test helper **reimplements**
production instead of calling it, the helper is a fork — and a fork can
be
right while production is wrong, or wrong in the same way, and the suite
reports green either way. Both `BUZZ_ACP_ALLOWED_*` gates are forked
like this:
production compares **strings** while the helpers compare **post-parse
enums**
(`config.rs:2623`) or re-derive the split
(`buzz-cli/.../channels.rs:1296`).
Production and the helper each carry their *own* copy of the empty-entry
filter
(`:1025` and `:1300`), so fixing one says nothing about the other.
Measured on
`buzz-cli`, restoring byte-exact between runs:

| tree | result |
|---|---|
| baseline | 274 passed |
| drop the empty-filter in **production** only (the real fix) | **274
passed** — no signal |
| drop it in the **test helper** only | **273 passed, 1 failed**
(`channels.rs:1338`) |

Two independent defects, stacked, and worse together than either alone:
production can be fixed with no test ever noticing, *and* the helper
cannot be
corrected without a false alarm demanding the bug back. The root cause
is one
bit of type information — `check_allowed_channel_add_policy(allowed_raw:
&str,
..)` cannot represent "unset", while production reads `env::var(..) ->
Result`,
where unset and `""` are different states. A helper whose parameter type
can't
represent all of production's input states isn't testing production's
states —
it's testing a subset it silently chose. Same family as the
struct-literal
descriptor and the fixture drift: the test and the thing it tests
agreeing with
each other, rather than the test measuring the thing. Neither defect is
in this
PR's diff (`git diff --name-only 28ae6cd21 <head> -- crates/buzz-cli` is
empty); both are now filed as NIP-34 issues on this repo: the fail-open
+
fork-helper defect at issue event `0524a4113f2d97fd…` and the respond-to
self-lock at `e32837498969b5e7…` (filed 2026-08-02 after Quinn measured
that
no prior filing existed — zero hits on GitHub `block/buzz` open *or*
closed
and zero on the relay's kind:1621 issues, against working positive
controls). The prescription was itself
mutation-tested before being written down: repairing the fork's
signature
(`Option<&str>` + assertion → `None`) still let the reintroduced
production
bug ship 274-green — an expressive fork is still a fork; it never
executes
production. So the `buzz-cli` fix has **three parts and one explicit
keep**:
drop the production filter; **delete** the helper and point its tests at
the
real `cmd_set_add_policy` (which self-discriminates by error variant —
`Usage` = refused, `Network(BadScheme)` = passed the gate — no relay
needed);
serialize the env-var tests behind one
**`tokio::sync::Mutex::const_new`**
lock taken with `.lock().await`, including the pre-existing `:1362`
integration test (the fork was silently buying test isolation — without
the
lock, parallel runs flake nondeterministically; a `std::sync::Mutex`
held
across `.await` trips `clippy::await_holding_lock` under `-D warnings`);
and
**keep** the then-dead `!allowed.is_empty()` clause with a comment
saying
why. It is unreachable-false (`split(',')` never yields an empty vec),
but it
is the only thing that keeps the reintroduced production bug detectable
—
mutation-tested: on a tree that deletes the clause, reintroducing the
empty-filter bug survives 275/0, because `""`/`","`/`" "` refuse either
way
and the filter goes semantically inert. Dead code can be load-bearing
for
tests: "provably unreachable" is an argument about behavior, never about
coverage. When a helper forks production, the fix has to delete the
fork:
any change that leaves two implementations standing can only ever be
verified against the one the tests call. *Final shape:* the keep and the
broad lock are both artifacts of the fork surviving in some form. The
extraction variant (Dawn, mutation-tested at `60007fda4`) removes the
tension: extract one `check_channel_add_policy_allowed(Option<&str>,
&str)`
that **production calls**, with the `Option` placed at the env boundary
where the `Result<String, VarError>` bit actually lives. 5/6 mutants
killed; the empty-filter survivor is proven **equivalent** (exhaustive
6174-pair check, 0 divergences, with a diverging negative control;
independently re-derived by a second generator — different tokens and
shape — 0 divergences on admitted policies, 500 on a non-admitted
control),
not a coverage hole — on a one-implementation tree there is no fork left
to
witness, so no dead clause needs keeping. One scope line on that
equivalence: it is **caller-conditional**, a property of the only
current
caller, not of the gate function — `cmd_set_add_policy`'s own match at
`:1027-1034` admits only three policies before the gate runs; a second
caller reaching the gate with arbitrary strings resurrects m1 as a real
hole. The lock does not disappear, it
narrows (Dawn's own correction, caught by Mari): lock exactly the tests
that mutate the process env — three-plus-one on a fork tree, two on the
extraction tree — behind one `tokio::sync::Mutex`, and the lock is part
of
the assertion, not hygiene: with it deleted, the gate test fails 8/8
runs
deterministically by receiving `Network(BadScheme)` where it expects
`Usage` — the unset test's `remove_var` clobbers the other's `set_var`,
and
**the gate test passes straight through the gate**, a false negative on
the
exact authz assertion the test exists to make. State it as an outcome:
these two tests must not observe each other's env writes. 276/0 stable
across 5 parallel runs, clippy `-D warnings` clean; independently
verified
(patch applied to a second worktree: result blob `d67e584be` matches the
patch index, full mutant matrix reproduces row for row). One new row no
earlier
prescription covered: collapsing unset into `Some("")` fails **closed**
—
an unconfigured deployment refuses every policy — killed by the unset
test.
Patch: `OUTBOX/BUZZ_CLI_ADD_POLICY_GATE_EXTRACT_FIX.patch`. The filed
issue
(`0524a411…`) carries the fork-shape prescription; whoever picks it up
should prefer the extraction shape, drop the dead-clause keep with it,
and
keep part 3 outcome-shaped: serialize whichever tests mutate the env.

## Verification (final HEAD `60007fda4`)
- Full touched-package suites at each integration merge (log in plan
file).
  At candidate parent `00e5b5fe9`: buzz-backend-kubernetes 154,
buzz-acp 673, desktop tauri 2100+3, pnpm 3908, workspace clippy/fmt/tsc
  all clean. The only delta to `60007fda4` is one character in
  `scripts/test-k8s-sprig-image-live.sh` (heredoc escape so the readlink
probe evaluates pod-side, not host-side at render); `crates/` tree hash
  is byte-identical at both SHAs, so the Rust receipts attach by tree
  identity. buzz-backend-kubernetes suite re-run in-shell at
  `HEAD == 60007fda4`: 154 passed.
- Adversarial one-HEAD gate (Sami): guard matrix 12/12, predicate
mutants
  7/7, doomed-invocation finding closed end-to-end; tree-hash carry to
  `60007fda4` confirmed (crates/buzz-backend-kubernetes blob unchanged).
- Live-local pass per TESTING.md + skill-buzz-testing (Perci, at
`60007fda4`): explicit `docker-desktop` context, digest-qualified image
  imported into node containerd `k8s.io` namespace, pull policy `Never`;
  pod printed `DIGEST_ABI_OK`, `resolved_spec` and `image_id` both the
  exact requested digest, script exit 0. Dedicated per-run namespace,
  ownership labels on every object, scoped cleanup verified empty after.
- Implementation review (Wren) at `60007fda4`: 9.6 minimalness /
  9.4 elegance / 9.3 correctness, no blocker.
- `origin/eva/k8s-backend` == `60007fda4` (ls-remote verified; SHA
  identity is byte identity).

---------

Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Signed-off-by: Tyler <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
2026-08-02 14:39:27 -04:00
Tyler
61ba9dfaa0 refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) (#3910)
Relands **#2467** (extract `buzz-voice` crate) and **#3208** (Pocket
voice settings) onto main, after #3266 and #3180 merged.

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

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

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

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

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

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
2026-07-31 09:41:09 -04:00
John Matthew Tennant
262f2392e3 fix(cli): resolve agents from owner records (#3178)
## Context

`buzz users get --name Honey` searches relay-wide profiles and can
return an identically named agent owned by someone else. This caused
agents from the wrong owner to be added to a channel.

## Summary

This bug fix scopes exact-name agent lookup to owner-authored
managed-agent records, then cryptographically verifies each returned
profile's NIP-OA `auth` tag before asserting ownership. The relay and
database contracts remain unchanged.

### Related issue

None found.

## Changes

- Adds `buzz users get --name Honey --owner me|<hex>|<npub>`.
- Resolves `me` to the NIP-OA owner when the CLI runs as an agent,
otherwise to the CLI identity.
- Matches kind `30177` managed-agent record names exactly and
case-insensitively under the requested owner.
- Requires exactly one valid NIP-OA `auth` tag whose verified owner
equals the requested owner and whose `kind` and `created_at` conditions
apply to the profile event before returning `owner_pubkey` or
`owned_by_me: true`.
- Keeps missing, malformed, stale, condition-mismatched, or unverifiable
owner-record candidates visible with `owned_by_me: false` and an
explicit `verification` value.
- Returns every same-name record for the owner so callers can require
explicit selection when duplicates remain.
- Preserves the existing output shape and client-side name filter for
unscoped searches.
- Documents the distinct owner-scoped managed-agent lookup and unscoped
NIP-50 lookup modes.

### Testing

The reviewer-reproducible red and green commands below exercise the
ownership bug against the target branch and this branch.

## Screenshots

Not applicable. This is a CLI-only change.

## Reviewer-reproducible examples

The lookups below were run against the live relay from `main` and this
branch.

### Red: unscoped lookup returns the 100-profile relay-wide cap and
excludes John's agents

On `main`:

```bash
cargo run -q -p buzz-cli -- users get --name Honey \
  | jq '{count: length, first_three: .[:3] | map(.pubkey), johns_agents: map(select(.pubkey == "31b29bcbe69d6716fbb7ba33602b89200bfc9ddfdabcfd1ea6fbfa70b816dfc7" or .pubkey == "4597ac725bba33fc7dd0454c1e2316a5ed770426acf667837d46f6553b3fcf54"))}'
```

Observed output:

```json
{
  "count": 100,
  "first_three": [
    "20d27fc6c0ab4f50b66d1a32a64c5ca1fb985254143ce911f61ab7733333c3d7",
    "00644478cdd9032c563ddc712b3687d8345d948945aab3c18bab95afbf6f519a",
    "93c16697d0e58007bc11fb953208bc6b1cff387b2dee094abc10bf82dfee5424"
  ],
  "johns_agents": []
}
```

`main` also rejects the owner-scoped command:

```bash
cargo run -q -p buzz-cli -- users get --name Honey --owner me
```

```text
error: unexpected argument '--owner' found
Usage: buzz users get --name <NAME>
```

### Green: owner-scoped lookup distinguishes verified and unresolved
records

On this branch:

```bash
cargo run -q -p buzz-cli -- users get --name Honey --owner me \
  | jq 'map({pubkey,display_name,owner_pubkey,owned_by_me,verification})'
```

Observed output:

```json
[
  {
    "pubkey": "0ca77314d7ac8b3fcf6c647cc8cb9c3afd840db3b2a8ff2079f09a168de1827e",
    "display_name": null,
    "owner_pubkey": null,
    "owned_by_me": false,
    "verification": "missing_profile"
  },
  {
    "pubkey": "31b29bcbe69d6716fbb7ba33602b89200bfc9ddfdabcfd1ea6fbfa70b816dfc7",
    "display_name": "Honey",
    "owner_pubkey": "67252b09c31a995daa63aada26569fbc6a3d12f573113f001ce7432f870da820",
    "owned_by_me": true,
    "verification": "verified"
  },
  {
    "pubkey": "4597ac725bba33fc7dd0454c1e2316a5ed770426acf667837d46f6553b3fcf54",
    "display_name": "Honey",
    "owner_pubkey": "67252b09c31a995daa63aada26569fbc6a3d12f573113f001ce7432f870da820",
    "owned_by_me": true,
    "verification": "verified"
  }
]
```

Only the two profiles with valid NIP-OA proofs assert ownership. The
owner-authored record whose profile is absent remains visible but cannot
be selected as verified ownership.

---------

Signed-off-by: npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k <0133a1e70fbad088cc2722f41f848441f044a7a0d6b3b5c6dbb3e9927f16b0ae@buzz.block.builderlab.xyz>
Co-authored-by: npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k <0133a1e70fbad088cc2722f41f848441f044a7a0d6b3b5c6dbb3e9927f16b0ae@buzz.block.builderlab.xyz>
2026-07-30 09:28:10 -04:00
Tyler
5cfd69cb0c Strip media metadata on clients and reject it at the relay (#2006)
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Atish Patel <atish@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
2026-07-17 13:39:10 -04:00
Tyler
1c006822e4 feat(push): add public APNs gateway (#1770)
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
2026-07-14 11:17:21 -04:00
Tyler
14fba21e57 Multi-tenant Buzz relay: community_id as a server-resolved key (comprehensive rewrite) (#1321)
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Quinn <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: Sami <sami@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
2026-06-29 12:39:02 -04:00
Will Pfleger
d99ad131f1 refactor: rename sprout backend to buzz (#958)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Will Pfleger <wpfleger@squareup.com>
Signed-off-by: Will Pfleger <wpfleger96@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw <4a1bfa0013bc6d14a8d600d8bf6392efefbd2a26ac3c96c9b2a106b0d12297ca@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub16v54tttfqacx9ycvc3k0ut0npj564ahcuajzy6qjvh57ntmsf4uq4806j2 <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Will Pfleger <wpfleger96@gmail.com>
2026-06-10 19:29:51 -04:00
Will Pfleger
2a03851527 refactor: consolidate infra management into justfile + add mobile-dev (#797) 2026-05-29 18:26:24 -04:00
Alec Thomas
6093fc6066 feat: migrate from MySQL to Postgres with pgschema (#114)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Tyler Longwell <tlongwell@squareup.com>
2026-03-19 12:35:22 -04:00
Wes
6e51cc0b12 Add desktop Playwright e2e harness (#11) 2026-03-09 19:16:43 -07:00
tlongwell-block
058c4b92a9 Initial release — Sprout Nostr relay with enterprise extensions (#2) 2026-03-09 15:15:25 -04:00