sendTo
Send to one peer — never to yourself. Suspends until accepted by the local transport.
A self-send is REFUSED, and not as PeerNotConnected
sendTo(selfId, …) throws IllegalArgumentException (require(peer != selfId)). Addressing yourself has no meaning at this layer: this call names another peer to route a frame to, and broadcast is already the loop-back surface for a consumer that wants its own frame back.
The exception type is the substance of the rule, not a detail. PeerNotConnected means "absent from peers" — and selfId is in peers, always, by that property's initial-value invariant. Reporting it would state something false about the roster, and a caller that (reasonably) reads it as "that peer has gone" would retry, reconnect, or drop the peer over what is really a programming error in its own addressing. An IllegalArgumentException says the one true thing: the argument was wrong.
Ordering: the SeamState.Torn check comes first, everywhere. State is the coarser precondition — a torn seam cannot carry a frame to anyone, so which peer was named is moot — and it keeps this refusal orthogonal to lifecycle: sendTo(selfId) on a live seam is an IllegalArgumentException, on a torn one an IllegalStateException, on every fabric.
Held by SeamConformanceSuite.sendToSelfIsRefused as an ungated core obligation, on both ends of a role-split pair. It is ungated deliberately (#2428): the check reads two ids the seam already holds and never touches the wire, so no transport can be unable to honour it, and a capability flag would be an opt-out from a universal contract — which is exactly how the behaviour drifted in the first place, unspecified and untested, between fabrics that refused and fabrics that delivered the frame to the other peer instead.
A send failure must NOT be reported as a cancellation
Throw an ordinary exception when the frame cannot be handed to the transport. An implementation must not let a CancellationException out of this method (or out of broadcast) unless it is signalling the caller's own cancellation, because the caller cannot tell the two apart: the idiomatic guard (runCatchingCancellable) rethrows any CancellationException, and a rethrown one cancels the calling coroutine rather than failing it — no failure handler runs, and there is not even a stack trace to find it by.
The trap is withTimeout(sendTimeout) { … }. withTimeout throws TimeoutCancellationException — which is a CancellationException — to its caller, without cancelling that caller's job. Convert it before it escapes: withTimeoutOrNull plus an explicit throw, or catch it and rethrow as a plain Exception.
This is the same obligation Loom.weave carries, for the same reason, and it is stated on both because a contract only one method carries is what let a real bug through: a long-lived consumer loop that sends per recipient is cancelled — not failed — by one such throw, so it stops sending for the rest of the session in complete silence. A caller that cannot afford to trust this should guard with try/catch plus currentCoroutineContext().ensureActive() rather than runCatchingCancellable; CompositeSeam.reconcile and SeamRoom's admit fan-out writer are the in-tree patterns.
Throws
if peer is selfId — see the first section. Checked after the SeamState.Torn state check and before the roster lookup, so it is never masked by a PeerNotConnected for an id peers names.
if peer is absent from peers. Reserved for a statement about the addressee — never about this seam. A SeamState.Torn seam owes its caller an IllegalStateException that is not a PeerNotConnected (#2448): the tear is a fact about this seam, and reporting it as an absent peer sends the caller re-resolving the roster and retrying against a fabric that can no longer carry anything. So the SeamState.Torn check comes ahead of the roster lookup, and SeamConformanceSuite.sendOnTornSeamThrows holds every fabric declaring throwsOnSendToTorn to the distinction. (Placing it ahead of the self-send guard too is the in-tree convention, but no suite property pins that ordering: the torn case is only ever exercised with a remote addressee, and the self-send obligation asserts a Woven precondition precisely because a torn seam's refusal would satisfy nothing it asks.)
if payload exceeds maxPayloadBytes — the obligation on a seam that publishes a budget, and what a caller must be ready for. Independent of state (a Woven seam raises it), so a catch (PeerNotConnected) written against the state-driven refusals alone does not cover it. Raised before the frame reaches the fabric, so a caller that gets it knows nothing was written — see maxPayloadBytes.