# WebSocket platform: how the design maps onto this repository The design models the platform as eighteen Gradle modules under `modules/websocket`. This repository's fail-closed registry (`src/config/architecture/modules.json`) owns the leaf list, so those modules are packages inside the registered `:adapter:inbound:websocket` leaf — the same resolution the JPA, GraphQL and web platforms reached. That is only honest if the boundaries are machine-checked, so `WebSocketStableModule` declares each module's package, its purity grade and its exact allowed edges, and `WebSocketModuleBoundaryTest` scans the source tree and fails when the two disagree in either direction. Promoting a package to its own Gradle leaf later is a registry edit rather than an archaeology exercise. ## Where the module map deviates from the design, and why | Design places it in | Here | Reason | | --- | --- | --- | | `WebSocketSubprotocolName` in `websocket-protocol` | `core` | The connection context must name the negotiated token, and the context is core — leaving the type in `protocol` made `core` depend on `protocol` while `protocol` already depended on `core`. The boundary test refused the cycle. The negotiation *policy* stays in `protocol`. | | `StrictWebSocketJsonCodec` in `websocket-protocol` | `codec` (its own FRAMEWORK_BOUND module) | `protocol` is CORE here, and a Jackson import in a CORE module is refused. Splitting is better than relaxing the rule: the envelope's field rules stay testable with no mapper, and everything that touches a parser sits in one package a reviewer can read end to end. | | `budget -> core` | `core -> budget` | `budget` imports nothing from `core` — numbers depend on nothing. The endpoint profile, which is core, has to name a budget. The declared direction was simply backwards. | | — | `stomp` module | The pre-existing STOMP-over-SockJS channel predates this platform and still ships. Declared so the boundary is complete rather than excused; it has no edge to any platform module and none to it. | ## What the design's rules actually prevent A few of the design's requirements read as style and are not. These are the ones worth keeping. **`websocket-core-api` names no framework.** Stricter here than in the HTTP platform, because a connection is a long-lived object owned by a container and reaching for the container's own session type is tempting from everywhere. A CORE module has no `WebSocketSession`, so the same decision serves both runtimes and is testable without a server. The detector's framework list was missing `tools.jackson` (this repo runs Jackson 3, not 2) — a CORE module could have imported a mapper unnoticed. Fixed in both this leaf and the web leaf. **No Java class name on the wire.** A FQCN publishes the package layout, breaks every client on a rename, and makes the receiver's type resolution an attack surface. `WebSocketMessageType` refuses anything that looks like one; the manifest binds published names to records, and records cannot run code while being populated. **The payload is an encoded string, not a `Map`.** A map accepts any shape, defers validation to whichever handler reaches for a missing key, makes an entity trivially serializable onto the wire, and brings unbounded nesting with it. **Handlers cannot write.** `WebSocketHandlerContext` has no session and no write method. This is what makes ordering, backpressure and the drain sequence guarantees rather than conventions — a handler that could write directly would bypass the queue, and every promise would hold only for the handlers that cooperated. ## Two findings from building it **Tomcat's graceful shutdown does not close WebSocket connections.** It waits for in-flight *requests*, and an established WebSocket is not a request — so the shutdown completes with the connections still open and they die when the socket is torn down. The client sees a **1006**, indistinguishable from a network failure, which sends it into its most aggressive reconnect path at exactly the moment the fleet is restarting. `PlatformWebSocketHandler` therefore implements `SmartLifecycle` and closes its own connections with **1001 going away**, at a phase that runs before the web server stops. Found by `TomcatWebSocketAbuseIT`, which failed on its first run. **Both runtimes on one classpath is a silent outage.** Spring Boot deduces one application type from what is present, and picks the servlet one. A deployment that declared reactive endpoints and shipped both starts, reports healthy, and never answers. `WebSocketStackExclusivity` reads what the classpath will actually produce and fails startup with a sentence explaining it. ## Lanes ```bash cd src ./gradlew :adapter:inbound:websocket:test # unit, boundary, architecture, Tomcat runtime ./gradlew :adapter:inbound:websocket:websocketJettyTest # the second servlet container ./gradlew :adapter:inbound:websocket:websocketNginxTest # real Nginx; needs Docker, fails without it ``` The Nginx lane carries a deliberately broken configuration alongside the correct one (`nginx-no-upgrade.conf`) so the lane proves its own assertions can fail. A contract that only ever runs against a correct configuration cannot tell whether it is checking anything. ## The Stable behaviours that had no code A late audit compared every named type in the Stable plan against the source tree rather than against memory, and found six that nothing implemented. They are listed because the way they were missed is more useful than the fact that they were: each is a *behaviour under failure*, and the plan named it inside a task whose other half was already built — so the task read as done. | Behaviour | Where it lives now | | --- | --- | | A queue observation an operator can read, separating a configured drop from a lossless overflow | `outbound/OutboundQueueSnapshot` + `OutboundQueue.snapshot()` | | A timed-out correlation id that is remembered, so a late answer is not attached to a reused id | `handler/LateResponseTombstone` | | Pooled-buffer retention on the reactive stack, bounded and counted | `webflux/WebSocketDataBufferPolicy` + `WebSocketDataBufferLifecycle` | | What a proxy in front of this platform has to do | `release/WebSocketNginxProxyProfile` | | What a rolling restart has to demonstrate, in order | `release/WebSocketRollingRestartScenario` | | What the platform must show before promotion | `release/WebSocketStableReleaseGate` | The three `release` types share a module identity (`RELEASE`, CORE) that names no other module. That is deliberate: a gate importing the parts it gates would be satisfiable by construction — the evidence and the checklist would come from the same source. They are predicates over facts a release engineer supplies, so a missing runtime stays a missing runtime. `WebSocketStableReleaseGate` overlaps `advanced/release/AdvancedPromotionGate` in shape and differs in one condition that matters. Advanced capabilities are off unless a deployment names them, so a broken one affects whoever enabled it; Stable is what every deployment gets, so its evidence has to cover every runtime it claims — Tomcat, Jetty, Reactor Netty and Nginx — rather than the one the author happened to test. It also refuses to promote an artifact containing an Advanced type. WS-ARCH-6 already refuses that as a source edge; nothing in it notices a type that arrived through packaging, and the effect is identical — Stable that does not build without Advanced is a naming convention. ## Not yet implemented **Tasks 47–48, the browser matrix.** The design asks for a protocol test client driven through Chromium, Firefox and WebKit. Not built: it needs Playwright and a browser download per engine, which is a dependency and a network requirement this template does not otherwise carry, and a browser lane that silently skips when the browsers are absent is worth less than no lane. The properties it would cover that the Java client does not — that a browser cannot set headers on the `WebSocket` constructor, and that it surfaces close codes to page script — are the reasons `ONE_TIME_TICKET` and the standard close codes exist, and both are asserted at the unit level. ## Advanced capabilities The Advanced expansion plan asks for fifteen Gradle modules under `modules/websocket-advanced/`. They are packages under `advanced.**` in this leaf, for the same reason the Stable platform is — `src/config/architecture/modules.json` is fail-closed and owns the leaf list, and a fifteen-leaf addition to satisfy a directory layout is a change to the registry, not to the architecture. The separation the design wanted is enforced by `WebSocketStableModule` and by WS-ARCH-6, which fails the build when a Stable class names an Advanced one. A feature flag decides whether a bean exists; it does nothing about a compile-time edge, and ArchUnit does. Three of the fifteen needed their own module identity rather than sharing `advanced`: - **`advanced-stomp`** (`advanced.stomp`) is `FRAMEWORK_BOUND`. STOMP here *is* Spring Messaging, and folding it into `advanced` would have relaxed that module's purity for every capability in it. - **`advanced-stomp-rabbit`** (`advanced.stomp.rabbit`) is separate again. 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, so a deployment can refuse one and keep the other. - Everything else resolves to `advanced` by longest-prefix, which is what lets `advanced.codec.cbor` exist without its own edge set. ### What was adapted rather than copied **Task 8's presence record shape.** The design specifies `(actorFingerprint, activeConnectionCount, state, lastObservedAt)` with a four-state observation model. Implemented with `WebSocketActorReference` in place of a bare fingerprint string — it carries the fingerprint and refuses to be constructed from raw identity, which is the property the design was buying with the field name. The four states are implemented as specified; `PresenceState.STALE` and `OFFLINE` are distinct because collapsing them reports every user as disconnected during a Redis partition, when the connections are fine and the index went dark. **Task 14's Protobuf codec.** The descriptor compatibility gate and the profile are implemented and tested. The encode/decode path is not: a Protobuf codec without generated message classes has nothing to encode, and generating them requires a `.proto` source this template does not have and should not invent. `DescriptorCompatibilityGate` is the part with the failure mode worth guarding — a changed or reused field number reinterprets bytes already on the wire, and during a rolling deploy both descriptor versions are live, so the receiver reads the wrong field without erroring. **Task 15's CBOR codec.** Same shape, same reason: `CborCodecProfile` fixes canonical encoding and the duplicate-key policy, which are the two settings that decide whether two systems reading the same bytes agree. `WebSocketCborCodec` implements the encode/decode path on top of it, and `SchemaParity` is what stops it publishing a different catalog than JSON. `jackson-dataformat-cbor` and `protobuf-java` are `compileOnly` plus `testImplementation`, not `implementation`. Both jars change an adopter's behaviour by their mere presence — Spring Boot registers a `cborMapper` bean for the first and Spring registers a Protobuf message converter for the second — so an adopting composition root would acquire both without enabling either capability. The sibling web leaf shipped exactly that mistake and it broke the composition root outright; see `docs/web/repository-adaptation.md`. `WebSocketBinaryCodecBackend` turns an absent backend into a sentence naming the missing coordinate, and `BinaryCodecBackendScopeTest` reads `build.gradle` and fails if either returns to `implementation`. **Task 12's relay configuration.** `RabbitBrokerRelayConfiguration` contributes only the broker, not the destination prefixes. Two `WebSocketMessageBrokerConfigurer` beans each setting the application prefix produce whichever ran last, silently — the same failure `StompBrokerExclusivity` exists to catch between the two STOMP channels. ### Verification ```bash cd src ./gradlew :adapter:inbound:websocket:test # 509 tests, Advanced included ./gradlew :adapter:inbound:websocket:websocketJettyTest ./gradlew :adapter:inbound:websocket:websocketNginxTest ``` See `docs/adr/ADR-WS-002-resume-and-cluster.md`, `docs/adr/ADR-WS-003-stomp-and-broker-relay.md`, `docs/websocket/advanced-support-matrix.md` and `docs/websocket/runbooks/`.