Files
buzz/crates
Bradley Axen 3ffbfa693c feat(relay): start a channel with its members in one create (#8421)
## What

A kind:9007 can now name the channel's first members with `p` tags.
Those members are added in the same database transaction that creates
the channel. Resending a create whose OK was lost is now accepted
instead of refused.

## Why

Starting a new Solo chat today takes about 6–8 sequential relay round
trips before the first message goes out: create, read, add each agent
(kind:9000), read the roster, then send. With members in the create, a
client can start a chat with its agents as two writes: the create, then
the message as soon as the create is OK.

The resend fix makes the create safe for an outbox to retry. Today an
identical resend after a lost OK is refused with `duplicate: channel
already exists`, so the client can't tell success from a real UUID
collision.

## How

- **buzz-db:** `Db::create_channel_with_event` writes the channel, its
owner, its members (role `member`, `invited_by` = creator) and the 9007
event itself in one transaction. A channel row therefore never exists
without its create event. `create_channel_with_id` is unchanged for its
other callers.
- **Validation** (`side_effects::initial_members` and the
`validate_admin_event` 9007 branch):
  - Each `p` must be a 32-byte hex pubkey.
  - Duplicates and the creator are dropped.
  - At most 32 members.
  - An `h` tag is required when there are members.
- Each member goes through the same `decide_channel_add_policy` check a
kind:9000 uses, so an agent that refuses third-party adds keeps the
whole create out.
- **Side effects:** each initial member gets the same `member_joined`
system message and member-added notification a kind:9000 would post. The
fallback in `handle_create_group` creates a bare channel when the
pre-created row is missing; it announces no members, because it added
none.
- **Resend:** when the UUID is already taken, the same transaction
checks whether this exact event is the stored create.
- If it is, the create is OK'd as `duplicate:` (it is the same signed
create).
- Anything else, including a changed create from the same author, is
refused and writes nothing, so it can never re-run side effects or
announce members.
- A resend that arrives while the first create is still uncommitted
waits on its channel insert. It is confirmed once the first commits, or
creates the channel itself if the first rolled back. Before this, a
resend in that window was refused, and the app then withdrew a chat that
existed.
- **No compensation:** ingest no longer soft-deletes a pre-created
channel when the event insert fails. The rollback covers it.
- **Announcements:** a failed `member_joined` notice for one initial
member is logged at `error` and the loop continues, so the members after
it still get theirs. A resend writes nothing, so nothing would retry
them.

## Compatibility

A 9007 with malformed `p` tags, or more than 32 of them, used to be
accepted with the tags ignored. It is now rejected with `invalid:`. The
relay's own SDK and CLI builders send no `p` tags on 9007.

## Out of scope

- The kind:9000 OK still doesn't prove the membership change committed:
side-effect failures are logged and OK'd, and a duplicate replay skips
side effects. The new path doesn't depend on kind:9000.
- Events in separate HTTP requests are still unordered. Clients send the
first message after the create's OK.

## Tests

`store::channel::postgres_tests` (buzz-db):
- `a_resend_beside_an_open_create_confirms_it_once_committed`: holds a
create open, checks that the resend waits, commits, and expects `Exists
{ resent: true }` with one owner and one member.
- `a_resend_beside_a_failed_create_creates_the_channel`: the same, but
the first create rolls back.
- `a_create_whose_event_fails_leaves_no_channel`: an event the insert
refuses leaves no channel row, and a retry creates the channel.
- `another_create_for_a_taken_channel_writes_nothing`.

Two mutations, each caught: committing the channel before its event
fails the "leaves no channel" test, and looking up the event before the
channel insert waits fails the "confirmed once committed" test.

`handlers/create_with_members_postgres_tests.rs`, all through the real
ingest path:
- `create_adds_its_p_tags_as_members` checks:
  - the roles and the 39002 roster
  - exactly one `member_joined` notice
  - that the agent can post with no separate add
- `create_respects_the_members_add_policy`: an agent with policy
`nobody` gets an `invalid:` rejection, and no channel is created.
- `resending_a_create_confirms_it`:
  - an identical resend is accepted
  - a changed create (from the creator or from someone else) is refused
  - the roster is unchanged and nobody new is announced
- Unit tests for `initial_members`: dedupe, creator excluded, bad hex,
the 32-member cap, and the missing `h` tag.

Each new test was mutation-checked against the change it covers: no
members, no policy check, refuse every resend, and let a changed creator
create through. Each mutation turns its test red.

Agent exercise on a local relay, over `POST /events` the way the app
sends:
- The 9007 with `p` = agent was accepted in 54–97ms (debug build).
- The kind 9 was accepted 12–15ms after the create's OK.
- The agent was in the 39002 roster, and the operator listener delivered
the mention.
- An identical resend came back `duplicate:` (accepted), and a changed
create was refused.

Local suite runs at 10adcf6dd (the transaction fix), on PG14:
- `postgres-test-run.sh -p buzz-relay -p buzz-db`: 28 failures. That is
the same 27 as below, plus `personal_read_frontier_is_monotonic…`, which
hit a statement timeout under load and passes when run alone.
- `cargo test -p buzz-db -p buzz-relay`: the same 6 `api::media`
failures and the flaky `mesh_demo` test.

Earlier local suite runs:
- New tests pass and clippy is clean.
- `postgres-test-run.sh -p buzz-relay -p buzz-db`: 27 failures
(partition maintenance, a huddle links test, a redis boot test), and the
same 27 fail on a96ace4e5.
- `cargo test -p buzz-relay`: 6 `api::media` failures (they need a DB at
the default URL) also fail on base.
- One `api::mesh_demo` test is flaky: it passed 2 of 3 branch runs and
failed once on a timeout.

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

---------

Signed-off-by: Bradley Axen <baxen@squareup.com>
Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: claude00 <fb8b76a01257932b8ca35060862c29bd09b2137638573731c2d5074fc02c5385@buzz.block.builderlab.xyz>
Co-authored-by: ART <ea39c1c8acf57180161ef4ba4ed68abf8589f5291956b909a5aa991fc66664ca@buzz.block.builderlab.xyz>
2026-10-18 11:32:09 -07:00
..