Skip to content

feat(relay): grow DMs in place, identified by their people - #9125

Open
morgmart wants to merge 8 commits into
mainfrom
morganm/growable-dms
Open

morgmart wants to merge 8 commits into
mainfrom
morganm/growable-dms

Conversation

@morgmart

@morgmart morgmart commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Lets a DM grow. Today adding someone to a DM always starts a new DM. This makes the relay side of "growable DMs" work. Joining an existing DM, and seeing its history, is always opt-in: without ["history", "include"] the relay behaves exactly as before this PR, so older clients can't put anyone into an existing DM unseen.

  • Opt-in tag on kind:41010 and kind:41011. ["history", "include"] is the only way anyone joins an existing DM. Any other value is refused.
    • 41011 with the tag: people and agents join this DM with its history. When people join, the DM's identity moves to the grown set of people, so messaging only the original people afterwards starts a new DM.
    • A DM of at most one person never gains people in place, even with the tag. Adding a person to you-and-your-agents starts or opens the DM of everyone, as on main, and the original keeps its members. Agents still join it in place.
    • 41011 without the tag: anyone added, agents included, starts a new DM of everyone, as before.
    • 41010 with the tag: finds the DM of the named people and adds any named agents it lacks.
    • 41010 without the tag: finds or makes the DM of exactly the named members, agents included, as before. That DM gets an all-members fingerprint, so it never collides with the people's DM.
  • A DM of people is identified by its people. Agents don't change which DM it is and don't count toward the 9-person limit. There is no agent limit, matching channels. A DM with at most one person (you and your agents) is identified by all its members: an agent joining it moves its identity, so reopening the original pair starts a new DM.
  • Collisions. If a DM of the grown set already exists, the relay returns it (response:{"channel_id","created":false}) and changes nothing here.
  • Agent add policies. Putting someone else's agent in a DM with anyone besides you follows its channel_add_policy, the same rule as channels. Messaging an agent alone ignores it, as today.
  • Only people grow DMs. An agent can't send the history tag, and a DM an agent opens is identified by all its members, so an agent can't take over or join a DM of its owner's people. Growing never reveals a DM the actor isn't in.
  • Leaving and removing agents. kind:9022 lets anyone leave a DM, and kind:9001 lets you remove yourself or your own agent (as deleting an agent does). kind:9000 and every other 9001 are refused on DMs; members join only through 41010/41011.
  • Removals keep identity in step. When anyone leaves or is removed, including moderation kicks (which remove members directly in the database), the DM keeps its identity only if its remaining members still match it. Otherwise it gives the identity up under the same membership lock: it keeps its history, and opening that group again starts a new DM. Removing an agent from a group DM keeps it that group's DM.
  • The relay's moderation DM stays two-party, on both 41010 and 41011.
  • SDK build_dm_add_member(..., include_history) and CLI buzz dms add-member --include-history. Without the flag the CLI keeps the old behavior.

Removing other people (beyond moderation kicks) and DM naming are out of scope.

Client follow-ups: block/buzz-app#2670 (desktop, sends the tag for history adds, agent adds, and New message opens that show an existing DM) and the mobile PR (sends it after its choice dialog).

Deploy this relay change before the app PR merges: an older relay ignores the history tag, so the app's Include history would start a new DM instead.

Decisions and known gaps

  • No agent cap, on purpose. This matches channels. The guard is the add policy instead: every agent the caller doesn't own needs its channel_add_policy to allow it whenever the DM holds anyone besides the caller and that one agent. So a DM stuffed with 40 agents whose policy is nobody is refused. Covered by adding_an_agent_follows_its_add_policy and opening_with_several_agents_follows_their_add_policy.
  • A tagless add opens the exact-member DM. Without the history tag, 41011 opens or creates the DM of every member, agents included, so agents are no longer dropped.
  • Where the protocol is documented. kind:41010 and kind:41011 have no NIP today. The history tag is documented in the SDK builders and the CLI --include-history help, and the created field of the response is documented on the SDK builder.
  • Known gap: lost announcements on retry. The membership change and the idempotency record commit separately, the same pattern main uses for every DM command. If the membership change commits but the idempotency commit then fails, a client retry finds the members already there and returns success without posting the join messages, notifications or the new-DM announcement. Membership and history are correct. This needs a commit failure between the two writes, and fixing it means moving the DM writes into the idempotency transaction for all DM commands, so it's out of scope here.

Rollout and rollback

  • No kill switch needed. The new behavior is opt-in per event: clients that don't send ["history", "include"] get the old behavior.
  • If the relay rolls back, DMs that grew are no longer found by their people, so opening one starts a new DM. History stays in the grown DM and no data is corrupted.

Related issue

None found.

Testing

  • Postgres-backed handler tests (handlers::dm_growth_postgres_tests, 23 cases) go through ingest_event: growing with and without history, the tagless 41010 and 41011 paths keeping the old behavior, collisions, agents joining in place only with the tag, per-agent owner DMs, the people-only limit, add policies (including several agents with no other person), agent actors, not revealing a DM the actor isn't in, the moderation DM on both kinds, a person added to an agent-only DM with the tag starting a separate DM, unknown history values, 9000 refused, leaving, owners removing their agents, and kicks releasing or keeping identity. Each new guard was mutation-checked: removing it turns a test red.
  • buzz-db DM tests, SDK builder tests, clippy (-D warnings) and fmt pass on 13b399c. After rebasing onto main, the 23 handler tests and the buzz-db DM tests pass again, and the 5c445a4 push passed the full pre-push hook. The only conflict was the CLI add-member call, where main made event signing async; DM relay and database code had no conflicts. The earlier f12a3f5 push skipped the pre-push hook after it ran past 30 minutes under heavy machine load; CI runs the full suite. Earlier pushes skipped hooks once for an unrelated flaky buzz-acp timing test.
  • Not yet done: exercising it against a running local relay, and a human test.

Human test steps (local relay + app on the paired app branch): open a DM with one person, open its access dialog, add a second person and choose Include history: they join the same DM and see earlier messages; then message only the original person: a new DM opens. Add a person with Start new DM: a new DM of everyone opens and the original is unchanged. Add an agent: it joins the same DM directly. In a DM with only you and an agent, add a person: a new DM of everyone opens without a question, and the agent DM is unchanged. Leave a group DM: you're removed, and messaging the group again starts a new DM.

🤖 Generated with Claude Code

morgmart and others added 8 commits November 6, 2026 16:53
A DM is now identified by its people: agents never change which DM it
is and do not count toward the 9-person limit.

kind:41011 gains ["history", "include"]: the added people join this DM
with its history, and its identity moves to the grown set of people. If
a DM of those people already exists, that DM is returned instead.
Without the tag an added person starts a new DM of everyone, as before.
Agents always join in place, subject to their channel_add_policy.

kind:41010 finds the DM of the named people and adds any named agents
it lacks. DMs made before this change are still found by all members.

Only people grow DMs: an agent cannot send the history tag, its adds
start a new DM, and a DM it opens is identified by all its members.
Growing never reveals a DM the actor is not in.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Carl <c217fe6b9d958f41c3a5e030dccc7f626775a923089cb6491305eade75ea1f1b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Generic membership kinds 9000, 9001 and 9022 are refused on DMs, so a
DM's members change only through kind:41010/41011, which keep its
identity current. Moderation and admin kicks remove members directly in
the database and still work on DMs; any removal from a DM now releases
its identity under the membership lock, so the DM keeps its history and
opening that group again starts a new DM instead of a stale match.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Carl <c217fe6b9d958f41c3a5e030dccc7f626775a923089cb6491305eade75ea1f1b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A removal releases a DM's identity only when the remaining members no
longer have it. Agents are not part of a DM of people's identity, so
kicking one leaves the DM as that group's DM; a DM of someone and their
agents, identified by all its members, is still released.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Carl <c217fe6b9d958f41c3a5e030dccc7f626775a923089cb6491305eade75ea1f1b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refusing kinds 9001 and 9022 on DMs took away leaving a DM, which
clients offer today, and broke agent deletion, which removes the agent
from each of its channels with kind:9001. Every removal already
releases the DM's identity when its people change, so a DM again
accepts kind:9022, and kind:9001 for yourself or your own agent.
People still join only through kind:41011.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Carl <c217fe6b9d958f41c3a5e030dccc7f626775a923089cb6491305eade75ea1f1b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gaps

- Opening a DM can no longer put anyone else in the relay's moderation DM.
- Opening a DM checks each agent's add policy unless it is just the caller
  and that one agent.
- An agent joining a DM of at most one person moves its identity to all its
  members, so reopening the original pair starts a new DM.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Carl <c217fe6b9d958f41c3a5e030dccc7f626775a923089cb6491305eade75ea1f1b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without ["history","include"], kind:41010 and kind:41011 behave as before
members could join in place: opening finds or makes the DM of exactly the
named members, agents included, and any add starts a new DM of everyone.
With the tag, opening finds the people's DM and adds the named agents, and
adds join in place. An older client can no longer put an agent into an
existing DM, and show it the history, without the user seeing a warning.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Carl <c217fe6b9d958f41c3a5e030dccc7f626775a923089cb6491305eade75ea1f1b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A DM of at most one person and their agents now never grows into a DM
of two or more people, even with ["history","include"]. grow_dm reports
PeopleStartNewDm under the membership lock, and 41011 falls back to the
fork path: it opens or creates the DM of everyone and leaves the
original untouched. This matches main, where adding a person to an
agent chat always started a new DM. Agents still join in place, and
DMs of two or more people are unchanged.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Carl <c217fe6b9d958f41c3a5e030dccc7f626775a923089cb6491305eade75ea1f1b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kind:41010 and kind:41011 have no NIP yet, so the SDK builder carries the
contract: the opt-in history tag and the channel_id/created response.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Carl <c217fe6b9d958f41c3a5e030dccc7f626775a923089cb6491305eade75ea1f1b@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

No deployments
codex-review — 0279a892 Deployed Nov 7, 2026 by morgmart via Run Codex Security Review #13707
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant