358 lines
23 KiB
Markdown
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`.
|