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
@@ -0,0 +1,69 @@
# ADR-WEB-ADV-001: Streaming is live delivery, and the web module stores no history
- Status: Accepted
- Date: 2026-08-25
- Scope: `adapter:inbound:web``advanced.stream.**`
## Context
Advanced Tasks 613 add SSE, NDJSON and JSON text sequences, with a `Last-Event-ID` resume path.
One fact drives every decision here: **after the first byte, the HTTP status is 200 and cannot
change.** A stream that ends because a dependency failed and one that ends because it finished are
identical at the transport layer — both are a closed connection after a 200. So is a stream that was
cut off mid-flight.
The second fact is that a resume path invites the web module to remember things. It must not: the
messaging platform already owns durable event history, and a second copy would have its own
retention, its own eviction and its own opinion about ordering.
## Decision
**Three outcomes, expressed in the stream rather than in the status.** `WebStreamEnvelope` is sealed
over `Item`, `Failure` and `Complete`. A client that sees neither terminal envelope has been cut off,
and that third case is recorded as `ABRUPT_CLOSE` rather than counted as a completion — which is
where a rising rate of mid-stream failures would otherwise hide.
**Nothing writes a problem document onto a committed response.** `WebStreamTerminationMapper`
branches on whether any byte has been written. Before commit, an RFC 9457 problem with a real
status; after, a terminal record. Attempting both produces a body that is half stream and half JSON,
which no client parses and every proxy caches as a success.
**Positions are monotonic, and it is enforced.** `WebStreamEvidence.recordDelivered` refuses a
repeated or regressing position. A client deduplicating on position would silently drop the second
item.
**A slow consumer is disconnected, not buffered.** `WebStreamPolicy.maxBufferedItems` is a hard
bound. Backpressure protects the reactive pipeline; it does not protect the server's heap from a
consumer that reads slowly for an hour.
**Every stream is in a registry, and shutdown drains it.** A node with a hundred open streams and no
other traffic looks idle by request rate. `WebStreamDrainCoordinator` stops accepting first, asks
clients to reconnect, and only then forces the remainder — because a client whose socket is cut
retries immediately, and if every socket is cut at once, every client retries at once.
**The web module stores no durable history.** `WebStreamReplaySource` is an interface this module
implements nowhere. An expired cursor raises `ReplayCursorExpiredException` rather than resuming from
the oldest retained position, because that delivers a stream with a hole the client cannot see.
**The replay-to-live seam is watched.** `GapAndDuplicateGuard` detects both directions. Neither is
visible in either half on its own.
## Consequences
- Clients must handle three outcomes. A client that treats a closed connection as completion will be
wrong, and no server change can fix that for it.
- An expired `Last-Event-ID` costs the client a full re-read. That is the honest answer.
- JSON-seq is preferred over NDJSON where truncation matters: its separator comes first, so a parser
resynchronises at the next record. NDJSON's delimiter is the thing that gets truncated away.
## Alternatives considered
- **Emit a problem document when a stream fails after commit.** Rejected: the body becomes
unparseable and the 200 is cached.
- **Resume from the oldest retained position when the cursor expires.** Rejected: positions are
contiguous from where the replay started, so nothing in the data says events are missing.
- **Store replay history in the web module.** Rejected: a second source of truth that drifts
invisibly.
- **Unbounded buffering for slow consumers.** Rejected: it moves the client's slowness into the
server's heap.
@@ -0,0 +1,66 @@
# ADR-WEB-ADV-002: Virtual threads change scheduling, not the concurrency budget
- Status: Accepted
- Date: 2026-08-25
- Scope: `adapter:inbound:web``advanced.virtualthread`, `advanced.blockingbridge`
## Context
Advanced Task 2 offers a virtual-thread executor for MVC; Task 3 offers a bounded blocking bridge
for WebFlux.
A platform-thread MVC deployment has an implicit concurrency limit — the thread pool — and that
limit is usually what has been protecting the database pool, the outbound HTTP bulkhead and every
downstream service from the full arrival rate. Nobody wrote it down as an admission policy; it was a
side effect of the pool size.
Switching to virtual threads deletes that limit without deleting anything that depended on it.
## Decision
**An explicit admission limit is required when virtual threads are enabled.**
`VirtualThreadProfile` refuses construction without one. Without it the deployment accepts every
arrival, queues all of them on the downstream budgets, and times out work that would have succeeded
had it been refused. The load that used to be shed at the front door is shed at the back, after the
cost of accepting it.
**The limit bounds concurrent use cases, not threads.** `VirtualThreadAdmissionGuard` is a fair
semaphore, not a pool. Bounding threads would put the waiting back and throw away what virtual
threads bought. Ten thousand virtual threads may exist while a hundred hold permits.
**The downstream budgets are carried in the profile and stated as unchanged.** The whole point is
that they did not grow. `admissionFitsDownstreamBudgets()` reports when the admission limit exceeds
them, without refusing — a deployment can legitimately admit more than its pool when the work is not
all database-bound, and that should be a choice rather than an accident.
**Blocking offloads are registered, bounded and timed out.** `boundedElastic()` is available from
anywhere and unbounded in practice, so a controller that calls it has silently opted the whole
application into an unbounded pool. `BlockingBridgeProfile` names the operations permitted to
offload; `BlockingBridgeBudget` bounds the concurrency and refuses a caller that cannot get a slot
in time, because otherwise a slow dependency's callers accumulate until the heap does and the fast
dependencies starve behind them.
**Pinning is observed, not assumed away.** `VirtualThreadProfile.requiredObservations()` lists what
has to be watched — `jdk.VirtualThreadPinned` above all. A synchronized block held across a blocking
call pins the carrier thread, the carrier pool is bounded by CPU count, and enough pinned carriers is
a deadlock a thread dump does not obviously show.
## Consequences
- Enabling virtual threads is a two-part change: the executor and the admission limit. The profile
will not let it be one.
- Refusals rise under load, and that is correct. A request refused in a millisecond is better for
the client than the same request accepted and timed out thirty seconds later behind a full pool.
An operator seeing 503s climb should read them as the limit working.
- `VirtualThreadAdmissionGuard.peakActive()` exists so a load test can assert the limit was applied.
It is invisible from throughput, which is why a load test that only measures throughput would pass
with the guard removed.
## Alternatives considered
- **Enable virtual threads and raise the downstream budgets to match.** Rejected: the budgets are
sized to what the dependencies can serve, not to what the web tier can accept.
- **Bound the virtual threads themselves with a fixed-size executor.** Rejected: that is a platform
thread pool with extra steps.
- **Let controllers call `boundedElastic()` directly.** Rejected: every such call site is invisible
until the pool is the thing consuming the heap.
@@ -0,0 +1,55 @@
# ADR-WEB-ADV-003: OpenAPI 3.2 is generated in parallel and stays experimental
- Status: Accepted
- Date: 2026-08-25
- Scope: `adapter:inbound:web``advanced.openapi`
## Context
Advanced Task 17 adds an OpenAPI 3.2 generation lane beside the Stable 3.1.2 snapshot.
Generating 3.2 is cheap. Adopting it is not, and the two get conflated because the generated
document looks fine. The value of an API description is entirely in what consumes it, and a document
in a version a client generator does not fully understand produces a client that compiles and is
wrong — which is worse than no document at all.
## Decision
**3.1.2 remains the release artifact.** `OpenApiVersionLane.STABLE_3_1.releaseArtifact()` is true and
`EXPERIMENTAL_3_2`'s is false. This is a property of the type, not a configuration setting.
**Generating 3.2 must not change the 3.1 snapshot.** Both are produced from the same model, so a
contributor that mutates it on the way to 3.2 changes the artifact that is actually shipped —
silently, and only when the experimental lane runs. `OpenApi32CompatibilityReport` compares the
snapshot hash before and after and makes a difference a promotion blocker.
**Four kinds of tool are checked separately.** A parser reports structural errors; a linter applies
style rules and accepts documents a parser rejects; a generator produces client code, and this is
where an unsupported construct surfaces — not as an error but as a method with the wrong signature;
a compile of that generated code is the only step that catches it. "OpenAPI 3.2 works" is not a
statement anybody can make. "This document is read correctly by these four tools at these versions"
is.
**Promotion requires an accepted ADR regardless of how green the matrix is.**
`promotionBlockers(false)` always contains that blocker. A machine-checkable matrix cannot decide
whether the consumer population is ready.
**Streaming description differences are reported separately.** They are the substantive difference
between the two versions for this application, and folding them into a pass/fail hides what
changed.
## Consequences
- The 3.2 document is published as an artifact of the experimental workflow, never of the release
workflow.
- A client generator that only understands 3.1 is unaffected, which is the point.
- Adopting 3.2 later is a documented decision with a named consumer matrix behind it.
## Alternatives considered
- **Switch to 3.2 and keep a 3.1 downgrade.** Rejected: the downgrade is lossy in exactly the
constructs 3.2 was wanted for, so it would ship a description that is wrong for both audiences.
- **Generate only 3.2 and let consumers cope.** Rejected: the failure mode is a generated client
that compiles and misbehaves.
- **Skip the client-compile step in the matrix.** Rejected: it is the only one that catches the
failure the others miss.
@@ -0,0 +1,56 @@
# ADR-WS-001: The WebSocket platform ships as packages in one leaf, with machine-checked boundaries
- Status: accepted
- Date: 2026-08-25
- Scope: `:adapter:inbound:websocket`
## Context
The realtime connection platform design models itself as eighteen Gradle modules under
`modules/websocket`, each with a declared purity grade and a declared set of allowed dependencies.
This repository's `src/config/architecture/modules.json` is a fail-closed registry that owns the
leaf list; adding eighteen leaves is a registry change of a size that needs its own decision, and
HARD-STOP #5 forbids doing it implicitly.
Three earlier platforms in this repository — JPA, GraphQL, and the HTTP platform — met the same
situation and resolved it the same way.
## Decision
The eighteen design modules ship as packages inside the single registered leaf. `WebSocketStableModule`
declares each one's package, purity grade and exact allowed edges, and `WebSocketModuleBoundaryTest`
scans the production tree and fails when the declaration and the tree disagree in either direction.
Three deviations from the design's module map were forced by the check and are recorded in
`docs/websocket/repository-adaptation.md`: `WebSocketSubprotocolName` moved to `core` and the codec
moved to its own FRAMEWORK_BOUND module, both to avoid cycles the design's placement created here;
and the `budget -> core` edge was inverted because `budget` imports nothing from `core`.
## Consequences
**The boundary is enforced, not documented.** Six violations were caught during implementation that
a document would not have: two would-be cycles, a duplicate module declaration where two ids claimed
one package, and three undeclared edges. The duplicate is the instructive one — with two ids on one
package, ownership depends on iteration order and one module's rules silently apply to nothing. A
guard against it is now part of the boundary test.
**The detector had a hole.** Its framework-import list named `com.fasterxml` (Jackson 2) and not
`tools.jackson` (Jackson 3), which is what Spring 7 actually uses — so a CORE module could have
imported a mapper unnoticed. Fixed here and in the HTTP platform, which shared the list.
**Promotion stays cheap.** Each enum constant is already shaped like a leaf specification, so
splitting one out later is a registry edit rather than an archaeology exercise.
**The design's own rules were kept where they cost something.** `core` names no framework, so the
same decisions serve both runtimes and are testable without a server; no Java class name reaches the
wire; the payload is an encoded string rather than a map; and handlers are given no way to write,
which is what makes ordering and backpressure guarantees rather than conventions.
## Alternatives considered
**Register eighteen leaves.** Faithful to the design and a large change to a fail-closed registry
for a platform that ships as one artifact either way. Rejected as disproportionate; the boundary
test provides the property the modules were for.
**Ship the modules as packages with no enforcement.** Cheapest, and it makes the boundary a claim.
The six violations found during implementation are the argument against it.
+60
View File
@@ -0,0 +1,60 @@
# ADR-WS-002: Resume and cluster state are caches, and are treated as caches
- Status: Accepted
- Date: 2026-08-25
- Scope: `adapter:inbound:websocket``advanced.resume`, `advanced.cluster`, `advanced.presence`
## Context
Advanced Tasks 28 add three things that all look like state and are not: a resume token that says
where a client got to, a cluster index that says which node holds a session, and a presence summary
derived from that index.
Each is a statement about the past. The resume token was minted before the disconnect; the index
entry was written by a node that may since have died; presence is a read of the index and inherits
everything wrong with it. The failure this ADR exists to prevent is treating any of them as current
fact, because each reads as one at the call site.
## Decision
**Resume is bounded by what the replay store actually holds, not by what the token claims.**
`ResumeCoordinator` consults `ReplayAvailability` before honouring a position. A token that names a
position the store has evicted produces a resynchronise, not a gap-filled stream. The alternative —
trusting the token — silently delivers a stream with a hole in it, which is worse than an explicit
resynchronise because the client believes it is complete.
**Cluster index entries carry an observation time and are checked against it on every read.**
`ExternalSessionSummary.staleAt` exists so that "the index says edge-2" cannot be used without also
answering "as of when". An entry whose node stopped reporting is not evidence that the node holds
the session.
**Durable fan-out is deduplicated by stream position, not by message id.** At-least-once is the
contract, so redelivery is normal operation: a redeploy, a slow consumer or a broker rebalance all
produce it. `FanoutDeduplicator` keys on `(stream, position)` and advances a high-water mark under
`compute`, so two consumer threads cannot both deliver the same position.
**Presence has four states, not two.** `OFFLINE` is a reported fact; `STALE` is the absence of one.
Collapsing them reports every user as disconnected during a Redis partition, when what happened is
that the index went dark and the connections are fine.
**Nothing security-relevant may depend on presence.** An attacker who can make a node stop reporting
can move the platform's belief about who is present. Presence answers "show a green dot".
## Consequences
- A resume that cannot be honoured is visible to the client as a resynchronise. Clients must
implement one; there is no mode in which the platform silently pretends.
- Every read of the cluster index needs a clock. This is deliberate friction.
- `PresenceSummary.classify` refuses an idle window at or past the stale window, because otherwise
`IDLE` is unreachable and the caller believes it has a four-state model when it has three.
- Fan-out envelopes carry a bounded reference and the catalog-encoded document, never a business
object. A rolling deploy has two versions of the code reading the same envelope.
## Alternatives considered
- **Trust the resume token.** Rejected: it makes a gap indistinguishable from a complete stream.
- **Deduplicate by message id.** Rejected: a broker that redelivers may re-mint ids, and a producer
that retries certainly does. Position is the property the ordering actually has.
- **A single `online` boolean.** Rejected for the partition case above.
- **Write presence separately from the session index.** Rejected: two sources of truth for "who is
connected" drift, and the drift is invisible — both look plausible and nothing reconciles them.
@@ -0,0 +1,74 @@
# 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 913 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.
+11 -1
View File
@@ -5,7 +5,7 @@
# split into capability artifacts.
# Update only after review with:
# ./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange
# types: 398
# types: 408
dev.caskeleton.adapter.inbound.graphql.HealthGraphqlController
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlAdminPrincipal
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAdminAuthorization
@@ -100,6 +100,7 @@ dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketAdmission
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketAuthentication
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketCapability
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketErrorMapper
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketHandlerFactory
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketProperties
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketRoutePolicy
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketRouteRejectedException
@@ -110,8 +111,16 @@ dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketCloseRe
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketCredentialExpiry
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketPrincipal
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketRevocationSignal
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryAllowlist
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryArgumentPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryExposure
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryExposureRejectedException
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryExposureValidator
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryPaginationPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryProjectionPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseAdmission
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseConnectionPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseHandlerFactory
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseHeartbeat
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseProperties
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseRejectedException
@@ -134,6 +143,7 @@ dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscription
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionTermination
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketAdmission
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketConnectionId
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketHandlerFactory
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketLifecycle
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProperties
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProtocol
+7 -1
View File
@@ -5,7 +5,7 @@
# root yet.
# Update only after review with:
# ./gradlew :adapter:outbound:persistence-jpa:updateJpaApiSurface -PapproveJpaApiSurfaceChange
# types: 332
# types: 338
dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName
dev.caskeleton.adapter.outbound.persistence.api.capability.CapabilitySupport
dev.caskeleton.adapter.outbound.persistence.api.capability.JpaCapability
@@ -165,6 +165,9 @@ dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyResponseObjec
dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyStoreAdapter
dev.caskeleton.adapter.outbound.persistence.idempotency.entity.IdempotencyRecordEntity
dev.caskeleton.adapter.outbound.persistence.idempotency.mapper.IdempotencyRecordEntityMapper
dev.caskeleton.adapter.outbound.persistence.liveevent.JpaLiveEventReplayAdapter
dev.caskeleton.adapter.outbound.persistence.liveevent.LiveEventJpaRepository
dev.caskeleton.adapter.outbound.persistence.liveevent.entity.LiveEventEntity
dev.caskeleton.adapter.outbound.persistence.lock.DistributedLockPersistenceConfig
dev.caskeleton.adapter.outbound.persistence.lock.LockRegistryDistributedLockAdapter
dev.caskeleton.adapter.outbound.persistence.lock.LockSettings
@@ -235,6 +238,9 @@ dev.caskeleton.adapter.outbound.persistence.observation.JpaTransactionObservatio
dev.caskeleton.adapter.outbound.persistence.observation.LowCardinality
dev.caskeleton.adapter.outbound.persistence.observation.MicrometerQueryObservation
dev.caskeleton.adapter.outbound.persistence.observation.SqlDiagnosticRedactor
dev.caskeleton.adapter.outbound.persistence.operation.DurableOperationJpaRepository
dev.caskeleton.adapter.outbound.persistence.operation.DurableOperationStoreAdapter
dev.caskeleton.adapter.outbound.persistence.operation.entity.DurableOperationEntity
dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository
dev.caskeleton.adapter.outbound.persistence.outbox.OutboxEventJpaRepository
dev.caskeleton.adapter.outbound.persistence.outbox.OutboxReaper
@@ -0,0 +1,6 @@
44ba9931722364a53fcb3b5f31a1d539eabcaf42db775f5a33fb558f558c7504 README.md
d064f0ac6c3be0e5c76ef22454db2a97e1d78ed287bd22f4c125f19aba3ad8e3 VALIDATION.md
1ef15812f33dc998a6332b87523ed5942ba46d79d984a0ca776b05bb9247a06a docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md
5ae70b53e22cdb852b2bb0df171dec868bfe99b15bb8e71fb2b0b3431cd7e2cd docs/superpowers/plans/2026-08-12-graphql-api-execution-platform-implementation-plan.md
8d0203203f6bfe4b2e18625eff23bb308ba6454703a4ca4cd3236dab31ecafc3 docs/superpowers/specs/2026-08-12-graphql-api-execution-platform-design.md
8048fe6a536de67d2cf5b0df05d35128f2c68ba8f0dd615831b40430fc76277b validate_graphql_docs.py
@@ -0,0 +1,43 @@
# GraphQL Superpowers 설계 패키지
이 패키지는 `GraphQL API 실행 플랫폼 심층 리서치`를 구현 기준선으로 변환한 설계서와 실행 계획서다.
## 문서
- `docs/superpowers/specs/2026-08-12-graphql-api-execution-platform-design.md`
- Stable·Advanced 전체 아키텍처, 공개 계약, 경계, 실패 의미론, 테스트와 지원 등급
- 입력 심층 리서치 원문을 추적 부록으로 포함
- `docs/superpowers/plans/2026-08-12-graphql-api-execution-platform-implementation-plan.md`
- Stable 구현 Task 148
- `docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md`
- Stable Release Gate 이후 실행하는 Advanced·Experimental Task 119
- `VALIDATION.md`
- 정적 검증 결과와 검증 범위
- `validate_graphql_docs.py`
- 패키지 내부 문서 재검증 스크립트
- `MANIFEST.sha256`
- 패키지 파일 무결성 목록
## 구현 순서
```text
Stable Task 148
→ Stable Release Gate
→ Advanced Task 119
→ Capability별 Promotion Gate
```
## 명시적 전제
```text
Java 21
Gradle Kotlin DSL
Spring Boot 4.1 BOM
Spring for GraphQL 2.0
Boot-managed GraphQL Java v25 계열
Stable module root: modules/graphql
Advanced module root: modules/graphql-advanced
Root package: io.backend.skeleton.graphql
```
실제 저장소에 적용할 때 기존 package·version catalog·module naming에 맞춰 경로만 조정하고, 문서의 공개 계약·불변 조건·테스트 의미는 유지한다.
@@ -0,0 +1,98 @@
# GraphQL Superpowers 문서 정적 검증 결과
- **검증 시각 기준:** 2026-08-12
- **검증 대상:** 설계서 1개, Stable 구현 계획서 1개, Advanced·Experimental 확장 계획서 1개
- **검증 명령:** `python3 validate_graphql_docs.py`
- **결과:** **PASS**
- **실행 검사:** 1,475
- **통과:** 1,475
- **실패:** 0
## 문서 규모
| 문서 | 행 수 | 크기 |
|---|---:|---:|
| GraphQL API 실행 플랫폼 설계서 | 2,553 | 93,359 bytes |
| Stable 구현 계획서 | 4,560 | 209,041 bytes |
| Advanced 확장 계획서 | 1,976 | 105,717 bytes |
## 계획 구조
| 항목 | Stable | Advanced |
|---|---:|---:|
| Task 수 | 48 | 19 |
| Create 경로 수 | 227 | 113 |
| Task 번호 연속성 | PASS | PASS |
| 모든 Task의 `Files`·`Interfaces` | PASS | PASS |
| 모든 Task의 Implementation Requirements | PASS | PASS |
| 모든 Task의 Step 15 | PASS | PASS |
| 실패·통과 예상 결과 | PASS | PASS |
| Task별 Git commit 명령 | PASS | PASS |
| Create 경로 중복 | 없음 | 없음 |
| Stable·Advanced 경로 충돌 | 없음 | 없음 |
## 핵심 계약 검증
```text
SDL-first external contract
Single Executable Schema Stable default
HTTP POST Stable profile
application/graphql-response+json preferred
Validation 이후 Field Error·Partial Data는 HTTP 200
Draft 294는 Stable에서 제외
JPA Entity·MongoDB Document 직접 노출 금지
GraphQL Multipart Upload 미지원·Fileserver 사용
request-wide database transaction 금지
DataLoader request scope
Finite Fetch Profile
HMAC-signed cursor
Mutation idempotency·expected version 분리
Parser·shape·complexity·runtime response budget
Actor·Field·Object·Tenant authorization
Low-cardinality observability
Stable/Advanced dependency isolation
Persisted Operation·WebSocket·SSE·Federation 분리
RSocket·HTTP GET·Incremental Delivery Experimental
```
위 계약은 설계서와 계획서의 필수 문자열·모듈 경로·Task별 파일·테스트를 대조해 검증했습니다.
## 입력 리서치 추적성
- 첨부된 `GraphQL API 실행 플랫폼 심층 리서치` 원문 전체가 설계서의 `부록 B`에 포함되어 있습니다.
- 설계 본문은 원문의 용어와 결론을 유지하면서 구현 판단을 Stable·Advanced·Experimental로 고정합니다.
- 설계서와 입력 원문의 exact text 포함 검사를 별도로 통과했습니다.
## 패키지 검증 항목
```text
문서 파일 존재
Markdown code fence 균형
Task 148 / 119 연속성
Task별 테스트·명령·commit
정확한 Create 경로
Placeholder 금지
Stable module에 WebSocket·Federation·Persisted Operation 경로 부재
Advanced module에 feature flag와 capability 경로 존재
금지 API pattern 부재
문서 SHA-256 계산
```
## 검증 범위의 한계
현재 PASS는 **문서의 정적 구조, 요구사항 추적성, 내부 계약과 실행 계획의 완결성**을 의미합니다. 실제 Backend Skeleton 저장소가 입력으로 제공되지 않았으므로 다음은 실행하지 않았습니다.
```text
Gradle configuration·compile
Spring Boot ApplicationContext 기동
SchemaMappingInspector 실제 결과
GraphQlTester HTTP·WebFlux contract
JPA·MongoDB statement/query-count integration
query bomb·complexity load test
Virtual Thread·event-loop blocking test
WebSocket·SSE soak test
Federation composition·router integration
actual Git commit
```
실제 구현에서는 Stable Task 148을 먼저 수행해 Stable Release Gate를 통과한 뒤 Advanced Task 119를 시작해야 합니다.
+249
View File
@@ -0,0 +1,249 @@
#!/usr/bin/env python3
from __future__ import annotations
from pathlib import Path
import re
import sys
import hashlib
ROOT = Path(__file__).resolve().parent
DESIGN = ROOT / "docs/superpowers/specs/2026-08-12-graphql-api-execution-platform-design.md"
STABLE = ROOT / "docs/superpowers/plans/2026-08-12-graphql-api-execution-platform-implementation-plan.md"
ADVANCED = ROOT / "docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md"
checks: list[tuple[str, bool, str]] = []
def check(name: str, condition: bool, detail: str = "") -> None:
checks.append((name, bool(condition), detail))
def read(path: Path) -> str:
check(f"file exists: {path.name}", path.exists(), str(path))
return path.read_text(encoding="utf-8") if path.exists() else ""
design = read(DESIGN)
stable = read(STABLE)
advanced = read(ADVANCED)
# Basic document integrity
check("design line floor", len(design.splitlines()) >= 2000, str(len(design.splitlines())))
check("stable plan line floor", len(stable.splitlines()) >= 4000, str(len(stable.splitlines())))
check("advanced plan line floor", len(advanced.splitlines()) >= 1500, str(len(advanced.splitlines())))
for label, text in [("design", design), ("stable", stable), ("advanced", advanced)]:
check(f"{label} code fences balanced", text.count("```") % 2 == 0, str(text.count("```")))
for marker in ["TODO", "TBD", "FIXME", "implement later", "fill in details"]:
check(f"{label} no placeholder {marker}", marker.lower() not in text.lower())
# Design required sections and source traceability
required_design_terms = [
"# GraphQL API 실행 플랫폼 설계서",
"GraphQL Platform owns",
"Domain/Application owns",
"G1 Standard GraphQL API",
"G2 Advanced Execution",
"G3 GraphQL Extension",
"G4 Admin Plane",
"SDL",
"September 2025",
"application/graphql-response+json",
"HTTP `200`",
"GraphQlRequestContext",
"DataLoader",
"GraphQlFetchProfile",
"HMAC",
"Idempotency",
"Partial Data",
"Persisted Operation",
"Subscription",
"Federation",
"GraphQL Multipart Upload",
"Fileserver",
"부록 B. 입력 심층 리서치 원문",
"# GraphQL API 실행 플랫폼 심층 리서치",
]
for term in required_design_terms:
check(f"design contains {term}", term in design)
# Critical design invariants
critical_pairs = [
("field error uses HTTP 200", "field error" in design.lower() and "HTTP `200`" in design),
("no draft 294 stable", "294" in design and "Stable" in design),
("dataloader request scope", "request" in design.lower() and "DataLoader" in design),
("cursor HMAC", "Cursor" in design and "HMAC" in design),
("no multipart upload", "Multipart Upload" in design and "Fileserver" in design),
("single schema default", "Single Executable Schema" in design),
("request-wide transaction prohibited", "request-wide" in design.lower() and "transaction" in design.lower()),
("entity/document boundary", "JPA Entity" in design and "MongoDB Document" in design),
]
for name, condition in critical_pairs:
check(name, condition)
# Plan headers and global constraints
stable_header_terms = [
"# GraphQL API 실행 플랫폼 Implementation Plan",
"REQUIRED SUB-SKILL",
"**Goal:**",
"**Architecture:**",
"**Tech Stack:**",
"## Global Constraints",
"Stable Task",
]
advanced_header_terms = [
"# GraphQL Advanced Capability Expansion Implementation Plan",
"REQUIRED SUB-SKILL",
"backend.graphql.advanced.*",
"Stable 구현 계획 Task `148`",
]
for term in stable_header_terms:
check(f"stable header contains {term}", term in stable)
for term in advanced_header_terms:
check(f"advanced header contains {term}", term in advanced)
# Task sequence and per-task structure
def task_sections(text: str) -> list[tuple[int, str]]:
matches = list(re.finditer(r"^### Task (\d+): .+$", text, re.MULTILINE))
result = []
for i, match in enumerate(matches):
start = match.start()
end = matches[i+1].start() if i+1 < len(matches) else len(text)
result.append((int(match.group(1)), text[start:end]))
return result
stable_tasks = task_sections(stable)
advanced_tasks = task_sections(advanced)
check("stable task count", len(stable_tasks) == 48, str(len(stable_tasks)))
check("advanced task count", len(advanced_tasks) == 19, str(len(advanced_tasks)))
check("stable task sequence", [n for n, _ in stable_tasks] == list(range(1, 49)))
check("advanced task sequence", [n for n, _ in advanced_tasks] == list(range(1, 20)))
def validate_tasks(label: str, tasks: list[tuple[int, str]]) -> None:
required = [
"**Files:**",
"**Interfaces:**",
"**Implementation requirements:**",
"**Step 1: Write the failing test**",
"**Step 2: Run the focused test and verify the failure**",
"**Step 3: Implement the smallest complete production contract**",
"**Step 4: Run the focused test and the owning suite**",
"**Step 5: Commit the independently reviewable change**",
"Expected: FAIL",
"Expected: PASS",
"git commit -m",
]
for number, section in tasks:
for token in required:
check(f"{label} task {number} contains {token}", token in section)
check(f"{label} task {number} has test path", "- Test: `" in section)
check(f"{label} task {number} has production file", "- Create: `" in section)
check(f"{label} task {number} fences balanced", section.count("```") % 2 == 0)
check(f"{label} task {number} has gradle test", "./gradlew" in section and ":test" in section)
validate_tasks("stable", stable_tasks)
validate_tasks("advanced", advanced_tasks)
# Create paths
def create_paths(text: str) -> list[str]:
return re.findall(r"^- Create: `([^`]+)`$", text, re.MULTILINE)
stable_paths = create_paths(stable)
advanced_paths = create_paths(advanced)
check("stable create paths exist", len(stable_paths) >= 150, str(len(stable_paths)))
check("advanced create paths exist", len(advanced_paths) >= 80, str(len(advanced_paths)))
check("stable create paths unique", len(stable_paths) == len(set(stable_paths)))
check("advanced create paths unique", len(advanced_paths) == len(set(advanced_paths)))
check("stable and advanced paths disjoint", set(stable_paths).isdisjoint(advanced_paths))
for index, path in enumerate(stable_paths, 1):
check(f"stable create path {index} exact", "*" not in path and "..." not in path and (path.startswith("modules/graphql/") or path.startswith("build-logic/")))
for index, path in enumerate(advanced_paths, 1):
check(f"advanced create path {index} exact", "*" not in path and "..." not in path and path.startswith("modules/graphql-advanced/"))
# Stable/Advanced separation
for forbidden in [
"modules/graphql/graphql-websocket/",
"modules/graphql/graphql-federation/",
"modules/graphql/graphql-persisted-operation/",
"modules/graphql/graphql-rsocket/",
]:
check(f"stable excludes {forbidden}", forbidden not in stable)
for required in [
"modules/graphql-advanced/graphql-persisted-operation/",
"modules/graphql-advanced/graphql-websocket/",
"modules/graphql-advanced/graphql-subscription/",
"modules/graphql-advanced/graphql-federation/",
"modules/graphql-advanced/graphql-rsocket/",
]:
check(f"advanced includes {required}", required in advanced)
# Stable coverage
stable_required_terms = [
"GraphQlRequestContext",
"GraphQlClientPolicy",
"GraphQlSchemaContract",
"SchemaMappingInspector",
"@oneOf",
"GraphQlHttpProfile",
"application/graphql-response+json",
"GraphQlExecutionProfile",
"GraphQlWireError",
"GraphQlTenantIsolationPolicy",
"GraphQlParserLimits",
"GraphQlComplexityCalculator",
"GraphQlRuntimeBudget",
"GraphQlPreparsedCacheKey",
"GraphQlBatchPolicy",
"GraphQlFetchProfile",
"HmacGraphQlCursorCodec",
"GraphQlConnection",
"GraphQlMutationIdempotencyContext",
"GraphQlMetricCardinalityPolicy",
"GraphQlPlatformStartupValidator",
"GraphQlReleaseGate",
]
for term in stable_required_terms:
check(f"stable coverage {term}", term in stable)
advanced_required_terms = [
"GraphQlPersistedOperation",
"GraphQlWebSocketProtocol",
"GraphQlSubscriptionBufferPolicy",
"GraphQlSubscriptionOrderingProfile",
"GraphQlSseConnectionPolicy",
"GraphQlReplayPosition",
"GraphQlDataLoaderDependencyGraph",
"GraphQlFederationEntityKey",
"GraphQlFederationCompositionGate",
"GraphQlGeneratedSourceBoundary",
"GraphQlRepositoryAllowlist",
"GraphQlRSocketRoutePolicy",
"GraphQlHttpGetOperationPolicy",
"GraphQlIncrementalCompatibilityGate",
"GraphQlAdvancedReleaseGate",
]
for term in advanced_required_terms:
check(f"advanced coverage {term}", term in advanced)
# Prohibited API patterns
prohibited_patterns = [
(r"interface\s+GenericGraphQlRepository", "no generic graphql repository"),
(r"public\s+.*\bEntityManager\b", "no public entity manager"),
(r"public\s+.*\bMongoTemplate\b", "no public mongo template"),
(r"scalar\s+Upload\b", "no upload scalar declaration"),
(r"@Transactional\s+.*GraphQL request", "no request-wide transaction implementation"),
]
for pattern, name in prohibited_patterns:
check(name, re.search(pattern, stable, re.IGNORECASE | re.MULTILINE) is None)
# File hashes can be printed for package evidence
for path in [DESIGN, STABLE, ADVANCED]:
if path.exists():
digest = hashlib.sha256(path.read_bytes()).hexdigest()
check(f"sha256 computed: {path.name}", len(digest) == 64, digest)
failed = [(n, d) for n, ok, d in checks if not ok]
print(f"CHECKS={len(checks)}")
print(f"PASSED={len(checks)-len(failed)}")
print(f"FAILED={len(failed)}")
for name, detail in failed:
print(f"FAIL: {name}" + (f" :: {detail}" if detail else ""))
sys.exit(1 if failed else 0)
+1 -1
View File
@@ -63,7 +63,7 @@ Root package: `io.backend.skeleton.httpclient` → `dev.caskeleton.adapter.outbo
| Design assumption | Repository reality | Adaptation |
|---|---|---|
| Gradle Kotlin DSL, `build-logic` convention plugin | Groovy DSL, root `build.gradle` conventions, `LockMode.STRICT` dependency locking | Dependencies declared in `src/adapter/outbound/httpclient/build.gradle`; `gradle.lockfile` regenerated. |
| Spring Framework 6.2 baseline with 7.0 compatibility | Spring Boot 4.0.0 / Spring Framework 7.0 is the repository baseline | Common code targets the Spring 6.2 **API surface** (no 6.2-only or 7.0-only classes in common packages). The Spring 7 HTTP Service Group integration stays isolated in `…httpclient.spring7`, exactly as the design requires. |
| Spring Framework 6.2 baseline with 7.0 compatibility | Spring Boot 4.0.8 / Spring Framework 7.0 is the repository baseline | Common code targets the Spring 6.2 **API surface** (no 6.2-only or 7.0-only classes in common packages). The Spring 7 HTTP Service Group integration stays isolated in `…httpclient.spring7`, exactly as the design requires. |
| `settings.gradle.kts` module registration | Fail-closed registry | No registry change; leaf identity, gradle path, allowed dependencies unchanged. |
| Design §6.2 grades Apache HttpClient 5 as HTTP/2-capable | Spring's blocking factory drives Apache's **classic** client, which is HTTP/1.1 only; HTTP/2 lives in Apache's async client | `ApacheBlockingTransportProvider` declares HTTP/1.1 and rejects an HTTP/2 profile at startup. Blocking HTTP/2 is served by the JDK transport, measured by `NegotiatedProtocolContractTest`. |
| Design §28.1 names WireMock for stateful fixtures | WireMock's Jetty modules bind a different Jetty 12 ABI than the Boot-managed one this module already needs for HTTP/3, and fail at server start | `StatefulUpstream` provides path-keyed stateful responses on the existing fixture server; the WireMock dependency was removed rather than worked around with a shaded jar |
+1 -1
View File
@@ -95,7 +95,7 @@ Docker-dependent lanes fail closed rather than skipping, matching the existing
| Plan assumption | Repository reality | Adaptation |
|---|---|---|
| Gradle Kotlin DSL, `build-logic` convention plugin, `jpa-library-conventions.gradle.kts` | Groovy DSL, root `src/build.gradle` conventions (spotless google-java-format, checkstyle, SpotBugs + FindSecBugs, ErrorProne, `-Werror`, one-type-per-file), `LockMode.STRICT` dependency locking | Source sets and dependencies declared in `src/adapter/outbound/persistence-jpa/build.gradle`; `gradle.lockfile` regenerated with `resolveAndLockAll --write-locks`. |
| Spring Boot 4.1 dependency management, Spring Data JPA 4.1 | Repository baseline is Spring Boot 4.0.0 | Versions are inherited from the repository BOM and never pinned per module, exactly as the plan requires ("do not override Hibernate/Flyway/Hikari versions outside the Boot BOM"). |
| Spring Boot 4.1 dependency management, Spring Data JPA 4.1 | Repository baseline is Spring Boot 4.0.8 | Versions are inherited from the repository BOM and never pinned per module, exactly as the plan requires ("do not override Hibernate/Flyway/Hikari versions outside the Boot BOM"). |
| Hibernate ORM 7.4 is the Stable provider | Boot 4.0.0 resolves `org.hibernate.orm:hibernate-core:7.1.8.Final` | The *declared* Stable provider baseline of the design stays 7.4 in `HibernateProviderPolicy`; the runtime provider version is read from Hibernate itself and reported. The collection-fetch-pagination gate runs against whatever provider the BOM resolves, and `HibernateProviderPolicy.driftsFromDeclaredBaseline()` makes the difference visible instead of hiding it behind a green check. |
| PostgreSQL 16·17·18 Stable matrix | This leaf's existing evidence image is `postgres:16-alpine` | `PostgreSqlVersion` declares exactly PG 16, 17, 18. The default lane runs the repository's existing 16 image; 17 and 18 are selected by `-Pjpa.matrix.versions=16,17,18`, and an unknown or empty selection is an error rather than a skip. |
| `settings.gradle.kts` module registration | Fail-closed 19-leaf registry | No registry change: leaf identity, Gradle path, allowed dependencies, and runtime memberships are unchanged. |
+1 -1
View File
@@ -91,7 +91,7 @@ otherwise. Being on the classpath is not being enabled.
|---|---|---|
| Gradle Kotlin DSL under `modules/mongodb*` | Groovy DSL, root `build.gradle` conventions, `LockMode.STRICT` locking | Dependencies declared in `src/adapter/outbound/persistence-mongo/build.gradle`; `gradle.lockfile` regenerated. |
| `mongodb-spring-boot-starter` is a separate module the app depends on | `modules.json` gives `adapter-outbound-persistence-mongo` `runtime_memberships: []` and does **not** list it among `app-bootstrap`'s allowed dependencies | The `autoconfigure` package stays inside the leaf and registers through the leaf's own `META-INF/spring/…AutoConfiguration.imports`. This differs from the httpclient precedent, where the starter moved to `:app-bootstrap`; here the registry forbids that edge. |
| Spring Boot 4.1 / Spring Data MongoDB 5.1 baseline | Repository baseline is Spring Boot 4.0.0 / Spring Data MongoDB 5.0.0 | The platform targets the Spring Data MongoDB **API surface** common to both; no 5.1-only type is referenced. The support matrix records the actual pinned versions. |
| Spring Boot 4.1 / Spring Data MongoDB 5.1 baseline | Repository baseline is Spring Boot 4.0.8 / Spring Data MongoDB 5.0.x | The platform targets the Spring Data MongoDB **API surface** common to both; no 5.1-only type is referenced. The support matrix records the actual pinned versions. |
| `MongoRetryScope` lives in `mongodb-transaction` | The `mongodb-spring-data` failure translator must classify retry scope, and it cannot depend on `mongodb-transaction` | `MongoRetryScope` lives in `…api.error` (core-api), which both packages already depend on. Same values, same meaning, one legal position in the DAG. |
| `mongodb-migration-flamingock` depends on Flamingock | Adding an unvetted external dependency is out of scope for this task, and the design itself requires the public contract not to depend on Flamingock types | The adapter is provider-neutral: it consumes a platform-owned `FlamingockChangeUnitView`. Wiring an actual Flamingock distribution is a one-file change behind that view. |
| Testkit as its own Gradle module | The design forbids production modules depending on the testkit | A dedicated `testkit` source set whose output is on the test compile/runtime classpaths only. ArchUnit rule `productionNeverDependsOnTestkit` enforces the direction. |
@@ -0,0 +1,7 @@
f3e98b6e72a3a43c38799aac7a333387afa12d7d007dc6966e58a3d725c0f4b7 README.md
c04f805a3f05bee50cf3d62fc531dfc9a33664ee2621b5281b1f3c0ec0885dca VALIDATION.md
27c0ddd7ab0f390fe074f14d0cb14e815c60e7544eabe8aa7a2e4ae462f91cad docs/superpowers/plans/2026-08-13-web-advanced-capabilities-expansion-plan.md
c780fb635da38b7a1c2f2f73969e129c5ac72d1852133dcb29fc10701fa453c5 docs/superpowers/plans/2026-08-13-web-inbound-http-api-execution-platform-implementation-plan.md
7b82fd9850878a5f43828243ddec992b3fae0066d31eaa898b9e1e13528bace7 docs/superpowers/specs/2026-08-13-web-inbound-http-api-execution-platform-design.md
b8c9f6d7ac055653a425f62458ece3c68ee8d7dc48827c7c28266947fadb5daf research/source-web-deep-research.md
9af756a602f320abee5f40ad7dd71936644ee28aef30b442e72f85e0b8086d0f validate_web_docs.py
+73
View File
@@ -0,0 +1,73 @@
# Web Superpowers Package
Spring Boot 기반 **인바운드 HTTP API 실행 플랫폼 `web`**의 설계, Stable 구현 계획, Advanced 확장 계획, 원본 심층 리서치, 정적 검증 도구를 묶은 패키지입니다.
## 구성
```text
web-superpowers-package/
├── docs/
│ └── superpowers/
│ ├── specs/
│ │ └── 2026-08-13-web-inbound-http-api-execution-platform-design.md
│ └── plans/
│ ├── 2026-08-13-web-inbound-http-api-execution-platform-implementation-plan.md
│ └── 2026-08-13-web-advanced-capabilities-expansion-plan.md
├── research/
│ └── source-web-deep-research.md
├── README.md
├── VALIDATION.md
├── validate_web_docs.py
└── MANIFEST.sha256
```
## 문서 규모
| 항목 | 규모 |
|---|---:|
| 설계서 | 1,656행 |
| Stable 구현 계획 | 4,762행 / 58 Task |
| Advanced 계획 | 1,532행 / 19 Task |
| 원본 리서치 | 1,597행 |
## 핵심 구현 순서
```text
Stable Task 1~58
→ Stable Release Gate
→ Advanced Task 1~19
→ Advanced Promotion Gate
```
Stable 구현 완료 전 Advanced module을 적용하지 않습니다.
## 정적 검증
```bash
./validate_web_docs.py
sha256sum -c MANIFEST.sha256
```
## 실행 방식
구현 시 `superpowers:subagent-driven-development`를 권장합니다.
각 Task마다 다음 review를 분리합니다.
```text
1. Specification review
2. Code quality·test evidence review
```
## 명시적 가정
```text
Java 21
Spring Boot 4.1 BOM
Gradle Kotlin DSL
root package: io.backend.skeleton.web
stable modules: modules/web
advanced modules: modules/web-advanced
```
실제 저장소가 포함되지 않았으므로 이 패키지는 설계·계획·정적 검증 산출물이며, Gradle compile과 서버·DB·Redis·Nginx 통합 시험 결과를 포함하지 않습니다.
+193
View File
@@ -0,0 +1,193 @@
# Web Superpowers 문서 정적 검증 결과
검증 기준일: 2026-08-13
## 1. 검증 대상
| 문서 | 행 수 |
|---|---:|
| `web-inbound-http-api-execution-platform-design.md` | 1,656 |
| `web-inbound-http-api-execution-platform-implementation-plan.md` | 4,762 |
| `web-advanced-capabilities-expansion-plan.md` | 1,532 |
| 원본 심층 리서치 | 1,597 |
## 2. 구조 검증
| 항목 | 결과 |
|---|---:|
| 실행 검사 | 1,097 |
| 통과 | 1,097 |
| 실패 | 0 |
| Stable Task | 58 |
| Advanced Task | 19 |
| Stable Create 경로 | 289 |
| Advanced Create 경로 | 94 |
| Stable Task 번호 연속성 | PASS |
| Advanced Task 번호 연속성 | PASS |
| 모든 Task의 Files·Interfaces | PASS |
| 모든 Task의 Implementation requirements | PASS |
| 모든 Task의 Step 1~5 | PASS |
| 모든 Task의 실패·통과 예상 결과 | PASS |
| 모든 Task의 Git commit 명령 | PASS |
| Stable Create 경로 중복 | 없음 |
| Advanced Create 경로 중복 | 없음 |
| Stable·Advanced Create 경로 충돌 | 없음 |
| Markdown code fence 균형 | PASS |
| `TODO`·`TBD`·`FIXME` | 없음 |
검증 명령:
```bash
cd /mnt/data
./validate_web_docs.py
```
실행 결과:
```text
checks=1097 passed=1097 failed=0
```
## 3. 설계 핵심 계약 검증
다음 계약이 설계서와 계획서에 모두 존재하는지 확인했습니다.
```text
W1 / W2 / W3 / W4 공개 계층
MVC와 WebFlux Starter 상호 배타성
Request / Application / Response Evidence 분리
APPLICATION_COMMITTED와 HTTP response delivery 분리
RFC 9457 Problem Details
400 / 422 분리
409 / 412 분리
OpenAPI 3.1.2 Stable
Path major API version
HMAC keyset cursor
ETag / If-Match
Idempotency semantic fingerprint
Redis가 DB commit evidence의 유일한 source가 아님
Business mutation + JPA idempotency evidence same transaction
Application commit 후 response-loss fault test
Durable acceptance 이후에만 202
Trusted Nginx forwarded-header boundary
Tomcat / Jetty / Reactor Netty / Nginx 실제 gate
Rate limit 429와 Admission 503 분리
Low-cardinality metric·access log
Stable·Advanced dependency 격리
```
## 4. 계획 완결성 검증
Stable 계획은 다음 단계로 구성됩니다.
```text
Task 1~14
→ Core·HTTP·JSON·Problem·Architecture Foundation
Task 15~22
→ MVC·Tomcat·Jetty·WebFlux·Reactor Netty
Task 23~30
→ Security·Proxy·Versioning·Route·OpenAPI
Task 31~36
→ Collection Query·Cursor·Conditional·Evidence
Task 37~43
→ Idempotency·JPA Evidence·Redis Gate·Response Loss
Task 44~48
→ Durable Operation·Outbox·HTTP Resource·Cache
Task 49~55
→ Budget·Rate·Admission·CORS/CSRF·Order·Observability·Nginx
Task 56~58
→ Cross-stack Contract·Performance·Stable Release
```
Advanced 계획은 다음 단계로 구성됩니다.
```text
Task 1~3
→ Module isolation·Virtual Thread·Controlled Blocking Bridge
Task 4~5
→ JSON Merge Patch·JSON Patch
Task 6~13
→ Streaming Core·SSE·NDJSON·JSON Sequence·Drain·Replay
Task 14~18
→ Functional WebFlux·CBOR·XML·OpenAPI 3.2·RateLimit Draft
Task 19
→ Soak·Rollback·Promotion Gate
```
## 5. 검증 범위의 한계
현재 검증은 **문서의 구조, 요구사항 추적성, 내부 계약, 파일 경로, 작업 순서에 대한 정적 검증**입니다.
실제 Backend Skeleton 저장소가 이번 입력에 포함되지 않았으므로 다음은 실행한 상태가 아닙니다.
```text
Gradle configuration·compile
Spring Boot ApplicationContext 기동
Tomcat·Jetty·Reactor Netty 실제 contract
PostgreSQL JPA idempotency transaction
Redis concurrent gate
Messaging outbox operation
Nginx TLS·Forwarded topology
commit 후 TCP reset fault injection
OpenAPI generated client compile
abuse·load·graceful shutdown
Git commit
```
계획서의 경로와 package는 다음 명시적 가정을 사용합니다.
```text
Java 21
Gradle Kotlin DSL
Spring Boot 4.1 BOM
root package: io.backend.skeleton.web
stable module root: modules/web
advanced module root: modules/web-advanced
```
## 6. 패키지 무결성 검증
패키지 조립 후 다음 검증을 추가로 수행했습니다.
```text
Package-local validator
→ checks=1104 passed=1104 failed=0
MANIFEST.sha256
→ 모든 7개 파일 OK
ZIP CRC
→ No errors detected
독립 문서와 패키지 내부 문서
→ byte 단위 일치
```
검증 명령:
```bash
cd /mnt/data/web-superpowers-package
./validate_web_docs.py
sha256sum -c MANIFEST.sha256
cd /mnt/data
unzip -t web-superpowers-package.zip
cmp web-inbound-http-api-execution-platform-design.md \
web-superpowers-package/docs/superpowers/specs/2026-08-13-web-inbound-http-api-execution-platform-design.md
cmp web-inbound-http-api-execution-platform-implementation-plan.md \
web-superpowers-package/docs/superpowers/plans/2026-08-13-web-inbound-http-api-execution-platform-implementation-plan.md
cmp web-advanced-capabilities-expansion-plan.md \
web-superpowers-package/docs/superpowers/plans/2026-08-13-web-advanced-capabilities-expansion-plan.md
```
File diff suppressed because it is too large Load Diff
+286
View File
@@ -0,0 +1,286 @@
#!/usr/bin/env python3
from __future__ import annotations
import hashlib
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
if (ROOT / "docs").exists():
DESIGN = ROOT / "docs/superpowers/specs/2026-08-13-web-inbound-http-api-execution-platform-design.md"
STABLE = ROOT / "docs/superpowers/plans/2026-08-13-web-inbound-http-api-execution-platform-implementation-plan.md"
ADVANCED = ROOT / "docs/superpowers/plans/2026-08-13-web-advanced-capabilities-expansion-plan.md"
RESEARCH = ROOT / "research/source-web-deep-research.md"
else:
DESIGN = ROOT / "web-inbound-http-api-execution-platform-design.md"
STABLE = ROOT / "web-inbound-http-api-execution-platform-implementation-plan.md"
ADVANCED = ROOT / "web-advanced-capabilities-expansion-plan.md"
RESEARCH = ROOT / "붙여넣은 마크다운(1)(20260813-120656).md"
checks: list[tuple[str, bool, str]] = []
def check(name: str, condition: bool, detail: str = "") -> None:
checks.append((name, bool(condition), detail))
def read(path: Path) -> str:
check(f"file exists: {path.name}", path.exists(), str(path))
return path.read_text(encoding="utf-8") if path.exists() else ""
design = read(DESIGN)
stable = read(STABLE)
advanced = read(ADVANCED)
research = read(RESEARCH)
check("design line floor", len(design.splitlines()) >= 1000, str(len(design.splitlines())))
check("stable plan line floor", len(stable.splitlines()) >= 3000, str(len(stable.splitlines())))
check("advanced plan line floor", len(advanced.splitlines()) >= 900, str(len(advanced.splitlines())))
check("research line floor", len(research.splitlines()) >= 1000, str(len(research.splitlines())))
for name, text in [
("design", design),
("stable", stable),
("advanced", advanced),
]:
check(f"{name} markdown fence balanced", text.count("```") % 2 == 0, str(text.count("```")))
for forbidden in ["TODO", "TBD", "FIXME", "implement later", "fill in details"]:
check(f"{name} has no placeholder {forbidden}", forbidden not in text, forbidden)
def task_sections(text: str) -> list[tuple[int, str]]:
matches = list(re.finditer(r"(?m)^### Task (\d+): .+$", text))
result: list[tuple[int, str]] = []
for index, match in enumerate(matches):
start = match.start()
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
result.append((int(match.group(1)), text[start:end]))
return result
stable_tasks = task_sections(stable)
advanced_tasks = task_sections(advanced)
check("stable task count", len(stable_tasks) == 58, str(len(stable_tasks)))
check("advanced task count", len(advanced_tasks) == 19, str(len(advanced_tasks)))
check(
"stable task numbering consecutive",
[number for number, _ in stable_tasks] == list(range(1, 59)),
str([number for number, _ in stable_tasks]),
)
check(
"advanced task numbering consecutive",
[number for number, _ in advanced_tasks] == list(range(1, 20)),
str([number for number, _ in advanced_tasks]),
)
required_markers = [
"**Files:**",
"**Interfaces:**",
"**Implementation requirements:**",
"**Step 1: Write the failing test**",
"**Step 2: Run the focused test and verify the expected failure**",
"**Step 3: Implement the minimum production contract**",
"**Step 4: Run the task test and its module contract suite**",
"**Step 5: Commit the independently reviewable change**",
"git commit -m",
]
for plan_name, sections in [("stable", stable_tasks), ("advanced", advanced_tasks)]:
for number, section in sections:
for marker in required_markers:
check(
f"{plan_name} task {number} contains {marker}",
marker in section,
marker,
)
check(
f"{plan_name} task {number} has test path",
"- Test: `" in section,
"",
)
check(
f"{plan_name} task {number} has exact run command",
"Run: `" in section,
"",
)
check(
f"{plan_name} task {number} has expected failure",
"Expected:" in section and "FAIL" in section,
"",
)
check(
f"{plan_name} task {number} has expected pass",
"Expected: PASS" in section,
"",
)
def create_paths(text: str) -> list[str]:
return re.findall(r"(?m)^- Create: `([^`]+)`$", text)
stable_creates = create_paths(stable)
advanced_creates = create_paths(advanced)
check(
"stable create paths unique",
len(stable_creates) == len(set(stable_creates)),
f"{len(stable_creates)} paths",
)
check(
"advanced create paths unique",
len(advanced_creates) == len(set(advanced_creates)),
f"{len(advanced_creates)} paths",
)
check(
"stable and advanced create paths do not collide",
set(stable_creates).isdisjoint(set(advanced_creates)),
str(set(stable_creates) & set(advanced_creates)),
)
design_terms = [
"W1",
"W2",
"W3",
"W4",
"APPLICATION_COMMITTED",
"CLIENT_OBSERVATION_UNKNOWN",
"RFC 9457",
"OpenAPI 3.1.2",
"If-Match",
"Idempotency",
"202 Accepted",
"Tomcat",
"Jetty",
"Reactor Netty",
"Nginx",
"business mutation + authoritative evidence same DB transaction",
"Redis",
"Request Evidence",
"Application Evidence",
"Response Evidence",
]
for term in design_terms:
check(f"design contains key term: {term}", term in design, term)
stable_terms = [
"same-PostgreSQL-transaction",
"Application Commit 후 HTTP Response 유실 Fault Test",
"Redis Concurrent Gate와 Replay Cache Adapter",
"실제 Nginx Trusted Proxy",
"실제 Tomcat MVC HTTP 계약 Gate",
"Jetty MVC 호환성 Gate",
"실제 Reactor Netty WebFlux 계약 Gate",
"OpenAPI 3.1.2 Snapshot",
"OpenAPI Breaking Diff",
"Durable Operation",
"same-PostgreSQL-transaction",
"DB commit evidence의 유일한 source가 아니다",
"webStableCheck",
]
for term in stable_terms:
check(f"stable plan contains key term: {term}", term in stable, term)
advanced_terms = [
"Virtual Thread",
"Controlled Blocking Bridge",
"JSON Merge Patch RFC 7396",
"JSON Patch RFC 6902",
"MVC SSE",
"WebFlux SSE",
"NDJSON",
"JSON Text Sequence",
"Messaging-backed SSE Replay",
"OpenAPI 3.2 Experimental",
"RateLimit Draft",
"10k",
"rollback",
]
for term in advanced_terms:
check(f"advanced plan contains key term: {term}", term in advanced, term)
# Stable/advanced dependency boundary.
check(
"stable module map excludes modules/web-advanced",
"modules/web-advanced/" not in stable.split("## 1. Stable 파일·모듈 구조", 1)[1].split("## 2.", 1)[0],
"",
)
check(
"advanced plan requires stable completion",
"Stable Task 1~58" in advanced,
"",
)
# Evidence and idempotency invariants.
for text_name, text in [("design", design), ("stable", stable)]:
check(
f"{text_name} separates ETag and idempotency",
"ETag/If-Match" in text and "Idempotency" in text,
"",
)
check(
f"{text_name} says Redis is not sole commit evidence",
("sole DB commit evidence" in text)
or ("유일한 source" in text)
or ("유일한 Source" in text),
"",
)
check(
f"{text_name} includes commit-response-loss",
("Response Loss" in text)
or ("response-loss" in text)
or ("response write 전 TCP reset" in text),
"",
)
# No unsupported architecture in design/plan.
for name, text in [("design", design), ("stable", stable)]:
check(
f"{name} forbids controller transaction",
"Controller transaction" in text or "Controller @Transactional" in text or "Controller 또는 HTTP adapter에 업무 `@Transactional`" in text,
"",
)
check(
f"{name} forbids entity/document wire types",
"Entity/Document" in text or "Entity·Document" in text or "JPA Entity·MongoDB Document" in text,
"",
)
check(
f"{name} does not declare Idempotency-Key as final RFC",
"IETF 표준" not in text or "금지" in text,
"",
)
# Research grounding.
check(
"design title matches research topic",
"인바운드 HTTP API 실행 플랫폼" in design and "인바운드 HTTP API 실행 플랫폼" in research,
"",
)
check(
"research includes execution evidence chain",
"HTTP_RECEIVED" in research and "CLIENT_OBSERVATION_UNKNOWN" in research,
"",
)
check(
"research includes actual server matrix",
"MVC + Tomcat" in research and "WebFlux + Reactor Netty" in research,
"",
)
# Optional package integrity.
manifest = ROOT / "MANIFEST.sha256"
if manifest.exists():
for line in manifest.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
digest, relative = line.split(" ", 1)
target = ROOT / relative
actual = hashlib.sha256(target.read_bytes()).hexdigest() if target.exists() else ""
check(f"manifest: {relative}", actual == digest, actual)
passed = sum(1 for _, ok, _ in checks if ok)
failed = [(name, detail) for name, ok, detail in checks if not ok]
print(f"checks={len(checks)} passed={passed} failed={len(failed)}")
for name, detail in failed:
print(f"FAIL: {name} :: {detail}")
sys.exit(1 if failed else 0)
+59
View File
@@ -0,0 +1,59 @@
# Web Advanced: support matrix
Every capability is off unless named. `WebAdvancedPromotionGate.forFeature` is the machine-checked
form of the last two columns; if this table disagrees with it, the code wins.
| Capability | Flag | What it adds | Required suites | Soak |
| --- | --- | --- | --- | --- |
| `MVC_VIRTUAL_THREADS` | `backend.web.advanced.mvc-virtual-threads.enabled` | A different scheduling model for **every** request | `web:test`, `webCrossStackParityTest`, `virtual-thread-admission`, `pinning-jfr` | 24h |
| `WEBFLUX_BLOCKING_BRIDGE` | `…webflux-blocking-bridge.enabled` | A bounded offload; affects the shared event loop | `web:test`, `blocking-bridge-bounded`, `event-loop-guard` | 24h |
| `JSON_MERGE_PATCH` | `…json-merge-patch.enabled` | RFC 7396, partial documents | `web:test`, `patch-security`, `patch-atomicity` | 8h |
| `JSON_PATCH` | `…json-patch.enabled` | RFC 6902, arbitrary pointers | as merge patch | 8h |
| `SSE` | `…sse.enabled` | Long-lived connections | `web:test`, `streaming-soak-10k`, `slow-consumer-bounded`, `cancellation-propagation`, `pod-drain` | 24h |
| `NDJSON` | `…ndjson.enabled` | Long-lived connections | as SSE | 24h |
| `JSON_SEQUENCE` | `…json-sequence.enabled` | Long-lived connections | as SSE | 24h |
| `FUNCTIONAL_WEBFLUX` | `…functional-webflux.enabled` | Routes with no annotation to scan | `web:test`, `functional-route-parity` | 8h |
| `CBOR` | `…cbor.enabled` | A second decoder | `web:test`, `codec-security`, `codec-budget` | 8h |
| `XML` | `…xml.enabled` | A decoder with dangerous defaults | as CBOR | 8h |
| `OPENAPI_32` | `…openapi-32.enabled` | A parallel description artifact | `web:test`, `openapi-32-toolchain-matrix` | 8h |
| `RATELIMIT_DRAFT_HEADERS` | `…ratelimit-draft-headers.enabled` | Additive response headers | `web:test`, `ratelimit-draft-headers` | 8h |
## Two capabilities change requests that do not use them
`WebAdvancedFeature.affectsUnrelatedRequests()` is true for exactly `MVC_VIRTUAL_THREADS` and
`WEBFLUX_BLOCKING_BRIDGE`. A codec affects only requests that negotiate it; a virtual-thread
executor affects every request in the process, and the blocking bridge affects the event loop they
all share. `WebAdvancedFeatureFlags.stableBehaviourPreserved()` reports whether either is on.
That distinction is why those two soak for 24 hours and why their rollback test is the one that
matters most.
## What is refused, and why the row is here
| Refused | Reason |
| --- | --- |
| Virtual threads without an admission limit | The pool size *was* the admission policy. Removing it accepts every arrival and queues them on downstream budgets that did not grow. |
| A blocking offload for an unregistered operation | `boundedElastic()` is reachable from anywhere and unbounded in practice; an unenumerable set of offloads is invisible until the pool is the heap. |
| A merge patch outside its field allowlist | A merge patch is partial, so no DTO's absent fields say "not permitted". Without an allowlist the writable set grows every time somebody adds a field. |
| A JSON Patch pointer above its allowed prefix | Replacing a parent deletes every sibling, so permission on a child cannot grant it. |
| A `move` whose source is unauthorized | Checking only the destination lets a caller relocate data out of a field they may not touch. |
| XML with a DTD or an external entity | Billion-laughs and XXE. Neither errors when it fires; the parse succeeds and the document contains something it should not. |
| A non-JSON representation without all three gates | Flag, route `produces`, and client allowlist. An `Accept` header is not evidence the route was tested against that codec. |
| Compression-style silent fallback on a 406 | A client that asked only for CBOR and gets JSON parses the bytes as CBOR and fails somewhere far away. |
| Changing HTTP status after commit | The body becomes half stream, half JSON, and the 200 is cached. |
| Unbounded buffering for a slow consumer | It moves the client's slowness into the server's heap. |
## Promotion
Each capability is promoted on its own evidence. Two conditions apply to all and are not waivable:
- **Rollback exercised.** A flag nobody has turned off is not known to turn off.
- **Stable behaviour unchanged with the feature off.** If it is not, the feature was never optional
and every deployment has it.
Parsers and patch appliers additionally require a security review —
`WebAdvancedPromotionGate.needsSecurityReview` names them.
See `docs/adr/ADR-WEB-ADV-001-streaming-is-live-delivery.md`,
`ADR-WEB-ADV-002-virtual-threads-do-not-remove-admission.md`, and
`ADR-WEB-ADV-003-openapi-32-remains-experimental.md`.
+57
View File
@@ -0,0 +1,57 @@
# OpenAPI 3.2: the experimental lane
## Why it is a lane rather than an upgrade
Generating 3.2 is cheap. Adopting it is not, and the two get conflated because the generated
document looks fine.
The value of an API description is entirely in what consumes it. A document in a version a client
generator does not fully understand does not fail — it produces a client that compiles and is wrong,
which is worse than no document at all.
So 3.1.2 stays the release artifact and 3.2 is generated beside it, with a report.
## The invariant
**Generating 3.2 must not change the 3.1 snapshot.** Both come from the same model, so a contributor
that mutates it on the way to 3.2 changes the artifact that is actually shipped — silently, and only
when the experimental lane runs.
`OpenApi32CompatibilityReport` hashes the snapshot before and after, and a difference is a promotion
blocker with its own message.
## The toolchain matrix
Four kinds of tool, checked separately, because passing one says nothing about the others:
| Kind | What it catches | What it misses |
| --- | --- | --- |
| Parser | Structural errors | Anything semantically odd |
| Linter | Style rule violations | Accepts documents a parser rejects |
| Generator | Unsupported constructs — sometimes | Usually emits a wrong-but-valid signature instead of failing |
| Client compile | The wrong signature the generator emitted | Runtime behaviour |
`OpenApiToolchainMatrix.complete()` requires at least one passing tool of each kind.
`gaps()` names the kinds that have none.
"OpenAPI 3.2 works" is not a statement anybody can make. "This document is read correctly by these
four tools at these versions" is.
## Streaming descriptions
The substantive difference between 3.1 and 3.2 for this application is how streaming responses are
described. Those differences are reported separately rather than folded into a pass/fail, because a
green pass hides what changed.
## Promotion
`promotionBlockers(adrAccepted)` always includes the ADR blocker until one is accepted, however
green the matrix is. A machine-checkable matrix cannot decide whether the consumer population is
ready; that is a judgement, and it belongs in
`docs/adr/ADR-WEB-ADV-003-openapi-32-remains-experimental.md`.
## Running it
The experimental generation runs in its own workflow and publishes the 3.2 document and the
compatibility report as artifacts. It never runs in the release workflow, so the release artifact
cannot depend on whether it ran.
+78
View File
@@ -0,0 +1,78 @@
# Web patch: the client contract
Two patch formats, and they are not interchangeable.
## Choosing one
A route accepts exactly one, and sending the other is a 415 rather than a best-effort guess. The
reason is that a mismatch is silent in the worst direction:
- `{"a": null}` as a **merge patch** deletes `a`. As a **JSON Patch** it is not a patch at all — it
is an object where an array was required.
- A JSON Patch array read as a merge patch is a document whose fields are array indices. It merges
nothing and reports success.
| | Merge patch | JSON Patch |
| --- | --- | --- |
| Media type | `application/merge-patch+json` | `application/json-patch+json` |
| RFC | 7396 | 6902 |
| Shape | A partial document | An ordered array of operations |
| Delete | `"field": null` | `{"op":"remove","path":"/field"}` |
| Arrays | Replaced whole | Addressable per index |
| Preconditions | `If-Match` only | `If-Match`, plus per-field `test` |
Use merge patch for "change these fields". Use JSON Patch when you need array element edits or a
precondition on one field that `If-Match` cannot express.
## What is refused
**Fields outside the allowlist.** The server declares which fields a merge patch may modify and
which pointers a JSON Patch may address. Anything else is refused, and *every* refused path is
named — you will not discover them one round trip at a time.
The allowlist is checked **before** anything is applied. A refusal tells you nothing about the
current values, which is deliberate: deciding afterwards whether a refused field "actually changed
anything" would answer a question about data you may not read.
**Pointer permission does not travel upwards.** Permission on `/profile` covers
`/profile/displayName`; permission on `/profile/displayName` does not cover `/profile`, because
replacing the parent deletes every sibling.
**A `move` needs permission on both ends.** Checking only the destination would let you relocate
data out of a field you may not touch.
**Limits.** At most 100 operations, pointer depth at most 16, merge-patch nesting depth at most 32.
The first two multiply — each operation walks its pointer over a document the server deep-copied
first.
## Atomicity
A JSON Patch document applies entirely or not at all. Every operation runs against a working copy;
the resource is not touched until all of them have succeeded and the result has passed full
validation.
This is what makes `test` useful. A client putting a `test` first is relying on nothing after it
having happened when the test fails, and that reliance holds:
```json
[
{"op": "test", "path": "/version", "value": 4},
{"op": "replace", "path": "/displayName", "value": "new"}
]
```
A failed `test` is **409**, not 400: the document was well-formed and permitted, and the resource
simply was not in the state you expected. Re-read and retry. The response names the pointer that
failed and does not return the server's value — returning it would make `test` a read primitive for
fields you may not read.
## Validation
The patched result is validated as a whole document, not field by field. A patch whose individual
fields are all valid can produce an object that is not — two fields that must agree, a state
transition that is not allowed — and validating only what changed sees none of it.
## Preconditions
Patch routes require `If-Match`. A partial update against a resource that moved underneath you
applies your changes to a version you never saw.
+357
View File
@@ -0,0 +1,357 @@
# Web platform — repository adaptation
The inbound HTTP API execution platform design
(`docs/web-superpowers-package/docs/superpowers/specs/2026-08-13-web-inbound-http-api-execution-platform-design.md`)
was written against a standalone repository. Four of its assumptions do not hold here, and this
document records what each became and why, so a reader comparing the design to the tree is not left
guessing whether a difference is a decision or a mistake.
## 1. Twenty-three Gradle modules became sub-packages of one leaf
The design lays the platform out as `modules/web/web-core-api`, `web-contract`, `web-mvc` and so on.
This repository's `src/config/architecture/modules.json` is a fail-closed registry that owns the leaf
list, and adding twenty-three leaves to it is an architecture decision the design cannot make on the
repository's behalf. The JPA and GraphQL platforms reached the same fork and resolved it the same
way, so this is the established answer rather than a new one.
The substitution only holds if the boundaries are enforced, because a Gradle dependency gate cannot
see inside a leaf. `WebStableModule` declares each module's identity, package, purity grade and
allowed edges; `WebModuleBoundaryTest` scans the real source tree and fails when the tree and the
declaration disagree **in either direction** — an undeclared edge, an undeclared package, or a
declared module with no source. Four negative fixtures prove each rule can fail.
Each enum constant is already shaped like a leaf specification, so promoting a module to its own
Gradle path later is a registry edit rather than an archaeology exercise.
| Design module | Package under `dev.caskeleton.adapter.inbound.web` | Status |
| --- | --- | --- |
| `web-core-api` | `core` | planned (Task 2+) |
| `web-contract` | `contract` | planned |
| `web-validation` | `validation` | planned |
| `web-error` | `error` | present |
| `web-pagination` | `pagination` | present |
| `web-idempotency` | `idempotency` | present |
| `web-idempotency-jpa` | `idempotency.jpa` | planned |
| `web-idempotency-redis` | `idempotency.redis` | planned |
| `web-versioning` | `versioning` | planned |
| `web-security-integration` | `auth`, `authz` | present |
| `web-observability` | `observability` | present |
| `web-openapi` | `openapi` | planned |
| `web-mvc` | `mvc` | planned |
| `web-webflux` | `webflux` | planned |
| `web-admin` | `admin` | planned |
| `web-operation-jpa` | `operation.jpa` | planned |
| `web-operation-messaging` | `operation.messaging` | planned |
| `web-spring-boot-starter-mvc` | `autoconfigure.mvc` | planned |
| `web-spring-boot-starter-webflux` | `autoconfigure.webflux` | planned |
| `web-testkit-*` | `testkit`, `testkit.mvc`, `testkit.webflux`, `testkit.contract` | planned |
| — | `conditional`, `cursor`, `http`, `filter`, `envelope`, `config`, `controller`, `ratelimit`, `settings` | present, pre-dates the design |
| — | `fileserver.*`, `notification.*` | present, feature integrations rather than platform modules |
A module is added to `WebStableModule` at the moment its package gains its first file, never before:
a declared module with no source is a claim about a rename that has not happened.
## 2. Root package
The design uses `io.backend.skeleton.web`. This repository's package root is
`dev.caskeleton.adapter.inbound.web`, and the registry, the ArchUnit rules and the composition root's
component scan are all written against it.
## 3. Gradle DSL
The design's snippets are Kotlin DSL. Every build file in this repository is Groovy DSL, and
`src/build-logic` carries the shared convention plugins the leaf inherits. The build logic is
translated, not copied.
## 4. Spring Boot baseline
The design names "Spring Boot 4.1 BOM". This repository's baseline is **4.0.8** — see
`.vscode/settings.json` for why the 4.1 minor line is a planned migration rather than a currency
fix. Where the design depends on a 4.1-only API the difference is recorded at the call site rather
than silently absorbed.
## 5. Sample application
The design's `examples/web-platform-sample` is the existing `sample-portfolio` leaf, which already
carries the OpenAPI drift gate and the contract lanes the design's sample is specified to provide.
## 6. Idempotency: the design's store SPI is already owned by `application-core`
The design gives `web-idempotency` its own `IdempotencyStore`, `IdempotencyRecord`,
`IdempotencyState`, `IdempotencyScope` and `RequestFingerprint`. This repository already has all
five in `application-core/idempotency`, with a JPA adapter
(`PostgreSqlOwnerSafeIdempotencyStore`, `IdempotencyStoreAdapter`) and a Redis cache behind them —
which is what the design's Tasks 39 and 40 ask for, built in the direction the registry permits.
Implementing the design's SPI literally would have required
`adapter-outbound-persistence-jpa → adapter-inbound-web`, an edge the registry forbids and one that
points the wrong way regardless: the store is an application concern that two transports could
share, not something the HTTP layer owns.
So the web leaf keeps only the parts that are genuinely HTTP and have no application-core
equivalent, and everything else binds to the existing port:
| Design type | Here |
| --- | --- |
| `IdempotencyStore`, `IdempotencyRecord`, `IdempotencyState`, `IdempotencyScope` | `application-core/idempotency` (already present) |
| `RequestFingerprint` | `application-core/idempotency` — the web factory *produces* it |
| `ResponseSnapshot` | `application-core/idempotency/StoredResponse` |
| `IdempotencyKey` | web: the `Idempotency-Key` header's grammar and bounds |
| `FingerprintHeaderPolicy` | web: which HTTP headers change what a request means |
| `DeterministicCommandEncoder` | web: canonical JSON, because the application port's `ofSha256(byte[])` hashes raw bytes |
| `SemanticRequestFingerprintFactory` | web: builds the application type from operation, path identifiers, canonical body and selected headers |
The last three are the substance the design adds over what was here. The application port's
`RequestFingerprint.ofSha256(byte[])` digests the raw request body, and raw bytes are not stable
across a client library that reorders JSON members or a proxy that reformats — the design names that
explicitly, and the semantic factory is the fix.
## 7. Durable operations: the design's `web-operation-jpa` cannot exist here
The design gives the web platform its own persistence module — `modules/web/web-operation-jpa`
holding a JPA store for long-running operations, with the port
(`DurableOperationStore`) in `web-core-api` beside `WebProblem`.
`src/config/architecture/modules.json` forbids both halves of that:
- `:adapter:inbound:web` may depend only on `domain-core`, `application-core` and
`shared-contract`, so the web leaf cannot take a JPA dependency.
- `:adapter:outbound:persistence-jpa` is an outbound adapter and cannot depend on an inbound one,
so a JPA store cannot see a web type.
This is the same shape as §6 and takes the same resolution — the one the repository already used
for idempotency, where `IdempotencyStorePort` lives in `application-core` and
`IdempotencyStoreAdapter` in `persistence-jpa`:
| Design type | Here |
| --- | --- |
| `DurableOperationStore` | `application-core/operation/DurableOperationStorePort` |
| `OperationSubmission` | `application-core/operation/DurableOperationSubmission` |
| the stored operation | `application-core/operation/DurableOperation` |
| the lease | `application-core/operation/OperationLease` |
| the stored failure | `application-core/operation/OperationFailure` — a code and an already-safe message, never an exception |
| `JpaDurableOperationStore`, `JpaOperationEntity`, `V002__web_operation.sql` | `persistence-jpa/operation/*`, `db/migration/postgresql/V11__durable_operation.sql` |
| `OperationResource`, `OperationStatus`, `OperationProgress`, `OperationId` | web `operationasync` — the HTTP projection |
| — | web `operationasync/OperationResourceFactory`, which is the projection itself |
The split is not only a registry workaround; it puts each half where its invariants belong. A
problem document is an HTTP concept and has no business being persisted, so the stored failure is a
code plus a safe message and the factory turns it into a `WebProblem` through the same catalog and
the same sanitiser as every synchronous error. An async failure therefore cannot publish something
a synchronous one would have redacted, and a worker cannot extend the published code vocabulary by
writing a row — an unrecognised code becomes `INTERNAL_ERROR`.
The state machine is enforced three times on purpose: in `DurableOperation`'s constructor, in the
`CHECK` constraints of `V11__durable_operation.sql`, and in the `WHERE` clause of every statement
that changes a row. The constructor catches application bugs, the constraints catch any other
writer, and the predicates make each transition atomic — a worker whose lease lapsed while it was
still working cannot record a result over the worker that took over, because its `UPDATE` matches
no row and it learns that from the affected count.
## 8. Budget enforcement: what the edge stops and what the application stops
The design (Task 49) requires that the difference between an edge proxy's limits and the
application's own be written down rather than discovered during an incident. Two limits on the same
dimension always exist, they are never equal, and which one fires decides what the caller sees.
| Dimension | Edge (nginx) | Application | Who answers when crossed |
| --- | --- | --- | --- |
| Request line / URI | `large_client_header_buffers` (default 8k) | `maxUriBytes` | Edge first — nginx answers 414 with its own HTML |
| Request headers | `large_client_header_buffers` | `maxHeaderBytes` | Edge first — nginx 400, no problem document |
| Request body | `client_max_body_size` (default 1m) | `maxBodyBytes` (platform ceiling 8 MiB) | Whichever is smaller; nginx answers 413 with HTML |
| Query parameter count | not enforced | `maxQueryParameters` | Application, always |
| JSON depth / array size | not enforced | `maxJsonDepth`, `maxArrayElements` | Application, always |
| Execution time | `proxy_read_timeout` (default 60s) | `maxExecutionTime` (ceiling 2 min) | Edge first if the platform's is larger |
| Response size | not enforced | `maxResponseBytes` (ceiling 32 MiB) | Application, always |
Two consequences worth stating, because both are counterintuitive:
**An edge limit below the application's means clients never see a problem document.** nginx answers
its own HTML error page, so a client that branches on `ProblemCode` gets a body it cannot parse.
Where a dimension matters to clients, the edge limit must be set *above* the application's so the
application is the one that refuses.
**An edge limit above the application's is not redundant.** It is the only thing standing between
the process and a body large enough to matter before the application's own meter has counted it.
Both belong; only their ordering is a decision.
The enforcement itself never materializes a body to measure it. `WebBudgetMeter` counts bytes as
they move — through a wrapped `ServletInputStream` on the servlet side and a `doOnNext` on the
buffer flux on the reactive side — and throws at the byte that crosses. A check that read the body
in order to size it would be the heap exhaustion the check exists to prevent, so the contract asserts
directly that an oversized body never reaches the handler in full.
On the way out the split is between committed and not:
- **Not committed** — the response is reset, which discards the partial body *and* the meter's
count of it, and the overrun is answered as a problem document. Resetting only the buffer was the
first implementation and it failed: the meter still held the count of the discarded bytes, so the
small problem document was refused too and the client got the container's error page.
- **Committed** — there is no status left to send. The exception propagates, the connection ends
mid-document, and the client sees a truncated response. That is worse for the client than a clean
error and better than a short response it would accept as complete.
Statuses are never restated. `BudgetProblemMapper.statusFor` reads `ProblemCatalog` through the
violation's code; an earlier draft kept its own violation-to-status table and disagreed with the
catalog on two entries, which `requireStatusAgreement` turned into a 500. One table, no drift.
## 9. The Nginx lane: what a real proxy caught that no unit test could
Task 55 asks for the proxy contract to be verified against a real Nginx. It is implemented as a
separate `nginxProxyTest` source set with a Testcontainers-managed `nginx:1.27-alpine`, run by
`./gradlew :adapter:inbound:web:webNginxProxyTest`. Its own lane because it is the only one that
needs Docker; folded into `test`, every developer's `check` would depend on a container runtime,
and the usual end of that is an `@Disabled` nobody notices.
**TLS is terminated in configuration, not in the container.** The design describes an `ssl` listener.
What the application can observe about TLS is exactly one thing — that the edge set
`X-Forwarded-Proto: https` authoritatively — and a proxy that terminates TLS and one that declares
the scheme produce an identical request upstream. Generating a certificate per run would add a
second failure mode to a lane whose subject is header handling. The lane asserts the observable
property; it does not assert that Nginx can do TLS.
**The defect the lane found on its first run.** The first configuration set the forwarded headers
once at the `server` level and added only `X-Forwarded-Prefix` per `location`. Six of the ten cases
failed. Nginx's inheritance rule for array directives is *replacement*: a single `proxy_set_header`
inside a `location` discards every `proxy_set_header` inherited from `server`. So none of the
security headers were sent, the application fell back to the upstream's own `Host`, and a client's
`X-Forwarded-Host` would have been trusted.
That configuration reads as correct, is a shape found throughout the wild, and no test of the
application could detect it — the application's forwarded-header handling is thoroughly unit-tested
and every one of those tests still passed. The bug lived entirely in the seam. The headers now live
in `proxy_headers.conf` and are `include`d by each location.
| Bound | Where it fires | What the client sees |
| --- | --- | --- |
| `client_max_body_size 2m` | Nginx, before the application | Nginx's HTML 413, no problem document |
| application `maxBodyBytes` | the application | RFC 9457 problem, `REQUEST_TOO_LARGE` |
| unknown prefix | Nginx | 404; only `/api/` and `/dev-api/` are routed at all |
The first row is the §8 table's consequence made concrete: an edge limit below the application's
means clients never see a problem document for that dimension.
## Advanced capabilities
The Advanced expansion plan asks for eleven Gradle modules under `modules/web-advanced/`. They are
packages under `advanced.**` in this leaf, for the reason the Stable platform is one leaf:
`src/config/architecture/modules.json` is fail-closed and owns the leaf list, and eleven entries to
satisfy a directory layout is a registry change rather than an architecture one.
What the design wanted from the separation is enforced instead by two machine checks:
- **`WEB-ARCH-ADV`** in `WebArchitectureRules.stableDoesNotDependOnAdvanced()` fails the build when a
Stable class names an Advanced type. A feature flag decides whether a bean is created; it does
nothing about a compile-time edge, and one such edge makes Stable unbuildable without Advanced.
- **`WebStableModule`** declares eleven Advanced module identities with their own purity and edge
sets, checked by `WebModuleBoundaryTest`. Nine of the eleven are `CORE` — pure policy with no
framework import — which is stricter than the design's Gradle layout would have been.
Two modules are `FRAMEWORK_BOUND` and had to be: `advanced-patch` and `advanced-codec`. Jackson's
tree model is the reason for the first — a merge patch's null-means-delete has no representation in
a Java object, so the applier works on nodes — and `XMLInputFactory` is the reason for the second.
A third, `advanced-stream-encoding`, is separated from the pure `advanced-stream` for the same
reason: framing serializes, and serializing binds to Jackson.
### What was adapted rather than copied
**`WebStreamEnvelope.Error` is named `Failure`.** A nested type called `Error` shadows
`java.lang.Error` inside its own file, so an unrelated `catch (Error e)` there would catch the wrong
thing. Error Prone's `JavaLangClash` refuses it outright, and the rename is the only difference from
the design's sealed hierarchy.
**`WebStreamErrorPolicy`'s factory methods are `startOver` / `resumeFromPosition` / `nothingToDo`.**
The design's names collided with the record's own accessors, which Java rejects.
**The codec backends are compile-only, and finding out why cost a broken composition root.**
`WebCborMapperFactory` and `WebXmlMapperFactory` are implemented, along with the parts that carry
the failure modes: `SecureXmlInputFactory` (the DTD and external-entity defaults, both of which
produce no error when they fire), `CodecBudget` (per representation, because a megabyte of CBOR can
declare an array of a billion elements in a handful of bytes), and `RepresentationNegotiationPolicy`
(three gates, because an `Accept` header is not evidence the route was tested against that codec).
`jackson-dataformat-cbor` and `jackson-dataformat-xml` were declared `implementation` first, so that
a missing backend could not surface as a `NoClassDefFoundError` at the first request that negotiated
one. That reasoning was wrong about what the jars do. Spring Boot's Jackson auto-configuration
registers an `xmlMapper` and a `cborMapper` bean the moment each backend is on the runtime
classpath, and Spring registers an XML message converter with it. Two things followed, and only the
first was noisy:
- The composition root held three `ObjectMapper` beans — `webStrictObjectMapper`, `xmlMapper`,
`cborMapper` — so every `@Autowired ObjectMapper` became ambiguous and the application would not
start. Six `app-bootstrap` tests failed with `UnsatisfiedDependencyException`.
- Every deployment silently began accepting `application/xml` request bodies. An XXE surface,
acquired by adding a dependency, on a capability that is supposed to be off unless a deployment
names it.
So both are `compileOnly` plus `testImplementation`: the factories compile, their tests run against
real backends, and the runtime classpath belongs to the deployment that enables the capability.
`WebRepresentation.available()` turns an absent backend into a sentence naming the missing
coordinate. `RepresentationBackendScopeTest` reads `build.gradle` and fails if either coordinate
returns to `implementation`, because nothing else catches it — the codec's own tests pass either
way, and the failure only appears in whatever composes this leaf.
**The framing and the writers are separate, and both exist.** `NdjsonFraming` and
`JsonSequenceFraming` hold the contract; `MvcStreamWriter`, `WebFluxStreamWriter` and
`WebFluxSseAdapter` are what actually put it on a response. There is no MVC SSE writer: this
repository's `NO_SSE_EMITTER` ArchUnit rule forbids `SseEmitter` outright, so the servlet stack
streams NDJSON and JSON-seq and SSE is reactive-only. An earlier pass
stopped after the framing, which left the platform advertising SSE while nothing could serve it —
the same "control reached by nothing" shape this leaf has caught five other times.
**`MessagingReplayBridge` is `WebStreamReplaySource`, an interface this module implements nowhere.**
The design's requirement is that the web module store no durable event history; an implementation
here would be the thing it forbids.
### What the execution layer had to get right
Three of these were only found by building the adapter rather than the policy.
**A merged heartbeat means a finished source never completes.** `Flux.interval` is infinite, so
`source.mergeWith(heartbeat)` holds the connection for the full `maxStreamAge` after the last item —
thirty minutes of keepalives on a stream that ended. `WebFluxSseAdapter` therefore ends at the
terminal envelope (`takeUntil`), and supplies one for a source that finished without emitting its
own.
**`onBackpressureBuffer(n)` does not close anything.** It propagates demand, so a subscriber that
stops requesting simply stops the source and the bound never fires. That is correct for a
well-behaved source and useless as a slow-consumer policy, because the sources this carries push
whether or not anybody asked. The working composition is `onBackpressureBuffer(n)` followed by
`onBackpressureError()`, verified by mutation: removing the second half makes both slow-consumer
tests fail. The merge prefetch is set to the same `n`, because its default of 256 would otherwise
be a larger backlog sitting behind the configured one.
**The blocking bridge's permit cannot be released in `doFinally`.** That fires on the subscriber's
cancel signal, which arrives while the callable is still blocked on a thread — releasing there hands
the permit to another caller while the first still holds the database connection it was accounting
for. Acquire and release are in the same `try`/`finally` inside the callable, and the consequence,
stated rather than hidden, is that cancellation does not interrupt a blocking call.
On the servlet side the equivalent is that there is no disconnect event at all: the first sign a
client is gone is a write that throws, which is why the heartbeat is the probe rather than a
courtesy and why a failed beat is recorded as disconnect evidence.
### One pre-existing flake was fixed
`HttpThrottleFixture.awaitSlotTaken` polled for a 503 while each probe spent quota, and refilled the
quota only after the loop. On a machine loaded enough that the holding request took a while to
occupy the slot, the quota ran out first and every remaining probe answered 429 — so the loop never
saw its 503 and failed on the deadline, which reads as a capacity bug and is a fixture bug. It now
refills before every probe and fails loudly if a probe is refused for quota immediately after a
refill. Found because the new virtual-thread and blocking-bridge tests load the machine enough to
trigger it.
### Verification
```bash
cd src
./gradlew :adapter:inbound:web:test # 861 tests, Advanced included
./gradlew :adapter:inbound:web:webAdvancedTest # 147, the Advanced lane on its own
./gradlew :adapter:inbound:web:webCrossStackParityTest
./gradlew :adapter:inbound:web:webNginxProxyTest # Docker
```
See `docs/web/advanced-capabilities.md`, `docs/web/streaming-contract.md`,
`docs/web/patch-contract.md`, `docs/web/virtual-thread-profile.md`,
`docs/web/openapi-32-compatibility.md`, and `docs/adr/ADR-WEB-ADV-00{1,2,3}-*.md`.
+116
View File
@@ -0,0 +1,116 @@
# Web platform runbook
What an operator needs when the HTTP boundary misbehaves. Organised by what you observe, because
that is what you have at 3am — not by which module owns it.
## Reading the two "too much traffic" answers
The platform never answers these two interchangeably, and the difference tells you where to look.
| You see | It means | Where to look |
| --- | --- | --- |
| `429 RATE_LIMITED` climbing for one caller | that caller exceeded its quota | the caller. Nothing is wrong with the service. |
| `429` climbing across **all** callers | quota is being charged for requests the service then shed | look at 503 first; see below |
| `503 ADMISSION_REJECTED` | the service has no capacity | saturation: check the admission profile's concurrency and what is holding slots |
The second row is a real consequence of the design and worth knowing before it confuses you. Quota
is charged *before* capacity is requested, deliberately — the reverse order lets a caller who is
about to be rate-limited occupy a slot on the way to being told so. Under sustained overload every
caller's quota therefore drains on requests that never ran. **Rising 429 during an incident is a
symptom of shedding, not of callers misbehaving.** Read the 503 rate first.
## A control that appears configured and does nothing
This has happened here, and the failure is silent by construction. A configured-but-unwired control
behaves exactly like a working one until it is needed.
- **Check first:** the platform snapshot's `uninstalledControls()`. `WebPlatformStartupValidator`
fails startup on a missing required control, so a running instance with one missing means it was
not in the required list.
- **The instance found the hard way:** the problem catalog was complete, fully unit-tested, and
reached by nothing on the framework's error path. Spring answered failures with its own
`ProblemDetail` — RFC 9457-shaped, so it looked correct — carrying no `code` field. It was found
by a cross-stack parity recording, not by any test of the catalog.
- **How to confirm quickly:** send a request that must fail validation and check the body has a
`code`. No `code` means the platform's handler is not installed.
## A response is truncated or the connection dies mid-document
Two different causes, distinguished by the status the client did receive.
- **Client got a status, then nothing** — the response budget was crossed after commit. The
platform cannot retract a status, so it ends the connection: a truncated response the client
rejects is better than a short one it accepts as complete. Raise `maxResponseBytes` for that
operation's profile, or make the endpoint paginate.
- **Client got nothing at all** — either the request budget was crossed before headers, in which
case there is a problem document, or the proxy refused it. `client_max_body_size` in nginx is
below the application's body budget by default, and nginx answers with its own HTML rather than a
problem document. See `repository-adaptation.md` §8 for which bound fires where.
## A retried write happened twice
The idempotency key is the control, and there are exactly three ways it fails to apply.
1. **The client did not send one.** The operation profile says `OPTIONAL`, so it ran unguarded.
Change the profile to `REQUIRED` if a duplicate is unacceptable.
2. **The client sent a different body.** Answered `422 IDEMPOTENCY_KEY_REUSED`, never a replay —
replaying would hand back a receipt for a request the caller never made.
3. **The record expired.** TTL is per-operation with a 72h cap. A retry after expiry is a new
request by definition.
A `409 IDEMPOTENCY_REQUEST_IN_PROGRESS` is not a failure: an earlier attempt is still running and
the caller should retry after `Retry-After`.
## Rolling deploy stalls with instances half-drained
`shutDownGracefully` waits for in-flight work, and on Reactor Netty an open-but-idle keep-alive
connection counts as in-flight. Calling `stop()` while that wait is in progress hangs.
- **Symptom:** an instance neither serving nor exiting, no error in its log.
- **Cause:** an unbounded graceful wait. `GracefulShutdownProbe` bounds it at a stated grace period
and stops the server regardless once it elapses; a deployment must do the same.
- **Setting:** `spring.lifecycle.timeout-per-shutdown-phase`. Without a bound the deploy waits for
a connection that may never go idle.
## Metrics stopped arriving, or the bill jumped
Almost always a high-cardinality tag. `WebMetricCardinalityPolicy` refuses anything off an
eight-name allowlist at the point of recording, so a new tag cannot appear by accident — but a
`routeTemplate` carrying a *resolved* path can, and that is one series per resource.
- **Check:** the tag values in the metrics backend for `routeTemplate`. Braces mean templates;
identifiers mean the resolved path leaked through.
- **Never tags:** the URL, the query string, any identifier, the tenant, a key, a token, a cookie,
a body. A tag value reaches the metrics backend unredacted and usually a third-party SaaS with it.
## Cross-origin requests fail only in the browser
The API answers correctly and the browser refuses the response. Everything below is refused at
startup by `WebCorsPolicyValidator`, so a running instance with one of these means the profile was
built somewhere that does not validate.
- `*` with credentials — no browser honours it.
- An origin with a path, a trailing slash, or uppercase — never matches what the browser sends.
- `https://*.example.com` — matches nothing; CORS compares origins exactly.
- Wildcard `allowedHeaders` on a credentialed profile — not honoured with credentials.
A preflight answered `401` means CORS ran after authentication. Preflights carry no credentials by
design; the order is asserted by `WebPipelineOrderContract`.
## Two access-log lines for one request
An async request passes through the servlet filter chain twice — once for the initial request and
again on the ASYNC redispatch. A completion recorded without checking `isAsyncStarted()` is written
both times, and every latency percentile computed from that data is wrong while looking plausible.
`WebPipelineOrderContract` asserts exactly one observation per logical request on every container.
## Verification commands
```bash
cd src
./gradlew :adapter:inbound:web:test # unit, boundary, architecture
./gradlew :adapter:inbound:web:webCrossStackParityTest # Tomcat vs Jetty vs Reactor Netty
./gradlew :adapter:inbound:web:webNginxProxyTest # real proxy; needs Docker, fails without it
./gradlew :adapter:inbound:web:webJettyCompatTest # second servlet container
./gradlew :adapter:inbound:web:webFluxContractTest # reactive stack
```
+78
View File
@@ -0,0 +1,78 @@
# Web streaming: the client contract
What a client must implement to consume SSE, NDJSON or `application/json-seq` from this platform.
## Three outcomes, not two
After the first byte the HTTP status is 200 and cannot change. So the status tells you nothing about
whether the stream succeeded, and a closed connection is ambiguous. There are three endings:
1. **A `Complete` envelope.** The stream finished. `lastSequence` is the final position.
2. **A `Failure` envelope.** Something failed after commit. Carries a `ProblemCode` and a
client-safe message. The status is still 200.
3. **Neither.** The connection closed mid-stream. The server records this as `ABRUPT_CLOSE`; from
the client's side it is indistinguishable from a completed stream *unless the client is looking
for the terminal envelope*.
**A client that treats a closed connection as completion will silently truncate data.** No server
change can fix that for it. Looking for the terminal envelope is the contract.
## Positions
Every `Item` carries a `sequence`, counting from 1. Positions are strictly increasing, and the
server enforces it — a repeat or a regression is a server-side error, not something a client has to
tolerate.
Zero is never a valid position. A stream whose first item claimed 0 and one whose sequence was never
set would look identical.
## Resuming
Where a route supports it, send the last position you applied as `Last-Event-ID`.
- If the source still holds it, delivery continues from the next position.
- If it does not, the response is a **resnapshot-required** error, not a partial stream. Resuming
from the oldest retained position would give you contiguous positions with a hole in the middle,
and nothing in the data would say so.
A resnapshot means re-reading the resource from its normal endpoint and starting a fresh stream.
Clients that cannot do that cheaply should not use the resume path.
## Framing
| Format | Media type | Framing | Truncation behaviour |
| --- | --- | --- | --- |
| SSE | `text/event-stream` | `data:` lines, blank-line separated | Partial event at the end |
| NDJSON | `application/x-ndjson` | One JSON value, then `\n` | The truncated line has no newline, and the delimiter is what was lost |
| JSON-seq | `application/json-seq` | `0x1E`, value, `\n` | The next `0x1E` unambiguously starts the next record |
Prefer JSON-seq where truncation matters. Its separator comes *first*, so a parser resynchronises at
the next record rather than trying to parse the truncation joined to what follows. For a long-lived
stream, truncation is the normal way it ends.
Records never contain a raw newline. The server refuses to write one, because for NDJSON the
consumer's line split is the only record boundary there is.
## Heartbeats and timeouts
The server writes a keepalive every `heartbeatInterval`. A client that sees nothing for longer than
that should assume the connection is dead — a silent connection and a disconnected one are the same
thing at the socket, and the heartbeat is what separates them.
Streams are closed at `idleTimeout` (nothing produced) and at `maxStreamAge` (regardless of
activity). Both are normal endings, and both send a terminal envelope where the connection permits.
## Slow consumers
The server buffers at most `maxBufferedItems` for a consumer that is behind. Past that it closes the
connection rather than growing the buffer. A client that cannot keep up should reconnect with a
resume cursor, not expect the server to hold its backlog.
## Shutdown
During a rolling deploy, open streams receive a reconnect request before the node stops accepting.
Reconnect promptly — the node force-closes anything still open at its drain deadline, and that
arrives as an abrupt close.
Do not reconnect immediately on an abrupt close without backoff and jitter. If a node's streams are
all cut at once, every client reconnecting at once is what keeps the replacement down.
+78
View File
@@ -0,0 +1,78 @@
# The virtual-thread MVC profile
## What it changes, and what it does not
Virtual threads remove the cost of a thread waiting. They do not remove the reason the waiting was
bounded.
A platform-thread MVC deployment has an implicit concurrency limit — the thread pool. Nobody wrote
it down as an admission policy, but it is what has been protecting the database pool, the outbound
HTTP bulkhead and every downstream service from the full arrival rate.
Switching to virtual threads deletes that limit and deletes nothing that depended on it. The result
is not a slow system. It is a system that accepts ten thousand concurrent requests, queues all of
them on a twenty-connection pool, and times out every one — having done no useful work. The load
that used to be shed at the front door is shed at the back, after the cost of accepting it.
## Enabling it
Two settings, and the profile refuses to be constructed with only one:
```yaml
backend:
web:
advanced:
mvc-virtual-threads:
enabled: true
admission-limit: 100 # required
database-pool-size: 20 # stated, and unchanged
outbound-bulkhead: 20 # stated, and unchanged
```
The downstream numbers are carried in the profile because the whole point is that they did not grow.
`VirtualThreadProfile.admissionFitsDownstreamBudgets()` reports when the admission limit exceeds
them. It does not refuse — a deployment can legitimately admit more than its pool when the work is
not all database-bound — but it makes the choice a choice.
## The limit bounds use cases, not threads
`VirtualThreadAdmissionGuard` is a fair semaphore, not a pool. Bounding the threads would put the
waiting back and throw away what virtual threads bought. Ten thousand virtual threads may exist
while a hundred hold permits and the rest are refused at the door.
A refusal is a **503 with `Retry-After`**, and it is the outcome the limit exists to produce. A
request refused in a millisecond is strictly better for the client than the same request accepted
and timed out thirty seconds later behind a full pool.
The semaphore is fair on purpose. An unfair one is faster and lets newer arrivals overtake waiting
ones, which the overtaken client experiences as a random timeout.
## What to watch
| Signal | Why |
| --- | --- |
| `jdk.VirtualThreadPinned` JFR events | A `synchronized` block held across a blocking call pins the carrier thread. The carrier pool is bounded by CPU count, so enough pinned carriers is a deadlock — and a thread dump does not obviously show it. |
| Carrier pool queue depth | The same problem, from the other side. |
| Admission rejections | They should rise under load. If they do not, the limit is not being applied. |
| Downstream wait time | The signal that admission is admitting more than the pools serve. |
`VirtualThreadProfile.requiredObservations()` is the same list, in code.
## Testing it
`VirtualThreadAdmissionGuard.peakActive()` exists so a load test can assert the limit was applied.
It is invisible from throughput — a load test that only measures throughput passes with the guard
removed, which is precisely the failure this profile guards against.
The test that matters asserts two things together: peak concurrency at or below the limit, **and**
more threads created than the limit. Without the second, the test would pass on a deployment that
never used virtual threads at all.
## Rolling back
Set `enabled: false`. The rollback test asserts that Stable behaviour is then identical — if it is
not, the feature was never optional and every deployment has it.
This is one of only two web Advanced capabilities that affect requests which do not use them (the
other is the WebFlux blocking bridge), which is why its soak is 24 hours and its rollback test is
the one that matters most.
@@ -0,0 +1,7 @@
f145f6b13d665c52edd061695fd384825891ea6146946c81dae438c1cb9ee4c3 README.md
bceb2d92489db305aa07f9f6a51fe6cb40e9971cee600275887192caff220e31 VALIDATION.md
fdc801db5190213d213eb59e16a289acd22096bcdba4cd1b3e89ca98a1af45a2 docs/superpowers/plans/2026-08-14-websocket-advanced-capabilities-expansion-plan.md
97969421447fc3b54ca5b18baacd13e84ebad1e2c8ab054a977e488daba02e4c docs/superpowers/plans/2026-08-14-websocket-realtime-connection-platform-implementation-plan.md
8a49c95ef8bec0312ca028f80302332ef811c12d578ff8a89b7843c966f44ff9 docs/superpowers/specs/2026-08-14-websocket-realtime-connection-platform-design.md
57dedc7fd8e6f5de96a61a7295a1d4b474507cdd9029760e1907f5f47abc6de8 research/source-websocket-deep-research.md
459a713434fa74c7182b947fdf79d6b89a7aa9c7f5070d26903744bc722be5b1 validate_websocket_docs.py
@@ -0,0 +1,28 @@
# WebSocket Superpowers Package
이 패키지는 WebSocket 실시간 양방향 연결 실행 플랫폼의 설계서, Stable 구현 계획, Advanced 확장 계획, 요구사항 원본과 정적 검증 도구를 포함한다.
## 적용 순서
```text
Stable Task 153
→ Stable Release Gate
→ Advanced Task 122
→ 기능별 Promotion Gate
```
## 문서
- `docs/superpowers/specs/2026-08-14-websocket-realtime-connection-platform-design.md`
- `docs/superpowers/plans/2026-08-14-websocket-realtime-connection-platform-implementation-plan.md`
- `docs/superpowers/plans/2026-08-14-websocket-advanced-capabilities-expansion-plan.md`
- `research/source-websocket-deep-research.md`
## 검증
```bash
python validate_websocket_docs.py
sha256sum -c MANIFEST.sha256
```
이 검증은 문서 구조·계약 일관성·패키지 무결성 검증이며 실제 Gradle compile, Browser, Nginx, Container, Fault, Performance 실행을 대체하지 않는다.
@@ -0,0 +1,47 @@
# WebSocket Superpowers 문서 정적 검증 결과
- **검증일:** 2026-08-14
- **검증 명령:** `python validate_websocket_docs.py`
- **검증 출력:** `checks=719 passed=719 failed=0`
- **결과:** PASS
## 문서 규모
| 문서 | 행 | Task | Create 경로 |
|---|---:|---:|---:|
| 설계서 | 2,810 | - | - |
| Stable 구현 계획 | 4,293 | 53 | 121 |
| Advanced 확장 계획 | 1,817 | 22 | 48 |
## 검증 항목
- Stable Task 153 번호 연속성
- Advanced Task 122 번호 연속성
- 모든 Task의 `Files`, `Interfaces`, `Implementation requirements`, Step 15, commit 명령
- Stable·Advanced Create 경로 중복 및 충돌 부재
- `TODO`, `TBD`, `FIXME` placeholder 부재
- Markdown code fence 균형
- Stable Raw Typed JSON·Evidence·Ticket·Queue·Runtime·Nginx·Browser 계약 포함
- Advanced Resume·Cluster·STOMP·Broker Relay·Binary·Compression·HTTP/2·3 계약 포함
- 요구사항 원본 Appendix 및 research file 보존
- SHA-256 manifest와 ZIP CRC 검증 가능 구조
## 검증 범위의 한계
현재 검증은 설계서와 구현 계획서의 정적 구조·내부 계약·패키지 무결성 검증이다. 실제 Backend Skeleton 저장소가 제공되지 않았으므로 다음은 실행하지 않았다.
```text
Gradle configuration·compile
Spring Boot ApplicationContext
Tomcat·Jetty·Reactor Netty WebSocket contract
Nginx TLS Upgrade path
Chromium·Firefox·WebKit browser matrix
Redis ticket·session index integration
JPA result ledger transaction
Messaging replay/fan-out
Commit 후 socket reset fault
Slow consumer·memory·latency performance
STOMP·RabbitMQ broker relay
HTTP/2·HTTP/3 compatibility
Git commit
```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,84 @@
from __future__ import annotations
from pathlib import Path
import re
import sys
import zipfile
root = Path(__file__).resolve().parent
if (root / 'docs').exists():
design = root / 'docs/superpowers/specs/2026-08-14-websocket-realtime-connection-platform-design.md'
stable = root / 'docs/superpowers/plans/2026-08-14-websocket-realtime-connection-platform-implementation-plan.md'
advanced = root / 'docs/superpowers/plans/2026-08-14-websocket-advanced-capabilities-expansion-plan.md'
else:
design = Path('/mnt/data/websocket-realtime-connection-platform-design.md')
stable = Path('/mnt/data/websocket-realtime-connection-platform-implementation-plan.md')
advanced = Path('/mnt/data/websocket-advanced-capabilities-expansion-plan.md')
checks = []
def check(name, condition):
checks.append((name, bool(condition)))
for path in (design, stable, advanced):
check(f'exists:{path.name}', path.exists())
if not path.exists():
continue
text = path.read_text(encoding='utf-8')
check(f'code-fence:{path.name}', text.count('```') % 2 == 0)
check(f'no-placeholders:{path.name}', not re.search(r'\b(TODO|TBD|FIXME)\b', text))
check(f'nontrivial:{path.name}', len(text.splitlines()) > 300)
stable_text = stable.read_text(encoding='utf-8')
advanced_text = advanced.read_text(encoding='utf-8')
design_text = design.read_text(encoding='utf-8')
stable_tasks = [int(n) for n in re.findall(r'^### Task (\d+):', stable_text, re.M)]
advanced_tasks = [int(n) for n in re.findall(r'^### Task (\d+):', advanced_text, re.M)]
check('stable-task-sequence', stable_tasks == list(range(1, 54)))
check('advanced-task-sequence', advanced_tasks == list(range(1, 23)))
for label, text, expected in [('stable', stable_text, 53), ('advanced', advanced_text, 22)]:
sections = re.split(r'(?=^### Task \d+:)', text, flags=re.M)[1:]
check(f'{label}-task-count', len(sections) == expected)
for idx, section in enumerate(sections, 1):
for token in ['**Files:**', '**Interfaces:**', '**Implementation requirements:**',
'Step 1:', 'Step 2:', 'Step 3:', 'Step 4:', 'Step 5:',
'git commit -m']:
check(f'{label}-task-{idx}-{token}', token in section)
create_pattern = re.compile(r'^- Create: `([^`]+)`', re.M)
stable_paths = create_pattern.findall(stable_text)
advanced_paths = create_pattern.findall(advanced_text)
check('stable-create-unique', len(stable_paths) == len(set(stable_paths)))
check('advanced-create-unique', len(advanced_paths) == len(set(advanced_paths)))
check('stable-advanced-create-disjoint', set(stable_paths).isdisjoint(set(advanced_paths)))
required_design = [
'Inbound Evidence', 'Outbound Evidence', 'Connection Evidence',
'hyeonworks.realtime.v1.json', 'ONE_TIME_TICKET',
'APPLICATION_COMMITTED', 'WRITTEN_LOCALLY',
'Outbound Queue·Backpressure', 'Nginx', 'Tomcat', 'Jetty',
'Reactor Netty', 'WebSocket exactly-once', 'Appendix A'
]
for token in required_design:
check(f'design-token:{token}', token in design_text)
required_stable = [
'Commit 후 Response Loss', 'Slow Consumer', 'Browser Matrix',
'MVC·WebFlux Stack 상호 배타성', 'Stable Release Gate'
]
for token in required_stable:
check(f'stable-token:{token}', token in stable_text)
required_advanced = [
'Resume Token', 'Messaging 기반 Durable Replay', 'STOMP 1.2',
'RabbitMQ STOMP Broker Relay', 'HTTP/3 WebSocket Experimental',
'GraphQL WebSocket Transport Bridge'
]
for token in required_advanced:
check(f'advanced-token:{token}', token in advanced_text)
failed = [name for name, ok in checks if not ok]
print(f'checks={len(checks)} passed={len(checks)-len(failed)} failed={len(failed)}')
for name in failed:
print('FAIL', name)
sys.exit(1 if failed else 0)
+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.