# ADR-WS-003: STOMP is an Advanced adapter with a declared destination catalog - Status: Accepted - Date: 2026-08-25 - Scope: `adapter:inbound:websocket` — `advanced.stomp`, `advanced.stomp.rabbit` ## Context Advanced Tasks 9–13 add STOMP 1.2 alongside the platform's own protocol, plus a RabbitMQ broker relay and cross-node user destinations. This leaf already ships an older STOMP-over-SockJS channel (`stomp`, gated on `ca-skeleton.websocket.enabled`). Two `@EnableWebSocketMessageBroker` configurations in one context do not conflict loudly — both contribute a configurer, both call `configureMessageBroker`, and the broker that results is whichever ran last. Nothing errors and nothing logs. STOMP also brings a destination model that is a free string from the client. Without a catalog, the set of reachable destinations is whatever the broker accepts, which for the simple broker is every string. ## Decision **The Advanced adapter is its own module (`advanced-stomp`), separate from `advanced`.** It is the one Advanced capability that cannot be pure — STOMP here *is* the Spring Messaging types — and folding it into `advanced` would relax that module's purity for every capability in it. **The relay is a further module (`advanced-stomp-rabbit`).** The adapter parses a protocol; the relay opens a TCP connection to somebody else's broker and makes every delivery depend on it. Different blast radius, different decision, different module. **Destinations are declared, per operation.** `StompDestinationCatalog` maps `(operation, destination)` to a required permission. Undeclared is refused. `SUBSCRIBE` and `SEND` are separate declarations, because reading a feed and publishing into it are different rights. **The authorization decision is a value, not an interceptor method.** `StompAuthorizationPolicy` returns a `StompAuthorizationDecision`; `StompSecurityInterceptor` only extracts and enforces. A rule reachable only through a `MessageChannel` gets tested for the cases somebody built a channel for. **Only one STOMP runtime may run.** `StompBrokerExclusivity` fails the context when both channels are enabled, when both brokers are, or when the adapter is enabled with no broker behind it. **A `RECEIPT` is never promoted to a commit.** `StompEvidence` has six stages and `StompAckPolicy.evidenceForReceipt()` is fixed at `PROTOCOL_RECEIPT`. The receipt is written by the protocol layer, which knows nothing about whether the work succeeded. **The simple broker declares what it cannot do.** `SimpleBrokerProfile` cannot be constructed claiming cluster support or durable acks, and refuses activation outside local/test — in a multi-node deployment it does not error, it delivers to whichever fraction of users is on the publishing node. **Unresolved user destinations are broadcast once and then dead-lettered.** `MultiNodeUserDestination` distinguishes a message that arrived *via* the broadcast from one that did not. Without that, every node rebroadcasts every unresolvable message on receipt. ## Consequences - Enabling Advanced STOMP requires disabling the legacy channel. There is no migration path that runs both; the exclusivity check makes that explicit at startup rather than at 3am. - A deployment must write its own catalog. There is deliberately no default: an empty one refuses every frame and reads as a broken adapter, and a non-empty one publishes destinations nobody chose. - The relay's cost is one broker connection per authenticated session plus one system connection. `brokerConnectionsFor` exists so this is computed before the first outage. - User-destination metrics are tagged with `UserDestinationAction`, never the destination — a user destination contains a user identifier by construction. ## Alternatives considered - **Extend the existing `stomp` package.** Rejected: it is Stable, and WS-ARCH-6 forbids a Stable module naming an Advanced one. Making the legacy channel profile-driven would have required that edge. - **One `advanced-stomp` module including the relay.** Rejected: the relay is a separate operational decision and deserves to be refusable on its own. - **Allow undeclared destinations with a wildcard permission.** Rejected: the wildcard becomes the default and the catalog becomes documentation.