feat: web, websocket 어댑터 추가 구현

This commit is contained in:
DongHyeonka
2026-08-28 17:01:27 +09:00
parent 0137263441
commit a24ece9cf7
883 changed files with 100584 additions and 2623 deletions
+47
View File
@@ -0,0 +1,47 @@
# WebSocket Advanced: support matrix
Every capability is off unless named. This table is what each one costs and what has to be true
before it is promoted. `AdvancedPromotionGate.forCapability` is the machine-checked form of the last
two columns; if they disagree, the code wins and this table is stale.
| Capability | Flag | Adds | Required suites | Soak |
| --- | --- | --- | --- | --- |
| `RESUME` | `…advanced.resume.enabled` | A signed token and a replay store | `websocket:test`, `websocketJettyTest`, `resume-history-loss`, `resume-replay` | 8h |
| `CLUSTER_REDIS` | `…advanced.cluster-redis.enabled` | A Redis dependency on the routing path | `websocket:test`, `multi-node-fanout`, `node-loss`, `index-partition` | 24h |
| `CLUSTER_MESSAGING` | `…advanced.cluster-messaging.enabled` | A broker dependency on the delivery path | as `CLUSTER_REDIS` | 24h |
| `PRESENCE` | `…advanced.presence.enabled` | A read model over the cluster index | as `CLUSTER_REDIS` | 24h |
| `STOMP` | `…advanced.stomp.enabled` | A second protocol parser, pre-authentication | `websocket:test`, `broker-outage`, `broker-reconnect`, `user-destination` | 8h |
| `BROKER_RELAY_RABBIT` | `…advanced.stomp.relay.enabled` | A TCP dependency on an external broker | as `STOMP` | 8h |
| `CODEC_PROTOBUF` | `…advanced.codec-protobuf.enabled` | A second decode path | `websocket:test`, `websocketJettyTest` | 8h |
| `CODEC_CBOR` | `…advanced.codec-cbor.enabled` | A second decode path | `websocket:test`, `websocketJettyTest` | 8h |
| `COMPRESSION` | `…advanced.compression.enabled` | Per-connection memory, and a length side channel | `websocket:test`, `decompression-bound`, `memory-under-load` | 24h |
| `OUTBOUND_CLIENT` | `…advanced.outbound-client.enabled` | Long-lived connections this service initiates | `websocket:test`, `websocketJettyTest` | 8h |
| `SOCKJS_COMPAT` | `…advanced.sockjs.enabled` | Credentialed cross-origin HTTP, so CSRF | `websocket:test`, `websocketJettyTest` | 8h |
| `HTTP2_COMPAT` | `…advanced.http2.enabled` | RFC 8441 extended CONNECT | `websocket:test`, `websocketNginxTest`, `proxy-matrix`, `classic-upgrade-fallback` | 7d |
| `HTTP3_EXPERIMENTAL` | `…advanced.http3.enabled` | QUIC, and every hop problem HTTP/2 has | as `HTTP2_COMPAT` | 7d |
| `GRAPHQL_TRANSPORT` | `…advanced.graphql-transport.enabled` | A subprotocol on the Stable runtime | `websocket:test`, `websocketJettyTest` | 8h |
## What is *not* supported, and why the row is here
| Not supported | Reason |
| --- | --- |
| Simple broker in a multi-node deployment | It does not error. It delivers to whichever fraction of users is on the publishing node, which reads as intermittent loss. `SimpleBrokerProfile.activatableUnder` refuses it. |
| `permessage-deflate` on an endpoint mixing a secret with attacker-influenced content | The CRIME/BREACH shape. No parameter combination makes it safe; the leak is in the compressed length. `CompressionPolicy.mayCompress` refuses it. |
| JSONP polling on a sensitive endpoint | JSONP executes server-supplied script in the page. |
| Protobuf payloads above 256KB | The broker, the replay store and every in-memory queue would each hold the message whole. |
| Extended CONNECT on an untested hop | RFC 8441 fails by killing the connection, not by negotiating a fallback. |
| HTTP/3 as stable support | `Http3ExperimentalProfile.stableSupport()` returns false, always. |
| Both STOMP channels at once | The broker that results is whichever configurer ran last, with no error. `StompBrokerExclusivity` refuses it. |
## Promotion
Each capability is promoted on its own evidence. They share a feature-flag mechanism and nothing
else, so promoting them together means the evidence for the cheapest is treated as evidence for the
most dangerous.
Two conditions apply to all of them and are not waivable:
- **Rollback exercised.** A flag nobody has turned off is not known to turn off.
- **Stable artifact unchanged.** If enabling the capability changed Stable's wire contract or
dependency graph, Stable was never independent of it, and deployments that did not enable it are
affected anyway.
+180
View File
@@ -0,0 +1,180 @@
# 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 4748, 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/`.
+107
View File
@@ -0,0 +1,107 @@
# WebSocket platform runbook
Organised by what you observe, because that is what you have during an incident.
## Clients report 1006 — "closed abnormally, no reason"
The most important symptom here, because 1006 is indistinguishable from a network failure and
drives every client into its most aggressive reconnect path.
- **During a deploy** — the platform did not close its connections. `PlatformWebSocketHandler`
implements `SmartLifecycle` and closes with **1001 going away**; a container's own graceful
shutdown does **not** do this, because it waits for in-flight *requests* and an established
WebSocket is not a request. Check the handler bean is registered and that its phase runs before
the web server stops.
- **Behind a proxy** — the upgrade is mishandled, so the close frame never becomes a close frame.
See the next section.
- **Neither** — something threw while closing. A close reason longer than 123 bytes makes the
container throw mid-close and turns a clean refusal into a 1006; the platform truncates for that
reason.
## Connections work locally and not behind the load balancer
Three nginx directives, each producing a different failure:
| Missing | Symptom |
| --- | --- |
| `proxy_http_version 1.1` | Handshake answered 400, or simply not upgraded. HTTP/1.0 has no Upgrade mechanism. |
| `Upgrade` / `Connection` forwarded | Upstream sees an ordinary GET and answers 404 or 200 for a route that works when tested directly. They are hop-by-hop headers, so a proxy is *required* to drop them. |
| `proxy_read_timeout` raised | Every connection quieter than 60s is killed with no close frame. A WebSocket is idle by nature. |
`websocketNginxTest` asserts all three against a real Nginx, and carries a deliberately broken
configuration so it proves its own assertions can fail.
## Connections vanish with no close frame and no error
Almost always an intermediary's idle timeout, not the application. TCP does not report a departed
peer — a closed laptop lid, a phone switching to cellular and a NAT forgetting its mapping all
produce no FIN.
- Check `HeartbeatPolicy`: the ping interval must be under ~20s, because intermediaries commonly
drop idle connections at 3060s and say nothing.
- The idle timeout must be at least two ping intervals. Below that, one dropped ping on a congested
network closes a healthy connection, and the reconnect storm makes the congestion worse.
## Memory grows and nothing is failing
A slow consumer. It does not error — it reads more slowly than the server writes and the difference
accumulates. Arranging it requires no tooling: reading slowly is enough.
- **Check first:** `WebSocketNodeSnapshot.droppedOutboundMessages`. Connection counts and buffered
bytes both look healthy while data is being lost; the drop count is the only number that says so.
- **Bounds:** per connection (`maxBufferedOutboundBytes`) *and* node-wide (`GlobalBufferBudget`).
The second is not redundant — a megabyte each is fine at a hundred connections and is the whole
heap at fifty thousand.
- **If drops are zero and memory still grows:** check that the queue lock and the socket lock are
separate. When they were one lock, a stalled peer blocked every producer, the queue never filled,
and the shedding bound never fired. That is fixed, and it is the shape to look for if it recurs.
## A command ran twice
The reconnect is what makes this a WebSocket problem: a client replays everything it never saw an
answer for, on a new connection, all at once.
1. **Key scoped to the connection?** It must not be. `WebSocketCommandKey` is endpoint + actor +
the client's own message id, so it survives the reconnect.
2. **Ledger not transactional?** The only implementation that works writes in the application's own
transaction. Anything else has a window between the business commit and the ledger write.
3. **Outcome `UNKNOWN`?** That is the window. Neither replaying nor re-running is safe — replaying
invents a result, re-running duplicates a committed write. Only `CommandReconciliation` reading
the business data can settle it, and `INDETERMINATE` is a real answer to act on.
## A client cannot connect and the endpoint is definitely there
- **403** — origin. The same-origin policy does **not** protect a WebSocket handshake and there is
no preflight, so the server's `Origin` check is the entire defence and it is strict: exact match,
lowercase, no path, no wildcard.
- **401** — the ticket was spent, expired (30s cap), or minted for another endpoint. All three are
deliberate; a longer-lived or unbound ticket is a bearer token in a query string again.
- **400** — no offered subprotocol is supported. Production refuses a client that offers none.
- **503** — the endpoint is at its connection cap.
Order matters and is asserted: route → origin → capacity → credential → subprotocol. Origin is
checked before authentication because a cookie handshake from an attacker's page authenticates
perfectly; the credential is valid and the page is not allowed to use it.
## Endpoints never answer and the app reports healthy
Both runtimes on one classpath. Spring Boot deduces one application type and picks the servlet one,
so declared reactive endpoints are simply never served — no error, no warning.
`WebSocketStackExclusivity` fails startup on this; if a running instance has it, the check is not
wired.
## Metrics stopped arriving
A high-cardinality tag, and the risk is worse than for HTTP: a request produces one observation,
a connection produces them for hours, so the series outlive the connections and only accumulate.
`WebSocketMetricTags` allows eight names; `connectionId`, `sessionId` and `actor` are not among
them. `nodeId` is, because a fleet has a knowable number of nodes.
## Verification
```bash
cd src
./gradlew :adapter:inbound:websocket:test # unit, boundary, architecture, Tomcat runtime
./gradlew :adapter:inbound:websocket:websocketJettyTest # second servlet container
./gradlew :adapter:inbound:websocket:websocketNginxTest # real Nginx; needs Docker, fails without it
```
+55
View File
@@ -0,0 +1,55 @@
# Runbook: STOMP broker outage
Applies when `advanced.stomp.relay` is enabled and the RabbitMQ broker becomes unreachable.
## What the symptom looks like
Not an error rate. The relay holds one TCP connection to the broker plus one per authenticated
session, and a broker that stops responding without closing leaves all of them **open**. The
platform's own health check stays green; connections stay established; publishes are accepted.
The first real signal is one of:
- subscriptions established but no `MESSAGE` frames arriving, for everyone at once;
- the relay's system heartbeat failing to receive (`systemHeartbeatReceiveInterval` elapsed);
- new sessions failing to subscribe while existing ones appear fine — this is the broker's
connection limit, not the outage itself.
If the heartbeat is not configured, none of the above fires and the first signal is a user report.
`RabbitBrokerRelayProfile` refuses a zero heartbeat for exactly this reason.
## Triage
1. **Confirm the direction.** From a platform node, open a STOMP connection to the broker's host and
port directly. If that succeeds, the problem is the relay's connection state, not the broker.
2. **Check the connection count against the broker's limit.** `brokerConnectionsFor(sessions)` is
`sessions + 1`. A broker at its limit refuses new connections and serves existing ones, which
produces the "new users cannot subscribe" shape.
3. **Check whether messages are being accepted.** A half-open connection accepts every publish
silently. Publishes succeeding is not evidence the broker is alive.
## Recovery
- **Broker restarted, relay did not reconnect.** The relay reconnects on its own; if it has not
within two heartbeat intervals, restart the platform nodes one at a time. Do not restart them all
at once — every session reconnects simultaneously and the broker meets its whole client population
in one instant.
- **Broker at its connection limit.** Raise the limit or shed sessions. Shedding is the faster of
the two and the connection count falls with the sessions.
- **Broker gone and not coming back.** There is no safe fallback to the simple broker in a
multi-node deployment: it delivers to whichever fraction of users is on the publishing node.
`SimpleBrokerProfile.activatableUnder` refuses it outside local/test, and that refusal should not
be overridden during an incident. Scale to a single node first if the simple broker is the only
option.
## What is lost
Anything the broker held and did not persist. `StompEvidence.BROKER_ACK` is only durable if the
broker is durable, and RabbitMQ's durability is a property of the queue topology, not of the relay.
Messages acknowledged at `PROTOCOL_RECEIPT` were never in the broker at all.
## Afterwards
- If the heartbeat did not fire first, that is the finding. Fix it before the postmortem closes.
- If the connection limit was reached, record the session count that reached it. It is a hard
ceiling on the deployment and it is not otherwise written down anywhere.
@@ -0,0 +1,56 @@
# Runbook: resume history loss
Applies when `advanced.resume` is enabled and clients present resume tokens the replay store can no
longer satisfy.
## What the symptom looks like
Clients reconnecting and resynchronising rather than resuming. This is the **designed** behaviour,
not a fault — `ResumeCoordinator` consults `ReplayAvailability` and refuses to honour a position the
store has evicted, because delivering a stream with a hole in it is worse than an explicit
resynchronise.
It becomes an incident when the resynchronise rate is high enough to matter:
- a resynchronise means the client re-reads its whole state, so a spike is a load spike on whatever
serves that state;
- for a client that cannot resynchronise cheaply, it is user-visible as a stall.
## Triage
1. **Establish which of the three causes it is.**
- *Store eviction under load.* The replay store's retention is shorter than the disconnect
durations being seen. Look at retention against reconnect latency, not against a nominal
figure.
- *Store restarted or partitioned.* Availability drops to nothing and every token fails at once.
- *Key rotation.* `ResumeTokenKeyRing` verifies against retired keys as well as the current one;
if a key was removed rather than retired, every token minted under it fails to verify. This
produces the same symptom and a different fix.
`ResumeTokenOutcome` distinguishes these. A token that fails verification is not the same as one
that verifies and names an evicted position.
2. **Check whether the resynchronise is succeeding.** A high resynchronise rate that completes is a
capacity problem. One that fails is a correctness problem and is more urgent.
## Recovery
- **Eviction under load.** Raise retention if the store can hold it. Retention is bounded by memory,
so this trades against the store's own stability — do not raise it past what the store survives.
- **Store restarted.** Nothing to recover; the tokens are genuinely unsatisfiable. Let clients
resynchronise. If the resynchronise load is the problem, shed connections so they arrive in
batches rather than all at once.
- **Key removed rather than retired.** Restore the key to the ring as a verify-only entry. Minting
continues under the current key.
## What is lost
Nothing that was acknowledged. Resume is an optimisation over resynchronise; the client's ability to
rebuild its state from the authoritative source is the actual guarantee, and it is unaffected.
## Afterwards
- If retention was the cause, record the disconnect duration distribution that exceeded it. The
nominal retention figure is meaningless without it.
- If a key was removed, that is a process finding, not a platform one. Keys are retired, never
deleted, and the ring is the place that is enforced.