Files

358 lines
23 KiB
Markdown

# 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`.