23 KiB
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:webmay depend only ondomain-core,application-coreandshared-contract, so the web leaf cannot take a JPA dependency.:adapter:outbound:persistence-jpais 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 included 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-ADVinWebArchitectureRules.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.WebStableModuledeclares eleven Advanced module identities with their own purity and edge sets, checked byWebModuleBoundaryTest. Nine of the eleven areCORE— 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
ObjectMapperbeans —webStrictObjectMapper,xmlMapper,cborMapper— so every@Autowired ObjectMapperbecame ambiguous and the application would not start. Sixapp-bootstraptests failed withUnsatisfiedDependencyException. - Every deployment silently began accepting
application/xmlrequest 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
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.