Commit Graph

153 Commits

Author SHA1 Message Date
Logan Johnson
f148c0f22c feat(relay): add profile search over name and display_name only (#8301)
🤖
> **Review as a pair with block/buzz-app#1084** (the app change that
uses this endpoint). Either can merge first: the app falls back to the
old search on relays without this endpoint.

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

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

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

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

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

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

### Related issue
None found.

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

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-15 17:35:33 -04:00
Tom Brow
6766174673 chore(push): retire push notification database trigger (#8352)
Buzz must schedule mobile push notifications when it saves an eligible
message. The relay, the server that receives and stores messages, now
does this in application code. An older database trigger still exists.
This trigger is a rule that runs automatically when a message is saved.

This PR removes the old trigger and its supporting database function.
The relay remains responsible for scheduling notifications. Migration
0063 makes this change in existing databases; updated setup scripts
apply it to new databases.

The change does not turn on push notifications. Production migration and
deployment need separate approval. Before deployment, verify the release
images and all pending database migrations. If push needs to be stopped
after release, disable it with the feature flag and keep the qualified
application version and database schema. Do not downgrade the
application image.

### Validation

- Local checks passed: 519 isolated database integration tests, 166
database unit tests, 17 source-policy tests, and the relay push-runtime
tests.
- Tests verify that the migration removes the old trigger, preserves
earlier migration records, and lets the application schedule push work
correctly. A concurrency test exercises the real application path;
deliberately removing its lock makes the test fail.
- [Current-head CI passed on
retry](https://github.com/block/buzz/actions/runs/38752849383/attempts/2).
The first attempt had one database-connection timeout during test setup;
the failed jobs passed when rerun on the unchanged commit.
- Initial local CI and push checks hit unrelated ACP test failures,
which passed when run separately. Only the Rust-test push hook was
skipped under the repository's local-check exception; other applicable
hooks passed.

### Before merge

- [x] Migration 0063 is available and the push trigger remains on [main
at
5397ea4](5397ea49e2).
Recheck if main advances before merge.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-15 08:37:55 -07:00
Logan Johnson
cdf03f618a feat(relay): leave system channels out of reads that do not name them (#8181)
🤖
## Summary
After part 1, a system channel is still returned by every query that
returns ordinary channels, so existing clients would show it as a chat.
This PR makes the relay leave system channels out of every read that
does not name them. Clients do not need to filter, so clients that know
nothing about system channels stay correct.

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

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

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

Part 2 of 3, stacked on part 1.

### Related issue
None found.

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

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

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

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

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

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

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

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-15 08:40:15 -04:00
Wes
7b11c6147d test: fix recurring CI flakes in desktop and Postgres lanes (#8343)
Fixes the CI flakes that failed most often across `main` and unrelated
branches from Oct 5 to Oct 14. The counts come from 150 failed `CI`
runs. Every change except the nextest config is test-only.

| Flake | Runs hit | Root cause | Fix |
|---|---|---|---|
| `probe_codex_acp_version_…descendant_holds_pipe_open` | 5 | The test
writes its script in-process and then execs it. If a sibling test spawns
a process in that window, the child inherits the open write fd and Linux
rejects the exec with `ETXTBSY`. The probe swallows that error as
`None`. | Write probe scripts from a short-lived `/bin/sh`
(`write_executable_script`) |
| `empty-edit-delete.spec.ts` (`:51`, `:74`, `:95`) | 11 | The seeded
`mock-general-welcome` id isn't hex, so `formatTimelineMessages` can't
overlay its edit. The post-subscribe window refetch then reverts the
row. | Emit a per-test message with a real id after the exact channel
subscription is live |
| `huddle-transcription.spec.ts:798` | 9 | The composer's delayed
initial autofocus (TipTap `focus()` runs in rAF) lands after the voice
popover opens and dismisses it. | Wait for `message-input` focus before
opening the popover |
| `voice-note.spec.ts:906` | 6 | The fixture `ping.mp3` is about 1s
long, so playback can end before `paused === false` is observed. | Loop
the fixture audio |
| `workflows.spec.ts:291` | 5 | The `Message text` fill runs before the
inspector swaps to the new step, so it fills the trigger's condition
instead. | Scope the fill to the step inspector after its `Action` menu
appears |
| `cluster_global_probe_commits_tokens_and_sessions_prove_coverage` | 3
| The `max-threads = 1` group only serializes these tests with each
other. Other tests' sessions still mask cluster-wide `pg_stat_activity`
(`MaskedActivity`). | `threads-required = "num-test-threads"` |
| `test_usage_metrics_lock_has_single_owner_and_releases_on_drop` | 1
(this PR) | The test retries the lock right after dropping the leader.
Postgres releases a session advisory lock only after the backend notices
the disconnect, so the retry can run first. | Poll for the successor,
bounded at 5s |

### Validation
- **ETXTBSY:** a Linux experiment with Rust 1.95 using the same
write→chmod→spawn pattern under concurrent spawns hit `ETXTBSY` 4–7% of
the time. Using the `sh` writer, it hit 0 of 6,000. I did not catch it
inside the full suite locally, so this fix is based on the mechanism,
not an in-suite repro. All 11 probe tests pass on Linux and macOS.
- **empty-edit-delete:** the baseline failed 16 of 45 runs
(`--repeat-each`, 6 workers, no retries). The spec fix alone brings that
to 4/45. The remaining failures come from a separate product race,
described below.
- **huddle:** the baseline failed 5 of 10 runs. With the fix, 60 of 60
passed without retries.
- **nextest:** checked with a throwaway crate on 0.9.136. A grouped test
with `num-test-threads` ran alone, even with `max-threads = 1`.
- I have not stress-run the voice-note and workflows fixes; they rely on
CI.

### Not in this PR
- **Product bug in `useChannelLinks`:** the debounced `#channel`
autocomplete close lets Enter accept a stale suggestion for about 120ms.
That accounts for the remaining `empty-edit-delete` failures, and it
affects users too. With the spec fix plus a synchronous close, the spec
passed 120 of 120 runs. This needs a product decision, so it's held for
a separate PR.
- **Composer autofocus race:** the delayed autofocus can close an
overlay the user just opened. The real fix is to revalidate when the
delayed focus runs. Overlaps #6530.
- **`thread-head-stale-edit.spec.ts:55`:** a forced click on a
pointer-events-none action bar is suspected, but I couldn't reproduce
it.
- **Remaining ETXTBSY exposure:** about 40 other Rust tests write and
then exec scripts, and 17 specs still edit the non-hex seed message.

Root-cause lanes by Princess Donut, Mongo and Mordecai. Carl integrated
and verified them.

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

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <5d97ac8c272fa949af56c71e586146b1706e5c4c3daa125db5b4625e12e0686c@buzz.block.builderlab.xyz>
Co-authored-by: Mongo <81cabd2ca1792494372ed410283c00770c0cf35afca052e940fa522883212b45@buzz.block.builderlab.xyz>
Co-authored-by: Mordecai <f314b6033b6e13df94c8e9e1187d3666b6b9ce54e66ee986e31cdaa909e8dcbc@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:32:00 -06:00
Bradley Axen
8b60dcf8e3 feat(relay): deliver full signed events to operator listeners (#8309)
## Summary

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

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

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

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

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

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

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

## Testing

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

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

---------

Signed-off-by: Bradley Axen <baxen@squareup.com>
Co-authored-by: murderbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:53:25 -07:00
Will Pfleger
e645918c63 fix(relay): match pushed-down tags by position, not containment (#8335)
🤖 The relay pushes several tag filters into SQL as JSONB containment
(`tags @> [[name, value]]`). Containment ignores element position, so a
tag like `["x","e",<id>]` or `["meta","t",v]` satisfies `#e:[<id>]` or
`#t:[v]`, and `#t:["t"]` matches `["t","other"]`. Because the predicate
runs before `LIMIT`, a newer event that only matches by containment can
take a `limit: 1` slot; the exact NIP-01 post-filter then drops it and
the real match is never returned. Where no post-filter follows, the
decoy is returned or counted outright.

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

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

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

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

---------

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

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

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

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

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

### Related issue
None found.

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

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

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

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

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

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

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

---------

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

Part 3 of 3, stacked on part 2.

### Related issue
None found.

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

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

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

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

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

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

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

---------

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

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

Part 2 of 3, stacked on part 1.

### Related issue
None found.

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

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

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

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

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

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

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

---------

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

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

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

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

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

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-14 17:18:38 -04:00
Logan Johnson
ea24a3ee3d fix(ci): remove Detect Changed Paths timeout and deletion test race (#8300)
🤖
## Summary
This PR removes two CI flakes that failed relay-stack runs on
2026-10-13. Both passed when rerun.

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

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

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

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

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

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

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

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

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

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

### Related issue

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

### Testing

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

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

---------

Signed-off-by: Ian Oberst <ioberst@block.xyz>
Co-authored-by: Ian Oberst <ioberst@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:36:26 -07:00
Luke Tornquist
d25384a4f3 buzz-db: admit every fenced-table writer in app code (#8219)
## Summary

This makes every serving writer of a community-write-fenced table take
community admission in application code. That's the "Dual enforcement"
step of the Supported Writer Contract in `ARCHITECTURE.md`. Admission
means the shared community deletion lock plus the active/not-deleted
check from `deletion::guard_community_write`. It's taken before the
transaction's first tenant write or row lock and held until commit.

The 32 `community_write_fence_*` triggers, the six fence SQL functions
and `validate_serving_catalog` are unchanged. They stay authoritative.
Removing them is a separate migration that will be checked table by
table.

## Changes

- **New admission entry point.** `begin_community_write_transaction`
(`runtime/mod.rs`) builds an `AdmittedTx` for non-event writers. It sits
next to the existing event-write entry points and shares their
implementation.
- **Writers moved onto it:**
- `api_tokens`, `archived_identities`, `channels`, `channel_members`
(including DM and admin paths), `community_bans`, `git_repo_names`
- `moderation_actions` and `moderation_reports`,
`personal_read_accounts` and `personal_read_frontiers`,
`pubkey_allowlist`
  - `push_leases`, `push_match_queue`, `push_wake_outbox`
- `relay_invites`, `relay_members` (including the new-community owner
bootstrap and the startup `pubkey_allowlist` backfill),
`join_policy_acceptances`
- `thread_metadata`, `users`, `workflows`, `workflow_runs`,
`workflow_approvals`, `scheduled_workflow_fires`
  - Personal-read helpers now require `&mut AdmittedTx`.
- **`buzz-audit`.** An append now passes admission first, then takes a
transaction-scoped audit-chain lock (`pg_advisory_xact_lock`). This
replaces the session lock and the `catch_unwind` unlock cleanup. The
relay audit worker retries a `lock_timeout` on either lock and keeps the
entry; an admission refusal is terminal, as the trigger's refusal was.
- **Fleet-wide statements.** These keep `community_write_allowed(...)`
in the same SQL statement, because there's no single community to admit
up front:
  - scheduled-fire prune
  - invite expiry
  - push matcher claim and exhausted-job cleanup
  - startup `d_tag` backfill
  - channel TTL cleanup
- **Relay-member removal.** `remove_relay_member` and
`remove_relay_member_if_role` run `delete_member_with_acceptances`
inside their admitted transaction. The helper no longer opens its own
transaction, so the member lock, the `join_policy_acceptances` delete
and the member delete all hold admission until commit. `delete_workflow`
and `delete_workflow_for_owner` are gone on `main`, and the coordinate
deletion path they used to back was already admitted.
- **Unchanged exceptions.** The deletion-executor generation exception
and the serving-lease path work as before.
- **Tables with no production writer.** `delivery_log` and
`subscriptions` are only written by a historical cutover script and a
test fixture. Their triggers stay.
- **No cross-community moves.** No production `community_id` move exists
on a fenced table. The only `SET community_id = NULL` is the
deletion-executor purge. So no dual-community lock ordering was needed.

## What got simpler

- Per-store ad-hoc writer helpers are gone:
`begin_event_write_transaction` and `acquire_event_write_connection` in
`channel.rs`, and `begin_operation_transaction` in `push.rs`.
- Audit append drops the session advisory unlock, the
`catch_unwind`/`resume_unwind` panic handling, `log_inner` and the
`futures-util` dependency.

## Notes for reviewers

- **New crate dependency.** `buzz-audit` now depends on `buzz-db` only
to call the free function `buzz_db::deletion::guard_community_write`.
`ARCHITECTURE.md` names this as the one sanctioned subsystem edge, and
its audit section is updated to match.
- **Hot paths.** `ensure_user` first checks whether the user exists and
returns without admission when it does (no write happens). Only a real
insert takes admission. `touch_api_token` has no production caller
today.
- **No-op writers stay read-only.** The startup allowlist backfill
returns early on plain reads when the legacy table is gone, it has no
rows for the community, or the community already has members, so a
quiescing deployment community still starts. It re-checks under
admission before inserting.
- **Owner mutations.** When admission refuses a transfer or a
provisioning and a deletion request exists, both report
`DeletionPending`, as before. For a transfer, the operator API keeps its
coded `deletion_lifecycle_conflict` 409. For provisioning, it keeps the
existing uncoded 409, `owner_conflict: community deletion is pending`.
- **Per-community worker loops.** `claim_due_push_wakes` and similar now
return `AccessDenied` for a community that goes quiescing between the
active-host scan and the claim. The worker logs it at `debug` and the
next scan skips the community.
- **Kick side effects.** `membership_removal_fence` admits before the
membership lock to keep the lock order of `add_member`. A community that
leaves `active` between the kick and the fence skips live subscription
eviction; the membership cache is already invalidated and fan-out
re-checks membership. This is documented on the function.

## Testing

- **New refusal tests.** 19 tests: the 18 in
`store::admission_postgres_tests`, plus
`audit_append_requires_application_admission_with_triggers_bypassed`.
Each runs with ordinary triggers disabled (`session_replication_role =
replica`), checks that a raw fenced insert would succeed, and asserts
that the writer is refused for both `quiescing` and `fenced`
communities. Active writes still succeed.
- **The tests catch a missing check.** Measured in review at
`6fc073c3f`: with the guard in `AdmittedTx::admit` bypassed, 25 tests
fail. That includes 16 of the 18 database admission tests. The audit
test stays green there, because audit calls `guard_community_write`
directly.
- **Affected PG suites, with triggers on:** 183/183 passed across 16
store suites, push 18/18 run isolated, audit 7/7. The existing artifact,
reaction and replaceable admission tests pass.
- **Review fixes, each checked to fail without its fix:**
- `allowlist_backfill_requires_application_admission` and
`owner_mutations_report_pending_deletion_when_admission_refuses` fail
with the old `relay_members.rs`.
- `empty_allowlist_backfill_skips_admission` fails (`AccessDenied …
write-fenced (quiescing)`) without the allowlist-rows early return.
-
`audit_worker_retries_admission_lock_timeout_until_original_entry_is_appended_once`
holds the community deletion lock exclusive and fails without the
admission retry arm.
- The existing audit-chain retry test now looks for the
transaction-scoped lock and passes.
- The two owner-revoke mini-schema tests (`bridge.rs`, `ingest.rs`) now
include `communities` and `community_deletion_lock_key`, which admission
reads; both failed without that.
- **Static checks:** `cargo fmt --check` and workspace `cargo clippy
--all-targets -D warnings` pass.
- **Not run locally:** the `mobile-check` step of `just ci` (the build
machine couldn't reach pub.dev, and clean base fails the same way) and
the full `just test` lane. CI on this PR covers both.
`mesh_demo::demo_join_forwarded_arm_round_trips_echo` fails on the build
machine at base `394d4a434` as well; it uses no database.

Refs BUZZ-314.

---------

Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-10-14 10:58:12 -04:00
Logan Johnson
8bb49a1b6a feat(relay): publish channel type and creator on every channel-state event (#8174)
🤖
## Summary
Clients cannot tell who created a channel, and only the channel-metadata
event says what type of channel it is. This PR makes every relay-signed
channel-state event carry both facts, so a client can find "the channel
that this key created" with one ordinary Nostr filter.

- Channel metadata, admin list and member list events (kinds 39000,
39001 and 39002) now all carry the channel type as their first `t` tag,
then the creator as a `P` tag.
- The creator comes from the relay's own record of who signed the
channel's create request. No client can set it, and it does not change
when ownership moves.
- Channel metadata (kind:39000) also carries `["created_at", <unix
seconds>]`, the time the channel was created. The event's own
`created_at` changes on every edit, so before this a client could not
tell which of two channels is older.

This is part 1 of a 3-part stack (channel labels and creator lookup).
The system channels stack (#8180–#8182) builds on it.

### Related issue
None found.

### Details
- **Why `P`.** Several Nostr specs (NIP-22, NIP-34, NIP-72) use
uppercase `P` for the author of the root object, and lowercase `p` for
people involved. Lowercase `p` is already used on these events for
members and DM participants, and a filter matches tag values, not their
meaning, so `#p` would mix them.
- **Trust.** A client that trusts a channel because of its creator must
also check that the relay key signed the event.
- **Order.** The type `t` tag comes first, so clients that read the
first `t` tag still get the type after part 2 adds labels as more `t`
tags.
- **Creation time.** The relay reads it from `channels.created_at`, so
an edit does not change it. #8176 uses it to pick one channel when two
creates race.
- `buzz-admin reconcile` writes the same tags when it rebuilds these
events. `NOSTR.md` documents the tags.
- **Spec.** New `docs/nips/NIP-CL.md` defines these tags, the trust rule
and lookup by creator. Later PRs in both stacks add their parts to it.
`NOSTR.md` links to it.

### Testing
- Relay tests in `group_state_postgres_tests.rs`: each of the three
kinds carries the type first, then the creator; the creator stays the
same after an ownership transfer; kind:39000 carries the stored creation
time, and still does after an edit.
- Unit test for the shared tag builder that both the relay and
`buzz-admin reconcile` use.

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

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

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

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

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

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-14 09:22:42 -04:00
Luke Tornquist
52d13efc08 fix(buzz-db): delete workflow and membership children explicitly (#8212)
## What

Workflow deletion and relay-member removal relied on `ON DELETE CASCADE`
to remove child rows. Both paths now delete their own children inside
the same transaction, so a later migration can drop those foreign-key
actions without changing behavior.

**Workflow deletion** (`store/workflow/deletion.rs`, the coordinate path
used by NIP-09 deletes of workflow definitions):
- Locks the target workflow row with `SELECT … FOR UPDATE`, under the
same owner and coordinate predicate as before. A concurrent run or fire
insert has to lock that row for its foreign key, so the child deletes
see every committed child.
- Deletes scheduled fires first. A new fire has to lock the workflow
row, and a cron attach locks the fire before the run, so deleting fires
before the run lock keeps the lock order consistent and avoids a
deadlock.
- Then locks the workflow's runs `FOR UPDATE` in `id` order. An approval
that names another workflow but references one of these runs key-share
locks only the run, so the workflow lock alone doesn't exclude it.
- `delete_workflow_with_children` then deletes approvals (for the
workflow or any of its runs), then runs, then the workflow, and returns
its `channel_id`.

**Relay-member removal** (`store/relay_members.rs`):
`remove_relay_member` and `remove_relay_member_if_role` share a new
`delete_member_with_acceptances` helper. It locks the member row under
the existing predicate (any non-owner, or an exact role), deletes
`join_policy_acceptances`, then deletes the member, in one transaction.
The `RemoveResult` outcomes are unchanged.

**Removed:** `delete_workflow` (no callers) and
`delete_workflow_for_owner` (test-only), plus their `Db` wrappers. The
community-confinement test now drives the production coordinate
deletion.

## Tests

- `test_support::retire_foreign_key_delete_actions` rewrites every
`CASCADE`, `SET NULL` and `SET DEFAULT` foreign key in the test's own
per-test database as `NO ACTION`, which is the target schema of the
follow-up migration. It asserts that none remain. All triggers stay
live, including foreign-key checks and the community write fence. It
refuses to run unless `current_database()` has the per-test wrapper's
exact shape (`buzz_nt_` plus 24 hex digits), which excludes the run's
`_desired` template. It checks the name only.
- `workflow_deletion_removes_children_without_cascades` and
`member_removal_deletes_acceptances_without_cascades` run the production
paths on that schema and require every child row to be gone. One
approval references another workflow but this workflow's run, which pins
the `run_id` branch. The owner, role-mismatch and not-found outcomes
still hold.
- `workflow_deletion_waits_for_and_removes_concurrent_run` and
`member_removal_waits_for_and_removes_concurrent_acceptance` hold a
child insert open in a second transaction, wait until the removal is
blocked on a lock, and then commit. The removal must succeed with no
children left.
- `workflow_deletion_waits_for_concurrent_fire_attach_without_deadlock`
holds a fire row the way the cron attach does, then attaches a run to it
while the deletion waits. Both must succeed.
-
`workflow_deletion_waits_for_and_removes_concurrent_cross_workflow_approval`
does the same with an approval for another workflow that references this
workflow's run.
- The bridge test schema now includes `join_policy_acceptances`.

## What got simpler

Two delete paths no longer depend on implicit constraint actions, and
two unused workflow-delete functions are gone. This is the first step of
retiring the `CASCADE` and `SET NULL` foreign keys.

**Identified debt (later PRs):** `channel_members` and
`personal_read_frontiers` (no hard-delete path found yet for their
parents), the deletion `manifest_keys` cascade, the `SET NULL` actions
on `executor_heartbeats` and `product_feedback`, and the `users`
self-reference. That last one is a composite `(community_id,
agent_owner_pubkey)` `ON DELETE SET NULL` that would try to null the
`NOT NULL` `community_id`. Also, a scheduled fire for workflow B can
link to a run of A (that FK is already `NO ACTION`), which would block
A's run delete; the scheduler always attaches a run to its own
workflow's fire, so this needs same-workflow enforcement in the cascade
work.

## Verification

Head `081be4c0d`, tree `ab649487`, on a Linux cloud workstation: fmt,
buzz-db and workspace clippy, source policy and buzz-db lib pass; the
six new tests pass; buzz-db PostgreSQL passes 490/490 and buzz-relay
PostgreSQL 378/378. A sixth mutation, moving the fire delete back after
the run lock, makes
`workflow_deletion_waits_for_concurrent_fire_attach_without_deadlock`
fail with `40P01` (deadlock detected).

Previous head:

Head `787c7c42d`, tree `6b9cf5d5`, on a Linux cloud workstation: fmt,
buzz-db and workspace clippy, source policy and buzz-db lib pass; the
new tests pass 5/5; buzz-relay PostgreSQL passes 378/378; buzz-db
PostgreSQL passes 488/489, the one failure being
`parent_contention_times_out_without_blocking_the_other_table` in
`store/partition/`, which this PR doesn't touch and which passes on its
own at the same tree. A fifth mutation, removing the run lock, makes the
new cross-workflow approval test fail with the reviewer's exact 23503 on
`workflow_approvals_community_id_run_id_fkey`.

Previous head `00cba2f4a`, tree `2f89fed4`, base `e9269cbdf`. Results
from a Linux cloud workstation:
- fmt, buzz-db and workspace clippy, the source policy and buzz-db lib
all pass.
- The buzz-db PostgreSQL suite passes 488/488.
- The buzz-relay PostgreSQL suite passes 378/378.

Each of four mutations makes exactly one of the new tests fail with the
foreign-key violation you'd expect (23503):
- swapping the fires and runs deletes
- deleting approvals by `workflow_id` only
- removing the workflow row lock
- removing the member row lock

GitHub CI is green at this head.






buzz-review-completed

---------

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

## Why

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

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

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

## What

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

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

## How it was tested

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

## Related

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

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

---------

Signed-off-by: Ian Oberst <ioberst@block.xyz>
Co-authored-by: Ian Oberst <ioberst@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:57:54 -07:00
Will Pfleger
40c1c474e7 fix(relay): hide deletions of author-only events from other readers (#8197)
🤖 Opened by an agent (Hayt) for Will Pfleger.

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

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

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

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

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

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

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-13 14:45:59 -04:00
Bradley Axen
1d9f2c81a5 feat(relay): accept an owner's presence for its agents (kind 20003) (#8261)
## Summary

App-hosted agents (buzz-app agents2) run inside the desktop app without
a socket per agent, so they can't send their own kind 20001 presence and
always read as offline — including to their owner on another device.

This adds **kind 20003, an owner's presence for its agents**:

- Owner-signed; `content` is the status (`online` / `away` / `offline`);
each `p` tag names an agent (1–256 distinct).
- WebSocket only (rejected on HTTP ingest, like 20001).
- The relay keeps only the agents the signer owns
(`users.agent_owner_pubkey`) — cached `true` from
`observer_owner_cache`, the rest resolved with one
`list_agents_for_owner` query. Only positive links are cached, so a
just-linked agent isn't hidden for the cache TTL and the observer path
isn't poisoned.
- Sets (180s TTL) or clears each agent's presence in one Redis round
trip, the same keys kind 20001 writes, so the presence snapshot reports
them with no client change.
- Not fanned out: live presence is matched by author, and the author
here is the owner.

A new kind rather than `p` tags on 20001, so a relay without this
ignores the batch instead of storing it as the owner's own presence.

## Testing

- `agent_presence_request_names_distinct_agents_with_a_known_status`
(unit).
-
`presence_storage_postgres_tests::agent_presence_stores_only_owned_agents`
(Postgres + Redis): only the owner's agent goes online, another owner's
agent doesn't, a pre-cached `false` doesn't hide a linked agent, and
`offline` clears it. All 5 presence Postgres tests pass via
`scripts/postgres-test-run.sh -p buzz-relay --lib -E
'test(/presence/)'`.
- `buzz-pubsub` ignored Redis test
`test_presence_many_sets_with_ttl_and_clears` passes.
- clippy clean. Plain `cargo test` failures locally were environmental
only (buzz-media "role buzz does not exist", mesh_demo 504).
- Not yet exercised end to end against a local relay + app.

App side: block/buzz-app#862.

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

---------

Signed-off-by: Bradley Axen <baxen@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:43:24 -04:00
Tom Brow
868e2f8f32 refactor(push): one app profile per gateway (#8206)
Buzz push currently selects an application profile, such as `dogfood`,
during client registration. We want each gateway to serve one Apple app
configured by the operator instead. This removes profile selection from
the client, relay, and gateway, while allowing internal and public
gateways to use their own Apple identities.

This PR implements that model in the Buzz source and upstream deployment
chart. Discovery, enrollment, grants, and leases no longer carry an
application-profile selector. Each gateway has one configured Apple
application and APNs transport.

The protocol changes are incompatible with the old registration format,
so relays advertise `buzz-push-v1` instead of `nip-pl`. Old clients do
not discover the new push capability. Updated clients use separate
Keychain records and register again. The [push
notes](https://github.com/block/buzz/blob/push-capability-v1/docs/buzz-push.md)
describe the experimental contract.

Landing this PR is the first step. Publish the matching gateway image
and upstream chart, then land the [shared chart
update](https://github.com/squareup/cash-helm-charts/pull/5740) and
[gateway deployment
update](https://github.com/squareup/builderbot-platform-core-infrastructure/pull/1085).
Before cutover, stop old gateway instances and check legacy registration
state. Migrations refuse to proceed if old gateway installations or
active relay leases remain; they do not delete that state.
Physical-device verification remains necessary before production
activation.

### Validation
Gateway and focused Flutter push tests pass, as do the Swift package,
simulator build, and native notification/enrichment tests. A local relay
advertises `buzz-push-v1` without `nip-pl` or `app_profiles`. The
PostgreSQL suite passed 868 tests; its two migration-test failures were
corrected and pass a focused rerun together with the gateway cutover
guard. Chart rendering and release-contract checks pass.

Desktop JavaScript tests, desktop and web builds, native desktop
compilation, mobile analysis/tests, and desktop native tests pass. The
full Rust unit lane and prescribed `just test` command pass, including
workspace integration tests against task-isolated local services. The
production overlay now requires an explicitly supplied APNs topic, with
omission and rendered-value regression coverage. The corrected Swift
checksum assertion passes the complete package suite: 78 XCTest tests
and 42 Swift Testing tests. No physical-device enrollment or production
cutover is claimed.

Local merge validation: the embedded migration test and formatting pass
after reserving profile migration 0060 behind rollback migration 0059.
The local pre-push Rust lane failed ACP timing/steering tests
(`idle_resets_on_stdout_activity`,
`keepalive_resets_idle_past_deadline`, and the failed/missing-outcome
steering tests). ACP and its dependency manifests are unchanged by this
branch; all four pass in an isolated single-thread rerun. The documented
local-check exception skips only the Rust pre-push lane for publication;
required CI remains mandatory. Mobile and desktop pre-push lanes passed.

Combined migration validation: after rollback 0059 landed, the main
merge preserves profile migration 0060 and both embedded migration
assertions. The focused migration test and formatting pass. The next
local push again failed only the unchanged ACP idle/keepalive timing
tests (1053 passed, 2 failed, 3 ignored); other lanes passed.
Publication uses the same documented local Rust-lane exception; required
CI remains mandatory.

Follow-up review fix: removed the legacy profile column from the
copied-lease fixture. All four event-follow-up contract tests pass on
isolated PostgreSQL databases, covering desired and migrated schemas in
dual and app-only modes. Formatting passes. Publication retains the
prior passing local-lane evidence and uses the authorized pre-push
exception for the known unrelated ACP timing failures; required CI
remains mandatory.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-09 15:09:53 -07:00
Tom Brow
5b907b838a fix(push): isolate push enqueue and stop workers on rollback (#8216)
Push enqueueing previously ran inside the message transaction, so a push
lock or database error could block or reject a message. Configured relay
writers now submit bounded best-effort work only after commit. A
dedicated connection and bounded queue isolate enqueue waits and
failures from message-write connections. Overload, errors, process loss,
or shutdown may drop a notification; the committed message remains
authoritative.

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

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

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

### Validation


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

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

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

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

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

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

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-09 13:48:06 -07:00
Bradley Axen
1e80066c93 feat(relay): reshape read-state API to the sidebar wire format (#8220)
## Summary

Changes the private read-state API (`/buzz/v1`) to the sidebar wire
format agreed in #buzzin-in-secret. **The counting code is unchanged.**
Requests and responses take the new shape, so the client integration can
build against it now and later behavior fixes (counting rules, followed
threads, another-device pings) ship without another wire change. Nothing
ships against v1 yet (no buzz-app use on `main`), so this replaces
version 1 directly.

Supersedes #8188 (same branch content, rebased onto current `main`, plus
the thread-cap and doc changes below).

```
GET /buzz/v1/me/sidebar?limit=&cursor=  |  ?channel_ids=a,b,…
→ { channels: [channel], next_cursor }
channel: { channel_id, unread: bool, mentions, read_through_id|null, latest_id|null, threads: [thread] }
thread:  { root_id, unread: bool, mentions, read_through_id|null, latest_id }

POST /buzz/v1/me/read-state
{ intents: [{ type: "mark_through", target: { channel_id [, root_id] }, message_id }] }
→ { outcomes: [applied|blocked|invalid|unknown], channels: [channel] }
```

What changed and why:
- **Channel and thread positions are fully separate.** Channel
`unread`/`mentions` count only top-level messages. Thread replies count
only on their thread row. `mark_channel_read` and its whole-channel cut
are gone. "Mark channel read" sends a channel `mark_through` at
`latest_id`, plus one per thread row to clear threads too.
- **Channel `latest_id` is always a top-level message.** It used to be
able to point at a reply, and a channel `mark_through` rejects a reply
as its anchor. It also skips a reply-marked message whose ancestry never
resolved, because marking through one would fail on every retry.
- **`read_through_id`:** frontiers stored only the anchor's arrival
time. Migration 0058 adds `personal_read_frontiers.through_message_id`,
which follows the greatest arrival, and drops the now-unused
`threads_through_timestamp`. This is the only storage change.
- **Counts are plain numbers.** The exact/at_least/unknown wrappers are
gone. Evidence the relay can't use (unusable tags, unresolved ancestry,
undecided conversation membership, past the scan cap) leaves the message
out, so counts can undercount.
- **POST returns the updated row** of each distinct channel it applied
to, so the client doesn't refetch after every read. If those rows can't
be read after the writes commit, `channels` is omitted and the outcomes
still stand. The row refresh shares the 8 s intent deadline.
- **Up to 25 thread rows per channel** (was 5). Five hid a mention in a
sixth unread thread. 25 is the most that keeps a POST's rows for
`MAX_INTENTS` (100) channels inside the 1 MiB response limit; 50 would
be ~1.4 MB, a 503 that hides already-committed outcomes. A unit test
builds that worst-case response and fails if the cap outgrows the limit.
- **The doc separates shape from behavior.** `/buzz/v1` promises a
stable shape (fields, types, status variants, what `mark_through` does).
What the counts include (which messages are unread or directed, which
threads are listed, the bounds) may change while v1 is pre-release, so
clients replace their rows with each response instead of reconciling
against local counts.
- **Removed from the wire:** the explicit-contexts read (`GET
/me/read-state?targets=`), `account`, channel name/type/archived/hidden
(these come from Nostr metadata), `latest_message_at`/`_complete`, and
the NIP-11 `max_contexts`/`max_context_messages`/`max_thread_summaries`.

Deliberately deferred (behavior or additive, no shape change):
- Thread counting rule (relay counts a reply if you wrote or replied to
its direct parent; the client uses "posted anywhere in the thread or
followed"), the workflow-owner attribution p-tag, and thread-row
`mentions` counting every counted reply rather than directed ones.
- `follow`/`unfollow` intents, listing followed threads that are already
read, and a truncation flag for the thread list.

Thread: buzz://channel/6f773b94-34d0-4004-bec9-a100b6ad17d5 (#buzz-bff,
root 31020fff)

### Related issue
Follows #8157. Supersedes #8188; replaces the wire parts of the closed
#8155.

### Testing
- `cargo test --no-fail-fast -p buzz-db -p buzz-relay` at 1f041e46: all
pass except 7 relay unit tests (`api::media::tests` ×6 needing a seeded
local DB, `api::mesh_demo` round trip) that fail identically on `main`
(394d4a43) in the same environment.
- `scripts/postgres-test-run.sh -p buzz-db -p buzz-relay` against local
PG14 + Redis at 88d2f6fc: 836 passed, 26 failed. 25 also fail on `main`
in the same environment (partition maintenance tests and
`huddle_started_links`, which need PG16). The 26th,
`boot_lifecycle::hanging_redis_peer_times_out…`, is a timing assertion
that failed under full-suite load and passed 3/3 when rerun alone on
this branch.
- All `personal_read`, `migration`, `buzz_v1`/`accessory` and NIP-FI
wire-contract tests pass, including the new response-size bound test and
the 25-thread `summarize` cap test.
- `cargo fmt --check` and `cargo clippy -p buzz-db -p buzz-relay --tests
-D warnings` are clean.
- Not done: running a standalone relay binary (the dev object store
rejected writes with HTTP 403). The relay tests drive the real HTTP
router with NIP-98-signed requests. A human test against a local relay
is still needed.

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

---------

Signed-off-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz>
Co-authored-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:07:19 -07:00
Luke Tornquist
16eb0b6685 feat(buzz-db): run push enqueue and channel TTL refresh in app code (#8135)
## Summary

Moves the producer side of the two event follow-up triggers into
application code. The triggers stay in place, so both producers run
until a later change drops the triggers after this ships to every pod.

Builds on #8130 (`AdmittedTx`). Refs BUZZ-176.

### Push match enqueue

New `store::event_follow_up::enqueue_push_match` replaces the
`events_enqueue_push_match` trigger
(`schema/functions/enqueue_push_match_job.sql`, migration 0023) and
matches it exactly:

- Same kind allowlist (9, 40002, 45001, 45003), now `PUSH_MATCH_KINDS`.
- Takes the same SHARED `buzz_push_gate:<community>` advisory lock as
its own statement, so under READ COMMITTED the lease check gets a
snapshot taken after the lock is granted. Lease activations still take
it EXCLUSIVE in `push.rs`.
- Same lease predicate (`active AND endpoint_enabled AND expires_at >
now`), then `INSERT push_match_queue ... ON CONFLICT DO NOTHING`.
- Fails closed: any error rejects the event, as the trigger did.

It runs right after the INSERT, only when the insert affected a row. The
consumer side (claims, `lease_until`, attempts, reaper) is already
app-owned and is unchanged.

### Channel TTL refresh

`AdmittedTx` now records every channel that received an event (except
kind 9007 and channel-less events). `AdmittedTx::commit()` refreshes
their `ttl_deadline` as the last statement before COMMIT, which keeps
the commit-time behavior of the deferred `events_refresh_channel_ttl`
trigger (migration 0024):

- One savepoint per channel, in sorted order, under the same SHARED
`buzz_channel_ttl:<community>:<channel>` lock.
- Runs the same `UPDATE` with the same `archived_at`/`deleted_at`
filters as its own statement after the lock, so its snapshot follows the
lock grant. Its `ttl_seconds IS NOT NULL` qual filters a permanent
channel before any tuple lock, so a permanent channel's row is never
locked. An ephemeral channel's row lock is held through the remaining
refreshes and the COMMIT round trip (the trigger held it only inside
COMMIT).
- Lock keys come from one builder per protocol
(`channel::channel_ttl_lock_key`, `push::push_gate_lock_key`), shared by
the SHARED and EXCLUSIVE sides. The shared push-gate wait is observed
through `observe_advisory_lock(LockType::PushGate, …)`.
- On failure it rolls back the savepoint, logs a warning, and increments
`buzz_db_channel_ttl_refresh_failures_total{reason}`, where `reason` is
`lock_timeout` or `error`. The event still commits, matching the
trigger's `EXCEPTION WHEN OTHERS`. A cancelled statement (`57014`) still
rejects the transaction, because plpgsql never caught that either.
- While both producers run, an app refresh that hits `lock_timeout` is
counted, and then the trigger retries it inside COMMIT and may succeed.
So during the dual window the failure counter can over-report and the
worst-case wait doubles. Both go away when the retirement PR drops the
triggers, after which the counter is exact.

### Call sites

Every production `INSERT INTO events` calls
`event_follow_up::after_admitted_insert`:

- `insert_event_on`
- `insert_event_with_thread_metadata_tx`
- `LockedMemberSnapshot::replace_member_event`
- `replace_addressable_event`
- `publish_nip43_membership_locked`
- `accept_lease_event` (kind 30350, a no-op today, kept so the policy
has no exceptions)

`replace_parameterized_event_in_transaction_impl` inserts inside the
`parameterized_replace` savepoint. It enqueues the push job inside the
savepoint, so a rollback to that savepoint also drops the job, and
records the TTL channel only after the savepoint is released.

The two rolled-back verification probes (`verify_floor_guard_behavior`,
`verify_channel_roster_fence_behavior`) are named exceptions, because
they never commit.

### Source policy

`tests/observability_source.rs` adds
`every_production_event_insert_runs_the_follow_up_hook`. It scans every
`crates/*/src` and fails on any function that inserts into `events`
without the hook. Matching is normalized (letter case, whitespace, `\`
continuations, `\n`/`\t`/`\r` escapes, optional `public.`). The file is
masked once first: comments (`//`, nested `/* */`) and string,
raw-string (including `br"`/`cr"`), and char literals become spaces.
`#[cfg(test)]` markers, `fn` signatures and the hook are all found in
that masked view, so a marker in a comment (even a multiline block
comment) strips nothing, an `fn` line inside a multiline string doesn't
split a function, and a commented-out or quoted hook doesn't count. SQL
is still matched in the original text. A table test pins the masker's
output for each literal form. A fixture test pins what the scan rejects
(no hook, push without TTL, a hook in a line comment, block comment, or
string, lowercase or line-broken or escaped SQL, writers after test-only
items or under comment mentions) and what it ignores (writers in
`#[cfg(test)] mod`). A floor of seven hooked writers catches a scan that
silently stops seeing writers.

### Tests

`src/store/event_follow_up_postgres_tests.rs` is the contract suite from
the BUZZ-176 discovery work, ported to the `AdmittedTx` API. It is a
crate-internal `#[cfg(test)]` module because it runs `SET LOCAL`
timeouts and in-transaction reads through the crate-private
`AdmittedTx::conn()`. It runs on both the desired and migration schemas,
in two arms:

- **dual**: production during the migration window, with the triggers
and the app code both running.
- **app-only**: drops both triggers in the disposable database (and
asserts no partition clones remain), the way the retirement migration
will.

The trigger-only baseline is the base branch, where the same suite
passed before this change. The suite also pins the two branches that
differ from "swallow everything": a `lock_timeout` failure increments
`buzz_db_channel_ttl_refresh_failures_total{reason="lock_timeout"}`
exactly once, and a statement cancel (`57014`) during the refresh
rejects the event with no row, job, or deadline change.

## What got simpler

Nothing yet. This adds a parallel producer during the migration window.
It is what lets the follow-up PR delete two plpgsql functions, two
triggers, and the "keep in sync with migrations/00xx" duplication
between `schema/functions/` and `push.rs`.

## Verification

Rebased onto `main` @ `745501962`. All runs used a Linux cloud
workstation with the pinned toolchain (rustc/clippy 1.95.0, hermit), and
the tree was clean before and after each run. The final head,
`c5a06a82`, changes only `crates/buzz-db/tests/observability_source.rs`
compared with `1eb11bde` (via `fbc9a598`, `7c0baa41`, `9199992c` and
`d405b2f6`). Production source is identical between the two.

| Check | SHA | Result |
|---|---|---|
| `cargo fmt --check`, `cargo clippy -p buzz-db --all-targets -D
warnings` | `fbc9a598` | pass |
| `cargo clippy` (workspace) | `1eb11bde` | pass |
| source policy (`--test observability_source`), fmt, buzz-db clippy |
`c5a06a82` | 17/17, pass, pass |
| `cargo test --locked -p buzz-db` (non-PG) | `c5a06a82` | 148 lib + 17
policy + 5 doctests |
| source policy (`--test observability_source`) | `fbc9a598` | 16/16 |
| `cargo test --locked -p buzz-db` (non-PG) | `fbc9a598` | 148 lib + 16
policy + 5 doctests |
| contract suite (`store::event_follow_up_postgres_tests`,
desired/migration × dual/app-only) | `1eb11bde` | 4/4 |
| buzz-db PG full | `1eb11bde` | 464/464 |
| buzz-relay PG | `1eb11bde` | 378/378 |
| GitHub CI | `fbc9a598` | 53 pass, 0 fail (Codex security review
pending) |

**Falsifications** (each mutation made the named test fail, then was
reverted):
- dropping the `relay_members` hook → policy names
`publish_nip43_membership_locked`
- no-op push producer → app-only arms fail, dual arms pass (trigger
covers)
- skipping the TTL refresh → app-only fails on the deadline; dual fails
on the failure-metric assertion
- swallowing `57014` → all four arms fail
- dropping the metric increment → all four arms fail
- unanchored `#[cfg(test)]` match, or no escape handling → fixture test
fails
- a `#[cfg(test)]` constructor that skips admission, added inside `impl
AdmittedTx` (indented or as a column-0 `mod`) →
`admitted_tx_is_constructed_only_by_admitting_constructors` fails;
putting the strip back into the constructor scan → its fixture test
fails
- at `7c0baa41`: `accept_lease_event`'s hook commented out and the real
call made through an alias → policy names `accept_lease_event` (the same
mutation passes under the `fbc9a598` policy); hook matching on unmasked
text → the comment/string fixtures fail
- at `9199992c`/`d405b2f6`: no nested block-comment depth, no
string-escape skip, no `br"`/`cr"` raw-literal prefix, or no raw-string
branch at all in the masker → the follow-up fixture test fails
- at `c5a06a82`: finding `#[cfg(test)]` in the unmasked text →
`writer_after_block_commented_cfg_test` is no longer reported; finding
`fn` signatures in the unmasked text → `writer_after_raw_string_example`
is no longer reported; no `cr` raw prefix → the masker table test fails
on `cr"`
- unhooked writers appended to `side_effects.rs` (after test-only items,
under a doc-comment mention, with an escaped newline) → policy names
each

**Live relay E2E**: an independent reviewer ran this at `1eb11bde`
against a real relay and CLI, with PostgreSQL 17 and Redis 7, the
follow-up triggers absent, and no partition clones.
- A kind 9 on a TTL channel advanced the shortened deadline by about 580
s. On a permanent channel, the deadline stayed NULL.
- Each accepted event produced exactly one pending `push_match_queue`
row with the same event id.
- An exact signed replay was a no-op.
- While the exclusive TTL lock was held, the event and its job stayed
invisible and the deadline didn't change. After release, both persisted
once.
- Roster create/join/add/remove kept SQL and wire state in step
(1→2→3→2), with one current 39002 head.
- NIP-33 replace, duplicate, stale, failure and retry cases all passed.

Limits: the push lease was a SQL fixture, and push consumers were
disabled. Kind 30023 is global, so channel-scoped TTL on the
parameterized path is covered only by the PG contracts. No live relay
rerun was done at `fbc9a598`, because that delta is test-only.

**Latency** (measured at pre-rebase `f8380927`, same production delta;
debug build on loopback, 1,000 samples per cell, EVENT→OK): head
app-only p50/p99 3.80/4.58 ms (permanent) and 3.87/4.64 ms (TTL),
against base trigger-only 3.49–3.53/4.00–4.03 ms and 3.56–3.62/4.30–4.46
ms. About +0.3 ms p50, from the extra client round trips (lock, UPDATE,
savepoints, push lock/insert) that the triggers ran server-side. One
debug loopback run, not a production p99.

Not measured: contention from many writers on one hot TTL channel, where
each writer now waits on the channel row lock for about two more client
round trips (RELEASE SAVEPOINT and COMMIT) than with the trigger. In
review, a burst of 8 concurrent posts to one TTL channel committed with
no deadlock or lock timeout. Watch send p99 and `pg_locks` waits on
`channels` during rollout.

**Known pre-existing, not touched here:**
`api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo` is
flaky when Redis is present. It returns a 504 in 4 of 5 runs at base
`745501962` and 2 of 5 at `fbc9a598`, always at the same assertion. No
CI lane selects it.

---------

Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-10-09 12:33:31 -04:00
Tom Brow
4db7bb0e7f fix(push): stop backfilling messages on lease activation (#8178)
Remove the 120-second message scan from push lease activation and
renewal. Subsequent eligible messages continue through the existing
database trigger. The lease protocol, schema, trigger, and advisory-lock
order remain unchanged.

Related enqueue-gating work: #2145. No duplicate open PR or issue found.

### Validation

- All 18 PostgreSQL push persistence tests pass against isolated
desired-state databases after review changes.
- The existing concurrency test verifies activation waits for an
in-flight insert and a subsequent message queues once. The retention
fixture activates its lease before inserting its message.
- Formatting and PostgreSQL test discovery pass. The earlier full local
run passed repository lint/static checks, native desktop pre-push
checks, and every non-ACP integration step.
- Broad local checks hit unrelated ACP idle/keepalive timing failures. A
serial retry passed two affected tests but still failed the keepalive
deadline test. The pre-push hook was skipped with author authorization
after its other checks passed.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-08 17:34:41 -07:00
Kalvin C
8f66f7051c feat: raise hosted community limit to fifty (#8186)
## Summary
- Raise the stock relay active ownership quota from 5 to 50 for creation
and ownership transfer. Raise the absolute lifetime quota from 20 to 50
reserved hosts; completed owner deletions still permanently consume
lifetime capacity to bound create/delete host squatting.
- Honor the owner-list `can_create` projection across Desktop settings,
create flow, and onboarding instead of imposing a client-side count.
Display server `quota_used` / `quota_limit` when available; when the
projection is missing, let the server's `limit_reached` decide.
- Cover 49→50 and 50→51 admissions, deleted-owner lifetime exhaustion,
and rendered create gates under 5/50, 0/50 exhausted, and missing
projections.

## Verification
- Desktop full `pnpm test` and `pnpm typecheck` pass after the 50-cap
update; `cargo fmt --all -- --check` passes. Four targeted Playwright
cases and `pnpm build:e2e` passed for the original dynamic-quota change
at the previous commit, before changing test fixtures from 20 to 50.
- `cargo test -p buzz-db --lib` cannot compile here: macOS dyld rejects
the `sqlx_macros` dylib with `mis-aligned LINKEDIT string pool` on this
host before the crate's tests run. CI must validate the DB package.

## Rollout / manual validation
- Confirm `BUZZ_MAX_COMMUNITIES_PER_OWNER` is unset or 50 on **every**
production relay replica; this per-process cached override takes
precedence over the stock default.
- Confirm Builderlab's list/create/transfer proxy passes the relay's
`quota_used`, `quota_limit`, `can_create`, and `limit_reached` without
its own 5-community gate. Its backing implementation is not in this repo
and was not verified here.
- Human test: with an owner at 49 live communities, create the fiftieth,
verify the fifty-first returns `limit_reached`; transfer into an owner
at 49 then at 50 and confirm the same boundary. Check a deleted-owner
account at 0 active/50 lifetime displays the generic limit and disables
creation. This manual check remains outstanding.

---------

Signed-off-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Co-authored-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
2026-10-08 15:40:20 -07:00
Ian Oberst
a8fe2b612c feat(db): advance an empty partition catch-all under bounded locks (#7993)
## Summary

Adds bounded, serialized advancement of an empty right-edge partition
catch-all for `events` and `delivery_log`. The repaired catalogs
currently end in an empty catch-all from 2027-01-01, and the merged
manager (#6515) only audits them. Without this change, January writes
land in the catch-all, after which only an operator can move them.

When `BUZZ_PARTITION_MANAGER_ADVANCE_ENABLED=true`, each relay keeps six
dedicated monthlies after the current month. It does this at startup and
on every audit interval by replacing the empty catch-all with canonical
monthlies and a new canonical `<table>_p_future` after the horizon. From
today's catalogs the first run creates January–March 2027 and moves the
catch-all to April 2027.

## Safety design

- **Refuse before locking.** The following return `operator_required`
without taking any table lock:
  - a populated catch-all, or one whose occupancy is unknown;
- a catch-all with child-only triggers, which the replacements would not
inherit;
  - a misaligned bound;
  - a `DEFAULT`, anomalous or pending-detach child;
  - a name collision.
- **One winner.** A schema-scoped `pg_try_advisory_xact_lock` is taken
before any DDL. Losers report `skipped_locked` and never issue DDL. The
winner re-plans from a fresh audit before taking any table lock and
checks for collisions. A runner that lost a race therefore sees the
committed layout as a no-op, not as colliding names.
- **Writer lock order.** First `ONLY` the parent is locked `ACCESS
EXCLUSIVE` under a 2 s `lock_timeout`. Then each foreign-key counterpart
(today only `communities`) is locked in OID order `SHARE ROW EXCLUSIVE
NOWAIT`, then the catch-all `NOWAIT`.
- The counterparts are read again once the parent is locked, and that
set is the one locked. Any FK DDL on or to the parent conflicts with the
parent lock, so the set can't change after that. An FK committed while
maintenance waited is therefore locked `NOWAIT` too, so attaching never
waits on it under the parent lock. The read before the lock remains as a
cheap early refusal.
- This is the order an insert takes locks (parent, then the FK check on
`communities`), so maintenance cannot deadlock with ingest. The internal
manual runbook used to lock `communities` first; it now uses this order
too.
- `SHARE ROW EXCLUSIVE` is the strongest lock the DDL takes on
`communities`; measured on PG 16 and 17, the catch-all `DROP` takes
none, as long as the catch-all's only foreign keys are those inherited
from the parent. A catch-all with its own foreign key, or one that
another table's foreign key references, is refused: once before any
lock, and again after the catch-all lock, where the check is
authoritative. Readers and other writers' `FOR KEY SHARE` checks
proceed. A concurrent `communities` writer makes the run fail fast with
`lock_timeout`.
  - A `40P01` deadlock victim is also reported as `lock_timeout`.
- Parents that are referenced by a foreign key, or that reference an
inherited table, are refused, including when such a key commits while
maintenance waits on the parent.
- **Re-plan under lock.** The catalog is re-audited under those locks.
The transaction proceeds only if the plan is unchanged.
- **Bounded.** `statement_timeout` 5 s,
`idle_in_transaction_session_timeout` 5 s, and a 10 s client deadline
per table. A deadline-abandoned connection is closed, never returned to
the pool, and the server rolls back anything uncommitted. If the
deadline fires after `COMMIT` was sent, the change may have landed; the
next audit reports the truth. The orphaned backend keeps its locks until
its in-flight statement ends, at most `statement_timeout`.
- **Verify before commit.** The changed catalog is re-audited inside the
transaction for bounds, canonical kinds, trigger parity and catch-all
emptiness, and serving safety must not regress. It also checks that
every new child has valid, ready clones of each parent index and
validated clones of each PK/unique/FK constraint.

- **Audits tolerate a concurrent advance.** Catalog rows come from the
statement snapshot, but `pg_get_expr` renders from current catalog
state. So an audit that overlaps another relay's `DROP` of a catch-all
can see a partition that no longer exists. The read-only audit detects
this (a NULL bound, or `42P01` from the emptiness probe) and retries up
to twice with a fresh snapshot.
- **Fail fast on audit probes.** If another relay's audit is probing the
catch-all's emptiness, the catch-all `NOWAIT` lock fails and the run
reports `lock_timeout`. The parent lock is then released immediately,
and the next runner or interval completes the advance.

Uncovered-month creation (`BUZZ_PARTITION_MANAGER_CREATE_ENABLED`,
default `true`) now runs through the same locked transaction, replacing
the unlocked per-month `CREATE`. That flag remains the kill switch for
all automatic partition DDL: with it off, advancement is disabled too.
With advancement disabled (the default) the periodic loop stays
read-only and startup behaves as before, apart from the locking.

A runner that planned DDL always re-audits before returning, even when a
peer did the work, so it never reports the pre-maintenance layout. A
serialized re-plan that finds only disabled work reports
`skipped_disabled`.

## Behavior changes

- The relay audit horizon and the `buzz-admin partition-audit` default
are now 6 months (`PARTITION_MANAGER_MONTHS_AHEAD`), up from 3. Until
advancement is enabled, months already covered by the catch-all make
`degraded` true. That correctly reports a runway under six months.
- New metric `buzz_partition_maintenance_runs_total{table,outcome}` with
outcomes `noop | skipped_disabled | created | advanced | skipped_locked
| operator_required | lock_timeout | deadline | error`.
`buzz_partition_create_attempts_total` keeps its per-month `created |
skipped_covered | error` labels. `error` there still means a DDL or
collision failure; `lock_timeout` and `deadline` runs add nothing to it,
and only `buzz_partition_maintenance_runs_total` records them.
- `ensure_future_partitions` (free function and `Db` method) is removed;
`maintain_partitions` is the one entry point.
- New advisory-lock label `lock_type="partition_maintenance"`.

## Rollout

Enable `BUZZ_PARTITION_MANAGER_ADVANCE_ENABLED` per environment in BPCI:
`bb-block-staging` first, then production. The staging canary proves a
real advance because the six-month horizon already extends past January.
Fallback: rerun the internal manual runbook, which uses the same
parent-first lock order as of 2026-10-05. It keeps `ACCESS EXCLUSIVE` on
the counterparts, because its detach/attach can drop FK triggers on
them.

## Testing

- `cargo test -p buzz-db --lib partition`: 17 unit tests. The 10 new
ones cover:
- plans: repaired layout, policy gating, no-op, refusal states including
child-only triggers, December→January;
  - the create kill switch also stopping advancement;
  - the month cap at exactly 120;
- per-month metric outcomes for transient failures versus DDL failures;
  - the mid-audit-drop retry classifier.
- `cargo test -p buzz-db --lib partition -- --ignored` (PostgreSQL 17):
48/48 on 9 consecutive runs, including 17 new tests:
  - advance to the canonical layout, with routing and idempotent rerun;
  - replacing a canonically named catch-all in one transaction;
  - populated catch-all refused in under 1 s while the parent is locked;
  - a `communities` writer making the run fail fast and roll back;
  - a `communities` reader not blocking the advance;
- an in-flight insert's FK check, and its write to `communities`, behind
queued maintenance completing without a deadlock;
- a foreign key committed while maintenance waits on the parent, with a
writer on its new counterpart, failing fast as `lock_timeout` on that
table instead of waiting under the parent lock;
- a foreign key referencing `events`, committed while maintenance waits
on the parent, being refused under the parent lock;
- a catch-all's own foreign key, or a foreign key referencing it, being
refused before the parent lock with the key intact, and a catch-all key
committed while maintenance waits on the parent being refused after the
catch-all lock;
  - a `40P01` deadlock victim being classified as `lock_timeout`;
  - parent contention timing out without blocking the other table;
  - held advisory lock skipping with no DDL;
- concurrent runners converging, with at most a transient
`lock_timeout`;
- a stale audit after a concurrent advance being a no-op, and the losing
runner returning the committed layout;
- a re-plan that finds only disabled work reporting `skipped_disabled`;
  - an audit retrying when a listed partition is dropped mid-audit;
  - a catch-all with a child-only trigger being refused;
- client deadline abandoning the attempt and closing the session (scoped
to the test's own blocked backend);
  - catch-all name collision;
  - incoming foreign key refused.
- Mutation checks:
- restoring the old counterpart-first `ACCESS EXCLUSIVE` order fails the
reader test (`lock_timeout`) and the writer test (a ~1 s deadlock wait);
- dropping `NOWAIT` from the counterpart lock, or allowing a populated
catch-all, fails the matching tests;
- removing the parent-locked counterpart re-read fails the committed-FK
tests (the parent is held 2.06 s waiting on the new counterpart), and
ignoring its refusal fails the referencing-FK test;
- locking the counterparts `SHARE ROW EXCLUSIVE` before the parent fails
the writer test;
  - removing either catch-all foreign-key check fails its test.
- Full PostgreSQL lane: 716/718. Both failures are timing-sensitive
tests outside partition code, and each passes 3/3 when run alone:
  - `hanging_redis_peer_times_out_before_the_health_listener_binds`;
-
`readiness_check_cancellation_balances_waiter_and_inflight_connection`.
- `cargo clippy -p buzz-db -p buzz-relay -p buzz-admin --all-targets -D
warnings` and `cargo fmt --check` are clean.

## Related

Refs #2396.

This PR conflicts textually with #7994 (partition audit observability
follow-ups) in `crates/buzz-db/src/store/partition.rs` and
`crates/buzz-relay/src/main.rs`. Whichever PR merges second will be
rebased onto the other.

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

---------

Signed-off-by: Ian Oberst <ioberst@block.xyz>
Co-authored-by: Ian Oberst <ioberst@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:59:10 -07:00
Bradley Axen
7afdc2aac9 feat(relay): record when a read-state account starts (#8157)
## Goal

Add `started_at` to read-state accounts from the day the table is
created. This prevents users seeing ALL their channels as unread when
they first install a client update that starts using these APIs

---------

Signed-off-by: hMB2 <0400eb0da32afbbf16cdd1dee6ffd243587e8f3d6a2823fe7dfa95d16b002b7d@buzz.block.builderlab.xyz>
Signed-off-by: ART <ea39c1c8acf57180161ef4ba4ed68abf8589f5291956b909a5aa991fc66664ca@buzz.block.builderlab.xyz>
Signed-off-by: hMurderbot <219c58afff8ac2c76e4e527a329aa61c2ebbb896786b1916d4f9cca5140ecd04@buzz.block.builderlab.xyz>
Co-authored-by: hMB2 <0400eb0da32afbbf16cdd1dee6ffd243587e8f3d6a2823fe7dfa95d16b002b7d@buzz.block.builderlab.xyz>
Co-authored-by: hMurderbot <219c58afff8ac2c76e4e527a329aa61c2ebbb896786b1916d4f9cca5140ecd04@buzz.block.builderlab.xyz>
Co-authored-by: ART <ea39c1c8acf57180161ef4ba4ed68abf8589f5291956b909a5aa991fc66664ca@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:04:02 -07:00
Luke Tornquist
c6340a45a0 fix(deletion): give the scheduled drain the cold-start DB connector (#8146)
## Summary

The scheduled `buzz-admin deletions drain` Job never gets past its first
database connection in a hosted staging deployment. Every run exits 5
after exactly 3 seconds with `pool timed out while waiting for an open
connection`.

The drain starts in a fresh pod with no mesh sidecar, and that pod's
first connection can take longer than the relay's 3-second acquisition
budget. `connect_store()` in `buzz-deletion` inherits that budget from
`DbConfig::default()`, with no retry. In the same pod, the same command
run 15 seconds later succeeds. TCP to Postgres, Redis, and S3 from a
sidecar-less pod is fine.

#7770 fixed the same failure for `buzz-admin storage-snapshot`, but only
inside `buzz-admin`. This PR moves that connector into `buzz-db` as
`Db::connect_cold_start` and uses it from both run-once workers:

- **Pool settings:** a 30s acquisition budget, and no idle spare
connections (`min_connections: 0`).
- **Retries:** startup timeouts and transient transport errors retry at
most twice, after 2s and 5s, so startup waits at most 97s.
- **No retry:** configuration, authentication, TLS, and protocol errors
fail immediately, and the error message stays free of credentials.
- **Per-worker settings:** each worker keeps its own pool size.
Storage-snapshot stays at 1 connection, and deletion stays at
`BUZZ_DB_POOL_SIZE` (default 20). Each worker also keeps its session
timeouts.
- **Startup events:** the stderr events carry a worker prefix.
Storage-snapshot still emits `storage_snapshot_db_connect_*`, and the
deletion worker now emits `deletion_db_connect_*`.

- **Error text:** the exhaustion message is now `{worker} database
startup failed after …`. For storage-snapshot that reads
`storage_snapshot database startup failed …` instead of `storage
snapshot database startup failed …`. Nothing in this repo matches on
that string.
- **Session-setup failures:** SQLx retries a failed `after_connect` hook
(writer floor guard, session timeouts, isolation assertion) until the
acquire deadline. Those failures therefore surface as `timeout`, are
retried, and fail closed after at most 97s instead of immediately.

Relay pool defaults are unchanged. The deletion CLI's operator commands
(`list`, `approve`, …) use the same connector. They normally run inside
the warm relay container, where the first attempt succeeds.

**What got simpler:** there is now one cold-start connector instead of a
`buzz-admin`-private copy that the deletion worker couldn't reach.
`storage_snapshot_startup.rs` is gone, and its unit tests moved with the
code. The two Postgres startup tests now share one harness with the
drain's tests (`postgres_storage_snapshot.rs` →
`postgres_cold_start.rs`).

### Related issue

Follow-up to #7770 (same failure, other worker) and #7827 (the drain
CronJob). No duplicate found.

### Testing

All at head `1545de7b5`:

- **GitHub CI:** green on attempt 1, including `PostgreSQL Domain /
PostgreSQL Tests`.
- **Blox:**
- fmt, `clippy --workspace --all-targets -D warnings`, and the `buzz-db`
/ `buzz-admin` / `buzz-deletion` tests pass.
- All four `postgres_cold_start` tests pass, each against a freshly
migrated database.
- Falsification: with main's `connect_store` restored, the drain retry
test fails.
- **Agent review:** two rounds. All findings are fixed or disclosed; the
error-text change is listed above.
- **Agent exercise:** real `buzz-admin deletions list` / `deletions
drain` against local Postgres 17 behind a fault proxy.
  - **5s delay:** succeeds on attempt 1 (3/3). Main fails at about 3.5s.
- **Dropped handshake, then 5s delay:** attempt 1 fails with
`UnexpectedEof`, then a 2s backoff, then attempt 2 succeeds (3/3).
- **Wrong password, missing database or malformed URL:** exit 5 after
one attempt.
- **Persistent rejection:** exit 5 after three attempts, with 2s and 5s
backoffs.
  - The deletion queue stayed empty throughout.
- **Not covered:** an approved deletion, real S3 / Pod Identity, TLS and
session-setup failures, and a live 97s stall.

---------

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

## Access rule

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

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

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

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

## Routes

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

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

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

## Private events

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

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

## Method binding

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

## Directory query plan

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

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

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

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-07 16:07:38 -04:00
Luke Tornquist
6d07a29137 perf(relay): bound fleet usage metrics collection (#7176)
## Why

The usage poller pays memory and query costs proportional to community
count even when per-community emission is disabled. Every pod loads the
full host map, and the leader materializes all per-community aggregates
before suppressing their labels. That makes telemetry collection itself
a scaling constraint.

## What changed

- Fleet-only usage metrics are now the default. `all` remains available
as an explicit rollback mode.
- In fleet-only mode, the poller skips the community host map and all
community-sized query results.
- Exact community counting is replaced with the PostgreSQL planner's
table-size estimate.
- Stock totals are collected as one fixed row, scanning each stock table
once per hour.
- 1d/7d/30d active-user totals come from one bounded 30-day scan per
day.
- Fleet database snapshots run only on a proved read replica, with a
telemetry-specific freshness budget and server-side statement timeouts.
If no such replica is available, the collection is skipped; it never
falls back to the writer.
- Successful fixed-row snapshots are cached and re-emitted on each poll
tick, so gauges stay scrape-visible without raising query frequency.
- `buzz_usage_snapshot_available{family}` reports whether the latest due
refresh of that family succeeded. After a skipped or failed refresh, the
last values stay visible (with `buzz_usage_snapshot_age_seconds`) but
the family reads unavailable until a 60-second retry succeeds.
- The `all` rollback collector publishes the same availability gauges,
so dashboards gated on availability keep working during a rollback.
- Each due refresh outcome is applied by `apply_stock_refresh` /
`apply_activity_refresh`, the single production mapping from `Ok(Some)`
/ `Ok(None)` / `Err` to cache, availability, skip counter, and retry;
the regression test drives that mapping directly.
- `buzz_usage_snapshot_age_seconds` is emitted in the `all` rollback
too, so age-based alerts keep firing through a rollback.
- Every fleet telemetry attempt that completes lands in
`buzz_db_route_decision`; a query error on a proved reader records
`skipped/replica_error`. An attempt abandoned at the relay deadline
records no route decision and appears only as
`buzz_usage_query_skipped_total{reason="timeout"}`. The shared
`proved_reader` warnings no longer claim "routing to writer", because
the telemetry caller never does (serving callers still record their
writer decision in the route counter).
- The PostgreSQL production-method matrix now exercises
`usage_fleet_stock_snapshot`, so the new `reader/maintenance` pool pair
is proven to be emitted by a real Db method.
- Each fleet collection, reader proof included, runs under a relay-side
`tokio::time::timeout` (10s stock, 20s activity, both above their
server-side `statement_timeout`). A reader that stops answering expires
into the same failure mapping with
`buzz_usage_query_skipped_total{reason="timeout"}`, so it cannot stall
the leader's poller, hold the advisory lock past a liveness check, or
leave a frozen snapshot reporting available.
- Fleet telemetry reader checkouts (`ReaderOperation::Maintenance`) opt
into SQLx `close_on_drop` before their first post-acquire await, so an
abandoned collection's connection is closed within SQLx's 5s
`CLOSE_ON_DROP_TIMEOUT` instead of going through the unbounded
return-to-pool ping while it holds a slot in the reader pool that
serving reads share. Serving reads keep pooled reuse; the cost is one
reader reconnect per fleet refresh.
- On leader demotion, cached snapshots and availability are cleared, and
a fresh collection is forced after the lock is reacquired.
- Fleet-only mode passes no host map to the worker-snapshot storage
emitter. It therefore omits community attribution instead of reporting
every byte as `buzz_storage_unmapped_community_bytes`.
- Lifetime stored-message and active-channel totals are no longer
queried in fleet-only mode. Event throughput remains available through
the existing stored-event counter.
- No test-selector changes: main's `config::tests::` selector in `just
test-unit` and the `run-tests.sh unit` fallback already runs the new
replica-budget config test, and the new fleet schedule, availability,
and storage tests run through the existing bin `tests::` and
`storage_sweep::tests::` selectors.
- Added seeded PostgreSQL contract tests and operator docs covering
rollout, rollback, availability, the no-replica behavior, and leader
election.

No migrations, tables, indexes, or new persistent storage are added.

### What got simpler

The relay-side storage half of the original branch (totals-only S3
listing and `buzz_storage_community_breakdown_available`) was dropped.
Main moved S3 listing out of the relay in #7543 and #7845, so that code
was dead on arrival. The rework adds no storage code path; it only gates
attribution on the presence of a host map.

## Risk

Medium. The default changes which telemetry is emitted:

- In fleet-only mode, `buzz_communities_estimated` replaces the exact
`buzz_communities_total`.
- `buzz_total_messages` and `buzz_total_active_channels` are not
refreshed in fleet-only mode (also called out in the rollout steps of
`docs/usage-metrics.md`).
- Deployments without a verified reader (including local compose and
single-node self-hosts) skip database-derived fleet snapshots instead of
querying the writer, and report both families unavailable.
- Multi-pod dashboards must filter database and storage series to
`buzz_usage_poller_is_leader == 1`.

Setting `BUZZ_USAGE_METRICS_PER_COMMUNITY=all` restores the legacy
collector for temporary rollback.

## Verification

At head `1879ee3aedf4227942120a3b521c8d35fc7839fe` (tree `78477e74`;
merges main `da651ad6`; the PR's diff against main is byte-identical to
`1613823f`'s, minus the Justfile and `run-tests.sh` selector lines that
main's `config::tests::` selector now subsumes):

- On Blox: `cargo fmt --all -- --check` and `cargo clippy -p buzz-db -p
buzz-relay --all-targets -- -D warnings` pass. Relay bin `tests::`
34/34, `config::tests::` 55/55, `storage_sweep::tests::` 10/10 and
`store::usage::tests` pass, as do the four `fleet_` and lock PostgreSQL
tests that use scratch databases, including
`abandoned_fleet_collection_releases_its_reader_slot`.
- Against a freshly migrated PostgreSQL database:
`store::usage::postgres_tests::` 14/14,
`storage_sweep::postgres_tests::` and `buzz-db --test
observability_source` 14/14 pass. The new reader code passes main's
crate-wide typed-acquisition and serving-table policy checks.
- E2E at `1613823f` is reused for this head. It ran in an isolated relay
against a real PostgreSQL primary and streaming standby. In 2/2 runs a
stock read went dark mid-result; the deadline abandoned it at about 10s,
the reader pool's active and size counts dropped to zero about 1s later,
and a fresh activity read and the scheduled stock retry succeeded on new
backends while the dark session stayed open. The `all`-mode and
healthy-replica controls also passed. Main's merged changes are on the
writer path and in startup step timing, and touch none of the reader
checkout, the `close_on_drop` seam, the fleet poller or the deadlines.

At head `1613823fa8ef1a423bc1a499c57bc40d692fcd4f` (tree `12bc6d1b`;
test/docs-only on top of `57749418`), on a Blox workstation:

- `cargo fmt --all -- --check` and `cargo clippy -p buzz-db -p
buzz-relay --all-targets -- -D warnings` pass. Relay bin `tests::` 34/34
pass.
- New PostgreSQL test
`abandoned_fleet_collection_releases_its_reader_slot`: a one-slot reader
pool behind a loopback proxy that goes dark mid-session; the collection
is cut at a deadline, then a later acquire must succeed in under 8s on a
fresh connection. The dark call runs on a fresh `Db` with a cold Aurora
capability cache, so the first post-acquire await is the capability
probe. It passes in 3.4s. With `close_on_drop` removed, or moved below
the probe await, it fails with `PoolTimedOut` after the 15s acquire
timeout. The other three `fleet_` PostgreSQL tests that use scratch
databases pass.
- `each_fleet_family_is_abandoned_at_its_own_deadline` now has an outer
timeout: replacing the stock deadline with a bare `.await` fails it
(`Elapsed`) instead of hanging.

At head `fd194ec79d6c6796d6764564de225ad56485cfc7` (tree `79e94727`,
main merged at `932228936`), on a Blox workstation:

- `cargo fmt --all -- --check` and `cargo clippy -p buzz-relay -p
buzz-db --all-targets -- -D warnings` pass.
- Relay bin `tests::` nextest selection: 27/27 pass (plus
`store::usage::tests`), including
`fleet_refresh_outcomes_drive_availability_through_production_mapping`
and the new `stalled_fleet_collection_times_out_into_failed_refresh`
(paused time: two never-completing collections are abandoned at exactly
10s + 20s; both families read unavailable with cached values kept,
`reason="timeout"` skips, retry scheduled; the next tick runs and
recovers; no route decision is recorded for an abandoned attempt) and
`each_fleet_family_is_abandoned_at_its_own_deadline`. Compile-time
asserts keep each deadline above its `statement_timeout`.
- Falsifiability: replacing the stock deadline with a bare `.await`
makes the new test fail (the tick stalls); swapping the two deadlines
fails the per-family test; deleting `schedule.mark_stock_failure(now)`
from the `Ok(None)` arm fails the mapping test. Trees restored and
re-verified.
- At `970414db`: CI PostgreSQL Tests 809/809 including the
production-method matrix with `reader/maintenance`; Unit Tests ran
`finish_usage_read_records_every_attempt_in_route_decisions`.
- `just ci` / `just test` at `5b18914a` on Blox: not green for
environmental reasons (`mobile-check` could not reach pub.dev;
`_ensure-services` failed so service-backed suites did not run;
`buzz-acp git_bootstrap` fails identically on `origin/main`). PostgreSQL
and integration coverage comes from GitHub CI at this head.

E2E at the previous head `0a01e5f22` (isolated relay, real Postgres
primary plus streaming replica, worker storage snapshot, desktop
frontend in relay mode): with no reader, both families report
skipped/unavailable and route `skipped/disabled` (no writer queries);
fleet-only storage emits totals without `buzz_community_storage_*` or
unmapped bytes; `all` mode reports both availability gauges `1` with
per-community storage series; with a fresh replica, both fleet paths
route `replica/fresh` on `reader/maintenance` acquisitions; desktop
send/receive works. The cached success → due failure → recovery
transition was not reached live (hourly/daily cadence); it is covered by
the seam test above.

The original branch also recorded live verification at `aa3e7023`,
before this rework: reduced-idle cache re-emission, two-relay leader
takeover with demoted availability clearing, the no-reader skip with
60-second retry, `all` rollback restoring per-community series, and a
desktop smoke test. Those runs predate the availability and storage
changes and are not claimed for this head.

## Linear tracking

Related work: BUZZ-167.

---------

Signed-off-by: tornquist <tornquist@squareup.com>
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-10-07 15:46:18 -04:00
Luke Tornquist
da651ad672 buzz-db: purge retention-free deletions in the app and index mentions in the event transaction (#8128)
## Summary

Part 1a of removing database objects that exist only in migrations. This
PR moves into the app the work two migration-only triggers do today. It
doesn't drop anything.

On migration-built databases (production), `purge_soft_deleted_nip_rs`
(0009/0011) and `purge_soft_deleted_buzz_mesh_status` (0019) turn a
NIP-09 soft delete of a NIP-RS read-state event (kind 30078) or a
mesh-status heartbeat (kind 30003) into a hard delete, and delete its
mentions. `schema/schema.sql` has neither trigger, so on desired-state
databases (CI) those rows remain as soft-deleted. The two schemas behave
differently today.

Changes:
- `soft_delete_by_coordinate` and
`soft_delete_event_and_update_thread_in_tx` now hard-delete matching
NIP-RS and mesh-status rows and their `event_mentions` rows in the same
transaction, before the generic soft delete. The classification
predicate is the same one the 0011 and 0019 triggers use.
- `insert_event_with_serving_write_guard` now indexes mentions inside
the event transaction, under a savepoint, instead of after commit. A
mention failure still only logs a warning. A concurrent deletion can no
longer leave a mention row behind, and `guard_event_mention_live` will
no longer be needed. The other post-commit `insert_mentions` callers
write kinds 48101 and 13534 and a test helper, never 30078.
- Both soft-delete `UPDATE`s exclude the same predicate (`AND NOT
COALESCE(..., false)`). A retention-free row is either purged or left
live, never soft-deleted, even when a replacement races the deletion
under READ COMMITTED. The purge runs only for kinds 30078 and 30003.
- While the 0011 hard-delete fence is still installed, the purge sets
the transaction-local `buzz.nip_rs_hard_delete` opt-in. 1b removes it,
along with the fence.

**Rollout:** the triggers stay in place on purpose. Pods still running
N-1 during a deploy depend on them. A follow-up (1b) will add a
migration that drops the 5 functions and 5 triggers, remove the
`buzz.nip_rs_hard_delete` opt-in code, and add the catalog-parity test.
1b should merge only after this PR is deployed everywhere.

**What got simpler:** once 1b lands, NIP-RS and mesh deletion will
follow one rule owned by the app, the same on both schemas, and the
hidden trigger work will be gone. This PR adds about 130 lines to
`event.rs`. A known gap for 1b: the replace path's Rust mesh classifier
is stricter than the SQL predicate, and 1b has to make one of them
authoritative before the triggers are dropped. 1b will then delete 5
triggers and 5 functions, plus the opt-in plumbing in `replaceable.rs`
and `deletion.rs`.

### Related issue

BUZZ-175, BUZZ-177 (Linear).

### Testing

Blox, head `a2dac90646ca64fb3f961ffda292e3da976deffc`:
- `cargo fmt --check`, `clippy -D warnings` (`buzz-db`, `buzz-relay`),
and test discovery: all pass.
- New tests: 4 of 4 pass.
- `nip09_deletion_purges_retention_free_rows_and_mentions`,
desired-state and `migration_schema_` variants. On the migration schema,
a 13-shape table checks that the app and the triggers classify each
shape the same way. A separate case covers a mesh row with a NULL
`d_tag`.
-
`coordinate_deletion_racing_nip_rs_replacement_never_soft_deletes_read_state`,
a deterministic lock interleaving.
- `serving_write_guard_insert_indexes_mentions_in_event_transaction`:
mentions are indexed in the event's own transaction (checked via
`xmin`), and a mention failure leaves the event live.
- Falsification: the race test fails against the pre-fix `event.rs`. The
mention test fails with the merge-base post-commit `runtime/mod.rs`.
Both NULL-`d_tag` variants fail with `COALESCE` removed.
- `buzz-db` PostgreSQL lane (`scripts/postgres-test-run.sh -p buzz-db
--lib --tests`): **409 of 409 passed** in an unscoped run. One box hangs
on `writer_pool_rejects_non_read_committed_database_default` (an
environment issue; it also hangs there at merge-base), and there 408 of
408 others passed.
- E2E against a real relay on both schemas: 18 of 18 signed deletion
cases, the serving-write mention path (success, pre-commit invisibility,
isolated failure, recovery), and browser mention, send, reload and
delete.
- CI at `a2dac906`: all checks pass. The `PostgreSQL Tests` lane needed
one rerun: the first attempt hit a known flaky test,
`replica_fence::sample_writer_waits_for_shared_floor_lock_before_sampling_time`,
which fails the same way on `main` at `001290a34`.

buzz-review-completed (agent review and agent E2E complete; human test
waived by Luke Tornquist)

---------

Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-10-07 10:26:39 -04:00
Tyler
1a2ddf9eaa feat(relay): private read-state accessory API (#7906)
Buzz BFF: Private read-state accessory API (`/buzz/v1`)
2026-10-06 16:12:03 -04:00
Luke Tornquist
d0fd96fca6 BUZZ-175: Route serving event writes through the tenant lock (#7828)
## Summary

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

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

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

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

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

### Related issue

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

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

### Testing

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

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

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

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

Generated with Codex

---------

Signed-off-by: tornquist <tornquist@squareup.com>
Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-10-06 10:24:41 -04:00
Luke Tornquist
7f6ffd5ee5 fix(db): enumerate only active communities for maintenance (#8061)
Fixes #7558. Supersedes #7630.

The SQL predicate here comes from #7630 by @olympusbuildz, who is
credited as co-author on the commit. This PR keeps that fix unchanged
and adds the lifecycle fixtures and worker-path coverage the fix needed.

## Problem

`community_hosts_with_operation` ran `SELECT id, host FROM communities`
with no filter. After a whole-community deletion, the `communities` row
stays behind as a `tombstone` until retention expires, which is by
design. But the NIP-43 maintenance sweep kept handing that community to
`publish_nip43_membership_list`. The community write fence rejected the
write, so the relay logged `NIP-43 membership reconciliation failed`
about every 60s, indefinitely, for every deleted community.

## Change

The helper is split by caller purpose:

- `active_community_hosts` (maintenance) and `bootstrap_community_hosts`
filter to `archived_at IS NULL AND deleted_at IS NULL AND deletion_state
= 'active'`. That's the same active-community predicate already used in
`community.rs` and `deletion.rs`.
- `usage_community_hosts` still enumerates every row, exactly as on
main.

The startup large-roster reconciliation query
(`list_large_channel_rosters_needing_reconciliation`) joined
`communities` without any lifecycle filter, so it gets the same active
predicate on its `JOIN communities`.

Tombstone rows aren't modified.

### Caller impact

| Caller | Enumeration | Effect |
|---|---|---|
| NIP-43 maintenance sweep (`side_effects.rs`) | active | Stops trying
fenced writes and logging warnings forever. This is the bug fix. |
| NIP-43 bootstrap reconciliation | active | Same as maintenance, at
startup. |
| Push-wake worker (`push_runtime.rs`) | active | Stops claiming wakes
for these communities. Archived communities are unreachable through host
resolution; quiescing, fenced and tombstone writes are DB-fenced. Wakes
already queued for an archived community aren't delivered late after
unarchive, because claim and send both re-check `expires_at`. |
| Startup large-roster reconciliation (`side_effects.rs`, kind 39002) |
active | Stops rewriting roster snapshots for archived communities and
attempting fenced writes for deleting or tombstoned ones. |
| Usage-metrics host map (`main.rs`) | all rows | Unchanged.
Per-community labels, fleet totals and storage attribution keep covering
the same rows as the unfiltered usage count queries. |

## Tests

#7630's test tombstoned a row with a plain `UPDATE`. On current main,
the `enforce_community_tombstone` trigger from migration 0029 rejects
that unless the transaction carries the deletion-executor GUCs. The
fixtures here set those GUCs the way the executor does.

- `buzz-db`
`store::usage::postgres_tests::test_community_hosts_excludes_archived_and_deleted_communities`
sets up active, archived, quiescing, fenced and tombstoned fixtures.
Across two sweeps it checks that `active_community_hosts` (maintenance)
and `bootstrap_community_hosts` both return only the active community,
and that `usage_community_hosts` still returns all five. It also checks
that the tombstone row still exists with `deleted_at` set.
- `buzz-relay`
`handlers::side_effects::tests::postgres_tests::maintenance_sweep_skips_archived_and_deleted_communities`
is the worker-path regression. It runs
`reconcile_nip43_membership_snapshots_with_purpose(Maintenance)` three
times against the same five fixtures. It checks that
`buzz_nip43_membership_reconciliation_failures_total` stays at 0, the
active community gets exactly one membership snapshot, the four
non-active communities get no maintenance write, and the tombstone row
is still there.
- `buzz-db`
`store::channel_members::postgres_tests::large_roster_reconciliation_skips_archived_and_deleted_communities`
seeds a channel whose roster has diverged from its live relay-authored
kind-39002 snapshot in each of the five fixture communities. Only the
active community's channel may be returned.

All three tests are `#[ignore = "requires Postgres"]` inside
`postgres_tests::`, so the PostgreSQL CI lane picks them up through its
existing structural filter.

## What got simpler

The background writers share one definition of "community eligible for
maintenance": the same active predicate the rest of the store uses.
Usage metrics keep their existing semantics, so this PR changes no
metric. One enumeration helper is added (`active_community_hosts`), and
the old `community_hosts_with_operation` indirection is gone. The
executor-GUC lifecycle fixture lives once in `test_support` and is
shared by both DB tests instead of being duplicated.

---------

Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Olympusbuildz <Olympus.roots@outlook.com>
2026-10-05 13:25:21 -04:00
Luke Tornquist
fe9e2409a0 BUZZ-175: Add application-owned writer lock foundations (#7706)
## Summary

Buzz is moving database invariants from triggers and foreign keys into
application-owned transaction protocols. This PR adds the first
transition foundations while preserving all current database backstops.

- Add a shared community-write lock for single-community writer
transactions, and make deletion lifecycle transitions take the matching
exclusive lock. A supported serving transaction never spans communities.
- Add shared event-writer and exclusive replica-probe lock ordering
around the existing heartbeat publication model.
- Document the supported writer contract, reviewed backfill path, and
transition gates.
- Keep one replica-floor publication concept and remove an unrelated
reconciliation-script edit found during review. This narrows the change
rather than adding parallel protocols.

The blast radius is limited to new opt-in writer helpers plus lock
acquisition in deletion and replica probing. Existing trigger and GUC
enforcement remains authoritative, so callers can migrate in later pull
requests.

### Related issue

[BUZZ-175](https://linear.app/squareup/issue/BUZZ-175). This is the
first implementation slice from the OSC migration plan.

### Testing

No manual testing. CI covers the Rust checks and database tests added
here.

Generated with [Codex](https://openai.com/codex)

---------

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

## Audio

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

## Behavior

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

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

## Path table

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

## Known limits

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

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-01 23:24:56 +00:00
Luke Tornquist
53a12100b3 fix(deletion): require sole owner at admission (#7966)
Follow-up to #7830: reject owner-origin deletion admission for a legacy
co-owned community before creating a request. Under the existing
community-row lock, admission locks all current owner memberships in
pubkey order and requires exactly one matching asserted owner.
Existing-request idempotent replay remains unchanged; completion retains
its independent post-lock revalidation.

**New optional operator field: `community_id`** (`ab0cd713`,
`766eb07f`). Owner-origin `POST /operator/communities/delete` now
accepts an optional `community_id` that binds the request to the host's
community:
- **Fresh submission:** checked after sole-owner authority is proven, so
a non-owner still gets `404 community_not_found`. A mismatch returns
`409 community_id_mismatch` and writes no request row.
- **Replay:** checked against the stored request, and only when the
stored request is for the same host. A mismatch returns `409
community_id_mismatch`, and the stored request is unchanged. A known
UUID sent with a different host goes through the existing convergence
check and returns `409 deletion_request_conflict`.
- **Omitted:** behaves as before. `admit_owner_request` takes
`expected_community_id: Option<Uuid>` rather than a second public entry
point, and existing callers pass `None`.
- `docs/operator-community-deletion.md` documents the ordering.

Also folds in the non-blocking review nits from #7969 (wpfleger96, at
`e21151f4`):
- **`limit_reached` has a stable `code`.** Create
(`provision_community`) and transfer-in now return `409` with `code:
"limit_reached"`, matching the other coded lifecycle errors. The `error`
message keeps its `limit_reached:` prefix, so clients that match on the
message still work. The provision limit test now asserts the code, and a
new PostgreSQL test covers a transfer to an owner at the limit (coded
409, no membership change).
- **Docs: an ack-version mismatch is a 400, not a 409.**
`docs/operator-community-deletion.md` and the `delete_community` handler
doc now say a changed host or owner under a known UUID is `409
deletion_request_conflict`, and an unsupported acknowledgement version
is rejected first with `400 unsupported_acknowledgement_version`.
- **Docs: active-cap overrides above the lifetime cap can't be
reached.** The `max_communities_per_owner` doc and the quota section now
say `MAX_LIFETIME_COMMUNITIES_PER_OWNER` (20) counts live ownership, so
`BUZZ_MAX_COMMUNITIES_PER_OWNER` above 20 can't be reached.
- **Legolas P2s:**
- The UUID-conflict docs now list every 409 case, including a stored ack
version that doesn't match and a UUID held by an operator-origin
request.
- The runbook now says a legacy co-owned community is rejected as `404
community_not_found` and should be converged with a transfer first.
- The preparation drift test now pins `DeletionSafety` plus "sole-owner
authority drifted".
- **Guard wording:** the `delete_community` handler doc now says "sole
current owner", matching the admission check.

What got simpler: admission and automatic preparation now enforce the
same sole-owner authority, so no accepted-but-unexecutable co-owned
request needs a new recovery path. `deletion_api_error` is renamed to
`coded_api_error`, since it now carries non-deletion codes too. There's
one helper for coded operator errors instead of a second one.

Rebased onto main after #7969 merged (`d7a35afa`). The sole-owner commit
applied cleanly.

Earlier focused Blox verification (at the pre-rebase head `161e9857`):
the new admission regression was red before the change and green after
it. Five temporary, restored mutants each failed its matching
production-seam regression. At the current head `766eb07f`, the relay
`api::operator::` suite passes 23/0 on Blox. Each new `community_id`
guard, removed one at a time, fails its own regression test. CI owns the
full suites.

Remaining debt: the separate P2-A purge/completion membership/community
lock-order cycle is unchanged.

Generated with Codex

---------

Signed-off-by: Codex <noreply@openai.com>
Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-09-30 15:04:23 -04:00
Will Pfleger
020193f8e3 feat(relay): add direct staff ban/timeout/delete with staff guard (#7883)
Relay staff (the `RELAY_OPERATOR_PUBKEYS` config roster, the owner
fallback, and DB operator/moderator rows) can now ban, time out, and
delete without a report through the admin API. Staff never join the
community transport: these are admin-host, NIP-98 signed routes that
drive the existing report enforcement state machine. Community
admission, bans, and right-click menus are unchanged from `main`.

Stack: this PR → #7904

## Routes

| Route | Body |
|---|---|
| `POST /api/admin/v1/members/{pubkey}/ban?communityHost=` |
`{requestId, reason?}` |
| `POST /api/admin/v1/members/{pubkey}/timeout?communityHost=` |
`{requestId, expirationSecs, reason?}` |
| `POST /api/admin/v1/events/{eventId}/delete?communityHost=` |
`{requestId, reason?}` |

Responses: `200 {actionId, state: "succeeded", replayed}`, `202 {state:
"pending"}` when the action is accepted but not yet converged (the
recovery worker finishes it; the same `requestId` replays), `409
request_id_conflict`, `409 target_is_staff`, `404
event_not_in_community`, `400 invalid_community_host` /
`unknown_community_host`, and `422 enforcement_failed`. `422` is now the
single failed-enforcement code for both the report and direct paths
(shared `enforcement_failed()` mapping).

## Design

- **Migration 0055.** `relay_admin_actions.report_id` becomes nullable.
A direct row has `report_id NULL`, and `report_community_id` stays the
tenant. New columns: `enforcement_target_event_id` and `timeout_secs`.
The `relay_admin_actions_direct_shape` CHECK spells out each direct
action: timeout needs both `timeout_secs` and `timeout_until`, ban and
delete need neither, and delete needs both the event id and the author.
A partial unique index on `(report_community_id, request_id) WHERE
report_id IS NULL` makes direct actions idempotent. `pgschema` drops
multi-column CHECKs, so `scripts/reconcile-schema-after-pgschema.sql`
re-adds `relay_admin_actions_direct_shape` on desired-state databases.
- **Atomic acceptance.** `claim_direct_action` inserts the action row
and its `moderation_actions` audit row (`ban` / `timeout` /
`delete_message`, `relay_operator` / `relay_moderator`) in one
transaction. `ON CONFLICT DO NOTHING` sends a concurrent same-key
request to the comparison. Retries compare actor, action, reason,
`timeout_secs`, and the client-named target: the event id for delete,
the pubkey otherwise. The event author, channel, and `timeout_until` are
derived at acceptance and never compared.
- **Retry racing its own first attempt.** Handler order: stored-action
lookup → validation → staff guard → accept. If validation fails (for
example B's event lookup runs after A already deleted the event), the
handler re-reads the stored action. It returns a replay or 409 rather
than 404.
- **Report-less enforcement.** `finalize_success(report_id:
Option<Uuid>)` skips the report CAS and the `reporter_notice` for direct
actions. Tombstone and affected-user notices are unchanged.
`recover_one` drives a direct action from its persisted row.
`deliver_reporter_notice` treats a missing `report_id` as a terminal
error instead of panicking.
- **Permanent bans clear old expiry.** The ban upsert shared by direct
and report-backed bans sets `ban_expires_at = NULL`, matching the
kind-9040 path, so a permanent ban over an active or lapsed timed ban is
in effect with no deadline. Timeouts are untouched.
- **Community purge removes admin-action rows.** `purge_postgres`
deletes the community's `relay_admin_outbox` rows (joined through their
action), then its `relay_admin_actions` rows by `report_community_id`,
before `moderation_reports`. That order satisfies both FKs and covers
report-backed and direct (`report_id NULL`) rows in one pass. The same
keyed row sources (`RELAY_ADMIN_TABLES`) feed the deletion inventory,
which now records row counts for both tables, and post-purge logical
verification, which now fails if either table still has a row for the
community. Before this, a community with any admin-resolved report could
never finish deletion (23503 on
`relay_admin_actions_report_community_id_report_id_fkey`, stuck at
`bindings_removed`). The `_operator_global_tables` registry reasons for
both tables (seeded in migration 0036) are updated in 0055 and
`schema.sql` to say community deletion purges them explicitly.
- **Staff bans disconnect clusterwide.** The shared enforcement driver
(`drive_enforcement`, used by the report and direct paths, fresh and
recovery) calls `disconnect_pubkey_clusterwide` for `ban` before
finalizing, matching the kind-9040 ban. A crash before finalize leaves
the action non-terminal, so recovery re-runs the disconnect (idempotent)
even when the ban step marker is already set. Timeouts do not
disconnect, matching in-community timeout.
- **Staff guard (D2).** `lookup_admin_principal` returns
`Result<Option<AdminPrincipal>>`, with the same precedence as
`resolve_admin_principal` (now a wrapper). `Ok(None)` means not staff. A
roster lookup error is a 500, so the guard fails closed. Ban and timeout
(direct) and ban/timeout/kick (via `resolve_report`) refuse any
effective-roster target with `409 target_is_staff`, including the actor.
The report path refuses before claim, so the report stays `open`. Delete
is not guarded on either path. The check runs only when an action is
first accepted: a replay or crash recovery completes an accepted action
even if its target became staff afterwards.
- **`communityHost` grammar (D4).**
`buzz_core::tenant::validate_community_host` accepts only DNS labels and
an optional port, and rejects schemes, paths, queries, userinfo, IP
brackets, and whitespace before tenant binding.

## Tests binding fixes (red under mutation → green)

Postgres tests are in `api/admin/mod.rs` `postgres_tests`, run against a
DB migrated through 0055. Every rejection test seeds an existing
restriction and asserts, through the shared `assert_no_effects` helper,
that restriction values and action/audit/outbox/restriction/event counts
are unchanged.

| Fix | Test | Mutation → red |
|---|---|---|
| Racing retry replays |
`direct_delete_retry_racing_first_attempt_replays_not_404`
(barrier-paused HTTP interleaving; same-intent → 200 replay, conflicting
reason → 409; the retry is checked with `assert_no_effects` against a
snapshot taken after the first attempt, with the event author's
restriction seeded) | drop the post-failure re-read → `left: 404 right:
200`; conflicting retry updates a restriction on the re-read path →
`raced retry` snapshot mismatch (`ban_reason: "mutated"` vs `"seeded
ban"`) |
| Per-action retry comparison |
`direct_ban_replays_same_request_and_conflicts_on_changed_intent` | drop
`reason` from the comparison → `left: (200, None) right: (409,
Some("request_id_conflict"))` |
| Lookup fails closed (helper-level, calls `refuse_staff_target`
directly) | `staff_lookup_failure_fails_closed` | `.unwrap_or(None)` on
lookup error → panics on `unwrap_err` (guard passed) |
| Direct staff guard | `direct_ban_and_timeout_refuse_staff_targets`
(config/self and DB staff over HTTP with seeded restrictions unchanged;
owner fallback checked at helper level via `refuse_staff_target`) |
remove `refuse_staff_target` → `left: (200, None) right: (409,
Some("target_is_staff"))` |
| Report-path guard incl. kick |
`resolve_report_refuses_kick_and_ban_of_staff_target` | remove
report-path guard → `left: (200, None) right: (409,
Some("target_is_staff"))` |
| Accept-time-only check |
`recovery_completes_accepted_ban_after_target_became_staff` | re-check
staff in `drive_direct_action` → `left: "pending" right: "succeeded"` |
| Timeout CHECK | `migration_0055_rejects_half_filled_direct_timeout`
(buzz-db, migrated schema) and
`direct_timeout_shape_check_holds_on_desired_state_schema` (buzz-relay,
`pgschema` + reconcile schema) | loosen timeout branch of the CHECK, or
drop the reconcile block → half-filled insert accepted |
| No reporter notice for direct | `direct_ban_replays_…` (outbox
assertion) | enqueue `reporter_notice` unconditionally → assertion at
the `reporter_notice` check fails |
| Delete unguarded | `direct_delete_removes_staff_authored_event` | —
(behavioral control) |
| Unauthorized direct requests have no effect |
`direct_routes_refuse_unauthorized_requests_without_effects` (nonstaff,
community owner, disabled auth, wrong host, wrong URL, altered body;
restrictions on the targeted member and on the delete target's author,
plus action/audit/outbox rows, unchanged) | roster bypass / drop
mutation-principal check / admit owner / skip host check → `200 vs 403`;
drop URL binding / payload hash → `200 vs 401` |
| Foreign-community delete |
`direct_delete_of_foreign_community_event_has_no_effect` | unscoped
event lookup → `(200, None) vs (404, "event_not_in_community")` |
| Banned staff actor still acts |
`community_banned_staff_actor_still_acts_under_membership_enforcement`
(membership enforcement on) | gate the actor on community bans → `403 vs
200` |
| Timeout idempotency |
`direct_timeout_keeps_expiry_on_retry_and_conflicts_on_changed_intent` |
compare derived `timeout_until` → `409 vs 200`; compare action only →
code `None vs Some("request_id_conflict")` |
| Competing claims | `competing_direct_claims_accept_once` (identical →
existing, conflicting → conflict; one action + one audit row each) |
join skips compare → `[claimed, existing] vs [claimed, conflict]`; join
always conflicts → the reverse |
| Aborted acceptance rolls back | `aborted_direct_acceptance_rolls_back`
| accept outside the transaction → effects `(1,0,0,0,0) vs (0,0,0,0,0)`
|
| Recovery exactly once |
`recovery_after_committed_mutation_finalizes_exactly_once` (one
recovery, `succeeded`, one `affected_user_notice`; each `recover_one`
bounded by a 30s timeout) | ignore the enforcement step marker →
`recovery did not converge: Elapsed(())` after 30s |
| Permanent ban over a timed ban | `direct_ban_clears_prior_ban_expiry`
(active and expired prior ban; asserts `ban_expires_at` NULL, admission
`moderation_restriction_state` banned, timeout unchanged) | drop
`ban_expires_at = NULL` → active: `left: Some(+1 day) right: None`;
expired: `left: Some(-1 day) right: None` |
| Rejections never rewrite restrictions |
`direct_ban_and_timeout_refuse_staff_targets`,
`resolve_report_refuses_kick_and_ban_of_staff_target` (value snapshot
via `assert_no_effects`) | staff guard updates the target's existing
restriction before returning 409 → counts equal but `ban_reason:
"mutated"` vs `"seeded ban"` |
| Purge with admin-action rows |
`purge_postgres_succeeds_with_relay_admin_action_rows` (buzz-db;
report-backed and direct rows with outbox rows; inventory counts both
tables; deleted community left with none, second community untouched; a
straggler direct row fails logical verification) | remove the new purge
deletes → `23503 … violates foreign key constraint
"relay_admin_actions_report_community_id_report_id_fkey"`; drop the
relay admin tables from verification → `straggler relay admin action
must fail verification` |
| Ban disconnects, timeout doesn't |
`direct_ban_disconnects_target_and_timeout_does_not` | remove the
disconnect call (run alone) → `ban must close the target's socket` |
| Recovery still disconnects |
`recovery_past_ban_step_still_disconnects_target` | remove the
disconnect call → `recovery must disconnect the banned target` |
| Purge result keyed by table |
`checkpointed_resume_is_idempotent_and_tombstone_blocks_name_reuse`
compares returned keys to the scoped tables plus the two relay admin
tables | — (existing regression, updated) |
| Failed replay |
`direct_replay_of_failed_action_returns_422_without_effects` | —
(coverage) |
| Pending under contention |
`direct_action_under_contention_returns_202_pending` | — (coverage) |
| Timeout/delete recovery |
`recovery_completes_accepted_direct_timeout_and_delete` (test-only
`claim_stranded` acquires just the requested action through
`acquire_action_lease`, then runs `recover_one`, so tests don't claim
each other's rows) | — (coverage) |

## Rollout note

Before rolling the relay back to a binary without this PR, drain
non-terminal direct rows (`report_id IS NULL AND state IN
('pending','enforcing')`). An older binary cannot decode `report_id
NULL`, and recovery batches containing such rows stall.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-09-30 14:23:54 -04:00
Luke Tornquist
d7a35afa98 feat(buzz-relay): idempotent owner community deletion with quota reservation (#7969)
## Summary

This is the relay side of owner community deletion.

- **Idempotent delete is the recovery call.**
  - `POST /operator/communities/delete` checks the request UUID first.
- If the request already exists with the same host, owner and
acknowledgement version, it returns `202` with the request's current
`status`. That holds at any stage, even after membership is purged, and
no new work is admitted.
- The same UUID with a different host or owner returns `409
deletion_request_conflict`. An unsupported acknowledgement version is
rejected first, with `400 unsupported_acknowledgement_version`.
  - There's **no receipt endpoint**. Clients recover by resending.
- **Active quota reservation.** An incomplete owner deletion holds the
owner's slot until the deletion logically completes. Migration `0054`
adds the supporting index. Migrations 0052 and 0053 are unchanged.
- **Lifetime cap.** Deleted communities keep their hosts as permanent
tombstones.
- On create and on transfer-in, the relay counts live ownership plus
every non-aborted owner deletion, completed ones included.
- That total is capped at an absolute 20, regardless of the active
limit, and going over returns `limit_reached`.
  - This stops create-then-delete host squatting.
- **Stable lifecycle conflict codes,** plus `acknowledgement_version` in
the 202.
- **Owner-list quota projection:** `quota_used`, `quota_limit` and
`can_create`.
  - `can_create` reflects both caps.
  - The projection is advisory, and `limit_reached` is authoritative.
- **Operator doc** (`docs/operator-community-deletion.md`): the
acknowledgement version is a compile-time constant. Admission and the
executor's claim/lease both filter on it. A version bump must keep
replaying and executing old-version requests.

This follows on from #7830. The foundation PRs #7818, #7827 and #7830
are merged.

## Testing

New tests:
- `owner_delete_resubmission_reports_current_status_without_new_intent`
  - A replay returns 202 `submitted`, including after membership purge.
  - Changing any field returns 409 `deletion_request_conflict`.
  - A non-operator gets 403.
  - A replay after abort returns 202 `aborted`.
  - No request rows are added.
- `completed_owner_deletions_count_toward_lifetime_cap`
- After 20 created-then-deleted communities, the active count is 0 but
`can_create` is false.
  - The next create and a transfer-in both return `LimitReached`.
- `owner_quota_admits_only_under_active_and_lifetime_caps`: a unit test.

Fellowship gate: PASS at `59375c00`. The code review was clean. The E2E
run covered authenticated KGoose → relay → the real drain executor. The
later commits only change the operator doc.

## Rollout

- Deploy order: this relay first, then KGoose
squareup/cash-server#130634, then Web squareup/ext-builderbot-ui#241 and
App block/buzz-app#403.
- No shipped client ever called a receipt route, so removing it doesn't
break any existing client.
- The quota changes have no deploy-order requirement.
- Keep owner deletion off until this relay and the drain executor are
live.
- If you need to roll back, turn deletion off before rolling back the
relay.
- Recreate any database that ran the earlier version of migration 0054.
That only applies to disposable preview databases. Never apply this to a
database holding real data. The earlier draft 0054 has a different
checksum and index predicate.

## Complexity

Net simpler:
- One route, its handler and a writer-routed `get` are removed. Recovery
reuses the existing idempotent admission path.
- A single `OwnerQuota::admits()` backs create, transfer and the
projection.
- The stale "clients fail closed / strict deployment order" doc section
is replaced by the advisory quota contract.

---------

Signed-off-by: tornquist <tornquist@squareup.com>
Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Signed-off-by: Codex <noreply@openai.com>
Signed-off-by: Codex <codex@openai.com>
Signed-off-by: OpenAI Codex <codex@openai.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Codex <codex@openai.com>
2026-09-30 10:49:42 -04:00
Ian Oberst
14a752fb24 fix(db): audit partition catalog before creation (#6515)
## Why

The partition manager checks partition existence by name only and treats
Postgres 42P17 "would overlap partition" errors as success. On databases
where a catch-all partition (`events_p_future` /
`delivery_log_p_future`) covers future months, the monthly creation path
silently does nothing while real coverage erodes toward a write outage.
There is currently no visibility into partition catalog health and no
signal before writes start failing.

## What

- **Read-only catalog audit** in `buzz-db` (`partition.rs`): classifies
the live partition catalog for `events` and `delivery_log` via
`pg_inherits` and bound parsing — coverage bounds, catch-all detection,
anomalous children, and trigger parity checked recursively down to
routable leaf partitions.
- **Creation gated on the audit verdict**: the 42P17-to-success swallow
is removed; monthly CREATE runs only for months the audit proves
uncovered, a canonical-name mismatch is an error, and per-month outcome
capture replaces first-error abort. New
`BUZZ_PARTITION_MANAGER_CREATE_ENABLED` kill switch (default `true`).
- **Prometheus metrics** per table: `buzz_partition_serving_safe`,
`buzz_partition_uncovered_months` (audit window is now through +3
months), `buzz_partition_catch_all_covered_months`,
`buzz_partition_anomalous_children`,
`buzz_partition_trigger_parity_missing`/`_extra`,
`buzz_partition_audit_duration_seconds`,
`buzz_partition_audit_runs_total{outcome}`,
`buzz_partition_audit_failures_total`,
`buzz_partition_create_attempts_total`, and
`buzz_partition_audit_last_success_timestamp_seconds` for staleness
alerting.
- **Cached diagnostics**: `/_status.partition_catalog` reports partition
serving-safety, audit age, and freshness. `/_readiness` stays
process-local, consistent with #7341; missing, unsafe, or stale audits
do not remove pods from service.
- **Periodic audit task** in the relay, plus config surface for the
audit interval and the monthly-create kill switch. Catch-alls are
detected from catalog bounds rather than configured names.
- Doc correction: the mesh heartbeat comment no longer claims readiness
gating (it never was gated; behavior unchanged).

## Behavior changes & risk

- No DETACH/DROP/ATTACH and no schema migrations. The only DDL is the
pre-existing monthly CREATE path, now strictly narrower
(audit-proven-uncovered months only) and behind a kill switch.
- Partition audit health does not gate readiness. Until the first
completed audit, read-only retries back off from 5 seconds to a
60-second cap. Any completed audit, including unsafe coverage, restores
the normal interval.
- Partition-overlap errors now surface as errors instead of being
swallowed as success.
- Rollback: revert the commit, or set
`BUZZ_PARTITION_MANAGER_CREATE_ENABLED=false` to disable the creation
path while keeping the audit and metrics.

## Validation

- New Postgres-backed regression tests for catalog classification and
trigger parity (disabled child trigger, `ENABLE ALWAYS` parent, disabled
nested leaf clone in a multi-level layout, healthy-catalog zero-parity
checks). Each new regression was verified to fail against the previous
logic before the fix.
- Local affected-target validation passed against a disposable
current-schema database, with the known mesh forwarded-echo and tracing
callsite failures excluded. This is not a claim that the full suite
passes.

Follow-up work (separate PRs): bounded catch-all advance (empty-only
golden path; non-empty reattach only behind an explicit operator flag,
default off), fresh-install convergence, and proactive coverage-runway
alerting.
---

## Update — 2026-08-21

- Fixed nested-leaf trigger parity auditing so child-only row triggers
are checked on every routable descendant leaf and reported as
`{leaf}:{trigger}` under the owning root child.
- Added a Postgres regression test for a child-only trigger on a nested
leaf; mutation-reverting to immediate-child-only extra-trigger scanning
makes it fail.


Generated with Codex

Signed-off-by: coder 0 <d97ebdbb198c7237c94f84ea8bb8a73583ea067407eebd0062abbb3962527fb1@buzz.block.builderlab.xyz>
Signed-off-by: Ian Oberst <ioberst@squareup.com>
Signed-off-by: Ian Oberst <ioberst@block.xyz>
Co-authored-by: coder 0 <d97ebdbb198c7237c94f84ea8bb8a73583ea067407eebd0062abbb3962527fb1@buzz.block.builderlab.xyz>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:43:40 -07: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
Luke Tornquist
bba75a71eb Automate owner deletion preparation (#7830)
## Why

Owner deletion admission stops at `submitted`. Manual inventory and
approval still stand between owner intent and the existing deletion
executor, preventing self-serve deletion.

## What

The existing `buzz-admin deletions drain` process prepares and
automatically approves operator-attested owner requests. Operator-origin
requests retain manual approval.

## How

The drain prioritizes approved work, then claims one owner submission
with the existing generation lease. It freezes inventory and records
digest-bound `owner_automatic` approval in one transaction before
entering the unchanged executor.

Before approval, the transaction rechecks archived state and current
ownership. It locks the deletion advisory fence, community, owner
memberships, and request in that order. A regression test pins
privileged abort during a live preparation lease.

What got simpler: one drain still owns preparation and execution. There
is no second worker, queue, lifecycle, or retry authority. The rebased
branch contains only this feature and its review fixes.

## Risk

Automatic approval permits irreversible deletion without a human grace
period. Operator attestation is not a cryptographic owner signature.
Provenance, locked authority checks, generation fences, and digest-bound
approval constrain the operation.

This remains work in progress. Broad test failures and incomplete mobile
checks are disclosed below; publication is not a merge-ready verdict.

## Testing

Published head: `e059ae9056b447ca3b5d0c2893da86550ef16f5c`. All seven
commit trees match tested source
`5605f27eaece1e809f746cc1839b55c7ceef8d80`; only attribution metadata
changed. Source-SHA results below are not new-head execution receipts.

At source commit `5605f27eaece1e809f746cc1839b55c7ceef8d80`, isolated
PostgreSQL tests showed stale archive and ownership fixtures fail closed
after the fix. Before the fix both reached automatic approval. Removing
abort's generation increment or stage transition made the new live-lease
regression fail; restored production passed.

`just test` failed in two ACP native-git fixtures and one agent
body-timeout test. The failing source files match the pinned base, but
there is no exact-base execution comparison proving those failures
unrelated. `just test-unit` also failed in the ACP fixtures, leaving 18
tests unrun. `just ci` stopped in Flutter tooling package resolution;
separately run non-mobile lanes do not constitute a full `just ci` pass.

No live tenant deletion or human testing was performed. A dedicated
populated `0052` → `0053` approval-backfill fixture remains a coverage
gap. Final-head hosted CI and security review are required.

## Bigger picture

Rebased directly onto main `12670bd0f037c66a682272bb81c46c3f254fad74`;
prerequisite #7818/#7827 are already merged. Main owns
`0052_channel_artifacts`, so this feature's automatic-approval migration
is now `0053`. Client/KGoose and quota work are separate PRs and are not
part of this diff.

Generated with Codex

---------

Signed-off-by: Codex <noreply@openai.com>
Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-09-29 11:39:33 -04:00
Bradley Axen
12670bd0f0 feat(relay): implement NIP-AR channel artifacts (#7919)
## Summary

Implements NIP-AR channel artifacts (spec merged in #7791) on the relay,
after a simplicity pass over the earlier two-agent implementation.

An artifact is an editable record (kind 45010) with a stable identity
(`d`), one home channel (`h`), and full-snapshot revisions chained by
`prev`:

- **Writes:** one transaction per write. It locks the artifact's head
row, requires `prev` to name the current revision, records the revision
in a ledger that survives retention, and advances the head. Two
competing edits can't both win. A stale edit gets `conflict: artifact
head changed`. Resubmitting an accepted event succeeds without applying
it twice.
- **Permissions:** writes pass the same gates as a kind 9 message in
`h`. A move also passes them in the source channel. The move stores the
destination snapshot and a relay-signed kind 45011 removal marker for
the source in the same transaction, so both channels recover by replay.
The marker never names the destination.
- **Moderation:** redaction is the ordinary kind 9005 removal. The
ledger keeps the ID so `prev` checks still work. Redacting the current
revision retires the artifact.
- **Reads:** HTTP `/query` and `/count` accept explicit `artifact:
current|history` filters with exact multi-character tag matching (e.g.
`#assignee`). Generic REQ and search return only current revisions. Kind
5 against artifacts is rejected. NIP-11 advertises the limits.
- **Spec:** NIP-AR updated to match: kind 9 permission checks, 9005
redaction and retirement, no creator-only delete rule.

A separate small commit routes `insert_channel_head_checked` through the
metered writer pool (`acquire_writer`), like every other event write in
that module.

### Related issue

Follows #7791. No separate issue found.

### Testing

- Clippy clean.
- Unit lane passes. Failures seen along the way came from agent env
leaking into `buzz-acp` tests (they pass in a clean env) and known
timing flakes (`keepalive_resets_idle_past_deadline`, two `buzz-agent`
databricks tests that passed on retry). This diff doesn't touch those
crates.
- Postgres lane passes, including new `artifact_postgres_tests` in
`buzz-db` and `buzz-relay`. Three presence tests need `REDIS_URL` set.
- Docker smoke against a fresh local relay, 29/29: create and idempotent
resubmit, update and stale conflict, current, `#assignee` and history
queries, generic REQ, kind 5 rejection, delete and restore, move with
source marker, private-channel isolation, and 9005 retirement.
- A pasteable curl walkthrough of the same flows was shared with the
reviewer.

Draft until the human hands-on check (AGENTS.md step 3) is confirmed.
The pre-push hook was skipped because every lane above had already run
on this tree. CI is the gate.

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

---------

Signed-off-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz>
Signed-off-by: murderbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Co-authored-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Smartie <fb3185faacfc3760b6b4fe0085a8878d8dd537f7a7671c7d3184fe1aa1b04df4@buzz.block.builderlab.xyz>
Co-authored-by: Fizz <400e8babadcee6a7f420103f10a2849d84c4a9c71d5bd04f3948c814216648a3@buzz.block.builderlab.xyz>
Co-authored-by: murderbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
2026-09-28 16:52:09 -06: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
15d44dccee Add owner deletion admission control plane (#7818)
## Summary

Adds the relay and database admission boundary for owner-requested
deletion of archived hosted communities.

The operator endpoint records accepted owner intent with stable UUID
idempotency and immutable owner/operator provenance. Admission
serializes with unarchive and owner transfer, and accepted requests
disappear from actionable owner listings.

This keeps destructive execution out of the request path. The existing
durable deletion engine remains the only executor, so this change adds
no lifecycle state or second deletion mechanism.

Complexity stays bounded: one durable request is both the idempotency
record and the mutation fence.

### Related issue

None found.

### Testing

- `REDIS_URL=redis://127.0.0.1:6379
BUZZ_POSTGRES_ADMIN_URL=postgres://buzz:buzz_dev@localhost:5432/postgres
PGHOST=localhost PGPORT=5432 PGUSER=buzz PGPASSWORD=buzz_dev
PG_BIN_DIR=/tmp/buzz-pg-bin ./scripts/postgres-test-run.sh -p buzz-db -p
buzz-relay --lib --tests` — 385 passed.
- `cargo test -p buzz-db` — passed.
- `cargo fmt --all -- --check` and `cargo clippy -p buzz-db -p
buzz-relay --all-targets -- -D warnings` — passed.
- `just desktop-test desktop-build desktop-tauri-check
desktop-tauri-test web-build` — passed; Desktop ran 6,589 tests.
- Full `just test` could not start uncached Keycloak dependencies
because `quay.io` reset connections. Full `just ci` reached Flutter
dependency resolution, then `pub.dev` reset connections.

Generated with Codex

---------

Signed-off-by: tornquist <tornquist@squareup.com>
Signed-off-by: Codex <noreply@openai.com>
Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-09-28 15:12:15 -04:00
Jarrod Sibbison
1846094b31 fix(db): limit event transactions to listener mention kinds (#7932)
## Summary

- Keep non-listener-mention event writes on the plain insert path
without opening a transaction.
- Preserve the transaction around listener mention event insertion and
outbox enqueue.

## Validation

- `cargo fmt --all -- --check` (Hermit)
- Tests not run.

Follow-up to Baxen’s review of #7793, point 1.

Signed-off-by: Implementor <691cca7a870db1dad6990d5938d1b4a2a2ca9647ca81290b536c17b06b2e473f@buzz.block.builderlab.xyz>
Co-authored-by: Implementor <691cca7a870db1dad6990d5938d1b4a2a2ca9647ca81290b536c17b06b2e473f@buzz.block.builderlab.xyz>
2026-09-28 23:17:40 +10:00
Jarrod Sibbison
c4c86006f2 feat(relay): deliver pubkey mentions to relay companions (#7793)
We are introducing a companion service to the relay that needs pubkey
event mentions for pubkeys it manages. This PR introduces an outbox for
pubkey mentions that will be sent to these "operator listener" services.

## Summary

Deliver mentions of registered pubkeys to deployment-global operator
listeners.

- Configure listeners with `BUZZ_OPERATOR_LISTENERS`; listeners manage
target pubkeys through NIP-98-authenticated `POST`/`DELETE
/operator/listener/pubkeys` requests.
- During supported event ingestion, match `p` tags to registrations and
insert one outbox row per listener in the event transaction.
- A background worker claims up to 10 rows, sends NIP-98-signed
notifications, and retries transient failures with exponential backoff
for up to 9 attempts (idle polling backs off from 250 ms to 2 s).
- Outbox rows are ineligible for delivery after 15 minutes and are
reaped every 5 minutes. Registered pubkeys live for 30 days;
re-registering renews them, and a daily reaper removes expired
registrations.
- Keep persistence app-managed: no database triggers, functions, foreign
keys, or separate match queue.

## Changed behavior

Listener outbox rows are enqueued inside the shared event-insert and
thread-metadata transactions; enqueue failure rolls back the event.
`event_mentions` behavior is unchanged: ordinary event/reaction mention
indexing remains post-commit best effort with warning-only failures.

## Testing

- `cargo fmt --all`
- `cargo test -p buzz-db --lib`
- `cargo test -p buzz-relay --lib
config::tests::operator_listener_routes`
- `cargo check -p buzz-db -p buzz-relay`
2026-09-28 13:04:40 +10:00
Will Pfleger
676e8c4382 fix(relay-admin): make thread deletions atomic and fence expired action leases under row lock (#7853)
## What

Two fixes to `crates/buzz-db/src/store/relay_admin_actions.rs` and one
cleanup in `crates/buzz-db/src/store/thread.rs`.

### 1. Thread counter atomicity

The admin delete action now calls the canonical
`soft_delete_event_and_update_thread_in_tx` body shared with NIP-29 and
NIP-09 event-ID deletion. The delete, parent `reply_count` decrement,
root `descendant_count` decrement, and step marker commit or roll back
together in one transaction. A second action against an already-deleted
target is a no-op and does not double-decrement. After a committed
delete, `emit_live_thread_summary` refreshes the live thread count the
same way `DELETE_EVENT` does.

### 2. Lock-first lease expiry fence (all five admin-action paths)

On main, the final marker `UPDATE` in every admin-action path
(`execute_ban_with_marker`, `execute_timeout_with_marker`,
`execute_kick_with_marker`, `execute_delete_with_marker`,
`record_failure`) checks `clock_timestamp()` in the `WHERE` clause.
PostgreSQL can evaluate that predicate before waiting to lock the target
row. If another transaction holds the action row and releases it
unchanged after the lease expires, the update commits under the stale
timestamp.

On this branch, each path acquires the action row with `SELECT … FOR
UPDATE` inside the same transaction before any domain write. The
wall-clock expiry check in the marker `UPDATE` runs after that lock is
held, not before. The pre-entry ownership `SELECT EXISTS` checks remain
— they are early-rejection optimizations that are safe because the
correct final fence rolls back any provisional work.

`record_failure` on main uses a single-statement `execute(pool)`. On
this branch it begins a transaction, acquires the row lock with `SELECT
… FOR UPDATE` and an explicit expiry check, then writes `state =
'failed'` — matching the four mutation functions.

**Lease validity is checked at the protected ownership check under the
action-row lock, not at physical `COMMIT` completion.**

| Function | Change |
|---|---|
| `execute_ban_with_marker` | `SELECT … FOR UPDATE` added after
pre-entry check, before domain write |
| `execute_timeout_with_marker` | same |
| `execute_kick_with_marker` | same |
| `execute_delete_with_marker` | same |
| `record_failure` | wrapped in transaction; `SELECT … FOR UPDATE` +
clock check before state update |

### 3. Unused standalone counter helpers removed

`increment_reply_count`, `decrement_reply_count` (both in `thread.rs`),
and `Db::decrement_reply_count` have no production callers outside their
forwarding wrappers. Both perform unguarded autocommit writes that
bypass the deletion-transition/atomicity invariant. Removed to prevent
future misuse.

---------

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-24 15:55:28 -06:00