Files
buzz/docs/remote-mention-routing.md
Logan Johnson d595806fc3 fix(desktop): authorize remote mentions at publication (#7124)
🤖

## Summary

In Buzz Desktop, you could own an agent running on another device but be
unable to mention it in a channel where it had not yet joined: it was
filtered out before you could invite it. A selected agent could also
disappear from the message's recipients when permissions changed. This
lets you select an eligible agent in the existing **@ menu**, invite it
from the message composer, and send to that agent—or see an error and
keep your draft rather than silently sending without it.

#### Where the experience changes

| Screen / control | Before → after |
| --- | --- |
| A channel's **Message #…** composer, or a message's **Reply in thread
to …** composer | Type `@` (or use the existing @ button), choose your
agent, write the message and press **Send message**. An owned agent not
yet in the channel can now reach the existing **“Mention people outside
this channel?”** dialog when its response settings allow you to address
it. |
| That dialog's **Invite** button | Previously the membership
requirement could block the agent before the invitation. Now Invite
checks permission to add it, adds it as an agent member of the
**channel** (not just the thread), then rechecks membership and response
permission before sending the waiting message. An agent already in the
channel needs no invitation. |
| Existing direct message, or the new-message screen with the **To:**
field | A mention is checked against the conversation the message will
actually enter, including a newly created direct message, rather than
the old or not-yet-created destination. This does not add an Invite
control to direct messages. |
| Editing a message / sending attachments | The selected agent remains
part of the send or edit attempt through attachment upload and the final
permission check. Lost permission produces a visible error instead of
dropping that recipient. |

**Invite is not the only chat choice.** The existing **Do nothing**
button sends the message *without inviting or notifying the nonmembers*;
their names remain references in the text. Where you cannot invite, that
choice is labelled **Send anyway**. To abandon the send instead, dismiss
the dialog with Escape. Invitation actions are disabled while
preparation is pending, preventing duplicate clicks.

**Leaving and returning must not resurrect a cancelled send.** Switching
threads or leaving the composer cancels its pending invitation, even if
you return to the same thread. Cancellation before dispatch sends no
message; an accepted membership change cannot be automatically undone.
An ordinary send without a pending invitation remains bound to its
original destination rather than following you into another
conversation.

**Failed sends must not overwrite your next draft.** If you leave a
thread, return and replace or deliberately clear its draft while an
older send is pending, the older failure cannot restore deleted text,
recipients or files; success cannot erase the newer draft—even if its
text is identical. An untouched draft cleared automatically for sending
remains recoverable on failure. This protection also covers reopening
the composer and starting a newer send.

The channel timeline also keeps its existing **new-messages / Jump to
latest** button available when newer messages are waiting to be
displayed. For example, after sharing a reply to the channel and closing
the thread panel, you can click the catch-up button to reveal buffered
messages. Closing the thread does **not** guarantee the shared row
appears automatically or force you away from reading history.

### Related issue

Built on [#7122](https://github.com/block/buzz/pull/7122), base branch
`split/owned-agent-discovery`, which lets Desktop find and verify owned
agents independently of this device. Current integration head:
`1144465d00273cf74b7c22544ae5a3299bd98560`, built on exact published
root `3a56d17824522580fe04cae463b54f4c7ba66021`. Root #7122 has its own
CI and security gates; this PR must not land ahead of that dependency.
Finding an agent is not channel membership, online status or a promise
of a reply. This PR changes what the existing message controls can do
with those agents; it adds no profile, presence, cloud marker or remote
start/stop UI.

Standalone forum post/reply **Invite / Cancel** is added separately in
[#7125](https://github.com/block/buzz/pull/7125); here those composers
only gain visible authorization errors. Same-name selection/binding
fixes ([#7133](https://github.com/block/buzz/pull/7133)) and mention
spacing ([#7128](https://github.com/block/buzz/pull/7128)) are not
included.

Extracted from [#7114](https://github.com/block/buzz/pull/7114)
(historical source `98fe33ec`). [Behavior and draft-recovery
contract](1144465d00/docs/remote-mention-routing.md)
· [Originating
discussion](buzz://message?channel=f7a9536a-1738-4bad-a888-b3ea25010ef1&id=7aa1f0ab23dce514bd8a0221441cf005bf428914621171472b79747c50820848).

### Testing

![Channel composer after Send: RemoteScout is not a channel member;
Invite adds it before sending, while Do nothing sends without inviting
or notifying
it](https://raw.githubusercontent.com/block/buzz/b20345da0bd648ecbc78988637c89893a33ca2f3/pr-7124--remote-invite.png)

*Earlier candidate, mock desktop browser: the existing channel dialog
now reachable for an eligible owned agent on another device. The two
buttons have different send outcomes; Do nothing is not Cancel.*
[Success and denial
captures](https://github.com/block/buzz/pull/7124#issuecomment-5482292703)
· [Pending-state
capture](https://github.com/block/buzz/pull/7124#issuecomment-5482604329).
These show the relevant UI, not live agent availability, native
authorization or the later draft-storage/catch-up repairs. No
before-state screenshot is available.

Existing coverage exercises exact recipients, invitation
rejection/cancellation, new direct-message destinations, uploads, edits,
thread re-entry and stored-draft deletion. The timeline regression
checks the shared reply becomes visible using the available catch-up
action.

**Integration validation (2026-09-02):** independently reviewed the
routing delta onto root `3a56d178`: seven original patches unchanged;
two reconciliations retain generic publication-error toasts alongside
authorization errors and retain non-authored editability updates. Added
two production-hook regression tests (normal and queued-media
publication) requiring visible generic error, recovered draft and
released pending state.

- Writer validation: **5,995 Desktop tests**, **42 focused tests**, **22
mock-IPC browser journeys** (18 routing, 2 root provenance, 2
destination binding), and **1 voice-note failure journey** passed; lint,
types, size guards and E2E build also passed.
- The broad suite ran before the final formatting-only test amendment,
not as an exact-final-head rerun. Independent AST comparison confirmed
that amendment is semantics-preserving; **4 fresh assertions at final
`1144465d`** passed. Publication rechecked final-head TypeScript,
amended-test formatting and `git diff --check` successfully. No new full
repository `just ci` run is claimed.
- Browser tests use an isolated E2E build and **mock IPC**, not live
relay/native authorization. Historical screenshots above are explicitly
earlier UI evidence, not exact-head runtime certification. Packaged
Tauri/live-relay behavior was not independently witnessed.
- **Published-head gates:** [current CI
run](https://github.com/block/buzz/actions/runs/33657948560) and
[renewed exact-head formal review
request](https://github.com/block/buzz/pull/7124#issuecomment-5513218340)
must clear before landing. [Earlier CI
run](https://github.com/block/buzz/actions/runs/33438436438) and the two
earlier approvals cover `7ffead0f`, not this new head. Root CI/security
clearance remains separate; the independent scoped integration approval
is not merge authorization.

To try it: in a channel or thread, select an owned nonmember agent,
Send, then Invite or Escape and retry. Deny the add or revoke its
response permission before sending: expect a visible error and
recoverable draft, not a message missing the agent. During a pending
send, return to the source thread, edit or clear the draft, then leave
again: late completion must not overwrite that choice.

**Limits:** permission checks and sending are separate operations;
cancellation cannot retract a dispatched message. Draft protection is
same-window, not new cross-window deletion synchronization. Standalone
forum transport failure can still restore text/media without the exact
selected recipients. Native compatibility is inherited: open-source
builds may still recognize a valid legacy, self-declared agent already
in the channel when verified ownership is absent or rejected; that does
not establish ownership or unlock this owned-nonmember invitation path.
Invalid policy from a verified owner is still rejected. No agent
response is guaranteed.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-04 16:35:35 +00:00

5.5 KiB

Remote mention preparation and publication

Owned relay agents can be selected and invited without local runtime custody. The picker and preparation phase evaluate policy but may admit an owned nonmember. Publication separately refreshes authorization for the exact eventual destination, including a newly created DM. Ownership is not membership and cached picker evidence is never publication authorization.

Selections are intent: a vanished/revoked key must fail visibly, retain the draft and send nothing, rather than silently dropping a recipient. Captured agent keys survive composer clearing, media upload, edits and asynchronous preparation. A failed local inventory read cannot veto authenticated relay identities or admit stale local runtimes. Locally managed runtime readiness remains a separate existing path; remote identities never gain synthetic local management records.

Chat offers explicit Invite or reference-only send without inviting. Failed adds, revoked policy, failed final authorization and cancellation preserve recoverable drafts. Standalone forum sends report authorization failures, but standalone forum invitation is a subsequent change reusing this phase contract.

Native discovery prerequisite: docs/owned-agent-discovery.md (PR6). Regression coverage: agentAutocompleteEligibility.test.mjs, agentMentionRevalidation.test.mjs, useMentionSendFlow.helpers.test.mjs, submitMessageEdit.test.mjs, mentions.spec.ts, and remote-owned-mentions.spec.ts. The new remote browser fixtures use a single-word name deliberately: mention separator behavior belongs to the independent PR1.

NIP-OA establishes ownership, not physical hosting, availability, or lifecycle control. Final native queries do not provide an atomic relay transaction with message publication. Independent review of both native and publication boundaries is required before landing.

Invitation intent and cancellation

Each chat Invite owns one synchronous pending latch and abort signal, covering preparation, inventory, adds and the eventual publication continuation. Escape, navigation/unmount, reference-only selection or a replacement prompt invalidates that attempt. Clearing the prompt when promoting it to send is not cancellation; the signal remains live through final validation and queued media completion. Check cancellation after asynchronous preparation and before subsequent mutations (including nested local readiness), and again before publication. A completed add cannot be undone, but its late response cannot revive cancelled message intent. The pending state includes preparation, disabling both Invite and reference-only buttons. Cancellation after optimistic clearing restores the captured draft and exact mention refs without overwriting newer edits.

useMentionSendFlow.cancellation.test.mjs drives the actual hooks with React StrictMode and deferred dependencies; remote-owned-mentions.spec.ts covers visible pending, Escape/retry and route navigation at deferred IPC boundaries.

Source draft ownership

Invitation intent belongs to one visit of the effective persistence key and channel, not just the mounted composer or destination channel. Same-channel thread changes and A → B → A invalidate the original visit. The visible owner gates optimistic clear, editor recovery and pending state. Storage recovery instead consults shared authored intent for the source draft key, even after exit, re-entry or unmount. The draft store retains a revision and authoritative-empty marker independently of the stored value. Later same-key authored text/deletion or a new send revokes older recovery and sent-draft cleanup; editing B does not change A's authority. A visit-specific accessor reads that shared authority while preserving the separate visible-owner identity. Invalidation makes recovery durable synchronously, before a later visit can load or edit the source draft; late async completion cannot revive that recovery or release a newer attempt's latch.

The editor's authored revision distinguishes an intentional edit → clear from an optimistic empty composer. Programmatic send clear/recovery runs inside the draft lifecycle's restoration boundary, so it does not mark the source as authoritatively deleted. Pending clipboard identity verification settles before exact selected mention refs are captured. The source visit and authored revision are captured before that wait: an edit, navigation (including A → B → A), or unmount cancels rather than reading the new draft's maps. Subsequent asynchronous preparation consumes the captured selections. Persona preparation similarly consumes captured selections and returns resolved refs, writing them into the editor only while the original visit/revision remains current. Normal persona creation/reuse and ordinary destination-bound background sends retain their existing behavior; accepted membership is never rolled back.

Recovery additionally compares stored content/media/exact refs before replacing an existing record. Programmatic persistence does not itself change semantic intent. Explicit inbox deletion and replacement do; scope reset invalidates old handles. This is same-window authority for live continuations, not cross-window synchronization or a versioned storage protocol. Reload destroys continuations; authored deletion has already removed the durable value. Final membership/policy reads are not atomic with send, and cancellation cannot retract an already dispatched publication. Standalone forum transport-failure binding recovery and native compatibility remain separate review/follow-up boundaries.