refactor: 각 어댑터터별 리펙토링 진행

This commit is contained in:
DongHyeonka
2026-08-24 18:26:40 +09:00
parent e98b56eb03
commit 0137263441
439 changed files with 31935 additions and 4719 deletions
+102
View File
@@ -45,6 +45,96 @@ gates:
workflow: ci-quality-gates.yml workflow: ci-quality-gates.yml
job: quality-gates job: quality-gates
execution: check execution: check
- id: documented-leaf-count
release_blocking: true
mechanism: gradle-custom-task
ref: verifyDocumentedLeafCount
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: declared-dependency-absence
release_blocking: true
mechanism: gradle-custom-task
ref: verifyDependencyPolicy
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: notification-api-surface
release_blocking: true
mechanism: gradle-custom-task
ref: verifyNotificationApiSurface
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: notification-configuration-contract
release_blocking: true
mechanism: gradle-custom-task
ref: verifyNotificationConfiguration
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: notification-support-grade-evidence
release_blocking: true
mechanism: gradle-custom-task
ref: verifyNotificationEvidence
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: runbook-reference-drift
release_blocking: true
mechanism: gradle-custom-task
ref: verifyRunbookReferences
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: graphql-api-surface
release_blocking: true
mechanism: gradle-custom-task
ref: verifyGraphQlApiSurface
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: mongo-api-surface
release_blocking: true
mechanism: gradle-custom-task
ref: verifyMongoApiSurface
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
# The strongest evidence this repository produces, and CI does not run it. Fifteen Compose lanes
# start real PostgreSQL, MongoDB, Kafka, MinIO, Mailpit and Keycloak, take a real client-credentials
# JWT, and prove things no in-JVM test can: that all-off boots with no external resource, that the
# notification handoff delivers exactly once across a restart on the same volume, that the startup
# log is silent. It runs from a developer's machine via scripts/run-compose-runtime-smoke.sh and
# from nowhere else — no workflow invokes it, so nothing re-runs it on a pull request.
#
# Registered delegated-pending so the gap is a tracked absence rather than an unstated one.
# Executing it in CI needs a Docker-capable runner and a decision about the minutes fifteen
# container lanes cost, which is an infrastructure choice rather than a wiring oversight.
- id: runtime-smoke-matrix
release_blocking: conditional
mechanism: delegated-pending
ref: runtime-smoke-matrix-lane
workflow: ci-quality-gates.yml
job: release-gate
execution: job
# `conditional-transport-qualification` above is the registered GraphQL control, and it is a
# boundary test: a @SpringBootTest over a nested test application with in-memory Basic Auth. Its own
# javadoc says so — "the nested application deliberately owns only test authentication and CORS
# policy". That is a legitimate transport-boundary proof and it is not release evidence for the
# security posture, which is the distinction the Definition of Done draws.
#
# The real proof exists: the local-graphql Compose lane obtains a Keycloak client-credentials token
# and posts it to /graphql on the running bootJar, asserting that anonymous and malformed
# credentials are refused and the authenticated query answers. It is part of the runtime smoke
# matrix above, so it inherits that control's pending status rather than having none of its own.
- id: graphql-runtime-jwt
release_blocking: conditional
mechanism: delegated-pending
ref: graphql-runtime-jwt-lane
workflow: ci-quality-gates.yml
job: release-gate
execution: job
- id: one-type-per-file - id: one-type-per-file
release_blocking: true release_blocking: true
mechanism: gradle-custom-task mechanism: gradle-custom-task
@@ -287,3 +377,15 @@ gates:
workflow: httpclient-release.yml workflow: httpclient-release.yml
job: release-gate job: release-gate
execution: explicit execution: explicit
# The messaging platform's only claim that needs a real broker to be true. The gate is the
# evidence check rather than the lane, and it depends on the lane: passing means both that every
# fault scenario produced the outcome the shared contract fixes and that the committed manifest is
# what this run wrote. Before it existed, `CertifiedEvidence` was a hand-authored list and
# "certified against a live broker" was a sentence a developer could type.
- id: messaging-broker-certification
release_blocking: true
mechanism: gradle-custom-task
ref: verifyMessagingCertificationEvidence
workflow: messaging-certification.yml
job: broker-certification
execution: explicit
+89 -6
View File
@@ -27,8 +27,15 @@ readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml"
# Deliberately a literal: a gate silently appearing or disappearing is the drift this lint exists to # Deliberately a literal: a gate silently appearing or disappearing is the drift this lint exists to
# catch, so growing the matrix is an explicit edit here. 38 as of the HTTP Client platform hardening, # catch, so growing the matrix is an explicit edit here. 38 as of the HTTP Client platform hardening,
# which registered httpclient-spring62-runtime as a delegated-pending control — the 6.2 *runtime* # which registered httpclient-spring62-runtime as a delegated-pending control — the 6.2 *runtime*
# claim, distinct from the API-surface scan that was standing in for it. # claim, distinct from the API-surface scan that was standing in for it. 40 after the Gradle
readonly EXPECTED_GATE_COUNT=38 # convention wave registered documented-leaf-count and declared-dependency-absence, then 46 after
# the final qualification wave registered the four notification/runbook gates that existed but ran
# nowhere and the two API-surface gates the convention had already wired into check. 48 once the
# Compose runtime matrix and the GraphQL runtime JWT claim were registered as delegated-pending —
# both are real and neither runs in CI. 49 once the messaging broker certification lane registered
# its evidence gate — the first control in this repository whose subject is not "did the tests pass"
# but "is the committed evidence what the run produced".
readonly EXPECTED_GATE_COUNT=49
if [[ ! -f "${MATRIX}" ]]; then if [[ ! -f "${MATRIX}" ]]; then
printf '::error::gate-matrix-lint: missing %s\n' "${MATRIX}" >&2 printf '::error::gate-matrix-lint: missing %s\n' "${MATRIX}" >&2
@@ -135,6 +142,25 @@ gradle_custom_task_is_registered_in_build_file() {
return 0 return 0
fi fi
# A lane declared through the `ca.strict-test-lane` convention. The convention exists because the
# five lines every lane used to repeat were copied per lane and per leaf, and two copies had
# already lost `failOnNoDiscoveredTests`; registering through it is still registering, so this lint
# has to recognise the declaration or it reports every converted lane as missing.
if grep -qsE -- "lane\\(['\"]${task_name}['\"]\\)" "${build_file}"; then
return 0
fi
# An API surface gate declared through the `ca.api-surface` convention, which derives every task
# name from one label so a leaf cannot verify one surface while telling the reader about another.
# The name is computed, so there is no literal `tasks.register('verifyMongoApiSurface')` anywhere;
# what the build file says is `apiSurface { label = 'Mongo' }`.
if [[ "${task_name}" =~ ^verify(.+)ApiSurface$ ]]; then
local surface_label="${BASH_REMATCH[1]}"
if grep -qsE -- "label[[:space:]]*=[[:space:]]*['\"]${surface_label}['\"]" "${build_file}"; then
return 0
fi
fi
awk -v required_task="${task_name}" ' awk -v required_task="${task_name}" '
index($0, "registerStrictQualificationTest(") > 0 { inside_registration=1 } index($0, "registerStrictQualificationTest(") > 0 { inside_registration=1 }
inside_registration && /^[[:space:]]*name:[[:space:]]*/ { inside_registration && /^[[:space:]]*name:[[:space:]]*/ {
@@ -159,14 +185,64 @@ gradle_custom_task_is_registered_in_build_file() {
' "${build_file}" ' "${build_file}"
} }
# Every `dependsOn ... named('x')` in the build, collected once.
#
# This used to be one recursive grep per gate. That was affordable at 38 gates and stopped being so
# at 48: the whole lint crossed the ten-second budget its own contract test asserts, and the first
# symptom was that test failing rather than anything about gate coverage. One pass, then membership
# tests against the result.
CHECK_WIRING_CACHE=""
load_check_wiring() {
[[ -n "${CHECK_WIRING_CACHE}" ]] && return 0
CHECK_WIRING_CACHE="$(grep -RhoE -- "dependsOn[^\n]*named\((['\"])[A-Za-z0-9_.-]+\1\)" \
"${REPO_ROOT}/src" --include='build.gradle' --include='ca.*.gradle' 2>/dev/null \
| grep -oE "(['\"])[A-Za-z0-9_.-]+\1" | tr -d "\"'" | sort -u)"
# A build with no such wiring at all would leave this empty and make every membership test pass by
# vacuity, so an empty result is a marker rather than an answer.
[[ -z "${CHECK_WIRING_CACHE}" ]] && CHECK_WIRING_CACHE="<none>"
return 0
}
gradle_custom_task_wired_into_check() {
local task_name="$1"
load_check_wiring
if printf '%s\n' "${CHECK_WIRING_CACHE}" | grep -qxF -- "${task_name}"; then
return 0
fi
# `ca.api-surface` wires check as `dependsOn tasks.named(verifyName())`, where verifyName() is
# derived from the leaf's label. The declaration that makes the gate real is the label, so that is
# what proves the wiring — the convention has exactly one check wiring and it is unconditional.
if [[ "${task_name}" =~ ^verify(.+)ApiSurface$ ]]; then
local surface_label="${BASH_REMATCH[1]}"
if grep -RqsE -- "label[[:space:]]*=[[:space:]]*['\"]${surface_label}['\"]" "${REPO_ROOT}/src" \
--include='build.gradle' \
&& grep -qsE -- "dependsOn tasks\.named\(verifyName\(\)\)" \
"${REPO_ROOT}/src/build-logic/src/main/groovy/ca.api-surface.gradle"; then
return 0
fi
fi
return 1
}
# The build files, found once rather than once per gate. Same reason as the wiring cache above: the
# per-gate `find` was a fixed cost multiplied by a number that grew.
GRADLE_FILE_CACHE=""
load_gradle_files() {
[[ -n "${GRADLE_FILE_CACHE}" ]] && return 0
GRADLE_FILE_CACHE="$(find "${REPO_ROOT}/src" -type f -name '*.gradle' | sort)"
return 0
}
gradle_custom_task_is_registered() { gradle_custom_task_is_registered() {
local task_name="$1" local task_name="$1"
local build_file local build_file
while IFS= read -r -d '' build_file; do load_gradle_files
while IFS= read -r build_file; do
[[ -z "${build_file}" ]] && continue
if gradle_custom_task_is_registered_in_build_file "${task_name}" "${build_file}"; then if gradle_custom_task_is_registered_in_build_file "${task_name}" "${build_file}"; then
return 0 return 0
fi fi
done < <(find "${REPO_ROOT}/src" -type f -name '*.gradle' -print0) done <<< "${GRADLE_FILE_CACHE}"
return 1 return 1
} }
@@ -325,9 +401,16 @@ while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do
failures+=("gate '${id}' expects Gradle check in job '${job}'") failures+=("gate '${id}' expects Gradle check in job '${job}'")
continue continue
fi fi
# Build files *and* convention plugins. A gate can now be wired into check from an included
# build's convention rather than from a leaf's build.gradle, and a lint that only reads
# build.gradle would call such a gate unwired while it runs on every leaf — a false failure
# that teaches the next author to delete the matrix row instead of trusting it.
#
# A convention that derives the task name from a label wires check by that derived name, so
# there is no literal to grep for either; `gradle_custom_task_wired_into_check` handles both
# the literal and the derived form.
if [[ "${mechanism}" == "gradle-custom-task" ]] \ if [[ "${mechanism}" == "gradle-custom-task" ]] \
&& ! grep -RqsE -- "dependsOn.*named\\(['\"]${ref}['\"]\\)" "${REPO_ROOT}/src" \ && ! gradle_custom_task_wired_into_check "${ref}"; then
--include='build.gradle'; then
failures+=("gate '${id}' task '${ref}' exists but is not wired into Gradle check") failures+=("gate '${id}' task '${ref}' exists but is not wired into Gradle check")
continue continue
fi fi
+3 -2
View File
@@ -27,11 +27,12 @@ readonly EXPECTED_WORKFLOW_LOCK=(
'3be84c9f15fa3b2ac5a085f8d725ec6d05e7007ae0b433da9e79b3bf340d57ea .github/workflows/jpa-next-hibernate8.yml' '3be84c9f15fa3b2ac5a085f8d725ec6d05e7007ae0b433da9e79b3bf340d57ea .github/workflows/jpa-next-hibernate8.yml'
'a2b74bfb3af12d6d03cd2ea8a5e48490dd131afb89b79694d498c5798387ac53 .github/workflows/jpa-next-jpa4.yml' 'a2b74bfb3af12d6d03cd2ea8a5e48490dd131afb89b79694d498c5798387ac53 .github/workflows/jpa-next-jpa4.yml'
'cd955ef4af895df477896dad9577810f010b2beea8570b09b008f9e94e928bd0 .github/workflows/jpa-next-postgresql19.yml' 'cd955ef4af895df477896dad9577810f010b2beea8570b09b008f9e94e928bd0 .github/workflows/jpa-next-postgresql19.yml'
'b56b548a867b74eaeccb42e7df4f4e52cf7ce657ab27f91e2c8d7ea9944d64af .github/workflows/jpa-nightly.yml' '21e065880ef5d4c4ff973f52d8107ef08398ebaf9518ec6b2fd82d49c5d822c6 .github/workflows/jpa-nightly.yml'
'04851f44ba94533bfbc8fabe2b3a2b408726a9996e86ed3864986d1499d16b50 .github/workflows/jpa-pr.yml' '04851f44ba94533bfbc8fabe2b3a2b408726a9996e86ed3864986d1499d16b50 .github/workflows/jpa-pr.yml'
'59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml' '59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml'
'4748f2ba0a0b77dc1a858ebcfa7db6e41627d97843df5f0aa978bc2facccaad2 .github/workflows/jpa-release.yml' 'cf4f80134197dd6d7dc177f0d21294a6b9ffe8709be67d05089f7ff0ce6c9429 .github/workflows/jpa-release.yml'
'5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml' '5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml'
'8adafc59a2d87a6c65ef94b4726d7d036ac81b150ed3d301578308e6f9a3523f .github/workflows/messaging-certification.yml'
'4e4ccfa267ecd63b9369803d49f2dbdb2fa899517ad4cf23ab11d29104557a91 .github/workflows/notification-platform.yml' '4e4ccfa267ecd63b9369803d49f2dbdb2fa899517ad4cf23ab11d29104557a91 .github/workflows/notification-platform.yml'
'64245586cd5936f1a5647b57f2cd9acd316f96fd75f713b1890decb812e7d5fe .github/workflows/object-storage-qualification.yml' '64245586cd5936f1a5647b57f2cd9acd316f96fd75f713b1890decb812e7d5fe .github/workflows/object-storage-qualification.yml'
'cbc104ea486c746229895e804e3be7716e056a02cce0588c537bce9f442f8b38 .github/workflows/redis-sdk-topology.yml' 'cbc104ea486c746229895e804e3be7716e056a02cce0588c537bce9f442f8b38 .github/workflows/redis-sdk-topology.yml'
+7 -3
View File
@@ -119,10 +119,14 @@ jobs:
src/**/*.gradle src/**/*.gradle
src/**/gradle-wrapper.properties src/**/gradle-wrapper.properties
src/**/gradle.lockfile src/**/gradle.lockfile
- name: Measure pool saturation and REQUIRES_NEW pressure - name: Verify pool saturation and REQUIRES_NEW connection behaviour
working-directory: src working-directory: src
# Machine-dependent bounds are reported rather than asserted unless explicitly enabled, so a # A behaviour contract, not a measurement. This step used to switch assertions off with an
# noisy shared runner does not produce a red build that means nothing. # explicit property and call the result a certification, so the only threshold it ever
# asserted was that thresholds were not being asserted. What
# it checks now — that REQUIRES_NEW needs two connections per concurrent thread, that a
# saturated pool reports its pending count, that a caller waits rather than proceeding
# without a connection — is true on any runner, so there is nothing to switch off.
run: >- run: >-
./gradlew ./gradlew
:adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest :adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest
+7 -2
View File
@@ -1,8 +1,13 @@
name: jpa-release name: jpa-release
# The release gate. Every item in docs/jpa/support-matrix.md's gate table has a job or an assertion # The release gate. src/config/jpa/release-registry.json is the source: every gate it declares has a
# here, and JpaReleaseManifest parses that document so a gate removed from the docs fails the build # job or an assertion here, JpaReleaseRenderingTest holds this file's matrix and promotion lists to
# the registry's Stable majors, and verifyJpaReleaseGateTasks resolves each gate's task against the
# real Gradle graph. So a gate removed from the registry, or a major demoted in it, fails the build
# rather than quietly ceasing to be checked. # rather than quietly ceasing to be checked.
#
# The matrix below is therefore not free to drift: editing it without editing the registry fails the
# unit lane.
on: on:
workflow_dispatch: workflow_dispatch:
@@ -0,0 +1,59 @@
# The messaging platform's broker certification lane.
#
# Separate from ci-quality-gates.yml because it needs a container runtime and several minutes of it.
# The lane deliberately carries no Docker guard: every other container suite in the messaging tree
# skips with a stated reason when Docker is absent, and a certification lane that skipped would
# report success for a broker nobody started — which is the exact claim the evidence exists to rule
# out.
#
# The job runs the evidence gate rather than the lane, and the gate depends on the lane. What it
# proves is not only that the scenarios pass but that the committed manifest
# (messaging-testkit/src/main/resources/messaging/broker-certification-evidence.jsonl) is what this
# run produced, so "certified against a live broker" cannot be restored by editing a file.
name: messaging-certification
on:
pull_request:
paths:
- "src/messaging/**"
- ".github/workflows/messaging-certification.yml"
schedule:
- cron: "41 4 * * 3"
workflow_dispatch:
permissions:
contents: read
env:
# Reuse would hand one scenario the broker another scenario had already faulted.
TESTCONTAINERS_REUSE_ENABLE: "false"
jobs:
broker-certification:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Certify the Kafka adapter against a real broker
working-directory: src
# GITHUB_SHA is read by the lane and written into every evidence line, because "certified"
# is a claim about one source tree.
run: ./gradlew :messaging:messaging-kafka:verifyMessagingCertificationEvidence --no-daemon --stacktrace
- name: Publish the certification evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: messaging-broker-certification-evidence
path: src/messaging/messaging-kafka/build/messaging-certification/
if-no-files-found: warn
+11
View File
@@ -56,9 +56,20 @@ build when a policy document states a leaf count that the registry does not agre
| `adapter:outbound:persistence-*` | JPA/PostgreSQL and MongoDB persistence adapters | application/domain/shared contracts as registered | | `adapter:outbound:persistence-*` | JPA/PostgreSQL and MongoDB persistence adapters | application/domain/shared contracts as registered |
| `adapter:outbound:*` | support, messaging, cache, notification, storage, file, HTTP client, identifier capabilities | application/domain/shared and registered support edge | | `adapter:outbound:*` | support, messaging, cache, notification, storage, file, HTTP client, identifier capabilities | application/domain/shared and registered support edge |
| `shared-contract` | Skeleton-wide operational contracts | Java stdlib only | | `shared-contract` | Skeleton-wide operational contracts | Java stdlib only |
| `messaging:*` | Vendored messaging platform: a product with its own API, SPI, adapters and composition boundary, not a layer of this application | `messaging:*` only — it depends on no `domain-core`, `application-core`, or `shared-contract` type |
| `sample-portfolio` | Fixture/reference consumer | registered runtime leaves; never a production dependency | | `sample-portfolio` | Fixture/reference consumer | registered runtime leaves; never a production dependency |
| `app-bootstrap` | Spring Boot entrypoint and composition root | registered runtime leaves | | `app-bootstrap` | Spring Boot entrypoint and composition root | registered runtime leaves |
The `messaging:*` family is the one entry that is not a Clean Architecture layer, and it is listed so
that the exception is stated rather than inferred from a directory. It is a vendored library — its
own `*-api` leaves are its ports, its broker leaves are its adapters, its starter is its composition
root — and the messaging module review (`docs/reviews/2026-08-14-messaging-module-code-review.md`
MSG-023 §6.2) chose that layout deliberately over folding it into `adapter:outbound:*`. This
application is supposed to reach it the way it reaches any library: through an application-owned port
satisfied by an anti-corruption bridge in `adapter:outbound:messaging`. That bridge does not exist
yet (MSG-015), so today the composition root wires the starter directly; `src/messaging/CLAUDE.md`
holds the detail.
Never infer an individual leaf's Gradle path, allowed dependency, or test command from this table. Never infer an individual leaf's Gradle path, allowed dependency, or test command from this table.
Read its `gradle_path`, `allowed_dependencies`, and `runtime_memberships` from Read its `gradle_path`, `allowed_dependencies`, and `runtime_memberships` from
`src/config/architecture/modules.json`; derive the focused test from that Gradle path. `src/config/architecture/modules.json`; derive the focused test from that Gradle path.
+31
View File
@@ -325,6 +325,37 @@ services:
- caskeleton-infra - caskeleton-infra
restart: "no" restart: "no"
# The GraphQL transport, as a request. auth-smoke proves a token can be obtained and that public
# health answers; this proves /graphql is guarded and that an authenticated query executes.
graphql-smoke:
profiles:
- local-graphql
- all-adapters
image: curlimages/curl:8.10.1
depends_on:
keycloak:
condition: service_healthy
# uid 0 for the mounted 0600 client secret, same as auth-smoke.
user: "0:0"
entrypoint: ["/bin/sh", "/opt/graphql-smoke/graphql-smoke.sh"]
environment:
APP_BASE_URL: "http://app:8080"
KEYCLOAK_ISSUER: "http://keycloak:8080/realms/ca-skeleton"
KEYCLOAK_CLIENT_ID: "ca-skeleton-api"
# Spring for GraphQL serves its own endpoint through a router function rather than an
# annotated controller, so the presentation base-path prefix does not apply to it.
GRAPHQL_PATH: "${GRAPHQL_PATH:-/graphql}"
volumes:
- type: bind
source: ./infra/graphql/smoke
target: /opt/graphql-smoke
read_only: true
secrets:
- keycloak-graphql-smoke-client-secret
networks:
- caskeleton-infra
restart: "no"
# ---- One-shot smoke clients -------------------------------------------------- # ---- One-shot smoke clients --------------------------------------------------
# Never `up --wait` targets. Each is run with `run --rm` and must exit zero; a missing, skipped or # Never `up --wait` targets. Each is run with `run --rm` and must exit zero; a missing, skipped or
# non-zero one fails its lane rather than being treated as "not applicable". # non-zero one fails its lane rather than being treated as "not applicable".
@@ -0,0 +1,70 @@
# ADR-GQL-001 — GraphQL context stays inbound; object authorization moves to application-core; the persisted-operation store stays an inbound SPI
- Status: Accepted
- Date: 2026-08-24
- Review: `docs/reviews/2026-08-14-graphql-module-code-review.md` GQL-026
## Context
The GraphQL leaf's own documentation described three things crossing its boundary: a
`GraphQlRequestContext` with a deadline propagated into application, JPA, Mongo and the HTTP client;
object authorization decided inside the transport; and a persisted-operation registry implemented by
an external durable store.
Two of those invert the dependency direction. If `application-core` or an outbound adapter
implements a type that lives in `adapter:inbound:graphql`, the registry edge that says inbound
depends on application is satisfied while the real compile-time dependency runs the other way.
The third is a business rule in the wrong layer: whether an actor may see an object is a decision
about the domain, and GraphQL is one of four transports this skeleton ships.
## Decision
Three different answers, because the three problems are not the same problem.
**GraphQL context stays inbound-local.** It is mapped explicitly onto application command fields —
actor, tenant, deadline — rather than travelling as a type. Nothing outside the leaf references
`GraphQlRequestContext`, and the boundary test is that grep returns nothing outside it.
**Object authorization moves to `application-core`.** `ObjectAccessPolicy`, `ObjectAccessRequest`
and `ObjectAccessDecision` are transport-neutral and live with the other application policies;
`ApplicationObjectAuthorization` in the GraphQL leaf is the bridge that calls them. This is the one
of the three that was a real layering defect, and it is fixed rather than documented.
**The persisted-operation store stays an inbound-owned SPI.** `GraphQlPersistedOperationRegistry`
remains in `advanced/persisted`, and no leaf outside GraphQL implements it.
## Consequences
The third decision is the one that needs defending, because it leaves the reported risk in place
rather than removing it.
The risk is conditional: the direction inverts only when something outside the leaf implements the
interface. Nothing does. The template ships an in-memory registry and no durable one, because it
ships no persisted-operation store at all.
The alternative was to introduce a generic operational key-value store port owned by a neutral
contract holder, with the GraphQL adapter owning only the key and value mapping. That port would
have exactly one interface, zero implementations and one speculative consumer — a new abstraction
whose shape is guessed from a requirement nobody has stated. This repository has spent a full
remediation pass deleting controls that existed and were reached by nothing, and inventing a port
for a store that does not exist is how the next one of those gets written.
So the decision is to leave the SPI where it is and to move it when a durable store is actually
built. Moving it then is a rename across one leaf and one new adapter, which is cheaper than
carrying a wrong abstraction until then. What must not happen in the meantime is an outbound leaf
implementing the inbound interface, because that is the moment the direction actually inverts, and
it would happen in a commit whose diff looks like an implementation rather than a layering change.
The composition root wires these and owns no business or storage policy of its own.
## Enforcement
`verifyCleanArchitectureDependencies` and `modules.json` hold the leaf's edges to
`domain-core`, `application-core` and `shared-contract`. `ObjectAccessPolicyTest` covers the
application-side policy and `ApplicationObjectAuthorizationTest` the bridge.
The condition this ADR turns on — that nothing outside the GraphQL leaf implements the
persisted-operation SPI — is a claim about the whole repository, so it is checked at the
composition root rather than inside the leaf, next to the other GraphQL boundary rules in
`app-bootstrap`'s architecture suite.
@@ -0,0 +1,67 @@
# ADR-JPA-006 — `audit` is the canonical technical audit model; `auditing` stays a frozen candidate
- Status: Accepted
- Date: 2026-08-24
- Review: `docs/reviews/2026-08-14-jpa-module-code-review.md` JPA-022
## Context
Two complete technical-audit mechanisms live in this leaf and they disagree about the schema.
`audit/AuditableEntity` stamps `created_at`/`created_by`/`updated_at`/`updated_by` with an actor
column of length 256, captured through explicit `initializeAudit`/`applyModification` calls and an
`AuditContextPort`. `auditing/AuditMetadata` is a Spring Data embeddable that stamps
`created_*`/`modified_*` with an actor column of length 64, captured by `@CreatedDate` and friends
through an `AuditorAware`.
Only the first is real: it is what the sample entities extend and what the migrations were written
for. `JpaAuditingConfiguration` is not a Spring `@Configuration`, and nothing in production
constructs any of the three `auditing` types.
The review asked for one canonical model with a migration or activation decision. The failure mode
it was protecting against is specific: an author of a new entity picks whichever package they find
first, and column names, actor lengths and capture lifecycles then diverge per table.
## Decision
`audit/AuditableEntity` is canonical. `auditing` stays in the tree as a candidate and is excluded
from the Stable capability report.
The candidate is not deleted and not promoted. Deleting it would discard a working Spring Data
integration that a deployment preferring declarative auditing would want. Promoting it would mean
either renaming `modified_*` to `updated_*` and widening the actor column — a schema migration of
every audited table to gain nothing a caller asked for — or moving the sample entities onto
`modified_*`, which is the same migration in the other direction.
Neither is worth doing now. What the divergence actually needed was not consolidation but a rule
that an entity cannot straddle the two, and that rule is cheaper than either migration.
## Consequences
Two audit mechanisms remain readable in one leaf, and a reader has to be told which one is live.
That cost is paid in this document, in the package javadoc and in a test whose name says so.
Two failure modes stay silent unless they are asserted, so both are:
- The candidate acquires a stereotype and starts stamping in every deployment that has this module
on the classpath, including the ones whose tables have no `modified_*` columns — where the result
is a failed startup rather than a feature.
- Somebody "harmonises" the two by editing one side's column names, at which point the schema a
deployed table was migrated for and the schema its entity expects diverge with no migration
between them.
If the candidate is ever promoted, it is promoted atomically: forward migration, sample conversion,
`AuditContextPort → AuditorAware` and `Clock → DateTimeProvider` bridges land together, and this
ADR is superseded rather than amended.
Bulk and native updates stamp nothing under either mechanism. That is a property of JPA, not of the
choice made here, so it is enforced separately rather than assumed away.
## Enforcement
`JpaAuditMechanismRule.entitiesUseExactlyOneAuditMechanism` and
`bulkUpdatesOfAuditedEntitiesStampAudit`, run against the real production graph by
`JpaProductionArchitectureTest` at the composition root — not against fixtures, which is how the
earlier version of this rule pack passed while applying to nothing. `AuditingCandidateStatusTest`
asserts the candidate carries no composing stereotype and that the two column sets stay distinct.
`JpaAuditMechanismRuleTest` exercises the rules' own negative cases.
+4 -1
View File
@@ -5,7 +5,7 @@
# split into capability artifacts. # split into capability artifacts.
# Update only after review with: # Update only after review with:
# ./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange # ./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange
# types: 395 # types: 398
dev.caskeleton.adapter.inbound.graphql.HealthGraphqlController dev.caskeleton.adapter.inbound.graphql.HealthGraphqlController
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlAdminPrincipal dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlAdminPrincipal
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAdminAuthorization dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAdminAuthorization
@@ -179,6 +179,7 @@ dev.caskeleton.adapter.inbound.graphql.compat.GraphQlSchemaUsage
dev.caskeleton.adapter.inbound.graphql.context.ActorRef dev.caskeleton.adapter.inbound.graphql.context.ActorRef
dev.caskeleton.adapter.inbound.graphql.context.GraphQlCommandAttribution dev.caskeleton.adapter.inbound.graphql.context.GraphQlCommandAttribution
dev.caskeleton.adapter.inbound.graphql.context.GraphQlDeadline dev.caskeleton.adapter.inbound.graphql.context.GraphQlDeadline
dev.caskeleton.adapter.inbound.graphql.context.GraphQlIdentityFingerprinter
dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext
dev.caskeleton.adapter.inbound.graphql.context.TenantContext dev.caskeleton.adapter.inbound.graphql.context.TenantContext
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityCalculator dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityCalculator
@@ -358,6 +359,7 @@ dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformRejectionMapper
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformWebInterceptor dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformWebInterceptor
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPreparsedDocumentAdapter dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPreparsedDocumentAdapter
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPrincipalResolver dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPrincipalResolver
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlRequestObservationConventionAdapter
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlWireErrorMapper dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlWireErrorMapper
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlWireErrors dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlWireErrors
dev.caskeleton.adapter.inbound.graphql.runtime.servlet.GraphQlRequestBodyLimitFilter dev.caskeleton.adapter.inbound.graphql.runtime.servlet.GraphQlRequestBodyLimitFilter
@@ -387,6 +389,7 @@ dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaHash
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaMappingException dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaMappingException
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaOwnership dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaOwnership
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaResource dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaResource
dev.caskeleton.adapter.inbound.graphql.security.ApplicationObjectAuthorization
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticatedPrincipal dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticatedPrincipal
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationContextFactory dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationContextFactory
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationException dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationException
+340
View File
@@ -0,0 +1,340 @@
# JPA persistence leaf public API surface — every public top-level type in src/main/java.
# A public type in a single-jar leaf is reachable from every adopter's code, so
# additions are reviewed rather than discovered. `api` is the intended external
# surface; the rest is implementation that has not been moved under an internal
# root yet.
# Update only after review with:
# ./gradlew :adapter:outbound:persistence-jpa:updateJpaApiSurface -PapproveJpaApiSurfaceChange
# types: 332
dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName
dev.caskeleton.adapter.outbound.persistence.api.capability.CapabilitySupport
dev.caskeleton.adapter.outbound.persistence.api.capability.JpaCapability
dev.caskeleton.adapter.outbound.persistence.api.capability.SupportLevel
dev.caskeleton.adapter.outbound.persistence.api.error.CheckConstraintViolationException
dev.caskeleton.adapter.outbound.persistence.api.error.ConnectionUnavailableException
dev.caskeleton.adapter.outbound.persistence.api.error.ConstraintCode
dev.caskeleton.adapter.outbound.persistence.api.error.ConstraintViolationDetails
dev.caskeleton.adapter.outbound.persistence.api.error.DataCorruptionException
dev.caskeleton.adapter.outbound.persistence.api.error.DeadlockDetectedException
dev.caskeleton.adapter.outbound.persistence.api.error.FailureCategory
dev.caskeleton.adapter.outbound.persistence.api.error.ForeignKeyViolationException
dev.caskeleton.adapter.outbound.persistence.api.error.JpaEntityNotFoundException
dev.caskeleton.adapter.outbound.persistence.api.error.JpaFailureContext
dev.caskeleton.adapter.outbound.persistence.api.error.JpaPersistenceException
dev.caskeleton.adapter.outbound.persistence.api.error.NotNullConstraintViolationException
dev.caskeleton.adapter.outbound.persistence.api.error.OptimisticConflictException
dev.caskeleton.adapter.outbound.persistence.api.error.PessimisticLockTimeoutException
dev.caskeleton.adapter.outbound.persistence.api.error.QueryTimeoutException
dev.caskeleton.adapter.outbound.persistence.api.error.SchemaMismatchException
dev.caskeleton.adapter.outbound.persistence.api.error.SerializationFailureException
dev.caskeleton.adapter.outbound.persistence.api.error.SqlExceptionSqlStateResolver
dev.caskeleton.adapter.outbound.persistence.api.error.SqlStateResolver
dev.caskeleton.adapter.outbound.persistence.api.error.TransactionCompletionUnknownException
dev.caskeleton.adapter.outbound.persistence.api.error.TransactionTimeoutException
dev.caskeleton.adapter.outbound.persistence.api.error.UniqueConstraintViolationException
dev.caskeleton.adapter.outbound.persistence.api.error.VendorFailureTranslator
dev.caskeleton.adapter.outbound.persistence.api.query.CursorCodec
dev.caskeleton.adapter.outbound.persistence.api.query.CursorPayloadCodec
dev.caskeleton.adapter.outbound.persistence.api.query.KeysetPageRequest
dev.caskeleton.adapter.outbound.persistence.api.query.KeysetSlice
dev.caskeleton.adapter.outbound.persistence.api.query.NoopQueryObservation
dev.caskeleton.adapter.outbound.persistence.api.query.QueryName
dev.caskeleton.adapter.outbound.persistence.api.query.QueryObservation
dev.caskeleton.adapter.outbound.persistence.api.query.QueryScope
dev.caskeleton.adapter.outbound.persistence.api.query.SignedJsonCursorCodec
dev.caskeleton.adapter.outbound.persistence.api.query.SortDirection
dev.caskeleton.adapter.outbound.persistence.api.transaction.IsolationLevel
dev.caskeleton.adapter.outbound.persistence.api.transaction.JitterMode
dev.caskeleton.adapter.outbound.persistence.api.transaction.JpaRetryPolicy
dev.caskeleton.adapter.outbound.persistence.api.transaction.JpaTransactionExecutor
dev.caskeleton.adapter.outbound.persistence.api.transaction.PropagationMode
dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryDecision
dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryDisposition
dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryEventListener
dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryProfile
dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionAttempt
dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionCompletionEvidence
dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionProfile
dev.caskeleton.adapter.outbound.persistence.audit.AuditContextPort
dev.caskeleton.adapter.outbound.persistence.audit.AuditableEntity
dev.caskeleton.adapter.outbound.persistence.audit.DomainContextAuditContextPort
dev.caskeleton.adapter.outbound.persistence.auditing.AuditMetadata
dev.caskeleton.adapter.outbound.persistence.auditing.JpaAuditingConfiguration
dev.caskeleton.adapter.outbound.persistence.auditing.JpaAuditorProvider
dev.caskeleton.adapter.outbound.persistence.cache.CacheConcurrencyStrategy
dev.caskeleton.adapter.outbound.persistence.cache.CacheRegionCatalog
dev.caskeleton.adapter.outbound.persistence.cache.HibernateCacheGuard
dev.caskeleton.adapter.outbound.persistence.cache.HibernateCachePolicy
dev.caskeleton.adapter.outbound.persistence.cache.HibernateCacheSettings
dev.caskeleton.adapter.outbound.persistence.config.JpaAdapterComponentsConfig
dev.caskeleton.adapter.outbound.persistence.config.PersistenceJpaConfig
dev.caskeleton.adapter.outbound.persistence.config.PersistenceVendorSettings
dev.caskeleton.adapter.outbound.persistence.envers.EntityRevision
dev.caskeleton.adapter.outbound.persistence.envers.EnversConfigurationGuard
dev.caskeleton.adapter.outbound.persistence.envers.EnversHistoryPolicy
dev.caskeleton.adapter.outbound.persistence.envers.EnversHistoryReader
dev.caskeleton.adapter.outbound.persistence.envers.EnversRevisionMetadata
dev.caskeleton.adapter.outbound.persistence.envers.HibernateEnversHistoryReader
dev.caskeleton.adapter.outbound.persistence.experimental.ExperimentalFeature
dev.caskeleton.adapter.outbound.persistence.experimental.ExperimentalFeatureGate
dev.caskeleton.adapter.outbound.persistence.experimental.database.TenantDataSourceLifecycle
dev.caskeleton.adapter.outbound.persistence.experimental.database.TenantDataSourceRegistry
dev.caskeleton.adapter.outbound.persistence.experimental.database.TenantEntityManagerFactoryRegistry
dev.caskeleton.adapter.outbound.persistence.experimental.database.TenantPoolBudget
dev.caskeleton.adapter.outbound.persistence.experimental.next.CompatibilityLane
dev.caskeleton.adapter.outbound.persistence.experimental.next.ExperimentalPromotionGate
dev.caskeleton.adapter.outbound.persistence.experimental.next.HibernateCompatibilityPolicy
dev.caskeleton.adapter.outbound.persistence.experimental.next.PromotionDecision
dev.caskeleton.adapter.outbound.persistence.experimental.next.PromotionEvidence
dev.caskeleton.adapter.outbound.persistence.experimental.replica.ConsistencyAwareDataSourceRouter
dev.caskeleton.adapter.outbound.persistence.experimental.replica.ConsistencyToken
dev.caskeleton.adapter.outbound.persistence.experimental.replica.ReadConsistency
dev.caskeleton.adapter.outbound.persistence.experimental.replica.ReplicaLagMonitor
dev.caskeleton.adapter.outbound.persistence.experimental.replica.ReplicaRoutingDecision
dev.caskeleton.adapter.outbound.persistence.experimental.replica.ReplicaTarget
dev.caskeleton.adapter.outbound.persistence.experimental.replica.TransactionContext
dev.caskeleton.adapter.outbound.persistence.experimental.rls.RlsAdminBypassToken
dev.caskeleton.adapter.outbound.persistence.experimental.rls.RlsPolicyVerifier
dev.caskeleton.adapter.outbound.persistence.experimental.rls.RlsTenantSessionBinder
dev.caskeleton.adapter.outbound.persistence.experimental.schema.SchemaMultiTenantConnectionProvider
dev.caskeleton.adapter.outbound.persistence.experimental.schema.SchemaTenantMigrationOrchestrator
dev.caskeleton.adapter.outbound.persistence.experimental.schema.SchemaTenantRegistry
dev.caskeleton.adapter.outbound.persistence.experimental.schema.TenantMigrationStatus
dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantAwareRepositoryGuard
dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantContext
dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantEntityListenerGuard
dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantId
dev.caskeleton.adapter.outbound.persistence.failure.PersistenceExceptionTranslator
dev.caskeleton.adapter.outbound.persistence.failure.SqlStateErrorMapping
dev.caskeleton.adapter.outbound.persistence.failure.StandardSqlStateErrorMapping
dev.caskeleton.adapter.outbound.persistence.fileserver.FileserverJpaPersistenceConfig
dev.caskeleton.adapter.outbound.persistence.fileserver.FileserverSchemaActivation
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaCleanupQueue
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaContentReferenceLedger
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaFileMetadataStore
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaFileQuotaService
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaQuotaCommitGateway
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaQuotaReclaimGateway
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaRecoveryQueue
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaStagingUploadLocator
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaUploadSessionStore
dev.caskeleton.adapter.outbound.persistence.fileserver.entity.CleanupItemEntity
dev.caskeleton.adapter.outbound.persistence.fileserver.entity.FileEntity
dev.caskeleton.adapter.outbound.persistence.fileserver.entity.QuotaReservationEntity
dev.caskeleton.adapter.outbound.persistence.fileserver.entity.RecoveryItemEntity
dev.caskeleton.adapter.outbound.persistence.fileserver.entity.UploadSessionEntity
dev.caskeleton.adapter.outbound.persistence.fileserver.entity.VerificationResultEntity
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileTransitionRepository
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileserverCleanupRepository
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileserverQuotaRepository
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileserverRecoveryRepository
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.JpaFileRepository
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.JpaUploadSessionRepository
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.UploadLeaseRepository
dev.caskeleton.adapter.outbound.persistence.h2.H2IdempotencyClaimRepository
dev.caskeleton.adapter.outbound.persistence.h2.H2LocalTimeoutConfigurer
dev.caskeleton.adapter.outbound.persistence.h2.H2OutboxClaimRepository
dev.caskeleton.adapter.outbound.persistence.h2.H2PersistenceConfig
dev.caskeleton.adapter.outbound.persistence.h2.H2SqlStateErrorMapping
dev.caskeleton.adapter.outbound.persistence.hibernate.HibernateProviderPolicy
dev.caskeleton.adapter.outbound.persistence.hibernate.HibernateStatisticsCollector
dev.caskeleton.adapter.outbound.persistence.hibernate.HibernateStatisticsSnapshot
dev.caskeleton.adapter.outbound.persistence.hibernate.JdbcBatchCounter
dev.caskeleton.adapter.outbound.persistence.hibernate.NamedStatementInspector
dev.caskeleton.adapter.outbound.persistence.hibernate.QueryNameContext
dev.caskeleton.adapter.outbound.persistence.hibernate.batch.BatchExecutionResult
dev.caskeleton.adapter.outbound.persistence.hibernate.batch.HibernateBatchConfigurationGuard
dev.caskeleton.adapter.outbound.persistence.hibernate.batch.HibernateJpaBatchExecutor
dev.caskeleton.adapter.outbound.persistence.hibernate.batch.JpaBatchExecutor
dev.caskeleton.adapter.outbound.persistence.hibernate.batch.JpaBatchProfile
dev.caskeleton.adapter.outbound.persistence.hibernate.batch.JpaBatchProfileRegistry
dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.AffectedRowsExpectation
dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.BulkDmlExecutor
dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.BulkDmlResult
dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.BulkOperationName
dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.HibernateBulkDmlExecutor
dev.caskeleton.adapter.outbound.persistence.hibernate.stateless.HibernateStatelessSessionRunner
dev.caskeleton.adapter.outbound.persistence.hibernate.stateless.StatelessRowCapExceededException
dev.caskeleton.adapter.outbound.persistence.hibernate.stateless.StatelessSessionRunner
dev.caskeleton.adapter.outbound.persistence.hibernate.stateless.StatelessWorkName
dev.caskeleton.adapter.outbound.persistence.hibernate.stateless.StatelessWorkResult
dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyClaimRepository
dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyReaper
dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyRecordJpaRepository
dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyResponseObjectStore
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.lock.DistributedLockPersistenceConfig
dev.caskeleton.adapter.outbound.persistence.lock.LockRegistryDistributedLockAdapter
dev.caskeleton.adapter.outbound.persistence.lock.LockSettings
dev.caskeleton.adapter.outbound.persistence.migration.ConcurrentIndexMigrationInspector
dev.caskeleton.adapter.outbound.persistence.migration.FailedConcurrentIndexRecovery
dev.caskeleton.adapter.outbound.persistence.migration.FlywaySchemaPolicy
dev.caskeleton.adapter.outbound.persistence.migration.FlywayValidationGate
dev.caskeleton.adapter.outbound.persistence.migration.MigrationResource
dev.caskeleton.adapter.outbound.persistence.migration.NonTransactionalMigrationPolicy
dev.caskeleton.adapter.outbound.persistence.migration.SchemaManagementMode
dev.caskeleton.adapter.outbound.persistence.migration.SchemaVersionSnapshot
dev.caskeleton.adapter.outbound.persistence.notification.NotificationJpaPersistenceConfig
dev.caskeleton.adapter.outbound.persistence.notification.NotificationSchemaActivation
dev.caskeleton.adapter.outbound.persistence.notification.NotificationSchemaStream
dev.caskeleton.adapter.outbound.persistence.notification.configuration.NotificationJpaPersistenceFacade
dev.caskeleton.adapter.outbound.persistence.notification.crypto.DirectAeadNotificationPayloadCrypto
dev.caskeleton.adapter.outbound.persistence.notification.crypto.NotificationCiphertext
dev.caskeleton.adapter.outbound.persistence.notification.crypto.NotificationCryptoException
dev.caskeleton.adapter.outbound.persistence.notification.crypto.NotificationHmacDigester
dev.caskeleton.adapter.outbound.persistence.notification.crypto.NotificationKeyMaterialHandle
dev.caskeleton.adapter.outbound.persistence.notification.crypto.NotificationKeyMaterialProvider
dev.caskeleton.adapter.outbound.persistence.notification.platform.AdminAuditEntity
dev.caskeleton.adapter.outbound.persistence.notification.platform.AdminAuditJpaRepository
dev.caskeleton.adapter.outbound.persistence.notification.platform.ConsentEntity
dev.caskeleton.adapter.outbound.persistence.notification.platform.ConsentJpaRepository
dev.caskeleton.adapter.outbound.persistence.notification.platform.ContactPointEntity
dev.caskeleton.adapter.outbound.persistence.notification.platform.ContactPointJpaRepository
dev.caskeleton.adapter.outbound.persistence.notification.platform.DeduplicationClaimEntity
dev.caskeleton.adapter.outbound.persistence.notification.platform.DeduplicationClaimJpaRepository
dev.caskeleton.adapter.outbound.persistence.notification.platform.DeliveryAttemptEntity
dev.caskeleton.adapter.outbound.persistence.notification.platform.DeliveryAttemptJpaRepository
dev.caskeleton.adapter.outbound.persistence.notification.platform.JdbcNotificationServingState
dev.caskeleton.adapter.outbound.persistence.notification.platform.JdbcReconciliationJobStore
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaAdminOperationStore
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaContactPointStore
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaDeliveryAttemptStore
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaNotificationRequestStore
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaNotificationSideEffectStore
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaPolicyStores
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaProviderEventLedger
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaRecipientDeliveryStore
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaRecipientLeaseStore
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaSuppressionStore
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaTemplateRegistry
dev.caskeleton.adapter.outbound.persistence.notification.platform.NotificationRecordMapper
dev.caskeleton.adapter.outbound.persistence.notification.platform.NotificationRequestEntity
dev.caskeleton.adapter.outbound.persistence.notification.platform.NotificationRequestJpaRepository
dev.caskeleton.adapter.outbound.persistence.notification.platform.PreferenceEntity
dev.caskeleton.adapter.outbound.persistence.notification.platform.PreferenceJpaRepository
dev.caskeleton.adapter.outbound.persistence.notification.platform.ProviderEventEntity
dev.caskeleton.adapter.outbound.persistence.notification.platform.ProviderEventJpaRepository
dev.caskeleton.adapter.outbound.persistence.notification.platform.RecipientClaimSql
dev.caskeleton.adapter.outbound.persistence.notification.platform.RecipientDeliveryEntity
dev.caskeleton.adapter.outbound.persistence.notification.platform.RecipientDeliveryJpaRepository
dev.caskeleton.adapter.outbound.persistence.notification.platform.SuppressionEntity
dev.caskeleton.adapter.outbound.persistence.notification.platform.SuppressionJpaRepository
dev.caskeleton.adapter.outbound.persistence.notification.platform.TemplateVersionEntity
dev.caskeleton.adapter.outbound.persistence.notification.platform.TemplateVersionJpaRepository
dev.caskeleton.adapter.outbound.persistence.notification.platform.TenantBoundRepositoryGuard
dev.caskeleton.adapter.outbound.persistence.notification.platform.inbox.InboxCommitEventPublisher
dev.caskeleton.adapter.outbound.persistence.notification.platform.inbox.InboxItemEntity
dev.caskeleton.adapter.outbound.persistence.notification.platform.inbox.InboxItemJpaRepository
dev.caskeleton.adapter.outbound.persistence.notification.platform.inbox.InboxOutboxRecordFactory
dev.caskeleton.adapter.outbound.persistence.notification.platform.inbox.JpaNotificationInbox
dev.caskeleton.adapter.outbound.persistence.observation.JpaMetricTags
dev.caskeleton.adapter.outbound.persistence.observation.JpaRetryObservation
dev.caskeleton.adapter.outbound.persistence.observation.JpaTransactionObservation
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.outbox.OutboxClaimRepository
dev.caskeleton.adapter.outbound.persistence.outbox.OutboxEventJpaRepository
dev.caskeleton.adapter.outbound.persistence.outbox.OutboxReaper
dev.caskeleton.adapter.outbound.persistence.outbox.OutboxStoreAdapter
dev.caskeleton.adapter.outbound.persistence.outbox.entity.OutboxEventEntity
dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlIdempotencyClaimRepository
dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlLocalTimeoutConfigurer
dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlOutboxClaimRepository
dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlPersistenceConfig
dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlSqlStateErrorMapping
dev.caskeleton.adapter.outbound.persistence.postgresql.array.PostgreSqlArraySupport
dev.caskeleton.adapter.outbound.persistence.postgresql.constraint.PostgreSqlConstraintCatalog
dev.caskeleton.adapter.outbound.persistence.postgresql.constraint.PostgreSqlConstraintViolationTranslator
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.BoundedCopyInputStream
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyAdminCapability
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyFormat
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyLimits
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyOperationName
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyResult
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.PostgreSqlCopyLoader
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.RegisteredCopyStatement
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.RegisteredPostgreSqlCopyLoader
dev.caskeleton.adapter.outbound.persistence.postgresql.error.ConstraintCatalog
dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlExceptionTranslator
dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlFailureClassifier
dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlServerErrorFields
dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlState
dev.caskeleton.adapter.outbound.persistence.postgresql.idempotency.PostgreSqlOwnerSafeIdempotencyStore
dev.caskeleton.adapter.outbound.persistence.postgresql.inbox.PostgreSqlSameStoreInboxAdapter
dev.caskeleton.adapter.outbound.persistence.postgresql.json.JsonDocument
dev.caskeleton.adapter.outbound.persistence.postgresql.json.JsonDocumentCodec
dev.caskeleton.adapter.outbound.persistence.postgresql.json.JsonPathName
dev.caskeleton.adapter.outbound.persistence.postgresql.json.PostgreSqlJsonQuerySupport
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.LockWaitObservation
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.PostgreSqlLockExceptionTranslator
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.PostgreSqlLockOptions
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.PostgreSqlWorkClaimExecutor
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.WorkClaim
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.WorkClaimExecutor
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.WorkQueueDefinition
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.WorkQueueName
dev.caskeleton.adapter.outbound.persistence.postgresql.outbox.PostgreSqlImmutableOutboxAppendAdapter
dev.caskeleton.adapter.outbound.persistence.postgresql.outbox.PostgreSqlPollingDeliveryAdapter
dev.caskeleton.adapter.outbound.persistence.postgresql.range.PgRange
dev.caskeleton.adapter.outbound.persistence.postgresql.range.PgRangeCodec
dev.caskeleton.adapter.outbound.persistence.postgresql.range.PgRangeJdbcType
dev.caskeleton.adapter.outbound.persistence.postgresql.range.PostgreSqlRangeQuerySupport
dev.caskeleton.adapter.outbound.persistence.postgresql.write.NativeWriteName
dev.caskeleton.adapter.outbound.persistence.postgresql.write.PostgreSqlUpsertExecutor
dev.caskeleton.adapter.outbound.persistence.postgresql.write.RegisteredPostgreSqlUpsertExecutor
dev.caskeleton.adapter.outbound.persistence.postgresql.write.RegisteredUpsertStatement
dev.caskeleton.adapter.outbound.persistence.postgresql.write.UpsertConflictTarget
dev.caskeleton.adapter.outbound.persistence.postgresql.write.UpsertResult
dev.caskeleton.adapter.outbound.persistence.postgresql.write.WriteDisposition
dev.caskeleton.adapter.outbound.persistence.querydsl.PredicatePolicy
dev.caskeleton.adapter.outbound.persistence.querydsl.QueryPage
dev.caskeleton.adapter.outbound.persistence.querydsl.QuerydslJpaSupport
dev.caskeleton.adapter.outbound.persistence.security.DatabasePrivilegeReport
dev.caskeleton.adapter.outbound.persistence.security.DatabaseRolePolicy
dev.caskeleton.adapter.outbound.persistence.security.PostgreSqlRuntimeRoleVerifier
dev.caskeleton.adapter.outbound.persistence.security.SearchPathPolicy
dev.caskeleton.adapter.outbound.persistence.springdata.EntityGraphCatalog
dev.caskeleton.adapter.outbound.persistence.springdata.EntityManagerAccess
dev.caskeleton.adapter.outbound.persistence.springdata.FetchPlanApplier
dev.caskeleton.adapter.outbound.persistence.springdata.FetchPlanName
dev.caskeleton.adapter.outbound.persistence.springdata.JpaKeysetQuerySupport
dev.caskeleton.adapter.outbound.persistence.springdata.JpaRepositoryFragmentSupport
dev.caskeleton.adapter.outbound.persistence.springdata.JpaStreamExecutor
dev.caskeleton.adapter.outbound.persistence.springdata.JpaStreamScope
dev.caskeleton.adapter.outbound.persistence.springdata.KeysetPredicateBuilder
dev.caskeleton.adapter.outbound.persistence.springdata.KeysetSliceAssembler
dev.caskeleton.adapter.outbound.persistence.springdata.KeysetTerm
dev.caskeleton.adapter.outbound.persistence.springdata.RegisteredQuery
dev.caskeleton.adapter.outbound.persistence.springdata.SafeSortField
dev.caskeleton.adapter.outbound.persistence.springdata.SafeSortMapper
dev.caskeleton.adapter.outbound.persistence.springdata.SafeSortRegistry
dev.caskeleton.adapter.outbound.persistence.springdata.ScrollPolicy
dev.caskeleton.adapter.outbound.persistence.springdata.SpecificationPolicy
dev.caskeleton.adapter.outbound.persistence.transaction.BackoffCalculator
dev.caskeleton.adapter.outbound.persistence.transaction.CommitFailureClassifier
dev.caskeleton.adapter.outbound.persistence.transaction.CompletionUnknownRecord
dev.caskeleton.adapter.outbound.persistence.transaction.CompletionUnknownRecorder
dev.caskeleton.adapter.outbound.persistence.transaction.DefaultJpaRetryPolicy
dev.caskeleton.adapter.outbound.persistence.transaction.EffectiveTransactionTimeouts
dev.caskeleton.adapter.outbound.persistence.transaction.EvidenceAwareJpaTransactionManager
dev.caskeleton.adapter.outbound.persistence.transaction.FullTransactionRetryCoordinator
dev.caskeleton.adapter.outbound.persistence.transaction.JpaTransactionConfig
dev.caskeleton.adapter.outbound.persistence.transaction.JpaTransactionSettings
dev.caskeleton.adapter.outbound.persistence.transaction.OptimisticConflictTranslator
dev.caskeleton.adapter.outbound.persistence.transaction.PersistenceFailureTranslatorChain
dev.caskeleton.adapter.outbound.persistence.transaction.RetryBudget
dev.caskeleton.adapter.outbound.persistence.transaction.RetrySleeper
dev.caskeleton.adapter.outbound.persistence.transaction.SpringJpaTransactionExecutor
dev.caskeleton.adapter.outbound.persistence.transaction.SpringTransactionPort
dev.caskeleton.adapter.outbound.persistence.transaction.ThreadRetrySleeper
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionDefinitionMapper
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionEvidenceContext
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionEvidenceFrame
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionEvidenceScope
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionLocalTimeoutConfigurer
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionProfileRegistry
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionStartBudget
dev.caskeleton.adapter.outbound.persistence.transaction.UnknownOperation
+6 -3
View File
@@ -5,7 +5,7 @@
# root yet. # root yet.
# Update only after review with: # Update only after review with:
# ./gradlew :adapter:outbound:persistence-mongo:updateMongoApiSurface -PapproveMongoApiSurfaceChange # ./gradlew :adapter:outbound:persistence-mongo:updateMongoApiSurface -PapproveMongoApiSurfaceChange
# types: 343 # types: 346
dev.caskeleton.adapter.outbound.mongo.MongoOptInAutoConfigurationImportFilter dev.caskeleton.adapter.outbound.mongo.MongoOptInAutoConfigurationImportFilter
dev.caskeleton.adapter.outbound.mongo.MongoPersistenceConfig dev.caskeleton.adapter.outbound.mongo.MongoPersistenceConfig
dev.caskeleton.adapter.outbound.mongo.MongoPersistenceSettings dev.caskeleton.adapter.outbound.mongo.MongoPersistenceSettings
@@ -146,8 +146,6 @@ dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformAutoConfigurati
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformHealthIndicator dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformHealthIndicator
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformSettings dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformSettings
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoProfileProperties dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoProfileProperties
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoStableReleaseEvidence
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoStableReleaseGate
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoStartupValidator dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoStartupValidator
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoTopologyProbe dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoTopologyProbe
dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeEventIdentity dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeEventIdentity
@@ -158,6 +156,10 @@ dev.caskeleton.adapter.outbound.mongo.changestream.MongoClusterTime
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpoint dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpoint
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpointStore dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpointStore
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumePosition dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumePosition
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeTokenCodec
dev.caskeleton.adapter.outbound.mongo.changestream.consumer.MongoChangeStreamSource
dev.caskeleton.adapter.outbound.mongo.changestream.consumer.ReactiveMongoChangeStreamConsumer
dev.caskeleton.adapter.outbound.mongo.changestream.consumer.SpringReactiveChangeStreamSource
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeClaim dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeClaim
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeDeduplicationStore dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeDeduplicationStore
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeProjectionResult dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeProjectionResult
@@ -199,6 +201,7 @@ dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicUpdateResult
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicOperations dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicOperations
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicOperationsTemplate dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicOperationsTemplate
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicy dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicy
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicyRegistry
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoUpdateOperator dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoUpdateOperator
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.ReturnDocumentMode dev.caskeleton.adapter.outbound.mongo.imperative.atomic.ReturnDocumentMode
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkExecutor dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkExecutor
+1 -1
View File
@@ -85,7 +85,7 @@ in a fail-closed contract (`verifyJpaReadinessRegistry` in `src/build.gradle`).
|---|---| |---|---|
| `test` | `src/test` — hermetic unit lane, `./gradlew :adapter:outbound:persistence-jpa:test` | | `test` | `src/test` — hermetic unit lane, `./gradlew :adapter:outbound:persistence-jpa:test` |
| `contractTest`, `integrationTest`, `migrationTest`, `failureTest`, `compatibilityTest` | `src/postgresqlIntegrationTest` — real PostgreSQL containers; selected by the `jpaPlatform*` Gradle tasks | | `contractTest`, `integrationTest`, `migrationTest`, `failureTest`, `compatibilityTest` | `src/postgresqlIntegrationTest` — real PostgreSQL containers; selected by the `jpaPlatform*` Gradle tasks |
| `performanceTest` | `src/jpaPlatformPerformanceTest`machine-dependent bounds, never part of `check` | | `performanceTest` | `src/jpaPlatformPerformanceTest`pool and `REQUIRES_NEW` connection behaviour, run by `jpaPlatformPoolContractTest`; never part of `check`. The source set keeps the plan's name; the lane asserts behaviour rather than measuring, and no numeric performance bound is claimed anywhere from it. |
Docker-dependent lanes fail closed rather than skipping, matching the existing Docker-dependent lanes fail closed rather than skipping, matching the existing
`PostgreSqlReadinessSupport.assertDockerAvailable()` convention in this leaf. `PostgreSqlReadinessSupport.assertDockerAvailable()` convention in this leaf.
+37 -1
View File
@@ -10,6 +10,20 @@ major changed nothing so long as the string survived somewhere in the document.
declares a support level per major as a field, each gate names the Gradle task that produces its declares a support level per major as a field, each gate names the Gradle task that produces its
evidence, and this document describes what the registry says. evidence, and this document describes what the registry says.
Being a rendering used to be a claim rather than a mechanism: the tables below were still typed by
hand, so a major demoted in the registry stayed Stable here and kept its full release job.
`JpaReleaseRenderingTest` now compares the database table, the gate table and `jpa-release.yml`'s
matrix and promotion lists to the registry, and `verifyJpaReleaseGateTasks` resolves every gate's
task against the real Gradle task graph. Edit the registry; these tables follow, or the build fails.
Two renderings stayed outside that comparison until they were added to it. `jpa-nightly.yml` runs
its own matrix and nothing checked it, so a demotion corrected the release lane and left the nightly
lane certifying the major. And an Experimental major's "compatibility lane only" named no file: the
lane existed, but the registry, this document and the release workflow could each be read end to end
without establishing that, so a reader looking for it concluded there was none. An Experimental major
now has to be recorded as the target of a lane in `.github/workflows`, and a Stable lane may not run
it.
## Database ## Database
| Database | Support | Evidence | | Database | Support | Evidence |
@@ -17,7 +31,7 @@ evidence, and this document describes what the registry says.
| PostgreSQL 16 | Stable | full contract suite, release lane (own matrix job) | | PostgreSQL 16 | Stable | full contract suite, release lane (own matrix job) |
| PostgreSQL 17 | Stable | full contract suite, release lane (own matrix job) | | PostgreSQL 17 | Stable | full contract suite, release lane (own matrix job) |
| PostgreSQL 18 | Stable | full contract suite, release lane (own matrix job) | | PostgreSQL 18 | Stable | full contract suite, release lane (own matrix job) |
| PostgreSQL 19 | Experimental | compatibility lane only; promotion requires an ADR | | PostgreSQL 19 | Experimental | [`jpa-next-postgresql19.yml`](../../.github/workflows/jpa-next-postgresql19.yml) — `NOT_EXECUTABLE`: no `postgres:19-alpine` is published, so no container of that major has been started; promotion requires an ADR |
| H2 | Local convenience | **never** evidence of PostgreSQL behaviour | | H2 | Local convenience | **never** evidence of PostgreSQL behaviour |
Each major gets its **own release job**, because for a while it did not. The release lane passed Each major gets its **own release job**, because for a while it did not. The release lane passed
@@ -82,6 +96,8 @@ the difference visible instead of asserting a constant against itself. See
| PostgreSQL `COPY` | Admin (J4) | | PostgreSQL `COPY` | Admin (J4) |
| Hibernate second-level cache | Advanced | | Hibernate second-level cache | Advanced |
| Hibernate Envers | Advanced | | Hibernate Envers | Advanced |
| Technical auditing — `audit/AuditableEntity` | Stable (canonical) |
| Technical auditing — `auditing/AuditMetadata` | Candidate, not composed |
| Multi-tenancy (column, RLS, schema, database) | Experimental | | Multi-tenancy (column, RLS, schema, database) | Experimental |
| Consistency-aware read replica | Experimental | | Consistency-aware read replica | Experimental |
@@ -98,6 +114,26 @@ Each row is a way the platform could pass its tests and still be wrong in produc
| `runtime-role-no-ddl` | gate | the application's own credential being able to alter or drop schema objects | | `runtime-role-no-ddl` | gate | the application's own credential being able to alter or drop schema objects |
| `collection-fetch-pagination` | gate | a paged collection fetch silently reading the whole table and paginating in memory | | `collection-fetch-pagination` | gate | a paged collection fetch silently reading the whole table and paginating in memory |
### The two audit mechanisms
`audit/AuditableEntity` is the canonical one: `created_*`/`updated_*`, a 256-character actor,
stamped explicitly by the repository adapter. It is what the sample entities extend and what the
migrations were written for.
`auditing/AuditMetadata` is a second, complete mechanism with different column names
(`modified_*`), a different actor length (64) and a different capture lifecycle (Spring Data
listeners). Nothing embeds it and nothing composes `JpaAuditingConfiguration`, which is why it is
listed as a candidate rather than as a capability: promoting it means choosing between reshaping it
to the canonical columns and writing a forward migration for the new ones, and that choice has not
been made. Until it is, an entity picks one mechanism or none — enforced on the production graph by
`JpaAuditMechanismRule.entitiesUseExactlyOneAuditMechanism`.
Neither mechanism reaches a bulk or native update. Both stamp on an ordinary save — one in the
adapter, one on a managed entity's lifecycle — so a statement that goes straight to the database
leaves the audit columns showing the previous save. A bulk update of an audited entity must
therefore set the audit column in the statement, which
`JpaAuditMechanismRule.bulkUpdatesOfAuditedEntitiesStampAudit` checks over the production graph.
## Explicitly unsupported ## Explicitly unsupported
- Reactive JPA. JPA is a blocking specification; a reactive facade over it moves the blocking call - Reactive JPA. JPA is a blocking specification; a reactive facade over it moves the blocking call
+144 -91
View File
@@ -7,60 +7,76 @@
> either of the old prefixes now fails startup with a message naming the key — see > either of the old prefixes now fails startup with a message naming the key — see
> `MessagingPrefixMigrationValidator`. > `MessagingPrefixMigrationValidator`.
> **이 페이지는 실행된다.** 아래 YAML 블록은 `MessagingConfigurationBindingTest`가 이 파일에서 직접
> 읽어 컨텍스트에 올린다. 문서가 설명하는 모양이 곧 바인딩되는 모양이라는 뜻이고, 문서를 고치면서
> 코드를 고치지 않으면 테스트가 깨진다. 이전 판은 destination·broker·security 세 섹션을 설명했지만
> 어떤 binder도 그것을 읽지 않았다 — 문서대로 설정한 배포는 아무것도 바뀌지 않았고 아무 말도 듣지
> 못했다 (MSG-008).
## Destination profile ## Destination profile
```yaml ```yaml
app: app:
messaging: messaging:
destinations: destinations:
order-events: order-events:
broker: kafka-primary broker: kafka-primary
kind: EVENT_STREAM # ASYNC_COMMAND | DOMAIN_EVENT | INTEGRATION_EVENT kind: EVENT_STREAM # ASYNC_COMMAND | DOMAIN_EVENT | INTEGRATION_EVENT
# | WORK_QUEUE | PUBLISH_SUBSCRIBE | EVENT_STREAM | REQUEST_REPLY # | WORK_QUEUE | PUBLISH_SUBSCRIBE | EVENT_STREAM | REQUEST_REPLY
tier: M1 # M1 | M2 | M3 tier: M1 # M1 | M2 | M3
physical: physical:
topic: order.events.v1 topic: order.events.v1
schema: schema:
codec: application/json codec: application/json
compatibility: BACKWARD_TRANSITIVE compatibility: BACKWARD_TRANSITIVE
message-types: [order.created] message-types: [order.created]
guarantees: guarantees:
delivery: AT_LEAST_ONCE # AT_MOST_ONCE | AT_LEAST_ONCE delivery: AT_LEAST_ONCE # AT_MOST_ONCE | AT_LEAST_ONCE
ordering: KEY # NONE | DESTINATION | PARTITION | KEY ordering: KEY # NONE | DESTINATION | PARTITION | KEY
external-side-effect: INBOX_TRANSACTIONAL external-side-effect: INBOX_TRANSACTIONAL
producer: producer:
confirmation: REPLICATION_OR_PERSISTENCE_ACK confirmation: REPLICATION_OR_PERSISTENCE_ACK
timeout: 5s timeout: 5s
mandatory-routing: true mandatory-routing: true
idempotent: true idempotent: true
consumer: consumer:
group: order-projection group: order-projection
concurrency: 6 concurrency: 1 # DESTINATION 순서를 요구하면 1이어야 한다
max-in-flight-per-ordering-unit: 1 max-in-flight-per-ordering-unit: 1
prefetch: 16 prefetch: 16
handler-timeout: 30s handler-timeout: 30s
manual-settlement: false manual-settlement: false
retry: retry:
mode: PAUSE_PARTITION # NONE | INLINE | BLOCKING | PAUSE_PARTITION mode: PAUSE_PARTITION # NONE | INLINE | BLOCKING | PAUSE_PARTITION
# | RETRY_DESTINATION | BROKER_DELAYED # | RETRY_DESTINATION | BROKER_DELAYED
max-attempts: 3 max-attempts: 3
initial-delay: 200ms initial-delay: 200ms
max-delay: 2s max-delay: 2s
multiplier: 2.0 multiplier: 2.0
jitter: true jitter: true
ordering-impact: PRESERVE # PRESERVE | ALLOW_REORDER ordering-impact: PRESERVE # PRESERVE | ALLOW_REORDER
dlq: dlq:
destination: order-events-dlq destination: order-events-dlq
max-redrive-count: 1 max-redrive-count: 1
payload: payload:
max-bytes: 1048576 max-bytes: 1048576
claim-check-threshold-bytes: 1048576 claim-check-threshold-bytes: 1048576
key-resolver-configured: true key-resolver-configured: true
production: true production: false
topology-auto-create: false topology-auto-create: false
order-events-dlq:
broker: kafka-primary
kind: WORK_QUEUE
physical:
topic: order.events.v1.dlt
schema:
message-types: [order.created]
``` ```
`dlq.destination`이 가리키는 destination도 선언되어야 한다. 선언되지 않은 이름은 부팅 실패이며,
메시지가 갈 곳 없는 DLQ 설정이 조용히 통과하지 않는다. `retry.destination``dlq.destination`
섞여 만드는 순환(A의 retry가 B로, B의 dlq가 A로)도 하나의 그래프로 검사되어 경로와 함께 거절된다.
## 기본값 ## 기본값
| 설정 | 기본값 | 근거 | | 설정 | 기본값 | 근거 |
@@ -81,26 +97,36 @@ app:
| Outbox polling | 500ms | | | Outbox polling | 500ms | |
| metric dimension 상한 | 200 | cardinality 폭발 방지 | | metric dimension 상한 | 200 | cardinality 폭발 방지 |
`schema.codec``application/json`, `schema.compatibility``BACKWARD_TRANSITIVE`,
`guarantees.delivery``AT_LEAST_ONCE`, `retry.mode``NONE`이 기본값이다. 자동 retry가 기본으로
꺼져 있는 이유는 순서를 흐트러뜨리거나 비멱등 side effect를 두 번 실행하는 retry가 눈에 보이는
실패보다 나쁘기 때문이다.
## Broker profile ## Broker profile
브로커는 `app.messaging.brokers` 아래에 한 번만 기술한다. `type`이 어느 계열의 설정이 적용되는지
결정하며, 다른 계열의 키(Kafka 항목의 `prefetch` 같은)는 무시되지 않고 부팅 실패로 거절된다 —
무시하면 그 줄을 쓴 사람은 무언가가 적용됐다고 믿게 된다.
### Kafka ### Kafka
```yaml ```yaml
app: app:
messaging: messaging:
brokers: brokers:
kafka-primary: kafka-primary:
type: kafka type: kafka
stable: true stable: true
production: true production: false
bootstrap-servers: [broker-1:9093, broker-2:9093] bootstrap-servers: [broker-1:9093, broker-2:9093]
enable-idempotence: true # stable에서 필수 enable-idempotence: true # stable에서 필수
acks: all # stable에서 필수 acks: all # stable에서 필수
max-in-flight-requests-per-connection: 5 # 최대 5 max-in-flight-requests-per-connection: 5 # 최대 5
delivery-timeout: 30s delivery-timeout: 30s
enable-auto-commit: false # 항상 금지 enable-auto-commit: false # 항상 금지
tls-enabled: true # production 필수 consumer-group: order-projection
authentication-enabled: true # production 필수 tls-enabled: false # production이면 필수
authentication-enabled: false # production이면 필수
``` ```
### RabbitMQ ### RabbitMQ
@@ -108,40 +134,52 @@ app:
```yaml ```yaml
app: app:
messaging: messaging:
brokers: brokers:
rabbit-primary: rabbit-primary:
type: rabbitmq type: rabbitmq
stable: true stable: true
production: true production: false
addresses: [rabbit-1:5671] addresses: [rabbit-1:5671]
publisher-confirms: true # stable에서 필수 publisher-confirms: true # stable에서 필수
publisher-returns: true # stable에서 필수 publisher-returns: true # stable에서 필수
mandatory: true # stable에서 필수 mandatory: true # stable에서 필수
confirm-timeout: 5s confirm-timeout: 5s
auto-ack: false # 항상 금지 auto-ack: false # 항상 금지
prefetch: 16 prefetch: 16
quorum-queues: true # durable work queue 필수 quorum-queues: true # durable work queue 필수
tls-enabled: true tls-enabled: false
authentication-enabled: true authentication-enabled: false
``` ```
`production: true`인 브로커는 `tls-enabled``authentication-enabled`가 모두 참이어야 하고,
그렇지 않으면 `KafkaProfileValidator` / `RabbitProfileValidator`가 부팅을 거절한다. 위 예시가
`production: false`인 것은 이 페이지가 그대로 실행되는 fixture이기 때문이며, 실 배포는 셋 다 참이다.
## 보안 ## 보안
```yaml ```yaml
app: app:
messaging: messaging:
security: security:
kafka-primary: kafka-primary:
producer: { type: SASL_SCRAM, credential-id: kafka-producer } producer: { type: SASL_SCRAM, credential-id: kafka-producer }
consumer: { type: SASL_SCRAM, credential-id: kafka-consumer } consumer: { type: SASL_SCRAM, credential-id: kafka-consumer }
# admin은 application runtime에 설정하지 않는다 # admin은 application runtime에 설정하지 않는다
hostname-verification: true hostname-verification: true
access: access:
publishable: [order-events] publishable: [order-events]
consumable: [] consumable: []
administrable: [] administrable: []
``` ```
키는 `app.messaging.brokers`에 선언된 브로커 이름과 같아야 한다. `tls-enabled``production`
브로커 쪽에만 있고 여기에 중복되지 않는다 — 하나의 브로커가 두 곳에서 기술되면 두 값이 어긋나는
날이 오고, 어느 쪽이 이기는지는 아무도 모른다.
`credential-id`는 이름일 뿐이고 자격 증명 자체가 아니다. 실제 재료는 `CredentialProvider`
연결 시점에 해석하므로, 설정 덤프나 힙 덤프에서 나오는 것은 이름뿐이다. producer와 consumer는
서로 다른 `credential-id`를 써야 하며, 같으면 부팅에 실패한다.
## Experimental / Optional ## Experimental / Optional
기본값은 전부 `false`다. 기본값은 전부 `false`다.
@@ -149,12 +187,12 @@ app:
```yaml ```yaml
app: app:
messaging: messaging:
experimental: experimental:
kafka-share: false kafka-share: false
pulsar: false pulsar: false
nats: false nats: false
bridge: bridge:
spring-cloud-stream: false spring-cloud-stream: false
``` ```
## Backpressure ## Backpressure
@@ -162,9 +200,24 @@ app:
```yaml ```yaml
app: app:
messaging: messaging:
backpressure: backpressure:
global-limit: 512 global-limit: 512
per-destination-limit: 64 # global-limit 이하여야 한다 per-destination-limit: 64 # global-limit 이하여야 한다
``` ```
`per-destination-limit > global-limit`이면 global limit이 limit이 아니게 되므로 부팅에 실패한다. `per-destination-limit > global-limit`이면 global limit이 limit이 아니게 되므로 부팅에 실패한다.
## 바인딩되지 않는 키
섹션은 바인딩되는데 그 안의 키 하나가 오타인 경우는 접두사 오타와 달리 조용하다 — 섹션은 붙고,
플랫폼은 뜨고, 바꾸러 온 그 설정만 적용되지 않는다. `MessagingConfigurationKeyValidator`
`app.messaging.destinations|brokers|security` 아래의 모든 키를 settings 레코드에서 파생한 목록과
대조하고, 없는 키는 그 키 이름을 담아 부팅을 거절한다.
허용 키 목록은 이 문서가 아니라 레코드에서 나온다. 문서에 목록을 적으면 필드가 추가된 날 그
목록이 틀리고, 오타를 잡으라고 만든 검사가 정상 필드를 거절하게 된다.
환경변수(`APP_MESSAGING_...`)는 이 검사의 대상이 아니다. `APP_MESSAGING_DESTINATIONS_ORDER_EVENTS_
CONSUMER_PREFETCH`에서 entry 이름과 leaf를 가르는 밑줄은 둘 안에 있는 밑줄과 구별되지 않으므로,
되돌려 쪼개려면 추측해야 한다. 여기서의 추측은 정상 배포를 거절하는 쪽으로 틀리며, 그것은 배포
매니페스트에 손으로 적어야 하는 변수에서 오타 하나를 놓치는 것보다 나쁘다.
+23 -1
View File
@@ -3,6 +3,12 @@
플랫폼이 **무엇을 보장하는지**와 **무엇을 보장하지 않는지**를 브로커별로 고정한다. 플랫폼이 **무엇을 보장하는지**와 **무엇을 보장하지 않는지**를 브로커별로 고정한다.
여기 없는 조합은 지원되지 않는다. 여기 없는 조합은 지원되지 않는다.
> **등급은 증거를 따른다.** `CompatibilityMatrix.Entry.hasLiveBrokerCertification()`은 선언된
> boolean이 아니라 `CertifiedEvidence`가 가진 레인 증거에서 파생된다. RabbitMQ가 Stable에서 내려온
> 이유가 이것이다 — 어댑터는 공유 contract 7개를 통과하고 `RabbitBrokerIT`가 실 컨테이너에서 정상
> 경로를 돌리지만, 이 저장소의 Stable 기준인 **장애 시나리오 증거**가 하나도 없다. 레인이 생겨
> 증거를 내면 등급은 코드 수정 없이 따라 올라간다.
> **인증 근거.** 이 표의 버전은 이 저장소의 컨테이너 레인이 실제로 실행한 이미지다. 이전 판은 > **인증 근거.** 이 표의 버전은 이 저장소의 컨테이너 레인이 실제로 실행한 이미지다. 이전 판은
> Kafka 4.2/4.3을 선언했지만 fixture는 `apache/kafka:4.1.0`, lockfile client는 4.1.1이었다 — 표와 > Kafka 4.2/4.3을 선언했지만 fixture는 `apache/kafka:4.1.0`, lockfile client는 4.1.1이었다 — 표와
> 코드 상수가 서로 일치했을 뿐 어느 쪽도 실행된 적이 없었다. 장애 시나리오 커버리지도 마찬가지로 > 코드 상수가 서로 일치했을 뿐 어느 쪽도 실행된 적이 없었다. 장애 시나리오 커버리지도 마찬가지로
@@ -25,7 +31,7 @@
| 브로커 | 등급 | 인증 기준 | Stable 기능 | 제한 | | 브로커 | 등급 | 인증 기준 | Stable 기능 | 제한 |
|---|---|---|---|---| |---|---|---|---|---|
| Kafka | Stable | 4.1.x | producer idempotence, consumer group, batch, pause/resume, replay, transaction capability | Share Group은 Experimental | | Kafka | Stable | 4.1.x | producer idempotence, consumer group, batch, pause/resume, replay, transaction capability | Share Group은 Experimental |
| RabbitMQ | Stable | 4.3.x | exchange/routing, publisher confirm, mandatory return, manual ACK, quorum queue, retry queue, DLQ | stream 및 특수 plugin 미지원 | | RabbitMQ | Experimental | 4.3.x | exchange/routing, publisher confirm, mandatory return, manual ACK, quorum queue, retry queue, DLQ | 장애 시나리오 레인 미실행 — 증거 없음. stream 및 특수 plugin 미지원 |
| Pulsar | Experimental | 4.0 LTS + 4.2 | typed publish/consume, Shared, Key_Shared, schema | transaction 미승격, 기본 비활성 | | Pulsar | Experimental | 4.0 LTS + 4.2 | typed publish/consume, Shared, Key_Shared, schema | transaction 미승격, 기본 비활성 |
| NATS JetStream | Experimental | 2.14.x | stream, durable consumer, explicit ACK, dedupe, replay | native DLQ 없음(플랫폼이 대행), 기본 비활성 | | NATS JetStream | Experimental | 2.14.x | stream, durable consumer, explicit ACK, dedupe, replay | native DLQ 없음(플랫폼이 대행), 기본 비활성 |
| Artemis/JMS | Extension | 범위 밖 | adapter SPI만 | 별도 ADR + Contract Suite 통과 필요 | | Artemis/JMS | Extension | 범위 밖 | adapter SPI만 | 별도 ADR + Contract Suite 통과 필요 |
@@ -86,11 +92,15 @@ Kafka와 RabbitMQ가 동일한 7개 테스트를 변경 없이 통과한다. 결
|---|---| |---|---|
| `KafkaBrokerIT` | `acks=all`이 실제 replication 증거를 만든다 / 잘못된 토픽은 `REJECTED` / 발행-소비 왕복에서 identity 보존 및 contiguous commit | | `KafkaBrokerIT` | `acks=all`이 실제 replication 증거를 만든다 / 잘못된 토픽은 `REJECTED` / 발행-소비 왕복에서 identity 보존 및 contiguous commit |
| `KafkaAmbiguityChaosIT` | 브로커를 `docker pause`로 멈춘 상태의 publish가 **`AMBIGUOUS`** 로 보고된다 (broker acceptance 없음, confirmation level `NONE`, 비-retryable) | | `KafkaAmbiguityChaosIT` | 브로커를 `docker pause`로 멈춘 상태의 publish가 **`AMBIGUOUS`** 로 보고된다 (broker acceptance 없음, confirmation level `NONE`, 비-retryable) |
| `KafkaBrokerCertificationIT` | 인증 레인. Toxiproxy를 broker 앞에 두고 connection cut / confirm 유실 / 지연 / settlement 유실을 각각 주입하고, 통과한 시나리오마다 `BrokerCertificationEvidence` 한 줄을 manifest에 쓴다 |
| `RabbitBrokerIT` | exchange가 confirm했는데 어떤 큐에도 바인딩되지 않은 publish가 **`REJECTED` + `UNROUTABLE`** 로 보고된다 | | `RabbitBrokerIT` | exchange가 confirm했는데 어떤 큐에도 바인딩되지 않은 publish가 **`REJECTED` + `UNROUTABLE`** 로 보고된다 |
| `OutboxPostgresIT` | 롤백된 트랜잭션은 발행 가능한 행을 남기지 않는다 / `SKIP LOCKED` lease가 두 relay를 분리한다 / ambiguous 행이 같은 `messageId`로 재클레임된다 | | `OutboxPostgresIT` | 롤백된 트랜잭션은 발행 가능한 행을 남기지 않는다 / `SKIP LOCKED` lease가 두 relay를 분리한다 / ambiguous 행이 같은 `messageId`로 재클레임된다 |
| `InboxPostgresIT` | 재전달이 side effect를 두 번 적용하지 않는다 / 롤백은 예약도 되돌린다 | | `InboxPostgresIT` | 재전달이 side effect를 두 번 적용하지 않는다 / 롤백은 예약도 되돌린다 |
Docker가 없으면 `DockerAvailability` 가드로 skip되며, 이 표의 항목은 그때 **검증되지 않은 것**으로 취급한다. Docker가 없으면 `DockerAvailability` 가드로 skip되며, 이 표의 항목은 그때 **검증되지 않은 것**으로 취급한다.
`KafkaBrokerCertificationIT`만 예외다 — 인증 레인은 가드를 달지 않고 Docker가 없으면 실패한다. skip하는
레인은 아무도 켜지 않은 브로커에 대해 성공을 보고하기 때문이다. 그래서 이 레인은 `test`에서 태그로
제외되고 `messagingCertificationTest`로만 실행된다.
### 3. 장애 시나리오 커버리지 (`BrokerFailureMatrix`) ### 3. 장애 시나리오 커버리지 (`BrokerFailureMatrix`)
@@ -109,6 +119,18 @@ Docker가 없으면 `DockerAvailability` 가드로 skip되며, 이 표의 항목
커버해야 하고, Experimental 어댑터는 `LIVE_BROKER` 커버리지를 주장할 수 없다. 커버리지는 *능력*이 아니라 커버해야 하고, Experimental 어댑터는 `LIVE_BROKER` 커버리지를 주장할 수 없다. 커버리지는 *능력*이 아니라
*무엇을 실제로 돌렸는지*의 기록이다. *무엇을 실제로 돌렸는지*의 기록이다.
**증거의 출처.** `CertifiedEvidence`는 더 이상 손으로 쓴 목록이 아니라
`messaging-testkit/src/main/resources/messaging/broker-certification-evidence.jsonl`을 읽는다. 그 파일은
`messagingCertificationTest` 레인이 실제 Kafka 컨테이너에 장애를 주입하며 만들어낸 출력이고,
`verifyMessagingCertificationEvidence`가 커밋된 manifest와 이번 실행의 출력을 대조해 다르면 빌드를
실패시킨다. 즉 **manifest를 손으로 고치면 게이트가 깨지고, 레인을 돌리면 manifest가 다시 쓰인다.**
오늘 Kafka가 가진 증거는 `connection-cut-after-write` · `confirm-timeout` · `high-latency` ·
`settlement-lost` 네 개다. `connection-refused`는 남은 gap이며 그 이유가 있다 — Kafka producer는 연결
존재 여부를 알기 전에 레코드를 버퍼에 넣으므로, 연결 거부는 전송에 대해 아무것도 증명하지 못하는
delivery timeout으로 나타난다. 이를 `REJECTED`로 보고하는 것은 이 플랫폼이 금지한 추측이므로,
시나리오는 `CertifiedEvidence.knownGaps`가 이름으로 들고 있는 미커버 항목으로 남는다.
### 실 브로커가 실제로 잡아낸 결함 ### 실 브로커가 실제로 잡아낸 결함
이 스위트들은 장식이 아니다. 작성 과정에서 결정적 테스트가 통과하는데 실 인프라에서 실패한 이 스위트들은 장식이 아니다. 작성 과정에서 결정적 테스트가 통과하는데 실 인프라에서 실패한
@@ -26,8 +26,12 @@ dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.Notification
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformMode dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformMode
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformSettings dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformSettings
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationProviderAssembly dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationProviderAssembly
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationSecretRequirements
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationSmtpProviderConfig
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationSmtpSettings
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.ProviderRuntimeAssembler dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.ProviderRuntimeAssembler
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.ProviderType dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.ProviderType
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.SmtpProviderRuntimeAssembler
dev.caskeleton.adapter.outbound.notification.platform.dispatch.AttemptPermit dev.caskeleton.adapter.outbound.notification.platform.dispatch.AttemptPermit
dev.caskeleton.adapter.outbound.notification.platform.dispatch.CapabilityReconciliationGateway dev.caskeleton.adapter.outbound.notification.platform.dispatch.CapabilityReconciliationGateway
dev.caskeleton.adapter.outbound.notification.platform.dispatch.ConfiguredProfileCatalog dev.caskeleton.adapter.outbound.notification.platform.dispatch.ConfiguredProfileCatalog
@@ -56,6 +60,7 @@ dev.caskeleton.adapter.outbound.notification.platform.observation.LoggingNotific
dev.caskeleton.adapter.outbound.notification.platform.observation.NotificationHealthReporter dev.caskeleton.adapter.outbound.notification.platform.observation.NotificationHealthReporter
dev.caskeleton.adapter.outbound.notification.platform.observation.NotificationHealthSnapshot dev.caskeleton.adapter.outbound.notification.platform.observation.NotificationHealthSnapshot
dev.caskeleton.adapter.outbound.notification.platform.observation.NotificationServingThresholds dev.caskeleton.adapter.outbound.notification.platform.observation.NotificationServingThresholds
dev.caskeleton.adapter.outbound.notification.platform.provider.EmailAttachments
dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults
dev.caskeleton.adapter.outbound.notification.platform.provider.UnconfiguredAttachmentResolver dev.caskeleton.adapter.outbound.notification.platform.provider.UnconfiguredAttachmentResolver
dev.caskeleton.adapter.outbound.notification.platform.provider.apns.ApnsFailureClassifier dev.caskeleton.adapter.outbound.notification.platform.provider.apns.ApnsFailureClassifier
@@ -89,6 +94,7 @@ dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesRequestMap
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesSuppressionUpdater dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesSuppressionUpdater
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SnsCertificateProvider dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SnsCertificateProvider
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SnsSignatureVerifier dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SnsSignatureVerifier
dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.JavaMailSenderSmtpDispatch
dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpDispatch dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpDispatch
dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpDispatchException dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpDispatchException
dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpFailureClassifier dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpFailureClassifier
@@ -122,6 +128,7 @@ dev.caskeleton.adapter.outbound.notification.platform.reactor.ReactorContextBrid
dev.caskeleton.adapter.outbound.notification.platform.reactor.ReactorNotificationOrchestrator dev.caskeleton.adapter.outbound.notification.platform.reactor.ReactorNotificationOrchestrator
dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmCallbackPayloadProtection dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmCallbackPayloadProtection
dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector
dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmNotificationPayloadProtection
dev.caskeleton.adapter.outbound.notification.platform.security.CredentialGeneration dev.caskeleton.adapter.outbound.notification.platform.security.CredentialGeneration
dev.caskeleton.adapter.outbound.notification.platform.security.HmacProviderRequestIdHasher dev.caskeleton.adapter.outbound.notification.platform.security.HmacProviderRequestIdHasher
dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager
@@ -137,6 +144,7 @@ dev.caskeleton.adapter.outbound.notification.platform.template.NotificationTempl
dev.caskeleton.adapter.outbound.notification.platform.template.PlaceholderTemplateEngine dev.caskeleton.adapter.outbound.notification.platform.template.PlaceholderTemplateEngine
dev.caskeleton.adapter.outbound.notification.platform.template.Sha256MessageDigestAdapter dev.caskeleton.adapter.outbound.notification.platform.template.Sha256MessageDigestAdapter
dev.caskeleton.adapter.outbound.notification.platform.template.TemplateSlotMode dev.caskeleton.adapter.outbound.notification.platform.template.TemplateSlotMode
dev.caskeleton.adapter.outbound.notification.platform.template.TemplateSlotPolicy
dev.caskeleton.adapter.outbound.notification.platform.template.ThymeleafNotificationRenderer dev.caskeleton.adapter.outbound.notification.platform.template.ThymeleafNotificationRenderer
dev.caskeleton.adapter.outbound.notification.platform.template.ThymeleafStringTemplateEngine dev.caskeleton.adapter.outbound.notification.platform.template.ThymeleafStringTemplateEngine
dev.caskeleton.adapter.outbound.notification.provider.AttemptCorrelationId dev.caskeleton.adapter.outbound.notification.provider.AttemptCorrelationId
@@ -384,6 +392,7 @@ dev.caskeleton.application.notification.platform.callback.ProviderEventProjector
dev.caskeleton.application.notification.platform.callback.ProviderEventRecord dev.caskeleton.application.notification.platform.callback.ProviderEventRecord
dev.caskeleton.application.notification.platform.callback.ProviderEventRecordId dev.caskeleton.application.notification.platform.callback.ProviderEventRecordId
dev.caskeleton.application.notification.platform.callback.ProviderEventSource dev.caskeleton.application.notification.platform.callback.ProviderEventSource
dev.caskeleton.application.notification.platform.callback.ProviderRequestIdHash
dev.caskeleton.application.notification.platform.callback.StandardDeliveryProjector dev.caskeleton.application.notification.platform.callback.StandardDeliveryProjector
dev.caskeleton.application.notification.platform.callback.SuppressionFacts dev.caskeleton.application.notification.platform.callback.SuppressionFacts
dev.caskeleton.application.notification.platform.callback.VerifiedCallback dev.caskeleton.application.notification.platform.callback.VerifiedCallback
@@ -400,6 +409,7 @@ dev.caskeleton.application.notification.platform.contact.LegacyFcmRegistrationTo
dev.caskeleton.application.notification.platform.contact.MobilePushTarget dev.caskeleton.application.notification.platform.contact.MobilePushTarget
dev.caskeleton.application.notification.platform.contact.PhoneNumber dev.caskeleton.application.notification.platform.contact.PhoneNumber
dev.caskeleton.application.notification.platform.contact.WebPushSubscriptionValue dev.caskeleton.application.notification.platform.contact.WebPushSubscriptionValue
dev.caskeleton.application.notification.platform.dispatch.AcceptNotificationApplicationUseCase
dev.caskeleton.application.notification.platform.dispatch.ApplicationReceiptServiceImpl dev.caskeleton.application.notification.platform.dispatch.ApplicationReceiptServiceImpl
dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard
dev.caskeleton.application.notification.platform.dispatch.CancelNotificationApplicationUseCase dev.caskeleton.application.notification.platform.dispatch.CancelNotificationApplicationUseCase
@@ -430,6 +440,7 @@ dev.caskeleton.application.notification.platform.dispatch.PolicyRoutePlanner
dev.caskeleton.application.notification.platform.dispatch.ProviderDispatchGatewayPort dev.caskeleton.application.notification.platform.dispatch.ProviderDispatchGatewayPort
dev.caskeleton.application.notification.platform.dispatch.ProviderProfileCatalogPort dev.caskeleton.application.notification.platform.dispatch.ProviderProfileCatalogPort
dev.caskeleton.application.notification.platform.dispatch.ProviderRequestIdHasherPort dev.caskeleton.application.notification.platform.dispatch.ProviderRequestIdHasherPort
dev.caskeleton.application.notification.platform.dispatch.PublishNotificationTemplateApplicationUseCase
dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryRecord dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryRecord
dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryStorePort dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryStorePort
dev.caskeleton.application.notification.platform.dispatch.RecipientLease dev.caskeleton.application.notification.platform.dispatch.RecipientLease
@@ -499,12 +510,16 @@ dev.caskeleton.application.notification.platform.policy.SuppressionReason
dev.caskeleton.application.notification.platform.policy.SuppressionScope dev.caskeleton.application.notification.platform.policy.SuppressionScope
dev.caskeleton.application.notification.platform.policy.SuppressionSource dev.caskeleton.application.notification.platform.policy.SuppressionSource
dev.caskeleton.application.notification.platform.policy.SuppressionStorePort dev.caskeleton.application.notification.platform.policy.SuppressionStorePort
dev.caskeleton.application.notification.platform.port.in.AcceptNotificationCommand
dev.caskeleton.application.notification.platform.port.in.AcceptNotificationUseCase
dev.caskeleton.application.notification.platform.port.in.CancelNotificationCommand dev.caskeleton.application.notification.platform.port.in.CancelNotificationCommand
dev.caskeleton.application.notification.platform.port.in.CancelNotificationUseCase dev.caskeleton.application.notification.platform.port.in.CancelNotificationUseCase
dev.caskeleton.application.notification.platform.port.in.GetNotificationQuery dev.caskeleton.application.notification.platform.port.in.GetNotificationQuery
dev.caskeleton.application.notification.platform.port.in.GetNotificationUseCase dev.caskeleton.application.notification.platform.port.in.GetNotificationUseCase
dev.caskeleton.application.notification.platform.port.in.IngestProviderCallbackCommand dev.caskeleton.application.notification.platform.port.in.IngestProviderCallbackCommand
dev.caskeleton.application.notification.platform.port.in.IngestProviderCallbackUseCase dev.caskeleton.application.notification.platform.port.in.IngestProviderCallbackUseCase
dev.caskeleton.application.notification.platform.port.in.PublishNotificationTemplateCommand
dev.caskeleton.application.notification.platform.port.in.PublishNotificationTemplateUseCase
dev.caskeleton.application.notification.platform.port.in.ScheduleNotificationCommand dev.caskeleton.application.notification.platform.port.in.ScheduleNotificationCommand
dev.caskeleton.application.notification.platform.port.in.ScheduleNotificationUseCase dev.caskeleton.application.notification.platform.port.in.ScheduleNotificationUseCase
dev.caskeleton.application.notification.platform.port.in.SubmitNotificationCommand dev.caskeleton.application.notification.platform.port.in.SubmitNotificationCommand
@@ -539,6 +554,8 @@ dev.caskeleton.application.notification.platform.push.ReceiptKind
dev.caskeleton.application.notification.platform.push.ReceiptResult dev.caskeleton.application.notification.platform.push.ReceiptResult
dev.caskeleton.application.notification.platform.security.AccessContext dev.caskeleton.application.notification.platform.security.AccessContext
dev.caskeleton.application.notification.platform.security.ContactPointProtector dev.caskeleton.application.notification.platform.security.ContactPointProtector
dev.caskeleton.application.notification.platform.security.NotificationPayloadProtection
dev.caskeleton.application.notification.platform.security.NotificationPayloadUnreadableException
dev.caskeleton.application.notification.platform.security.NotificationRedactor dev.caskeleton.application.notification.platform.security.NotificationRedactor
dev.caskeleton.application.notification.platform.security.ProtectedContactPoint dev.caskeleton.application.notification.platform.security.ProtectedContactPoint
dev.caskeleton.application.notification.platform.security.SafeDiagnosticContext dev.caskeleton.application.notification.platform.security.SafeDiagnosticContext
@@ -41,6 +41,12 @@ still waiting on gets claimed by a second worker, and the recipient receives the
| `callbacks.enabled` | `APP_NOTIFICATION_PLATFORM_CALLBACKS_ENABLED` | `false` | boolean | | `callbacks.enabled` | `APP_NOTIFICATION_PLATFORM_CALLBACKS_ENABLED` | `false` | boolean |
| `callbacks.max-body-bytes` | `APP_NOTIFICATION_PLATFORM_CALLBACK_MAX_BODY_BYTES` | `65508` | 1..65508 | | `callbacks.max-body-bytes` | `APP_NOTIFICATION_PLATFORM_CALLBACK_MAX_BODY_BYTES` | `65508` | 1..65508 |
| `callbacks.replay-skew` | `APP_NOTIFICATION_PLATFORM_CALLBACK_REPLAY_SKEW` | `5m` | positive | | `callbacks.replay-skew` | `APP_NOTIFICATION_PLATFORM_CALLBACK_REPLAY_SKEW` | `5m` | positive |
| `callbacks.trusted-proxies` | `APP_NOTIFICATION_PLATFORM_CALLBACK_TRUSTED_PROXIES` | *(empty)* | CSV of peer addresses |
여러 provider가 요청 URL에 서명하므로, 그 URL을 잘못 재구성하면 정상 webhook이 전부 서명 실패가 된다.
`trusted-proxies`가 비어 있으면 forwarded 헤더를 **믿지 않고** 컨테이너가 관측한 값을 쓴다. 무조건 믿으면
아무 호출자나 자기 서명이 검증될 URL을 고를 수 있어 서명 자체가 무의미해진다. 로드밸런서 뒤에 있는 배포는
그 peer를 명시한다.
65508 is not a round number by accident: it is the ciphertext column's 65536 bytes minus the AES-GCM 65508 is not a round number by accident: it is the ciphertext column's 65536 bytes minus the AES-GCM
nonce and tag. A larger configured value would pass every check above the database and fail the nonce and tag. A larger configured value would pass every check above the database and fail the
@@ -69,6 +75,43 @@ them, because the keys are deployment-chosen; supply them as YAML or as
A profile pins provider type, environment, credential profile, timeouts, concurrency and rate limit. A profile pins provider type, environment, credential profile, timeouts, concurrency and rate limit.
Sender identity and credential profile are separate concerns. Sender identity and credential profile are separate concerns.
## SMTP relay
The one provider profile the template ships, off. A deployment that wants the common case sets
`APP_NOTIFICATION_PLATFORM_SMTP_ENABLED=true` and the relay address; one that wants a different
profile id or a second family declares it in its own YAML instead.
The profile and the relay are separate tables below because they answer different questions. The
profile says *which* provider serves EMAIL and under what limits; the relay says *what the transport
is*. Host, port and credentials are not here at all — they stay `spring.mail.*`, because Spring
already owns them and a second spelling would be a second thing to keep in step.
| Property | Environment variable | Default | Bound |
|---|---|---|---|
| `providers.smtp.enabled` | `APP_NOTIFICATION_PLATFORM_SMTP_ENABLED` | `false` | boolean |
| `providers.smtp.primary-for-channel` | `APP_NOTIFICATION_PLATFORM_SMTP_PRIMARY` | `true` | boolean; exactly one primary per channel |
| `providers.smtp.environment` | `APP_NOTIFICATION_PLATFORM_SMTP_ENVIRONMENT` | `local` | required when enabled |
| `providers.smtp.credential-profile` | `APP_NOTIFICATION_PLATFORM_SMTP_CREDENTIAL_PROFILE` | `default` | resolved through `SecretMaterialProvider`, never inline material |
| `providers.smtp.timeout` | `APP_NOTIFICATION_PLATFORM_SMTP_TIMEOUT` | `10s` | positive, finite |
| `providers.smtp.max-concurrency` | `APP_NOTIFICATION_PLATFORM_SMTP_MAX_CONCURRENCY` | `4` | positive |
| `providers.smtp.rate-per-second` | `APP_NOTIFICATION_PLATFORM_SMTP_RATE_PER_SECOND` | `10` | positive |
| Property | Environment variable | Default | Bound |
|---|---|---|---|
| `smtp.tls-mode` | `APP_NOTIFICATION_PLATFORM_SMTP_TLS_MODE` | `STARTTLS_REQUIRED` | `STARTTLS_REQUIRED` or `IMPLICIT_TLS` |
| `smtp.sender-identity` | `APP_NOTIFICATION_PLATFORM_SMTP_SENDER_IDENTITY` | `no-reply@example.invalid` | address |
| `smtp.connect-timeout` | `APP_NOTIFICATION_PLATFORM_SMTP_CONNECT_TIMEOUT` | `5s` | positive, finite |
| `smtp.read-timeout` | `APP_NOTIFICATION_PLATFORM_SMTP_READ_TIMEOUT` | `10s` | positive, finite |
| `smtp.write-timeout` | `APP_NOTIFICATION_PLATFORM_SMTP_WRITE_TIMEOUT` | `10s` | positive, finite |
| `smtp.max-concurrency` | `APP_NOTIFICATION_PLATFORM_SMTP_DISPATCH_CONCURRENCY` | `4` | positive |
The TLS mode enum has no plaintext member. An unencrypted relay is refused by construction rather
than by a validator somebody has to remember to run.
The default sender is an RFC 2606 reserved domain that resolves nowhere, so a deployment that forgot
to set one produces a traceable bounce instead of mail apparently sent from an address it does not
own.
## Startup failures ## Startup failures
Startup fails rather than degrading when: Startup fails rather than degrading when:
@@ -91,6 +134,33 @@ All key material arrives through `SecretMaterialProvider`. Nothing is read from
committed file, or from a plaintext log. Contact point encryption and lookup HMAC keys must be committed file, or from a plaintext log. Contact point encryption and lookup HMAC keys must be
distinct, and the encryption key must be exactly 256 bits. distinct, and the encryption key must be exactly 256 bits.
Eight purposes, eight keys. Each is base64 of at least 32 bytes and each must differ from every
other; the platform decodes them at startup and refuses to boot if one is blank, short or shared. A
blank value used to be skipped, which meant the platform started without the key and found out on
the first contact point — in production, on a recipient's notification.
Every default below is **unset**, deliberately. Supply the values out of band, per environment. Do
not write one into this table, into `application.yml`, into an `.env` file that is tracked, or into
any example: a value that appears in the repository is a value that has been disclosed.
| Purpose | Key material | Active key id |
|---|---|---|
| Contact point encryption | `APP_NOTIFICATION_PLATFORM_CONTACT_ENCRYPTION_KEY` | `APP_NOTIFICATION_PLATFORM_CONTACT_ENCRYPTION_KEY_ID` |
| Contact point lookup HMAC | `APP_NOTIFICATION_PLATFORM_CONTACT_LOOKUP_HMAC_KEY` | `APP_NOTIFICATION_PLATFORM_CONTACT_LOOKUP_HMAC_KEY_ID` |
| Callback signing | `APP_NOTIFICATION_PLATFORM_CALLBACK_SIGNING_KEY` | `APP_NOTIFICATION_PLATFORM_CALLBACK_SIGNING_KEY_ID` |
| Callback fingerprint HMAC | `APP_NOTIFICATION_PLATFORM_CALLBACK_FINGERPRINT_HMAC_KEY` | `APP_NOTIFICATION_PLATFORM_CALLBACK_FINGERPRINT_HMAC_KEY_ID` |
| Provider credential encryption | `APP_NOTIFICATION_PLATFORM_PROVIDER_CREDENTIAL_KEY` | `APP_NOTIFICATION_PLATFORM_PROVIDER_CREDENTIAL_KEY_ID` |
| Provider request lookup HMAC | `APP_NOTIFICATION_PLATFORM_PROVIDER_REQUEST_LOOKUP_HMAC_KEY` | `APP_NOTIFICATION_PLATFORM_PROVIDER_REQUEST_LOOKUP_HMAC_KEY_ID` |
| Payload encryption | `APP_NOTIFICATION_PLATFORM_PAYLOAD_ENCRYPTION_KEY` | `APP_NOTIFICATION_PLATFORM_PAYLOAD_ENCRYPTION_KEY_ID` |
| Web Push VAPID signing | `APP_NOTIFICATION_PLATFORM_VAPID_SIGNING_KEY` | `APP_NOTIFICATION_PLATFORM_VAPID_SIGNING_KEY_ID` |
A key id is not secret — an id identifies key material without revealing it — but it is required,
and it has no default on purpose. A constant id makes a rotation indistinguishable from the key it
replaced, so nothing could decrypt what was written before it. Change the id in the same deployment
that changes the material, and keep the superseded key readable under its old id until the data it
wrote has been re-encrypted. The rotation sequence is in
[at-rest-threat-model.md](at-rest-threat-model.md).
## Readiness ## Readiness
The platform contributes a `notifications` actuator endpoint and a health indicator. It reports DOWN The platform contributes a `notifications` actuator endpoint and a health indicator. It reports DOWN
+19 -1
View File
@@ -1672,9 +1672,12 @@ env_keys:
required_test: idempotency-contract:ttl-applied required_test: idempotency-contract:ttl-applied
- name: APP_IDEMPOTENCY_PROVIDER - name: APP_IDEMPOTENCY_PROVIDER
# postgresql selects the owner-safe V2 store on the primary data source. It had no value here
# while the store, its schema stream and its integration suite all existed, so the capability
# could only be reached by constructing it in a test.
type: enum type: enum
default: jdbc default: jdbc
allowed_values: [disabled, jdbc, redis] allowed_values: [disabled, jdbc, redis, postgresql]
classification: public-config classification: public-config
required: false required: false
reload_policy: restart-only reload_policy: restart-only
@@ -4970,6 +4973,21 @@ env_keys:
compatibility_impact: behavior-change compatibility_impact: behavior-change
required_test: adapter-contract:notification-callback-body-bound required_test: adapter-contract:notification-callback-body-bound
- name: APP_NOTIFICATION_PLATFORM_CALLBACK_TRUSTED_PROXIES
# source: NTF-001 — peers whose forwarded headers may be believed when reconstructing the URL a
# provider signed. Empty means the resolver uses what the container observed; honouring
# forwarded headers unconditionally would let any caller pick the URL its signature is checked
# against, which defeats the signature.
type: csv
default: ""
allowed_values: null
classification: security-relevant
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: none
compatibility_impact: behavior-change
required_test: adapter-contract:notification-callback-url-resolution
- name: APP_NOTIFICATION_PLATFORM_CALLBACK_REPLAY_SKEW - name: APP_NOTIFICATION_PLATFORM_CALLBACK_REPLAY_SKEW
# source: NTF-025 — how far a callback timestamp may differ from local time before it is treated as a replay # source: NTF-025 — how far a callback timestamp may differ from local time before it is treated as a replay
type: duration type: duration
@@ -0,0 +1,101 @@
# P1 remediation status — the five module reviews
**Reviews:** `docs/reviews/2026-08-14-{jpa,graphql,messaging,mongodb,notification}-module-code-review.md`
**Baseline:** the P0 pass was already complete when this pass began; this file records the P1 pass,
which is complete — all 31 findings closed, two of them by establishing that the review's own
accepted outcome was already met rather than by writing code.
**Verified at:** repo-wide `test`, `spotlessCheck`, `verifyCleanArchitectureDependencies`,
`verifyEnvKeys`, `verifyPublicPathSnapshot` and the root `CleanArchitectureTest` all green.
This file exists because the status was previously carried only in conversation and had to be
reconstructed. A finding's row is the claim; the evidence column is where the claim is falsifiable.
## The recurring defect
Nearly every P1 in these five reviews is one shape: **a control that exists, passes its own tests,
and is reached by nothing.** Not a wrong algorithm — an unreachable one. The tests passed because
they constructed the class directly; the capability was absent because no configuration could.
Examples closed in this pass: the Mongo typed-update path (`MongoBulkExecutor` and
`MongoAtomicOperationsTemplate` were constructed by nothing), the entire Mongo change-stream
capability (no production code opened a stream at all), `GraphQlBatchLoaderRegistrar.register` (no
caller, so the wrong-key refusal never ran on an executing query), `PublishOptions.timeout()` (read
by nobody on the real publish path), `markExhausted` (no caller, so a row that spent its budget
stayed `AMBIGUOUS` forever), `OutboxMessagePublishPort.publishForOutcome` (no caller, so every
ambiguous publish collapsed into an exception), the outbox relay itself (no bean ran a pass), and
`BackpressureController` (a limiter the publish path never consulted, reporting `globalInFlight: 0`
under any load).
The lesson worth keeping: **a passing unit test is not evidence a capability exists.** The
reachability question — what constructs this, and on which request path — has to be asked
separately, and several of the tests added in this pass exist only to ask it.
## Status
| Finding | Verdict | Evidence |
| --- | --- | --- |
| JPA-005 | closed already | roll-up in `NotificationRequestStatusPolicy`; port is tenant-scoped; ArchUnit `PERSISTENCE_DOES_NOT_DEPEND_ON_APPLICATION_SERVICES` |
| JPA-006 | closed already | `PersistenceJpaRootAutoConfiguration` is in `AutoConfiguration.imports` and imports the real runtime config |
| JPA-007 | closed under item 6 | the review offers two accepted outcomes: full integration, or the interim state under item 6's two conditions. Both hold and were verified in code — `FullTransactionRetryCoordinator:89-93` resolves the policy per call from the calling profile, and `DefaultJpaRetryPolicy:61-64` returns on `!failure.retryable()` before consulting the category allowlist |
| JPA-008 | closed | nothing registered a `VendorFailureTranslator`, so every executor ran `withoutCatalogs()` and a 40001 never reached the retry classifier |
| JPA-026 | closed | a rehydrated callback event had no matcher once `attempt_id` was null; hash fallback + write-back added |
| JPA-028 | closed | the reaper query had no caller, uploads had no terminal state, cleanup decided from a lease it had read rather than claiming |
| JPA-029 | closed already | tuple cutoff implemented; the signal is documented best-effort by decision |
| GQL-004 | not a defect | every evidence bullet false at HEAD; no WebFlux dependency exists, transport disagreement fails startup |
| GQL-011 | closed | unkeyed truncated SHA-256 over actor/tenant replaced with a keyed, rotating HMAC; no default key |
| GQL-015 | closed | schema extensions were invisible to the comparator, so a field removed by `extend type` produced no change at all |
| GQL-016 | closed | the batch executor returned the loader's map verbatim, so the wrong-key refusal and missing-key policy never ran |
| GQL-017 | closed | the blocking bridge was opt-in and null by default, so a reactive runtime ran blocking chunks on the event loop |
| MNG-010 | closed | the guardrail was a `Set<String>` asserted against itself; now an ArchUnit rule over the real production graph at the composition root |
| MNG-012 | closed | a failed abort or close on a committed transaction was discarded by a closing brace |
| MNG-018 | closed | TLS and auth were asserted against a settings object; four TLS cases now run against real servers |
| MNG-024 | closed | the reactive binder carried read preference and write concern only, so reactive writes skipped auditing and callbacks |
| MNG-026 | closed | both typed-update paths were unreachable, and the bulk executor could be built with no policy at all |
| MNG-028 | closed | no production code opened a change stream; the consumer now owns load → resume → stream → project → checkpoint |
| MSG-006 | closed | `markExhausted` had no caller and the scheduler's backoff was never written; a relay worker now runs passes |
| MSG-008 | closed | validators were beans nothing injected, and the documented configuration bound nowhere; destination/broker/security sections now bind under `app.messaging` and the reference document is executed by a test |
| MSG-010 | closed | `BackpressureController` deleted as an unreachable duplicate; its one unique capability moved into the gate that is called |
| MSG-012 | closed | header values accepted CR/LF/NUL, identifiers were bounded in chars not bytes, `traceparent` was any string, denylists matched exact spellings only |
| MSG-014 | closed | `hasLiveBrokerCertification` is derived from recorded evidence rather than declared, and the evidence is now a manifest a fault lane wrote against a real broker rather than a hand-authored list |
| MSG-015 | semantic half closed | the outcome-aware publish path is wired; the anti-corruption bridge needs a `modules.json` edge and is an architecture decision |
| MSG-016 | closed | no reserved name existed for tenant, so every consumed message was rebuilt with none; the canonical metadata is now columns, and the CDC event key moved off `destination`, which had put every message on a topic onto one partition |
| MSG-017 | closed | the timeout is an absolute deadline; contradictory `PublishResult` combinations are unrepresentable; `brokerHints` removed |
| NTF-015 | closed | webhook signing was one shared secret for every subscription; SES silently dropped attachments and now sends them as raw MIME |
| NTF-016 | closed | retired keys were forced to one purpose so a provider-credential drain failed; required purposes now follow enabled capabilities |
| NTF-019 | closed already | split inbound ports carry capabilities; four ArchUnit rules with negative fixtures close the gate |
## Found while closing, not in any review
`SmtpMimeMessageFactory` handed JavaMail the resolver's one-shot stream. JavaMail reads an
attachment twice — once to choose the part's transfer encoding, once to write it — so the second
read returned nothing and the message went out announcing a filename and carrying no bytes, with
the attempt recorded as accepted. Every existing test asserted on the outcome of the send rather
than on what was sent, which is why a bug that emptied every attachment on the one provider family
this platform can actually assemble survived a full review pass.
`SmtpAttachmentBodyTest` now reads the attachment back off the serialised message the way a
receiving client would. It was confirmed to fail against the original code and pass against the
fix, because a regression test nobody has watched fail is a regression test of unknown shape.
## Observations that are not open P1 items
Both were checked against the reviews rather than assumed, because "looks unfinished" and "is an open
finding" are different claims.
**The GraphQL cursor key gate.** `GraphQlPlatformStartupValidator` refuses to start a production
deployment without `backend.graphql.cursor.key-ids`, and nothing signs a cursor with it:
`GraphQlConnectionAssembler` and `HmacGraphQlCursorCodec` have no consumer anywhere in this
repository, because the template ships no paginating resolver. This is not GQL-010, which is about
the codec's framing, rotation and scope and is implemented — versioned framing, the codec choosing
the active key rather than the caller, v1 decode kept only for migration, tenant scope bound. It
belongs to the `modelled` grading the leaf's own `GraphQlPolicyRequestPathTest` already documents in
as many words. Declaring beans for it would create the unreachable-control defect this pass exists
to close, and the present behaviour fails closed, which is the safe direction. Left as it is, on
purpose.
## Product work, not remediation
**Notification provider transports.** Only SMTP has a `ProviderRuntimeAssembler`.
`NotificationProviderAssembly` refuses to start a profile whose family has no assembler, naming the
transport as a seam rather than an implementation — which is the honest fail-closed behaviour, not a
defect. Building SES, Twilio, FCM, APNs and WebPush transports is product work.
+3 -1
View File
@@ -25,7 +25,9 @@ status: stub
### Step 1 — 확인 ### Step 1 — 확인
1. ERROR log에서 `OUTBOX_PUBLISH_FAILED` 라인 확인: `event_type`, `event_id`, `correlation_id`, `attempt_count` 추출 1. ERROR log에서 `OUTBOX_PUBLISH_FAILED` 라인 확인: `event_type`, `event_id`, `correlation_id`, `attempt_count` 추출
2. broker 상태 확인: `APP_MESSAGING_BROKER`(공백이면 messaging 비활성)과 broker endpoint 가용성 2. broker 상태 확인: `APP_MESSAGING_BROKER` 값과 broker endpoint 가용성. 이 키는 활성화 스위치가
아니라 **선택자**다 — messaging을 끄는 것은 `APP_MESSAGING_ENABLED=false`이고, 이 값을 비운다고
messaging이 꺼지지는 않는다.
- `APP_MESSAGING_BROKER`가 공백인 채로 relay가 켜져 있으면 **애플리케이션이 기동하지 않는다** - `APP_MESSAGING_BROKER`가 공백인 채로 relay가 켜져 있으면 **애플리케이션이 기동하지 않는다**
(`OutboxRelayBrokerRequirementValidator`, MSG-024). 이 조합에서는 publish가 전부 (`OutboxRelayBrokerRequirementValidator`, MSG-024). 이 조합에서는 publish가 전부
`AdapterDisabledException`으로 실패하며 PENDING row가 DEAD까지 소진되기 때문이다. `AdapterDisabledException`으로 실패하며 PENDING row가 DEAD까지 소진되기 때문이다.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
$ ./gradlew verifyCleanArchitectureDependencies verifyEnvKeys verifyRuntimeModuleMembership verifyPublicPathSnapshot verifyDocumentedLeafCount --console=plain
run-at: 2026-08-20T01:59:14Z
> Task :build-logic:extractPluginRequests UP-TO-DATE
> Task :build-logic:generatePluginAdapters UP-TO-DATE
> Task :build-logic:compileJava UP-TO-DATE
> Task :build-logic:compileGroovy UP-TO-DATE
> Task :build-logic:compileGroovyPlugins UP-TO-DATE
> Task :build-logic:pluginDescriptors UP-TO-DATE
> Task :build-logic:processResources UP-TO-DATE
> Task :build-logic:classes UP-TO-DATE
> Task :build-logic:jar UP-TO-DATE
> Task :verifyCleanArchitectureDependencies
> Task :shared-contract:compileJava UP-TO-DATE
> Task :shared-contract:processResources UP-TO-DATE
> Task :shared-contract:classes UP-TO-DATE
> Task :shared-contract:jar UP-TO-DATE
> Task :application-core:compileJava UP-TO-DATE
> Task :application-core:processResources NO-SOURCE
> Task :application-core:classes UP-TO-DATE
> Task :application-core:jar UP-TO-DATE
> Task :adapter:outbound:support:compileJava UP-TO-DATE
> Task :adapter:outbound:support:processResources NO-SOURCE
> Task :adapter:outbound:support:classes UP-TO-DATE
> Task :adapter:outbound:support:jar UP-TO-DATE
> Task :adapter:outbound:cache-redis:compileJava UP-TO-DATE
> Task :verifyEnvKeys
verifyEnvKeys: OK — 341 env keys, 6 required placeholders covered, 225 application APP_ references registered, 61 typed properties registered, 338 rows with a consumer or a deprecation.
> Task :verifyRuntimeModuleMembership
verifyRuntimeModuleMembership: 2 runtime composition(s) match the registry
> Task :verifyPublicPathSnapshot
verifyPublicPathSnapshot: OK — committed public paths are unchanged.
> Task :verifyDocumentedLeafCount
BUILD SUCCESSFUL in 8s
21 actionable tasks: 5 executed, 16 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html
exit=0
@@ -0,0 +1,901 @@
$ ./gradlew check --warning-mode=fail --no-daemon --console=plain (re-run after the SpotBugs fix)
run-at: 2026-08-20T01:53:16Z
To honour the JVM settings for this build a single-use Daemon process will be forked. For more on this, please refer to https://docs.gradle.org/9.0.0/userguide/gradle_daemon.html#sec:disabling_the_daemon in the Gradle documentation.
Daemon will be stopped at the end of the build
> Task :build-logic:extractPluginRequests UP-TO-DATE
> Task :build-logic:generatePluginAdapters UP-TO-DATE
> Task :build-logic:compileJava UP-TO-DATE
> Task :build-logic:compileGroovy UP-TO-DATE
> Task :build-logic:compileGroovyPlugins UP-TO-DATE
> Task :build-logic:pluginDescriptors UP-TO-DATE
> Task :build-logic:processResources UP-TO-DATE
> Task :build-logic:classes UP-TO-DATE
> Task :build-logic:jar UP-TO-DATE
> Task :verifyCleanArchitectureDependencies
> Task :verifyConfigurationPropertiesProcessor
verifyConfigurationPropertiesProcessor: OK — all 44 registered leaves have exact configuration-processor parity.
> Task :verifyDocumentedLeafCount
> Task :shared-contract:compileJava UP-TO-DATE
> Task :shared-contract:processResources UP-TO-DATE
> Task :shared-contract:classes UP-TO-DATE
> Task :shared-contract:jar UP-TO-DATE
> Task :application-core:compileJava UP-TO-DATE
> Task :application-core:processResources NO-SOURCE
> Task :application-core:classes UP-TO-DATE
> Task :application-core:jar UP-TO-DATE
> Task :adapter:outbound:support:compileJava UP-TO-DATE
> Task :adapter:outbound:support:processResources NO-SOURCE
> Task :adapter:outbound:support:classes UP-TO-DATE
> Task :adapter:outbound:support:jar UP-TO-DATE
> Task :adapter:outbound:cache-redis:compileJava UP-TO-DATE
> Task :verifyEnvKeys
verifyEnvKeys: OK — 341 env keys, 6 required placeholders covered, 225 application APP_ references registered, 61 typed properties registered, 338 rows with a consumer or a deprecation.
> Task :verifyJpaReadinessRegistryContract
verifyJpaReadinessRegistryContract: OK — unknown card, duplicate task, missing prerequisite, cycle, duplicate migration ownership, missing selected task, and malformed evidence ownership all fail closed.
> Task :verifyJpaReadinessRegistry
verifyJpaReadinessRegistry: OK — 17 exact cards, 9 owned migration streams, acyclic prerequisites, unique tasks/locations/history tables, and selected task existence verified.
> Task :verifyNoIgnoredSourcePackages
verifyNoIgnoredSourcePackages: OK — 5164 Java sources are all committable.
> Task :verifyNoStaleTraceableJars
verifyNoStaleTraceableJars: OK — no stale traceable JARs in build/libs.
> Task :verifyNotificationApiSurface
verifyNotificationApiSurface: OK — 586 public types, unchanged.
> Task :verifyNotificationConfiguration
verifyNotificationConfiguration: OK — 42 platform settings, bound, documented and registered.
> Task :verifyNotificationEvidence
verifyNotificationEvidence: OK — 4 claims proven, every grade in support-matrix.md is backed.
> Task :verifyOneTypePerFile
verifyOneTypePerFile: OK — one public top-level type per file, names match.
> Task :verifyQuarantineSunset
verifyQuarantineSunset: OK — 0 registered, 0 tagged (14-day sunset enforced).
> Task :verifyReadmeCommands
verifyReadmeCommands: OK — executable commands in /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/README.md resolve.
> Task :verifyRunbookReferences
> Task :verifyRuntimeModuleMembership
verifyRuntimeModuleMembership: 2 runtime composition(s) match the registry
> Task :verifySpotBugsAnalysisFailureContract
verifySpotBugsAnalysisFailureContract: OK — clean and advisory bug-only reports pass; missing classes and analysis errors fail closed.
> Task :verifyTrivyignore
verifyTrivyignore: OK — 0 suppression(s) validated (reason + bounded, non-expired expiry).
> Task :domain-core:compileJava UP-TO-DATE
> Task :domain-core:processResources NO-SOURCE
> Task :domain-core:classes UP-TO-DATE
> Task :domain-core:jar UP-TO-DATE
> Task :messaging:messaging-core-api:compileJava UP-TO-DATE
> Task :messaging:messaging-observability:compileJava UP-TO-DATE
> Task :messaging:messaging-schema-api:compileJava UP-TO-DATE
> Task :messaging:messaging-policy:compileJava UP-TO-DATE
> Task :messaging:messaging-reliability-api:compileJava UP-TO-DATE
> Task :messaging:messaging-security:compileJava UP-TO-DATE
> Task :messaging:messaging-admin-api:compileJava UP-TO-DATE
> Task :messaging:messaging-transport-spi:compileJava UP-TO-DATE
> Task :messaging:messaging-admin-runtime:compileJava UP-TO-DATE
> Task :messaging:messaging-claim-check:compileJava UP-TO-DATE
> Task :messaging:messaging-cloudevents:compileJava UP-TO-DATE
> Task :messaging:messaging-inbox-jdbc-postgresql:compileJava UP-TO-DATE
> Task :messaging:messaging-kafka:compileJava UP-TO-DATE
> Task :messaging:messaging-outbox-jdbc-postgresql:compileJava UP-TO-DATE
> Task :messaging:messaging-rabbit:compileJava UP-TO-DATE
> Task :messaging:messaging-runtime-core:compileJava UP-TO-DATE
> Task :messaging:messaging-schema-json:compileJava UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:compileJava UP-TO-DATE
> Task :adapter:inbound:graphql:compileJava UP-TO-DATE
> Task :adapter:inbound:graphql:processResources UP-TO-DATE
> Task :adapter:inbound:graphql:classes UP-TO-DATE
> Task :adapter:inbound:graphql:jar UP-TO-DATE
> Task :adapter:inbound:web:compileJava UP-TO-DATE
> Task :adapter:inbound:web:processResources NO-SOURCE
> Task :adapter:inbound:web:classes UP-TO-DATE
> Task :adapter:inbound:web:jar UP-TO-DATE
> Task :adapter:outbound:cache-redis:processResources UP-TO-DATE
> Task :adapter:outbound:cache-redis:classes UP-TO-DATE
> Task :adapter:outbound:cache-redis:jar UP-TO-DATE
> Task :adapter:outbound:fileserver:compileJava UP-TO-DATE
> Task :adapter:outbound:fileserver:processResources NO-SOURCE
> Task :adapter:outbound:fileserver:classes UP-TO-DATE
> Task :adapter:outbound:fileserver:jar UP-TO-DATE
> Task :adapter:outbound:httpclient:compileJava UP-TO-DATE
> Task :adapter:outbound:httpclient:processResources NO-SOURCE
> Task :adapter:outbound:httpclient:classes UP-TO-DATE
> Task :adapter:outbound:httpclient:jar UP-TO-DATE
> Task :adapter:outbound:identifier:compileJava UP-TO-DATE
> Task :adapter:outbound:identifier:compileGroovy NO-SOURCE
> Task :adapter:outbound:identifier:processResources NO-SOURCE
> Task :adapter:outbound:identifier:classes UP-TO-DATE
> Task :adapter:outbound:identifier:jar UP-TO-DATE
> Task :adapter:outbound:messaging:compileJava UP-TO-DATE
> Task :adapter:outbound:messaging:processResources UP-TO-DATE
> Task :adapter:outbound:messaging:classes UP-TO-DATE
> Task :adapter:outbound:messaging:jar UP-TO-DATE
> Task :adapter:outbound:notification:compileJava UP-TO-DATE
> Task :adapter:outbound:notification:processResources NO-SOURCE
> Task :adapter:outbound:notification:classes UP-TO-DATE
> Task :adapter:outbound:notification:jar UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:jar UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processResources UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:classes UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:jar UP-TO-DATE
> Task :app-bootstrap:compileJava UP-TO-DATE
> Task :app-bootstrap:processResources UP-TO-DATE
> Task :app-bootstrap:classes UP-TO-DATE
> Task :adapter:inbound:grpc:compileJava UP-TO-DATE
> Task :adapter:inbound:grpc:processResources NO-SOURCE
> Task :adapter:inbound:grpc:classes UP-TO-DATE
> Task :adapter:inbound:grpc:jar UP-TO-DATE
> Task :adapter:inbound:websocket:compileJava UP-TO-DATE
> Task :adapter:inbound:websocket:processResources NO-SOURCE
> Task :adapter:inbound:websocket:classes UP-TO-DATE
> Task :adapter:inbound:websocket:jar UP-TO-DATE
> Task :app-bootstrap:compileConditionalTransportTestJava UP-TO-DATE
> Task :app-bootstrap:processConditionalTransportTestResources NO-SOURCE
> Task :app-bootstrap:conditionalTransportTestClasses UP-TO-DATE
> Task :app-bootstrap:checkstyleConditionalTransportTest UP-TO-DATE
> Task :app-bootstrap:compileFunctionalTestJava UP-TO-DATE
> Task :app-bootstrap:processFunctionalTestResources NO-SOURCE
> Task :app-bootstrap:functionalTestClasses UP-TO-DATE
> Task :app-bootstrap:checkstyleFunctionalTest UP-TO-DATE
> Task :app-bootstrap:checkstyleMain UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileTestkitJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:testkitClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:testkitJar UP-TO-DATE
> Task :app-bootstrap:compileSampleOffTestJava UP-TO-DATE
> Task :app-bootstrap:processSampleOffTestResources UP-TO-DATE
> Task :app-bootstrap:sampleOffTestClasses UP-TO-DATE
> Task :app-bootstrap:checkstyleSampleOffTest UP-TO-DATE
> Task :sample-portfolio:compileJava UP-TO-DATE
> Task :sample-portfolio:processResources UP-TO-DATE
> Task :sample-portfolio:classes UP-TO-DATE
> Task :sample-portfolio:jar UP-TO-DATE
> Task :app-bootstrap:compileTestJava UP-TO-DATE
> Task :app-bootstrap:processTestResources UP-TO-DATE
> Task :app-bootstrap:testClasses UP-TO-DATE
> Task :app-bootstrap:checkstyleTest UP-TO-DATE
> Task :app-bootstrap:runtimeClasspathManifest UP-TO-DATE
> Task :messaging:messaging-admin-api:processResources NO-SOURCE
> Task :messaging:messaging-admin-api:classes UP-TO-DATE
> Task :messaging:messaging-admin-api:jar UP-TO-DATE
> Task :messaging:messaging-admin-runtime:processResources NO-SOURCE
> Task :messaging:messaging-admin-runtime:classes UP-TO-DATE
> Task :messaging:messaging-admin-runtime:jar UP-TO-DATE
> Task :messaging:messaging-claim-check:processResources NO-SOURCE
> Task :messaging:messaging-claim-check:classes UP-TO-DATE
> Task :messaging:messaging-claim-check:jar UP-TO-DATE
> Task :messaging:messaging-cloudevents:processResources NO-SOURCE
> Task :messaging:messaging-cloudevents:classes UP-TO-DATE
> Task :messaging:messaging-cloudevents:jar UP-TO-DATE
> Task :messaging:messaging-core-api:processResources NO-SOURCE
> Task :messaging:messaging-core-api:classes UP-TO-DATE
> Task :messaging:messaging-core-api:jar UP-TO-DATE
> Task :messaging:messaging-inbox-jdbc-postgresql:processResources UP-TO-DATE
> Task :messaging:messaging-inbox-jdbc-postgresql:classes UP-TO-DATE
> Task :messaging:messaging-inbox-jdbc-postgresql:jar UP-TO-DATE
> Task :messaging:messaging-kafka:processResources NO-SOURCE
> Task :messaging:messaging-kafka:classes UP-TO-DATE
> Task :messaging:messaging-kafka:jar UP-TO-DATE
> Task :messaging:messaging-observability:processResources NO-SOURCE
> Task :messaging:messaging-observability:classes UP-TO-DATE
> Task :messaging:messaging-observability:jar UP-TO-DATE
> Task :messaging:messaging-outbox-jdbc-postgresql:processResources UP-TO-DATE
> Task :messaging:messaging-outbox-jdbc-postgresql:classes UP-TO-DATE
> Task :messaging:messaging-outbox-jdbc-postgresql:jar UP-TO-DATE
> Task :messaging:messaging-policy:processResources NO-SOURCE
> Task :messaging:messaging-policy:classes UP-TO-DATE
> Task :messaging:messaging-policy:jar UP-TO-DATE
> Task :messaging:messaging-rabbit:processResources NO-SOURCE
> Task :messaging:messaging-rabbit:classes UP-TO-DATE
> Task :messaging:messaging-rabbit:jar UP-TO-DATE
> Task :messaging:messaging-reliability-api:processResources NO-SOURCE
> Task :messaging:messaging-reliability-api:classes UP-TO-DATE
> Task :messaging:messaging-reliability-api:jar UP-TO-DATE
> Task :messaging:messaging-runtime-core:processResources NO-SOURCE
> Task :messaging:messaging-runtime-core:classes UP-TO-DATE
> Task :messaging:messaging-runtime-core:jar UP-TO-DATE
> Task :messaging:messaging-schema-api:processResources NO-SOURCE
> Task :messaging:messaging-schema-api:classes UP-TO-DATE
> Task :messaging:messaging-schema-api:jar UP-TO-DATE
> Task :messaging:messaging-schema-json:processResources NO-SOURCE
> Task :messaging:messaging-schema-json:classes UP-TO-DATE
> Task :messaging:messaging-schema-json:jar UP-TO-DATE
> Task :messaging:messaging-security:processResources NO-SOURCE
> Task :messaging:messaging-security:classes UP-TO-DATE
> Task :messaging:messaging-security:jar UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:processResources UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:classes UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:jar UP-TO-DATE
> Task :messaging:messaging-transport-spi:processResources NO-SOURCE
> Task :messaging:messaging-transport-spi:classes UP-TO-DATE
> Task :messaging:messaging-transport-spi:jar UP-TO-DATE
> Task :adapter:outbound:objectstorage:compileJava UP-TO-DATE
> Task :adapter:outbound:objectstorage:processResources NO-SOURCE
> Task :adapter:outbound:objectstorage:classes UP-TO-DATE
> Task :adapter:outbound:objectstorage:jar UP-TO-DATE
> Task :app-bootstrap:test UP-TO-DATE
> Task :app-bootstrap:functionalTest UP-TO-DATE
> Task :app-bootstrap:spotbugsConditionalTransportTest UP-TO-DATE
> Task :app-bootstrap:spotbugsFunctionalTest UP-TO-DATE
> Task :app-bootstrap:spotbugsMain UP-TO-DATE
> Task :app-bootstrap:spotbugsSampleOffTest UP-TO-DATE
> Task :app-bootstrap:spotbugsTest UP-TO-DATE
> Task :app-bootstrap:spotlessJava UP-TO-DATE
> Task :app-bootstrap:spotlessJavaCheck UP-TO-DATE
> Task :app-bootstrap:spotlessCheck UP-TO-DATE
> Task :app-bootstrap:check
> Task :verifyApplicationCoreDependencyPurity
verifyApplicationCoreDependencyPurity: OK — application-core production declarations are project-only and application classpaths contain no Spring/logging/metrics frameworks.
> Task :application-core:checkstyleMain UP-TO-DATE
> Task :application-core:compileTestJava UP-TO-DATE
> Task :application-core:processTestResources NO-SOURCE
> Task :application-core:testClasses UP-TO-DATE
> Task :application-core:checkstyleTest UP-TO-DATE
> Task :application-core:spotbugsMain UP-TO-DATE
> Task :application-core:spotbugsTest UP-TO-DATE
> Task :application-core:spotlessJava UP-TO-DATE
> Task :application-core:spotlessJavaCheck UP-TO-DATE
> Task :application-core:spotlessCheck UP-TO-DATE
> Task :application-core:test UP-TO-DATE
> Task :application-core:check
> Task :domain-core:checkstyleMain UP-TO-DATE
> Task :domain-core:compileTestJava NO-SOURCE
> Task :domain-core:processTestResources NO-SOURCE
> Task :domain-core:testClasses UP-TO-DATE
> Task :domain-core:checkstyleTest NO-SOURCE
> Task :domain-core:spotbugsMain UP-TO-DATE
> Task :domain-core:spotbugsTest NO-SOURCE
> Task :domain-core:spotlessJava UP-TO-DATE
> Task :domain-core:spotlessJavaCheck UP-TO-DATE
> Task :domain-core:spotlessCheck UP-TO-DATE
> Task :domain-core:test NO-SOURCE
> Task :domain-core:check
> Task :sample-portfolio:checkstyleMain UP-TO-DATE
> Task :sample-portfolio:compileTestJava UP-TO-DATE
> Task :sample-portfolio:processTestResources UP-TO-DATE
> Task :sample-portfolio:testClasses UP-TO-DATE
> Task :sample-portfolio:compilePosterImageMigrationTestJava UP-TO-DATE
> Task :sample-portfolio:processPosterImageMigrationTestResources NO-SOURCE
> Task :sample-portfolio:posterImageMigrationTestClasses UP-TO-DATE
> Task :sample-portfolio:checkstylePosterImageMigrationTest UP-TO-DATE
> Task :sample-portfolio:checkstyleTest UP-TO-DATE
> Task :sample-portfolio:spotbugsMain UP-TO-DATE
> Task :sample-portfolio:spotbugsPosterImageMigrationTest UP-TO-DATE
> Task :sample-portfolio:spotbugsTest UP-TO-DATE
> Task :sample-portfolio:spotlessJava UP-TO-DATE
> Task :sample-portfolio:spotlessJavaCheck UP-TO-DATE
> Task :sample-portfolio:spotlessCheck UP-TO-DATE
> Task :sample-portfolio:test UP-TO-DATE
> Task :sample-portfolio:check
> Task :shared-contract:compileEdgeRateLimitContractTestJava UP-TO-DATE
> Task :shared-contract:processEdgeRateLimitContractTestResources NO-SOURCE
> Task :shared-contract:edgeRateLimitContractTestClasses UP-TO-DATE
> Task :shared-contract:checkstyleEdgeRateLimitContractTest UP-TO-DATE
> Task :shared-contract:checkstyleMain UP-TO-DATE
> Task :shared-contract:compileTestJava UP-TO-DATE
> Task :shared-contract:processTestResources NO-SOURCE
> Task :shared-contract:testClasses UP-TO-DATE
> Task :shared-contract:checkstyleTest UP-TO-DATE
> Task :shared-contract:spotbugsEdgeRateLimitContractTest UP-TO-DATE
> Task :shared-contract:spotbugsMain UP-TO-DATE
> Task :shared-contract:spotbugsTest UP-TO-DATE
> Task :shared-contract:spotlessJava UP-TO-DATE
> Task :shared-contract:spotlessJavaCheck UP-TO-DATE
> Task :shared-contract:spotlessCheck UP-TO-DATE
> Task :shared-contract:test UP-TO-DATE
> Task :shared-contract:check
> Task :messaging:messaging-admin-api:checkstyleMain UP-TO-DATE
> Task :messaging:messaging-admin-api:compileTestJava UP-TO-DATE
> Task :messaging:messaging-admin-api:processTestResources NO-SOURCE
> Task :messaging:messaging-admin-api:testClasses UP-TO-DATE
> Task :messaging:messaging-admin-api:checkstyleTest UP-TO-DATE
> Task :messaging:messaging-admin-api:spotbugsMain UP-TO-DATE
> Task :messaging:messaging-admin-api:spotbugsTest UP-TO-DATE
> Task :messaging:messaging-admin-api:spotlessJava UP-TO-DATE
> Task :messaging:messaging-admin-api:spotlessJavaCheck UP-TO-DATE
> Task :messaging:messaging-admin-api:spotlessCheck UP-TO-DATE
> Task :messaging:messaging-admin-api:test UP-TO-DATE
> Task :messaging:messaging-admin-api:check
> Task :messaging:messaging-admin-runtime:checkstyleMain UP-TO-DATE
> Task :messaging:messaging-admin-runtime:compileTestJava UP-TO-DATE
> Task :messaging:messaging-admin-runtime:processTestResources NO-SOURCE
> Task :messaging:messaging-admin-runtime:testClasses UP-TO-DATE
> Task :messaging:messaging-admin-runtime:checkstyleTest UP-TO-DATE
> Task :messaging:messaging-admin-runtime:spotbugsMain UP-TO-DATE
> Task :messaging:messaging-admin-runtime:spotbugsTest UP-TO-DATE
> Task :messaging:messaging-admin-runtime:spotlessJava UP-TO-DATE
> Task :messaging:messaging-admin-runtime:spotlessJavaCheck UP-TO-DATE
> Task :messaging:messaging-admin-runtime:spotlessCheck UP-TO-DATE
> Task :messaging:messaging-admin-runtime:test UP-TO-DATE
> Task :messaging:messaging-admin-runtime:check
> Task :messaging:messaging-claim-check:checkstyleMain UP-TO-DATE
> Task :messaging:messaging-claim-check:compileTestJava UP-TO-DATE
> Task :messaging:messaging-claim-check:processTestResources NO-SOURCE
> Task :messaging:messaging-claim-check:testClasses UP-TO-DATE
> Task :messaging:messaging-claim-check:checkstyleTest UP-TO-DATE
> Task :messaging:messaging-claim-check:spotbugsMain UP-TO-DATE
> Task :messaging:messaging-claim-check:spotbugsTest UP-TO-DATE
> Task :messaging:messaging-claim-check:spotlessJava UP-TO-DATE
> Task :messaging:messaging-claim-check:spotlessJavaCheck UP-TO-DATE
> Task :messaging:messaging-claim-check:spotlessCheck UP-TO-DATE
> Task :messaging:messaging-claim-check:test UP-TO-DATE
> Task :messaging:messaging-claim-check:check
> Task :messaging:messaging-cloudevents:checkstyleMain UP-TO-DATE
> Task :messaging:messaging-cloudevents:compileTestJava UP-TO-DATE
> Task :messaging:messaging-cloudevents:processTestResources NO-SOURCE
> Task :messaging:messaging-cloudevents:testClasses UP-TO-DATE
> Task :messaging:messaging-cloudevents:checkstyleTest UP-TO-DATE
> Task :messaging:messaging-cloudevents:spotbugsMain UP-TO-DATE
> Task :messaging:messaging-cloudevents:spotbugsTest UP-TO-DATE
> Task :messaging:messaging-cloudevents:spotlessJava UP-TO-DATE
> Task :messaging:messaging-cloudevents:spotlessJavaCheck UP-TO-DATE
> Task :messaging:messaging-cloudevents:spotlessCheck UP-TO-DATE
> Task :messaging:messaging-cloudevents:test UP-TO-DATE
> Task :messaging:messaging-cloudevents:check
> Task :messaging:messaging-core-api:checkstyleMain UP-TO-DATE
> Task :messaging:messaging-core-api:compileTestJava UP-TO-DATE
> Task :messaging:messaging-core-api:processTestResources NO-SOURCE
> Task :messaging:messaging-core-api:testClasses UP-TO-DATE
> Task :messaging:messaging-core-api:checkstyleTest UP-TO-DATE
> Task :messaging:messaging-core-api:spotbugsMain UP-TO-DATE
> Task :messaging:messaging-core-api:spotbugsTest UP-TO-DATE
> Task :messaging:messaging-core-api:spotlessJava UP-TO-DATE
> Task :messaging:messaging-core-api:spotlessJavaCheck UP-TO-DATE
> Task :messaging:messaging-core-api:spotlessCheck UP-TO-DATE
> Task :messaging:messaging-core-api:test UP-TO-DATE
> Task :messaging:messaging-core-api:check
> Task :messaging:messaging-inbox-jdbc-postgresql:checkstyleMain UP-TO-DATE
> Task :messaging:messaging-testkit:compileJava UP-TO-DATE
> Task :messaging:messaging-inbox-jdbc-postgresql:compileTestJava UP-TO-DATE
> Task :messaging:messaging-inbox-jdbc-postgresql:processTestResources NO-SOURCE
> Task :messaging:messaging-inbox-jdbc-postgresql:testClasses UP-TO-DATE
> Task :messaging:messaging-inbox-jdbc-postgresql:checkstyleTest UP-TO-DATE
> Task :messaging:messaging-inbox-jdbc-postgresql:spotbugsMain UP-TO-DATE
> Task :messaging:messaging-testkit:processResources UP-TO-DATE
> Task :messaging:messaging-testkit:classes UP-TO-DATE
> Task :messaging:messaging-testkit:jar UP-TO-DATE
> Task :messaging:messaging-inbox-jdbc-postgresql:spotbugsTest UP-TO-DATE
> Task :messaging:messaging-inbox-jdbc-postgresql:spotlessJava UP-TO-DATE
> Task :messaging:messaging-inbox-jdbc-postgresql:spotlessJavaCheck UP-TO-DATE
> Task :messaging:messaging-inbox-jdbc-postgresql:spotlessCheck UP-TO-DATE
> Task :messaging:messaging-inbox-jdbc-postgresql:test UP-TO-DATE
> Task :messaging:messaging-inbox-jdbc-postgresql:check
> Task :messaging:messaging-kafka:compileTestJava UP-TO-DATE
> Task :messaging:messaging-kafka:processTestResources NO-SOURCE
> Task :messaging:messaging-kafka:testClasses UP-TO-DATE
> Task :messaging:messaging-kafka:compileJmhJava UP-TO-DATE
> Task :messaging:messaging-kafka:processJmhResources NO-SOURCE
> Task :messaging:messaging-kafka:jmhClasses UP-TO-DATE
> Task :messaging:messaging-kafka:checkstyleJmh SKIPPED
> Task :messaging:messaging-kafka:checkstyleMain UP-TO-DATE
> Task :messaging:messaging-kafka:checkstyleTest UP-TO-DATE
> Task :messaging:messaging-kafka:spotbugsJmh SKIPPED
> Task :messaging:messaging-kafka:spotbugsMain UP-TO-DATE
> Task :messaging:messaging-kafka:spotbugsTest UP-TO-DATE
> Task :messaging:messaging-kafka:spotlessJava UP-TO-DATE
> Task :messaging:messaging-kafka:spotlessJavaCheck UP-TO-DATE
> Task :messaging:messaging-kafka:spotlessCheck UP-TO-DATE
> Task :messaging:messaging-kafka:test UP-TO-DATE
> Task :messaging:messaging-kafka:check
> Task :messaging:messaging-kafka-share-experimental:compileJava UP-TO-DATE
> Task :messaging:messaging-kafka-share-experimental:processResources NO-SOURCE
> Task :messaging:messaging-kafka-share-experimental:classes UP-TO-DATE
> Task :messaging:messaging-kafka-share-experimental:checkstyleMain UP-TO-DATE
> Task :messaging:messaging-kafka-share-experimental:compileTestJava UP-TO-DATE
> Task :messaging:messaging-kafka-share-experimental:processTestResources NO-SOURCE
> Task :messaging:messaging-kafka-share-experimental:testClasses UP-TO-DATE
> Task :messaging:messaging-kafka-share-experimental:checkstyleTest UP-TO-DATE
> Task :messaging:messaging-kafka-share-experimental:spotbugsMain UP-TO-DATE
> Task :messaging:messaging-kafka-share-experimental:spotbugsTest UP-TO-DATE
> Task :messaging:messaging-kafka-share-experimental:spotlessJava UP-TO-DATE
> Task :messaging:messaging-kafka-share-experimental:spotlessJavaCheck UP-TO-DATE
> Task :messaging:messaging-kafka-share-experimental:spotlessCheck UP-TO-DATE
> Task :messaging:messaging-kafka-share-experimental:test UP-TO-DATE
> Task :messaging:messaging-kafka-share-experimental:check
> Task :messaging:messaging-nats-experimental:compileJava UP-TO-DATE
> Task :messaging:messaging-nats-experimental:processResources NO-SOURCE
> Task :messaging:messaging-nats-experimental:classes UP-TO-DATE
> Task :messaging:messaging-nats-experimental:checkstyleMain UP-TO-DATE
> Task :messaging:messaging-nats-experimental:compileTestJava UP-TO-DATE
> Task :messaging:messaging-nats-experimental:processTestResources NO-SOURCE
> Task :messaging:messaging-nats-experimental:testClasses UP-TO-DATE
> Task :messaging:messaging-nats-experimental:checkstyleTest UP-TO-DATE
> Task :messaging:messaging-nats-experimental:spotbugsMain UP-TO-DATE
> Task :messaging:messaging-nats-experimental:spotbugsTest UP-TO-DATE
> Task :messaging:messaging-nats-experimental:spotlessJava UP-TO-DATE
> Task :messaging:messaging-nats-experimental:spotlessJavaCheck UP-TO-DATE
> Task :messaging:messaging-nats-experimental:spotlessCheck UP-TO-DATE
> Task :messaging:messaging-nats-experimental:test UP-TO-DATE
> Task :messaging:messaging-nats-experimental:check
> Task :messaging:messaging-observability:checkstyleMain UP-TO-DATE
> Task :messaging:messaging-observability:compileTestJava UP-TO-DATE
> Task :messaging:messaging-observability:processTestResources NO-SOURCE
> Task :messaging:messaging-observability:testClasses UP-TO-DATE
> Task :messaging:messaging-observability:checkstyleTest UP-TO-DATE
> Task :messaging:messaging-observability:spotbugsMain UP-TO-DATE
> Task :messaging:messaging-observability:spotbugsTest UP-TO-DATE
> Task :messaging:messaging-observability:spotlessJava UP-TO-DATE
> Task :messaging:messaging-observability:spotlessJavaCheck UP-TO-DATE
> Task :messaging:messaging-observability:spotlessCheck UP-TO-DATE
> Task :messaging:messaging-observability:test UP-TO-DATE
> Task :messaging:messaging-observability:check
> Task :messaging:messaging-outbox-jdbc-postgresql:checkstyleMain UP-TO-DATE
> Task :messaging:messaging-outbox-jdbc-postgresql:compileTestJava UP-TO-DATE
> Task :messaging:messaging-outbox-jdbc-postgresql:processTestResources NO-SOURCE
> Task :messaging:messaging-outbox-jdbc-postgresql:testClasses UP-TO-DATE
> Task :messaging:messaging-outbox-jdbc-postgresql:checkstyleTest UP-TO-DATE
> Task :messaging:messaging-outbox-jdbc-postgresql:spotbugsMain UP-TO-DATE
> Task :messaging:messaging-outbox-jdbc-postgresql:spotbugsTest UP-TO-DATE
> Task :messaging:messaging-outbox-jdbc-postgresql:spotlessJava UP-TO-DATE
> Task :messaging:messaging-outbox-jdbc-postgresql:spotlessJavaCheck UP-TO-DATE
> Task :messaging:messaging-outbox-jdbc-postgresql:spotlessCheck UP-TO-DATE
> Task :messaging:messaging-outbox-jdbc-postgresql:test UP-TO-DATE
> Task :messaging:messaging-outbox-jdbc-postgresql:check
> Task :messaging:messaging-policy:checkstyleMain UP-TO-DATE
> Task :messaging:messaging-policy:compileTestJava UP-TO-DATE
> Task :messaging:messaging-policy:processTestResources NO-SOURCE
> Task :messaging:messaging-policy:testClasses UP-TO-DATE
> Task :messaging:messaging-policy:checkstyleTest UP-TO-DATE
> Task :messaging:messaging-policy:spotbugsMain UP-TO-DATE
> Task :messaging:messaging-policy:spotbugsTest UP-TO-DATE
> Task :messaging:messaging-policy:spotlessJava UP-TO-DATE
> Task :messaging:messaging-policy:spotlessJavaCheck UP-TO-DATE
> Task :messaging:messaging-policy:spotlessCheck UP-TO-DATE
> Task :messaging:messaging-policy:test UP-TO-DATE
> Task :messaging:messaging-policy:check
> Task :messaging:messaging-pulsar-experimental:compileJava UP-TO-DATE
> Task :messaging:messaging-pulsar-experimental:processResources NO-SOURCE
> Task :messaging:messaging-pulsar-experimental:classes UP-TO-DATE
> Task :messaging:messaging-pulsar-experimental:checkstyleMain UP-TO-DATE
> Task :messaging:messaging-pulsar-experimental:compileTestJava UP-TO-DATE
> Task :messaging:messaging-pulsar-experimental:processTestResources NO-SOURCE
> Task :messaging:messaging-pulsar-experimental:testClasses UP-TO-DATE
> Task :messaging:messaging-pulsar-experimental:checkstyleTest UP-TO-DATE
> Task :messaging:messaging-pulsar-experimental:spotbugsMain UP-TO-DATE
> Task :messaging:messaging-pulsar-experimental:spotbugsTest UP-TO-DATE
> Task :messaging:messaging-pulsar-experimental:spotlessJava UP-TO-DATE
> Task :messaging:messaging-pulsar-experimental:spotlessJavaCheck UP-TO-DATE
> Task :messaging:messaging-pulsar-experimental:spotlessCheck UP-TO-DATE
> Task :messaging:messaging-pulsar-experimental:test UP-TO-DATE
> Task :messaging:messaging-pulsar-experimental:check
> Task :messaging:messaging-rabbit:compileTestJava UP-TO-DATE
> Task :messaging:messaging-rabbit:processTestResources NO-SOURCE
> Task :messaging:messaging-rabbit:testClasses UP-TO-DATE
> Task :messaging:messaging-rabbit:compileJmhJava UP-TO-DATE
> Task :messaging:messaging-rabbit:processJmhResources NO-SOURCE
> Task :messaging:messaging-rabbit:jmhClasses UP-TO-DATE
> Task :messaging:messaging-rabbit:checkstyleJmh SKIPPED
> Task :messaging:messaging-rabbit:checkstyleMain UP-TO-DATE
> Task :messaging:messaging-rabbit:checkstyleTest UP-TO-DATE
> Task :messaging:messaging-rabbit:spotbugsJmh SKIPPED
> Task :messaging:messaging-rabbit:spotbugsMain UP-TO-DATE
> Task :messaging:messaging-rabbit:spotbugsTest UP-TO-DATE
> Task :messaging:messaging-rabbit:spotlessJava UP-TO-DATE
> Task :messaging:messaging-rabbit:spotlessJavaCheck UP-TO-DATE
> Task :messaging:messaging-rabbit:spotlessCheck UP-TO-DATE
> Task :messaging:messaging-rabbit:test UP-TO-DATE
> Task :messaging:messaging-rabbit:check
> Task :messaging:messaging-reliability-api:checkstyleMain UP-TO-DATE
> Task :messaging:messaging-reliability-api:compileTestJava NO-SOURCE
> Task :messaging:messaging-reliability-api:processTestResources NO-SOURCE
> Task :messaging:messaging-reliability-api:testClasses UP-TO-DATE
> Task :messaging:messaging-reliability-api:checkstyleTest NO-SOURCE
> Task :messaging:messaging-reliability-api:spotbugsMain UP-TO-DATE
> Task :messaging:messaging-reliability-api:spotbugsTest NO-SOURCE
> Task :messaging:messaging-reliability-api:spotlessJava UP-TO-DATE
> Task :messaging:messaging-reliability-api:spotlessJavaCheck UP-TO-DATE
> Task :messaging:messaging-reliability-api:spotlessCheck UP-TO-DATE
> Task :messaging:messaging-reliability-api:test NO-SOURCE
> Task :messaging:messaging-reliability-api:check
> Task :messaging:messaging-runtime-core:checkstyleMain UP-TO-DATE
> Task :messaging:messaging-runtime-core:compileTestJava UP-TO-DATE
> Task :messaging:messaging-runtime-core:processTestResources NO-SOURCE
> Task :messaging:messaging-runtime-core:testClasses UP-TO-DATE
> Task :messaging:messaging-runtime-core:checkstyleTest UP-TO-DATE
> Task :messaging:messaging-runtime-core:spotbugsMain UP-TO-DATE
> Task :messaging:messaging-runtime-core:spotbugsTest UP-TO-DATE
> Task :messaging:messaging-runtime-core:spotlessJava UP-TO-DATE
> Task :messaging:messaging-runtime-core:spotlessJavaCheck UP-TO-DATE
> Task :messaging:messaging-runtime-core:spotlessCheck UP-TO-DATE
> Task :messaging:messaging-runtime-core:test UP-TO-DATE
> Task :messaging:messaging-runtime-core:check
> Task :messaging:messaging-schema-api:checkstyleMain UP-TO-DATE
> Task :messaging:messaging-schema-api:compileTestJava UP-TO-DATE
> Task :messaging:messaging-schema-api:processTestResources NO-SOURCE
> Task :messaging:messaging-schema-api:testClasses UP-TO-DATE
> Task :messaging:messaging-schema-api:checkstyleTest UP-TO-DATE
> Task :messaging:messaging-schema-api:spotbugsMain UP-TO-DATE
> Task :messaging:messaging-schema-api:spotbugsTest UP-TO-DATE
> Task :messaging:messaging-schema-api:spotlessJava UP-TO-DATE
> Task :messaging:messaging-schema-api:spotlessJavaCheck UP-TO-DATE
> Task :messaging:messaging-schema-api:spotlessCheck UP-TO-DATE
> Task :messaging:messaging-schema-api:test UP-TO-DATE
> Task :messaging:messaging-schema-api:check
> Task :messaging:messaging-schema-avro:compileJava UP-TO-DATE
> Task :messaging:messaging-schema-avro:processResources NO-SOURCE
> Task :messaging:messaging-schema-avro:classes UP-TO-DATE
> Task :messaging:messaging-schema-avro:checkstyleMain UP-TO-DATE
> Task :messaging:messaging-schema-avro:compileTestJava UP-TO-DATE
> Task :messaging:messaging-schema-avro:processTestResources UP-TO-DATE
> Task :messaging:messaging-schema-avro:testClasses UP-TO-DATE
> Task :messaging:messaging-schema-avro:checkstyleTest UP-TO-DATE
> Task :messaging:messaging-schema-avro:spotbugsMain UP-TO-DATE
> Task :messaging:messaging-schema-avro:spotbugsTest UP-TO-DATE
> Task :messaging:messaging-schema-avro:spotlessJava UP-TO-DATE
> Task :messaging:messaging-schema-avro:spotlessJavaCheck UP-TO-DATE
> Task :messaging:messaging-schema-avro:spotlessCheck UP-TO-DATE
> Task :messaging:messaging-schema-avro:test UP-TO-DATE
> Task :messaging:messaging-schema-avro:check
> Task :messaging:messaging-schema-json:checkstyleMain UP-TO-DATE
> Task :messaging:messaging-schema-json:compileTestJava UP-TO-DATE
> Task :messaging:messaging-schema-json:processTestResources NO-SOURCE
> Task :messaging:messaging-schema-json:testClasses UP-TO-DATE
> Task :messaging:messaging-schema-json:checkstyleTest UP-TO-DATE
> Task :messaging:messaging-schema-json:spotbugsMain UP-TO-DATE
> Task :messaging:messaging-schema-json:spotbugsTest UP-TO-DATE
> Task :messaging:messaging-schema-json:spotlessJava UP-TO-DATE
> Task :messaging:messaging-schema-json:spotlessJavaCheck UP-TO-DATE
> Task :messaging:messaging-schema-json:spotlessCheck UP-TO-DATE
> Task :messaging:messaging-schema-json:test UP-TO-DATE
> Task :messaging:messaging-schema-json:check
> Task :messaging:messaging-schema-protobuf:compileJava UP-TO-DATE
> Task :messaging:messaging-schema-protobuf:processResources NO-SOURCE
> Task :messaging:messaging-schema-protobuf:classes UP-TO-DATE
> Task :messaging:messaging-schema-protobuf:checkstyleMain UP-TO-DATE
> Task :messaging:messaging-schema-protobuf:compileTestJava UP-TO-DATE
> Task :messaging:messaging-schema-protobuf:processTestResources NO-SOURCE
> Task :messaging:messaging-schema-protobuf:testClasses UP-TO-DATE
> Task :messaging:messaging-schema-protobuf:checkstyleTest UP-TO-DATE
> Task :messaging:messaging-schema-protobuf:spotbugsMain UP-TO-DATE
> Task :messaging:messaging-schema-protobuf:spotbugsTest UP-TO-DATE
> Task :messaging:messaging-schema-protobuf:spotlessJava UP-TO-DATE
> Task :messaging:messaging-schema-protobuf:spotlessJavaCheck UP-TO-DATE
> Task :messaging:messaging-schema-protobuf:spotlessCheck UP-TO-DATE
> Task :messaging:messaging-schema-protobuf:test UP-TO-DATE
> Task :messaging:messaging-schema-protobuf:check
> Task :messaging:messaging-security:checkstyleMain UP-TO-DATE
> Task :messaging:messaging-security:compileTestJava UP-TO-DATE
> Task :messaging:messaging-security:processTestResources NO-SOURCE
> Task :messaging:messaging-security:testClasses UP-TO-DATE
> Task :messaging:messaging-security:checkstyleTest UP-TO-DATE
> Task :messaging:messaging-security:spotbugsMain UP-TO-DATE
> Task :messaging:messaging-security:spotbugsTest UP-TO-DATE
> Task :messaging:messaging-security:spotlessJava UP-TO-DATE
> Task :messaging:messaging-security:spotlessJavaCheck UP-TO-DATE
> Task :messaging:messaging-security:spotlessCheck UP-TO-DATE
> Task :messaging:messaging-security:test UP-TO-DATE
> Task :messaging:messaging-security:check
> Task :messaging:messaging-spring-boot-starter:checkstyleMain UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:compileTestJava UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:processTestResources UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:testClasses UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:checkstyleTest UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:spotbugsMain UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:spotbugsTest UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:spotlessJava UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:spotlessJavaCheck UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:spotlessCheck UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:test UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:check
> Task :messaging:messaging-spring-cloud-stream-bridge:compileJava UP-TO-DATE
> Task :messaging:messaging-spring-cloud-stream-bridge:processResources NO-SOURCE
> Task :messaging:messaging-spring-cloud-stream-bridge:classes UP-TO-DATE
> Task :messaging:messaging-spring-cloud-stream-bridge:checkstyleMain UP-TO-DATE
> Task :messaging:messaging-spring-cloud-stream-bridge:compileTestJava UP-TO-DATE
> Task :messaging:messaging-spring-cloud-stream-bridge:processTestResources NO-SOURCE
> Task :messaging:messaging-spring-cloud-stream-bridge:testClasses UP-TO-DATE
> Task :messaging:messaging-spring-cloud-stream-bridge:checkstyleTest UP-TO-DATE
> Task :messaging:messaging-spring-cloud-stream-bridge:spotbugsMain UP-TO-DATE
> Task :messaging:messaging-spring-cloud-stream-bridge:spotbugsTest UP-TO-DATE
> Task :messaging:messaging-spring-cloud-stream-bridge:spotlessJava UP-TO-DATE
> Task :messaging:messaging-spring-cloud-stream-bridge:spotlessJavaCheck UP-TO-DATE
> Task :messaging:messaging-spring-cloud-stream-bridge:spotlessCheck UP-TO-DATE
> Task :messaging:messaging-spring-cloud-stream-bridge:test UP-TO-DATE
> Task :messaging:messaging-spring-cloud-stream-bridge:check
> Task :messaging:messaging-testkit:compileTestJava UP-TO-DATE
> Task :messaging:messaging-testkit:processTestResources NO-SOURCE
> Task :messaging:messaging-testkit:testClasses UP-TO-DATE
> Task :messaging:messaging-testkit:compileJmhJava UP-TO-DATE
> Task :messaging:messaging-testkit:processJmhResources NO-SOURCE
> Task :messaging:messaging-testkit:jmhClasses UP-TO-DATE
> Task :messaging:messaging-testkit:checkstyleJmh SKIPPED
> Task :messaging:messaging-testkit:checkstyleMain UP-TO-DATE
> Task :messaging:messaging-testkit:checkstyleTest UP-TO-DATE
> Task :messaging:messaging-testkit:spotbugsJmh SKIPPED
> Task :messaging:messaging-testkit:spotbugsMain UP-TO-DATE
> Task :messaging:messaging-testkit:spotbugsTest UP-TO-DATE
> Task :messaging:messaging-testkit:spotlessJava UP-TO-DATE
> Task :messaging:messaging-testkit:spotlessJavaCheck UP-TO-DATE
> Task :messaging:messaging-testkit:spotlessCheck UP-TO-DATE
> Task :messaging:messaging-testkit:test UP-TO-DATE
> Task :messaging:messaging-testkit:check
> Task :messaging:messaging-transport-spi:checkstyleMain UP-TO-DATE
> Task :messaging:messaging-transport-spi:compileTestJava UP-TO-DATE
> Task :messaging:messaging-transport-spi:processTestResources NO-SOURCE
> Task :messaging:messaging-transport-spi:testClasses UP-TO-DATE
> Task :messaging:messaging-transport-spi:checkstyleTest UP-TO-DATE
> Task :messaging:messaging-transport-spi:spotbugsMain UP-TO-DATE
> Task :messaging:messaging-transport-spi:spotbugsTest UP-TO-DATE
> Task :messaging:messaging-transport-spi:spotlessJava UP-TO-DATE
> Task :messaging:messaging-transport-spi:spotlessJavaCheck UP-TO-DATE
> Task :messaging:messaging-transport-spi:spotlessCheck UP-TO-DATE
> Task :messaging:messaging-transport-spi:test UP-TO-DATE
> Task :messaging:messaging-transport-spi:check
> Task :adapter:inbound:graphql:checkstyleMain UP-TO-DATE
> Task :adapter:inbound:graphql:compileTestFixturesJava UP-TO-DATE
> Task :adapter:inbound:graphql:compileTestJava UP-TO-DATE
> Task :adapter:inbound:graphql:processTestResources UP-TO-DATE
> Task :adapter:inbound:graphql:testClasses UP-TO-DATE
> Task :adapter:inbound:graphql:processTestFixturesResources NO-SOURCE
> Task :adapter:inbound:graphql:testFixturesClasses UP-TO-DATE
> Task :adapter:inbound:graphql:checkstyleTestFixtures UP-TO-DATE
> Task :adapter:inbound:graphql:spotbugsMain UP-TO-DATE
> Task :adapter:inbound:graphql:testFixturesJar UP-TO-DATE
> Task :adapter:inbound:graphql:spotbugsTest UP-TO-DATE
> Task :adapter:inbound:graphql:spotbugsTestFixtures UP-TO-DATE
> Task :adapter:inbound:graphql:spotlessJava UP-TO-DATE
> Task :adapter:inbound:graphql:spotlessJavaCheck UP-TO-DATE
> Task :adapter:inbound:graphql:spotlessCheck UP-TO-DATE
> Task :adapter:inbound:graphql:test UP-TO-DATE
> Task :adapter:inbound:graphql:verifyGraphQlApiSurface
verifyGraphQlApiSurface: OK — the committed public API surface is unchanged.
> Task :adapter:inbound:graphql:verifyGraphQlProductionJar UP-TO-DATE
> Task :adapter:inbound:grpc:checkstyleMain UP-TO-DATE
> Task :adapter:inbound:grpc:compileTestJava UP-TO-DATE
> Task :adapter:inbound:grpc:processTestResources NO-SOURCE
> Task :adapter:inbound:grpc:testClasses UP-TO-DATE
> Task :adapter:inbound:grpc:checkstyleTest UP-TO-DATE
> Task :adapter:inbound:grpc:spotbugsMain UP-TO-DATE
> Task :adapter:inbound:grpc:spotbugsTest UP-TO-DATE
> Task :adapter:inbound:grpc:spotlessJava UP-TO-DATE
> Task :adapter:inbound:grpc:spotlessJavaCheck UP-TO-DATE
> Task :adapter:inbound:grpc:spotlessCheck UP-TO-DATE
> Task :adapter:inbound:grpc:test UP-TO-DATE
> Task :adapter:inbound:grpc:check
> Task :adapter:inbound:web:checkstyleMain UP-TO-DATE
> Task :adapter:inbound:web:compileTestJava UP-TO-DATE
> Task :adapter:inbound:web:processTestResources NO-SOURCE
> Task :adapter:inbound:web:testClasses UP-TO-DATE
> Task :adapter:inbound:web:checkstyleTest UP-TO-DATE
> Task :adapter:inbound:web:spotbugsMain UP-TO-DATE
> Task :adapter:inbound:web:spotbugsTest UP-TO-DATE
> Task :adapter:inbound:web:spotlessJava UP-TO-DATE
> Task :adapter:inbound:web:spotlessJavaCheck UP-TO-DATE
> Task :adapter:inbound:web:spotlessCheck UP-TO-DATE
> Task :adapter:inbound:web:test UP-TO-DATE
> Task :adapter:inbound:graphql:checkstyleTest
> Task :adapter:inbound:web:webSecurityBoundaryTest
> Task :adapter:inbound:graphql:check
> Task :adapter:inbound:web:check
> Task :adapter:inbound:websocket:checkstyleMain UP-TO-DATE
> Task :adapter:inbound:websocket:compileTestJava UP-TO-DATE
> Task :adapter:inbound:websocket:processTestResources NO-SOURCE
> Task :adapter:inbound:websocket:testClasses UP-TO-DATE
> Task :adapter:inbound:websocket:checkstyleTest UP-TO-DATE
> Task :adapter:inbound:websocket:spotbugsMain UP-TO-DATE
> Task :adapter:inbound:websocket:spotbugsTest UP-TO-DATE
> Task :adapter:inbound:websocket:spotlessJava UP-TO-DATE
> Task :adapter:inbound:websocket:spotlessJavaCheck UP-TO-DATE
> Task :adapter:inbound:websocket:spotlessCheck UP-TO-DATE
> Task :adapter:inbound:websocket:test UP-TO-DATE
> Task :adapter:inbound:websocket:check
> Task :adapter:outbound:cache-redis:checkstyleMain UP-TO-DATE
> Task :adapter:outbound:cache-redis:compileTestJava UP-TO-DATE
> Task :adapter:outbound:cache-redis:processTestResources UP-TO-DATE
> Task :adapter:outbound:cache-redis:testClasses UP-TO-DATE
> Task :adapter:outbound:cache-redis:checkstyleTest UP-TO-DATE
> Task :adapter:outbound:cache-redis:spotbugsMain UP-TO-DATE
> Task :adapter:outbound:cache-redis:spotbugsTest UP-TO-DATE
> Task :adapter:outbound:cache-redis:spotlessJava UP-TO-DATE
> Task :adapter:outbound:cache-redis:spotlessJavaCheck UP-TO-DATE
> Task :adapter:outbound:cache-redis:spotlessCheck UP-TO-DATE
> Task :adapter:outbound:cache-redis:test UP-TO-DATE
> Task :adapter:outbound:cache-redis:check
> Task :adapter:outbound:fileserver:compileTestJava UP-TO-DATE
> Task :adapter:outbound:fileserver:processTestResources NO-SOURCE
> Task :adapter:outbound:fileserver:testClasses UP-TO-DATE
> Task :adapter:outbound:fileserver:checkstyleMain
> Task :adapter:outbound:fileserver:spotlessJava
> Task :adapter:outbound:fileserver:spotlessJavaCheck
> Task :adapter:outbound:fileserver:spotlessCheck
> Task :adapter:outbound:fileserver:test UP-TO-DATE
> Task :adapter:outbound:httpclient:compileTestkitJava UP-TO-DATE
> Task :adapter:outbound:httpclient:processTestkitResources NO-SOURCE
> Task :adapter:outbound:httpclient:testkitClasses UP-TO-DATE
> Task :adapter:outbound:fileserver:checkstyleTest
> Task :adapter:outbound:httpclient:compileHttpClientPerformanceTestJava
> Task :adapter:outbound:httpclient:processHttpClientPerformanceTestResources NO-SOURCE
> Task :adapter:outbound:httpclient:httpClientPerformanceTestClasses
> Task :adapter:outbound:httpclient:checkstyleHttpClientPerformanceTest
> Task :adapter:outbound:fileserver:spotbugsMain
> Task :adapter:outbound:fileserver:spotbugsTest
> Task :adapter:outbound:httpclient:compileJmhJava
> Task :adapter:outbound:httpclient:processJmhResources NO-SOURCE
> Task :adapter:outbound:httpclient:jmhClasses
> Task :adapter:outbound:httpclient:compileTestJava UP-TO-DATE
> Task :adapter:outbound:httpclient:processTestResources NO-SOURCE
> Task :adapter:outbound:httpclient:testClasses UP-TO-DATE
> Task :adapter:outbound:httpclient:checkstyleJmh
> Task :adapter:outbound:httpclient:httpClientBlockHoundTest
OpenJDK 64-Bit Server VM warning: Option AllowRedefinitionToAddDeleteMethods was deprecated in version 13.0 and will likely be removed in a future release.
> Task :adapter:outbound:httpclient:checkstyleMain
> Task :adapter:outbound:httpclient:checkstyleTestkit
> Task :adapter:outbound:httpclient:checkstyleTest
> Task :adapter:outbound:httpclient:httpClientSecurityTest
> Task :adapter:outbound:httpclient:httpClientStableContractTest
> Task :adapter:outbound:httpclient:spotbugsJmh SKIPPED
> Task :adapter:outbound:httpclient:spotlessJava
> Task :adapter:outbound:httpclient:spotlessJavaCheck
> Task :adapter:outbound:httpclient:spotlessCheck
> Task :adapter:outbound:httpclient:spring62ApiSurfaceScan
> Task :adapter:outbound:httpclient:test UP-TO-DATE
> Task :adapter:outbound:identifier:compileTestJava UP-TO-DATE
> Task :adapter:outbound:identifier:compileTestGroovy UP-TO-DATE
> Task :adapter:outbound:identifier:processTestResources NO-SOURCE
> Task :adapter:outbound:identifier:testClasses UP-TO-DATE
> Task :adapter:outbound:identifier:spotlessJava
> Task :adapter:outbound:httpclient:spotbugsHttpClientPerformanceTest
> Task :adapter:outbound:identifier:spotlessJavaCheck
> Task :adapter:outbound:identifier:spotlessCheck
> Task :adapter:outbound:identifier:test UP-TO-DATE
> Task :adapter:outbound:messaging:compileTestJava UP-TO-DATE
> Task :adapter:outbound:messaging:processTestResources UP-TO-DATE
> Task :adapter:outbound:messaging:testClasses UP-TO-DATE
> Task :adapter:outbound:identifier:checkstyleTest
> Task :adapter:outbound:identifier:checkstyleMain
> Task :adapter:outbound:messaging:spotlessJava
> Task :adapter:outbound:messaging:spotlessJavaCheck
> Task :adapter:outbound:messaging:spotlessCheck
> Task :adapter:outbound:messaging:test UP-TO-DATE
> Task :adapter:outbound:messaging:verifyJsonSchemaRuntimeGraph
> Task :adapter:outbound:notification:compileTestJava UP-TO-DATE
> Task :adapter:outbound:notification:processTestResources UP-TO-DATE
> Task :adapter:outbound:notification:testClasses UP-TO-DATE
> Task :adapter:outbound:fileserver:check
> Task :adapter:outbound:messaging:checkstyleTest
> Task :adapter:outbound:messaging:checkstyleMain
> Task :adapter:outbound:httpclient:spotbugsMain
> Task :adapter:outbound:httpclient:spotbugsTest
> Task :adapter:outbound:httpclient:spotbugsTestkit
> Task :adapter:outbound:notification:checkstyleTest
> Task :adapter:outbound:notification:spotlessJava
> Task :adapter:outbound:notification:spotlessJavaCheck
> Task :adapter:outbound:notification:spotlessCheck
> Task :adapter:outbound:notification:test UP-TO-DATE
> Task :adapter:outbound:notification:verifyDependencyPolicy
> Task :adapter:outbound:objectstorage:compileTestJava UP-TO-DATE
> Task :adapter:outbound:objectstorage:processTestResources UP-TO-DATE
> Task :adapter:outbound:objectstorage:testClasses UP-TO-DATE
> Task :adapter:outbound:notification:checkstyleMain
> Task :adapter:outbound:objectstorage:compileObjectStorageAwsQualificationTestJava
> Task :adapter:outbound:objectstorage:processObjectStorageAwsQualificationTestResources NO-SOURCE
> Task :adapter:outbound:objectstorage:objectStorageAwsQualificationTestClasses
> Task :adapter:outbound:objectstorage:checkstyleObjectStorageAwsQualificationTest
> Task :adapter:outbound:objectstorage:checkstyleMain
> Task :adapter:outbound:identifier:spotbugsMain
> Task :adapter:outbound:identifier:spotbugsTest
> Task :adapter:outbound:messaging:spotbugsMain
> Task :adapter:outbound:messaging:spotbugsTest
> Task :adapter:outbound:notification:spotbugsMain
> Task :adapter:outbound:notification:spotbugsTest
> Task :adapter:outbound:objectstorage:compileObjectStorageMinioContractTestJava
> Task :adapter:outbound:objectstorage:processObjectStorageMinioContractTestResources NO-SOURCE
> Task :adapter:outbound:objectstorage:objectStorageMinioContractTestClasses
> Task :adapter:outbound:objectstorage:checkstyleObjectStorageMinioContractTest
> Task :adapter:outbound:objectstorage:compileObjectStorageMinioFaultTestJava
> Task :adapter:outbound:objectstorage:processObjectStorageMinioFaultTestResources NO-SOURCE
> Task :adapter:outbound:objectstorage:objectStorageMinioFaultTestClasses
> Task :adapter:outbound:objectstorage:checkstyleObjectStorageMinioFaultTest
> Task :adapter:outbound:identifier:check
> Task :adapter:outbound:objectstorage:checkstyleTest
> Task :adapter:outbound:objectstorage:spotlessJava
> Task :adapter:outbound:objectstorage:spotlessJavaCheck
> Task :adapter:outbound:objectstorage:spotlessCheck
> Task :adapter:outbound:objectstorage:test UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileJpaPlatformPerformanceTestJava
> Task :adapter:outbound:persistence-jpa:processJpaPlatformPerformanceTestResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:jpaPlatformPerformanceTestClasses
> Task :adapter:outbound:persistence-jpa:checkstyleJpaPlatformPerformanceTest
> Task :adapter:outbound:persistence-jpa:checkstyleMain
> Task :adapter:outbound:objectstorage:spotbugsMain
> Task :adapter:outbound:objectstorage:spotbugsObjectStorageAwsQualificationTest
> Task :adapter:outbound:objectstorage:spotbugsObjectStorageMinioContractTest
> Task :adapter:outbound:objectstorage:spotbugsObjectStorageMinioFaultTest
> Task :adapter:outbound:objectstorage:spotbugsTest
> Task :adapter:outbound:persistence-jpa:compilePostgresqlIntegrationTestJava
> Task :adapter:outbound:persistence-jpa:processPostgresqlIntegrationTestResources
> Task :adapter:outbound:persistence-jpa:postgresqlIntegrationTestClasses
> Task :adapter:outbound:persistence-jpa:compileTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processTestResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:testClasses UP-TO-DATE
> Task :adapter:outbound:notification:check
> Task :adapter:outbound:persistence-jpa:checkstyleTestkit
> Task :adapter:outbound:persistence-jpa:checkstyleTest
> Task :adapter:outbound:persistence-jpa:checkstylePostgresqlIntegrationTest
> Task :adapter:outbound:persistence-jpa:spotlessJava
> Task :adapter:outbound:persistence-jpa:spotlessJavaCheck
> Task :adapter:outbound:persistence-jpa:spotlessCheck
> Task :adapter:outbound:persistence-jpa:test UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:verifyJpaEvidenceHarnessContract
verifyJpaEvidenceHarnessContract: OK — skip, dirty/local R2, and content mutation fail closed.
> Task :adapter:outbound:persistence-mongo:compileTestkitJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processTestkitResources NO-SOURCE
> Task :adapter:outbound:persistence-mongo:testkitClasses UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileMongoPerformanceTestJava
> Task :adapter:outbound:persistence-mongo:processMongoPerformanceTestResources NO-SOURCE
> Task :adapter:outbound:persistence-mongo:mongoPerformanceTestClasses
> Task :adapter:outbound:persistence-mongo:compileTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processTestResources NO-SOURCE
> Task :adapter:outbound:persistence-mongo:testClasses UP-TO-DATE
> Task :adapter:outbound:messaging:check
> Task :adapter:outbound:persistence-mongo:checkstyleMongoPerformanceTest
> Task :adapter:outbound:persistence-jpa:spotbugsJpaPlatformPerformanceTest
> Task :adapter:outbound:persistence-jpa:spotbugsMain
> Task :adapter:outbound:persistence-mongo:checkstyleTestkit
> Task :adapter:outbound:persistence-jpa:spotbugsPostgresqlIntegrationTest
> Task :adapter:outbound:persistence-jpa:spotbugsTest
> Task :adapter:outbound:persistence-jpa:spotbugsTestkit
> Task :adapter:outbound:persistence-mongo:checkstyleMain
> Task :adapter:outbound:persistence-mongo:checkstyleTest
> Task :adapter:outbound:persistence-mongo:mongoStableContractTest
> Task :adapter:outbound:persistence-mongo:spotlessJava
> Task :adapter:outbound:persistence-mongo:spotlessJavaCheck
> Task :adapter:outbound:persistence-mongo:spotlessCheck
> Task :adapter:outbound:persistence-mongo:test UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:verifyMongoApiSurface
verifyMongoApiSurface: OK — the committed public API surface is unchanged.
> Task :adapter:outbound:persistence-mongo:verifyMongoReleaseContractLanes
> Task :adapter:outbound:httpclient:check
> Task :adapter:outbound:objectstorage:check
> Task :adapter:outbound:persistence-mongo:verifyMongoTestLaneDisjointness
> Task :adapter:outbound:support:compileTestJava UP-TO-DATE
> Task :adapter:outbound:support:processTestResources NO-SOURCE
> Task :adapter:outbound:support:testClasses UP-TO-DATE
> Task :adapter:outbound:support:spotlessJava
> Task :adapter:outbound:support:spotlessJavaCheck
> Task :adapter:outbound:support:spotlessCheck
> Task :adapter:outbound:support:test UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:check
> Task :adapter:outbound:support:checkstyleMain
> Task :adapter:outbound:support:checkstyleTest
> Task :adapter:outbound:persistence-mongo:spotbugsMongoPerformanceTest
> Task :adapter:outbound:persistence-mongo:spotbugsMain
> Task :adapter:outbound:persistence-mongo:spotbugsTest
> Task :adapter:outbound:persistence-mongo:spotbugsTestkit
> Task :adapter:outbound:support:spotbugsTest
> Task :adapter:outbound:support:spotbugsMain
> Task :adapter:outbound:support:check
> Task :adapter:outbound:persistence-mongo:check
BUILD SUCCESSFUL in 5m 42s
547 actionable tasks: 117 executed, 430 up-to-date
exit=0
@@ -0,0 +1,265 @@
$ ./gradlew clean compileJava compileTestJava --warning-mode=fail --no-daemon --console=plain
run-at: 2026-08-20T01:18:16Z
To honour the JVM settings for this build a single-use Daemon process will be forked. For more on this, please refer to https://docs.gradle.org/9.0.0/userguide/gradle_daemon.html#sec:disabling_the_daemon in the Gradle documentation.
Daemon will be stopped at the end of the build
> Task :build-logic:extractPluginRequests UP-TO-DATE
> Task :build-logic:generatePluginAdapters UP-TO-DATE
> Task :build-logic:compileJava UP-TO-DATE
> Task :build-logic:compileGroovy UP-TO-DATE
> Task :build-logic:compileGroovyPlugins UP-TO-DATE
> Task :build-logic:pluginDescriptors UP-TO-DATE
> Task :build-logic:processResources UP-TO-DATE
> Task :build-logic:classes UP-TO-DATE
> Task :build-logic:jar UP-TO-DATE
> Task :app-bootstrap:clean
> Task :application-core:clean
> Task :domain-core:clean
> Task :sample-portfolio:clean
> Task :shared-contract:clean
> Task :messaging:messaging-admin-api:clean
> Task :messaging:messaging-admin-runtime:clean
> Task :messaging:messaging-claim-check:clean
> Task :messaging:messaging-cloudevents:clean
> Task :messaging:messaging-core-api:clean
> Task :messaging:messaging-inbox-jdbc-postgresql:clean
> Task :messaging:messaging-kafka:clean
> Task :messaging:messaging-kafka-share-experimental:clean
> Task :messaging:messaging-nats-experimental:clean
> Task :messaging:messaging-observability:clean
> Task :messaging:messaging-outbox-jdbc-postgresql:clean
> Task :messaging:messaging-policy:clean
> Task :messaging:messaging-pulsar-experimental:clean
> Task :messaging:messaging-rabbit:clean
> Task :messaging:messaging-reliability-api:clean
> Task :messaging:messaging-runtime-core:clean
> Task :messaging:messaging-schema-api:clean
> Task :messaging:messaging-schema-avro:clean
> Task :messaging:messaging-schema-json:clean
> Task :messaging:messaging-schema-protobuf:clean
> Task :messaging:messaging-security:clean
> Task :messaging:messaging-spring-boot-starter:clean
> Task :messaging:messaging-spring-cloud-stream-bridge:clean
> Task :messaging:messaging-testkit:clean
> Task :messaging:messaging-transport-spi:clean
> Task :adapter:inbound:graphql:clean
> Task :adapter:inbound:grpc:clean
> Task :adapter:inbound:web:clean
> Task :adapter:inbound:websocket:clean
> Task :adapter:outbound:cache-redis:clean
> Task :adapter:outbound:fileserver:clean
> Task :adapter:outbound:httpclient:clean
> Task :adapter:outbound:identifier:clean
> Task :adapter:outbound:messaging:clean
> Task :adapter:outbound:notification:clean
> Task :adapter:outbound:objectstorage:clean
> Task :adapter:outbound:persistence-jpa:clean
> Task :adapter:outbound:persistence-mongo:clean
> Task :adapter:outbound:support:clean
> Task :shared-contract:compileJava
> Task :shared-contract:processResources
> Task :shared-contract:classes
> Task :shared-contract:jar
> Task :application-core:compileJava
> Task :application-core:processResources NO-SOURCE
> Task :application-core:classes
> Task :application-core:jar
> Task :domain-core:compileJava
> Task :domain-core:processResources NO-SOURCE
> Task :domain-core:classes
> Task :domain-core:jar
> Task :messaging:messaging-core-api:compileJava
> Task :messaging:messaging-observability:compileJava
> Task :messaging:messaging-schema-api:compileJava
> Task :messaging:messaging-policy:compileJava
> Task :messaging:messaging-reliability-api:compileJava
> Task :messaging:messaging-security:compileJava
> Task :messaging:messaging-admin-api:compileJava
> Task :messaging:messaging-transport-spi:compileJava
> Task :messaging:messaging-admin-runtime:compileJava
> Task :messaging:messaging-claim-check:compileJava
> Task :messaging:messaging-cloudevents:compileJava
> Task :messaging:messaging-inbox-jdbc-postgresql:compileJava
> Task :messaging:messaging-kafka:compileJava
> Task :messaging:messaging-outbox-jdbc-postgresql:compileJava
> Task :messaging:messaging-rabbit:compileJava
> Task :messaging:messaging-runtime-core:compileJava
> Task :messaging:messaging-schema-json:compileJava
> Task :messaging:messaging-spring-boot-starter:compileJava
> Task :adapter:inbound:graphql:compileJava
> Task :adapter:inbound:graphql:processResources
> Task :adapter:inbound:graphql:classes
> Task :adapter:inbound:graphql:jar
> Task :adapter:inbound:web:compileJava
> Task :adapter:inbound:web:processResources NO-SOURCE
> Task :adapter:inbound:web:classes
> Task :adapter:inbound:web:jar
> Task :adapter:outbound:support:compileJava
> Task :adapter:outbound:support:processResources NO-SOURCE
> Task :adapter:outbound:support:classes
> Task :adapter:outbound:support:jar
> Task :adapter:outbound:cache-redis:compileJava
> Task :adapter:outbound:cache-redis:processResources
> Task :adapter:outbound:cache-redis:classes
> Task :adapter:outbound:cache-redis:jar
> Task :adapter:outbound:fileserver:compileJava
> Task :adapter:outbound:fileserver:processResources NO-SOURCE
> Task :adapter:outbound:fileserver:classes
> Task :adapter:outbound:fileserver:jar
> Task :adapter:outbound:httpclient:compileJava
> Task :adapter:outbound:httpclient:processResources NO-SOURCE
> Task :adapter:outbound:httpclient:classes
> Task :adapter:outbound:httpclient:jar
> Task :adapter:outbound:identifier:compileJava
> Task :adapter:outbound:identifier:compileGroovy NO-SOURCE
> Task :adapter:outbound:identifier:processResources NO-SOURCE
> Task :adapter:outbound:identifier:classes
> Task :adapter:outbound:identifier:jar
> Task :adapter:outbound:messaging:compileJava
> Task :adapter:outbound:messaging:processResources
> Task :adapter:outbound:messaging:classes
> Task :adapter:outbound:messaging:jar
> Task :adapter:outbound:notification:compileJava
> Task :adapter:outbound:notification:processResources NO-SOURCE
> Task :adapter:outbound:notification:classes
> Task :adapter:outbound:notification:jar
> Task :adapter:outbound:persistence-jpa:compileJava
> Task :adapter:outbound:persistence-jpa:processResources
> Task :adapter:outbound:persistence-jpa:classes
> Task :adapter:outbound:persistence-jpa:jar
> Task :adapter:outbound:persistence-mongo:compileJava
> Task :adapter:outbound:persistence-mongo:processResources
> Task :adapter:outbound:persistence-mongo:classes
> Task :adapter:outbound:persistence-mongo:jar
> Task :app-bootstrap:compileJava
> Task :sample-portfolio:compileJava
> Task :messaging:messaging-kafka-share-experimental:compileJava
> Task :messaging:messaging-nats-experimental:compileJava
> Task :messaging:messaging-pulsar-experimental:compileJava
> Task :messaging:messaging-schema-avro:compileJava
> Task :messaging:messaging-schema-protobuf:compileJava
> Task :messaging:messaging-spring-cloud-stream-bridge:compileJava
> Task :messaging:messaging-testkit:compileJava
> Task :adapter:inbound:grpc:compileJava
> Task :adapter:inbound:websocket:compileJava
> Task :adapter:outbound:objectstorage:compileJava
> Task :app-bootstrap:processResources
> Task :app-bootstrap:classes
> Task :sample-portfolio:processResources
> Task :sample-portfolio:classes
> Task :sample-portfolio:jar
> Task :adapter:outbound:persistence-jpa:compileTestkitJava
> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:testkitClasses
> Task :adapter:outbound:persistence-jpa:testkitJar
> Task :app-bootstrap:compileTestJava
> Task :application-core:compileTestJava
> Task :domain-core:compileTestJava NO-SOURCE
> Task :sample-portfolio:compileTestJava
> Task :shared-contract:compileTestJava
> Task :messaging:messaging-admin-api:processResources NO-SOURCE
> Task :messaging:messaging-admin-api:classes
> Task :messaging:messaging-admin-api:compileTestJava
> Task :messaging:messaging-admin-runtime:processResources NO-SOURCE
> Task :messaging:messaging-admin-runtime:classes
> Task :messaging:messaging-admin-runtime:compileTestJava
> Task :messaging:messaging-claim-check:processResources NO-SOURCE
> Task :messaging:messaging-claim-check:classes
> Task :messaging:messaging-claim-check:compileTestJava
> Task :messaging:messaging-cloudevents:processResources NO-SOURCE
> Task :messaging:messaging-cloudevents:classes
> Task :messaging:messaging-cloudevents:compileTestJava
> Task :messaging:messaging-core-api:processResources NO-SOURCE
> Task :messaging:messaging-core-api:classes
> Task :messaging:messaging-core-api:compileTestJava
> Task :messaging:messaging-inbox-jdbc-postgresql:processResources
> Task :messaging:messaging-inbox-jdbc-postgresql:classes
> Task :messaging:messaging-inbox-jdbc-postgresql:compileTestJava
> Task :messaging:messaging-kafka:processResources NO-SOURCE
> Task :messaging:messaging-kafka:classes
> Task :messaging:messaging-kafka:compileTestJava
> Task :messaging:messaging-kafka-share-experimental:processResources NO-SOURCE
> Task :messaging:messaging-kafka-share-experimental:classes
> Task :messaging:messaging-kafka-share-experimental:compileTestJava
> Task :messaging:messaging-nats-experimental:processResources NO-SOURCE
> Task :messaging:messaging-nats-experimental:classes
> Task :messaging:messaging-nats-experimental:compileTestJava
> Task :messaging:messaging-observability:processResources NO-SOURCE
> Task :messaging:messaging-observability:classes
> Task :messaging:messaging-observability:compileTestJava
> Task :messaging:messaging-outbox-jdbc-postgresql:processResources
> Task :messaging:messaging-outbox-jdbc-postgresql:classes
> Task :messaging:messaging-outbox-jdbc-postgresql:compileTestJava
> Task :messaging:messaging-policy:processResources NO-SOURCE
> Task :messaging:messaging-policy:classes
> Task :messaging:messaging-policy:compileTestJava
> Task :messaging:messaging-pulsar-experimental:processResources NO-SOURCE
> Task :messaging:messaging-pulsar-experimental:classes
> Task :messaging:messaging-pulsar-experimental:compileTestJava
> Task :messaging:messaging-rabbit:processResources NO-SOURCE
> Task :messaging:messaging-rabbit:classes
> Task :messaging:messaging-rabbit:compileTestJava
> Task :messaging:messaging-reliability-api:processResources NO-SOURCE
> Task :messaging:messaging-reliability-api:classes
> Task :messaging:messaging-reliability-api:compileTestJava NO-SOURCE
> Task :messaging:messaging-runtime-core:processResources NO-SOURCE
> Task :messaging:messaging-runtime-core:classes
> Task :messaging:messaging-runtime-core:compileTestJava
> Task :messaging:messaging-schema-api:processResources NO-SOURCE
> Task :messaging:messaging-schema-api:classes
> Task :messaging:messaging-schema-api:compileTestJava
> Task :messaging:messaging-schema-avro:processResources NO-SOURCE
> Task :messaging:messaging-schema-avro:classes
> Task :messaging:messaging-schema-avro:compileTestJava
> Task :messaging:messaging-schema-json:processResources NO-SOURCE
> Task :messaging:messaging-schema-json:classes
> Task :messaging:messaging-schema-json:compileTestJava
> Task :messaging:messaging-schema-protobuf:processResources NO-SOURCE
> Task :messaging:messaging-schema-protobuf:classes
> Task :messaging:messaging-schema-protobuf:compileTestJava
> Task :messaging:messaging-security:processResources NO-SOURCE
> Task :messaging:messaging-security:classes
> Task :messaging:messaging-security:compileTestJava
> Task :messaging:messaging-spring-boot-starter:processResources
> Task :messaging:messaging-spring-boot-starter:classes
> Task :messaging:messaging-spring-boot-starter:compileTestJava
> Task :messaging:messaging-spring-cloud-stream-bridge:processResources NO-SOURCE
> Task :messaging:messaging-spring-cloud-stream-bridge:classes
> Task :messaging:messaging-spring-cloud-stream-bridge:compileTestJava
> Task :messaging:messaging-testkit:processResources
> Task :messaging:messaging-testkit:classes
> Task :messaging:messaging-testkit:compileTestJava
> Task :messaging:messaging-transport-spi:processResources NO-SOURCE
> Task :messaging:messaging-transport-spi:classes
> Task :messaging:messaging-transport-spi:compileTestJava
> Task :adapter:inbound:graphql:compileTestFixturesJava
> Task :adapter:inbound:graphql:compileTestJava
> Task :adapter:inbound:grpc:processResources NO-SOURCE
> Task :adapter:inbound:grpc:classes
> Task :adapter:inbound:grpc:compileTestJava
> Task :adapter:inbound:web:compileTestJava
> Task :adapter:inbound:websocket:processResources NO-SOURCE
> Task :adapter:inbound:websocket:classes
> Task :adapter:inbound:websocket:compileTestJava
> Task :adapter:outbound:cache-redis:compileTestJava
> Task :adapter:outbound:fileserver:compileTestJava
> Task :adapter:outbound:httpclient:compileTestkitJava
> Task :adapter:outbound:httpclient:processTestkitResources NO-SOURCE
> Task :adapter:outbound:httpclient:testkitClasses
> Task :adapter:outbound:httpclient:compileTestJava
> Task :adapter:outbound:identifier:compileTestJava
> Task :adapter:outbound:messaging:compileTestJava
> Task :adapter:outbound:notification:compileTestJava
> Task :adapter:outbound:objectstorage:processResources NO-SOURCE
> Task :adapter:outbound:objectstorage:classes
> Task :adapter:outbound:objectstorage:compileTestJava
> Task :adapter:outbound:persistence-jpa:compileTestJava
> Task :adapter:outbound:persistence-mongo:compileTestkitJava
> Task :adapter:outbound:persistence-mongo:processTestkitResources NO-SOURCE
> Task :adapter:outbound:persistence-mongo:testkitClasses
> Task :adapter:outbound:persistence-mongo:compileTestJava
> Task :adapter:outbound:support:compileTestJava
BUILD SUCCESSFUL in 4m 48s
170 actionable tasks: 162 executed, 8 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html
exit=0
@@ -0,0 +1,378 @@
$ ./gradlew test --warning-mode=fail --no-daemon --console=plain
run-at: 2026-08-20T01:33:26Z
To honour the JVM settings for this build a single-use Daemon process will be forked. For more on this, please refer to https://docs.gradle.org/9.0.0/userguide/gradle_daemon.html#sec:disabling_the_daemon in the Gradle documentation.
Daemon will be stopped at the end of the build
> Task :build-logic:extractPluginRequests UP-TO-DATE
> Task :build-logic:generatePluginAdapters UP-TO-DATE
> Task :build-logic:compileJava UP-TO-DATE
> Task :build-logic:compileGroovy UP-TO-DATE
> Task :build-logic:compileGroovyPlugins UP-TO-DATE
> Task :build-logic:pluginDescriptors UP-TO-DATE
> Task :build-logic:processResources UP-TO-DATE
> Task :build-logic:classes UP-TO-DATE
> Task :build-logic:jar UP-TO-DATE
> Task :shared-contract:compileJava UP-TO-DATE
> Task :shared-contract:processResources UP-TO-DATE
> Task :shared-contract:classes UP-TO-DATE
> Task :shared-contract:jar UP-TO-DATE
> Task :application-core:compileJava UP-TO-DATE
> Task :application-core:processResources NO-SOURCE
> Task :application-core:classes UP-TO-DATE
> Task :application-core:jar UP-TO-DATE
> Task :domain-core:compileJava UP-TO-DATE
> Task :domain-core:processResources NO-SOURCE
> Task :domain-core:classes UP-TO-DATE
> Task :domain-core:jar UP-TO-DATE
> Task :messaging:messaging-core-api:compileJava UP-TO-DATE
> Task :messaging:messaging-observability:compileJava UP-TO-DATE
> Task :messaging:messaging-schema-api:compileJava UP-TO-DATE
> Task :messaging:messaging-policy:compileJava UP-TO-DATE
> Task :messaging:messaging-reliability-api:compileJava UP-TO-DATE
> Task :messaging:messaging-security:compileJava UP-TO-DATE
> Task :messaging:messaging-admin-api:compileJava UP-TO-DATE
> Task :messaging:messaging-transport-spi:compileJava UP-TO-DATE
> Task :messaging:messaging-admin-runtime:compileJava UP-TO-DATE
> Task :messaging:messaging-claim-check:compileJava UP-TO-DATE
> Task :messaging:messaging-cloudevents:compileJava UP-TO-DATE
> Task :messaging:messaging-inbox-jdbc-postgresql:compileJava UP-TO-DATE
> Task :messaging:messaging-kafka:compileJava UP-TO-DATE
> Task :messaging:messaging-outbox-jdbc-postgresql:compileJava UP-TO-DATE
> Task :messaging:messaging-rabbit:compileJava UP-TO-DATE
> Task :messaging:messaging-runtime-core:compileJava UP-TO-DATE
> Task :messaging:messaging-schema-json:compileJava UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:compileJava UP-TO-DATE
> Task :adapter:inbound:graphql:compileJava UP-TO-DATE
> Task :adapter:inbound:graphql:processResources UP-TO-DATE
> Task :adapter:inbound:graphql:classes UP-TO-DATE
> Task :adapter:inbound:graphql:jar UP-TO-DATE
> Task :adapter:inbound:web:compileJava UP-TO-DATE
> Task :adapter:inbound:web:processResources NO-SOURCE
> Task :adapter:inbound:web:classes UP-TO-DATE
> Task :adapter:inbound:web:jar UP-TO-DATE
> Task :adapter:outbound:support:compileJava UP-TO-DATE
> Task :adapter:outbound:support:processResources NO-SOURCE
> Task :adapter:outbound:support:classes UP-TO-DATE
> Task :adapter:outbound:support:jar UP-TO-DATE
> Task :adapter:outbound:cache-redis:compileJava UP-TO-DATE
> Task :adapter:outbound:cache-redis:processResources UP-TO-DATE
> Task :adapter:outbound:cache-redis:classes UP-TO-DATE
> Task :adapter:outbound:cache-redis:jar UP-TO-DATE
> Task :adapter:outbound:fileserver:compileJava UP-TO-DATE
> Task :adapter:outbound:fileserver:processResources NO-SOURCE
> Task :adapter:outbound:fileserver:classes UP-TO-DATE
> Task :adapter:outbound:fileserver:jar UP-TO-DATE
> Task :adapter:outbound:httpclient:compileJava UP-TO-DATE
> Task :adapter:outbound:httpclient:processResources NO-SOURCE
> Task :adapter:outbound:httpclient:classes UP-TO-DATE
> Task :adapter:outbound:httpclient:jar UP-TO-DATE
> Task :adapter:outbound:identifier:compileJava UP-TO-DATE
> Task :adapter:outbound:identifier:compileGroovy NO-SOURCE
> Task :adapter:outbound:identifier:processResources NO-SOURCE
> Task :adapter:outbound:identifier:classes UP-TO-DATE
> Task :adapter:outbound:identifier:jar UP-TO-DATE
> Task :adapter:outbound:messaging:compileJava UP-TO-DATE
> Task :adapter:outbound:messaging:processResources UP-TO-DATE
> Task :adapter:outbound:messaging:classes UP-TO-DATE
> Task :adapter:outbound:messaging:jar UP-TO-DATE
> Task :adapter:outbound:notification:compileJava UP-TO-DATE
> Task :adapter:outbound:notification:processResources NO-SOURCE
> Task :adapter:outbound:notification:classes UP-TO-DATE
> Task :adapter:outbound:notification:jar UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:jar UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processResources UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:classes UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:jar UP-TO-DATE
> Task :app-bootstrap:compileJava UP-TO-DATE
> Task :app-bootstrap:processResources UP-TO-DATE
> Task :app-bootstrap:classes UP-TO-DATE
> Task :sample-portfolio:compileJava UP-TO-DATE
> Task :sample-portfolio:processResources UP-TO-DATE
> Task :sample-portfolio:classes UP-TO-DATE
> Task :sample-portfolio:jar UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileTestkitJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:testkitClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:testkitJar UP-TO-DATE
> Task :app-bootstrap:compileTestJava UP-TO-DATE
> Task :app-bootstrap:runtimeClasspathManifest UP-TO-DATE
> Task :app-bootstrap:processTestResources UP-TO-DATE
> Task :app-bootstrap:testClasses UP-TO-DATE
> Task :messaging:messaging-admin-api:processResources NO-SOURCE
> Task :messaging:messaging-admin-api:classes UP-TO-DATE
> Task :messaging:messaging-admin-api:jar UP-TO-DATE
> Task :messaging:messaging-admin-runtime:processResources NO-SOURCE
> Task :messaging:messaging-admin-runtime:classes UP-TO-DATE
> Task :messaging:messaging-admin-runtime:jar UP-TO-DATE
> Task :messaging:messaging-claim-check:processResources NO-SOURCE
> Task :messaging:messaging-claim-check:classes UP-TO-DATE
> Task :messaging:messaging-claim-check:jar UP-TO-DATE
> Task :messaging:messaging-cloudevents:processResources NO-SOURCE
> Task :messaging:messaging-cloudevents:classes UP-TO-DATE
> Task :messaging:messaging-cloudevents:jar UP-TO-DATE
> Task :messaging:messaging-core-api:processResources NO-SOURCE
> Task :messaging:messaging-core-api:classes UP-TO-DATE
> Task :messaging:messaging-core-api:jar UP-TO-DATE
> Task :messaging:messaging-inbox-jdbc-postgresql:processResources UP-TO-DATE
> Task :messaging:messaging-inbox-jdbc-postgresql:classes UP-TO-DATE
> Task :messaging:messaging-inbox-jdbc-postgresql:jar UP-TO-DATE
> Task :messaging:messaging-kafka:processResources NO-SOURCE
> Task :messaging:messaging-kafka:classes UP-TO-DATE
> Task :messaging:messaging-kafka:jar UP-TO-DATE
> Task :messaging:messaging-observability:processResources NO-SOURCE
> Task :messaging:messaging-observability:classes UP-TO-DATE
> Task :messaging:messaging-observability:jar UP-TO-DATE
> Task :messaging:messaging-outbox-jdbc-postgresql:processResources UP-TO-DATE
> Task :messaging:messaging-outbox-jdbc-postgresql:classes UP-TO-DATE
> Task :messaging:messaging-outbox-jdbc-postgresql:jar UP-TO-DATE
> Task :messaging:messaging-policy:processResources NO-SOURCE
> Task :messaging:messaging-policy:classes UP-TO-DATE
> Task :messaging:messaging-policy:jar UP-TO-DATE
> Task :messaging:messaging-rabbit:processResources NO-SOURCE
> Task :messaging:messaging-rabbit:classes UP-TO-DATE
> Task :messaging:messaging-rabbit:jar UP-TO-DATE
> Task :messaging:messaging-reliability-api:processResources NO-SOURCE
> Task :messaging:messaging-reliability-api:classes UP-TO-DATE
> Task :messaging:messaging-reliability-api:jar UP-TO-DATE
> Task :messaging:messaging-runtime-core:processResources NO-SOURCE
> Task :messaging:messaging-runtime-core:classes UP-TO-DATE
> Task :messaging:messaging-runtime-core:jar UP-TO-DATE
> Task :messaging:messaging-schema-api:processResources NO-SOURCE
> Task :messaging:messaging-schema-api:classes UP-TO-DATE
> Task :messaging:messaging-schema-api:jar UP-TO-DATE
> Task :messaging:messaging-schema-json:processResources NO-SOURCE
> Task :messaging:messaging-schema-json:classes UP-TO-DATE
> Task :messaging:messaging-schema-json:jar UP-TO-DATE
> Task :messaging:messaging-security:processResources NO-SOURCE
> Task :messaging:messaging-security:classes UP-TO-DATE
> Task :messaging:messaging-security:jar UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:processResources UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:classes UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:jar UP-TO-DATE
> Task :messaging:messaging-transport-spi:processResources NO-SOURCE
> Task :messaging:messaging-transport-spi:classes UP-TO-DATE
> Task :messaging:messaging-transport-spi:jar UP-TO-DATE
> Task :adapter:outbound:objectstorage:compileJava UP-TO-DATE
> Task :adapter:outbound:objectstorage:processResources NO-SOURCE
> Task :adapter:outbound:objectstorage:classes UP-TO-DATE
> Task :adapter:outbound:objectstorage:jar UP-TO-DATE
> Task :app-bootstrap:test UP-TO-DATE
> Task :application-core:compileTestJava UP-TO-DATE
> Task :application-core:processTestResources NO-SOURCE
> Task :application-core:testClasses UP-TO-DATE
> Task :application-core:test UP-TO-DATE
> Task :domain-core:compileTestJava NO-SOURCE
> Task :domain-core:processTestResources NO-SOURCE
> Task :domain-core:testClasses UP-TO-DATE
> Task :domain-core:test NO-SOURCE
> Task :sample-portfolio:compileTestJava UP-TO-DATE
> Task :sample-portfolio:processTestResources UP-TO-DATE
> Task :sample-portfolio:testClasses UP-TO-DATE
> Task :sample-portfolio:test UP-TO-DATE
> Task :shared-contract:compileTestJava UP-TO-DATE
> Task :shared-contract:processTestResources NO-SOURCE
> Task :shared-contract:testClasses UP-TO-DATE
> Task :shared-contract:test UP-TO-DATE
> Task :messaging:messaging-admin-api:compileTestJava UP-TO-DATE
> Task :messaging:messaging-admin-api:processTestResources NO-SOURCE
> Task :messaging:messaging-admin-api:testClasses UP-TO-DATE
> Task :messaging:messaging-admin-api:test UP-TO-DATE
> Task :messaging:messaging-admin-runtime:compileTestJava UP-TO-DATE
> Task :messaging:messaging-admin-runtime:processTestResources NO-SOURCE
> Task :messaging:messaging-admin-runtime:testClasses UP-TO-DATE
> Task :messaging:messaging-admin-runtime:test UP-TO-DATE
> Task :messaging:messaging-claim-check:compileTestJava UP-TO-DATE
> Task :messaging:messaging-claim-check:processTestResources NO-SOURCE
> Task :messaging:messaging-claim-check:testClasses UP-TO-DATE
> Task :messaging:messaging-claim-check:test UP-TO-DATE
> Task :messaging:messaging-cloudevents:compileTestJava UP-TO-DATE
> Task :messaging:messaging-cloudevents:processTestResources NO-SOURCE
> Task :messaging:messaging-cloudevents:testClasses UP-TO-DATE
> Task :messaging:messaging-cloudevents:test UP-TO-DATE
> Task :messaging:messaging-core-api:compileTestJava UP-TO-DATE
> Task :messaging:messaging-core-api:processTestResources NO-SOURCE
> Task :messaging:messaging-core-api:testClasses UP-TO-DATE
> Task :messaging:messaging-core-api:test UP-TO-DATE
> Task :messaging:messaging-testkit:compileJava UP-TO-DATE
> Task :messaging:messaging-inbox-jdbc-postgresql:compileTestJava UP-TO-DATE
> Task :messaging:messaging-inbox-jdbc-postgresql:processTestResources NO-SOURCE
> Task :messaging:messaging-inbox-jdbc-postgresql:testClasses UP-TO-DATE
> Task :messaging:messaging-testkit:processResources UP-TO-DATE
> Task :messaging:messaging-testkit:classes UP-TO-DATE
> Task :messaging:messaging-testkit:jar UP-TO-DATE
> Task :messaging:messaging-inbox-jdbc-postgresql:test UP-TO-DATE
> Task :messaging:messaging-kafka:compileTestJava UP-TO-DATE
> Task :messaging:messaging-kafka:processTestResources NO-SOURCE
> Task :messaging:messaging-kafka:testClasses UP-TO-DATE
> Task :messaging:messaging-kafka:test UP-TO-DATE
> Task :messaging:messaging-kafka-share-experimental:compileJava UP-TO-DATE
> Task :messaging:messaging-kafka-share-experimental:processResources NO-SOURCE
> Task :messaging:messaging-kafka-share-experimental:classes UP-TO-DATE
> Task :messaging:messaging-kafka-share-experimental:compileTestJava UP-TO-DATE
> Task :messaging:messaging-kafka-share-experimental:processTestResources NO-SOURCE
> Task :messaging:messaging-kafka-share-experimental:testClasses UP-TO-DATE
> Task :messaging:messaging-kafka-share-experimental:test UP-TO-DATE
> Task :messaging:messaging-nats-experimental:compileJava UP-TO-DATE
> Task :messaging:messaging-nats-experimental:processResources NO-SOURCE
> Task :messaging:messaging-nats-experimental:classes UP-TO-DATE
> Task :messaging:messaging-nats-experimental:compileTestJava UP-TO-DATE
> Task :messaging:messaging-nats-experimental:processTestResources NO-SOURCE
> Task :messaging:messaging-nats-experimental:testClasses UP-TO-DATE
> Task :messaging:messaging-nats-experimental:test UP-TO-DATE
> Task :messaging:messaging-observability:compileTestJava UP-TO-DATE
> Task :messaging:messaging-observability:processTestResources NO-SOURCE
> Task :messaging:messaging-observability:testClasses UP-TO-DATE
> Task :messaging:messaging-observability:test UP-TO-DATE
> Task :messaging:messaging-outbox-jdbc-postgresql:compileTestJava UP-TO-DATE
> Task :messaging:messaging-outbox-jdbc-postgresql:processTestResources NO-SOURCE
> Task :messaging:messaging-outbox-jdbc-postgresql:testClasses UP-TO-DATE
> Task :messaging:messaging-outbox-jdbc-postgresql:test UP-TO-DATE
> Task :messaging:messaging-policy:compileTestJava UP-TO-DATE
> Task :messaging:messaging-policy:processTestResources NO-SOURCE
> Task :messaging:messaging-policy:testClasses UP-TO-DATE
> Task :messaging:messaging-policy:test UP-TO-DATE
> Task :messaging:messaging-pulsar-experimental:compileJava UP-TO-DATE
> Task :messaging:messaging-pulsar-experimental:processResources NO-SOURCE
> Task :messaging:messaging-pulsar-experimental:classes UP-TO-DATE
> Task :messaging:messaging-pulsar-experimental:compileTestJava UP-TO-DATE
> Task :messaging:messaging-pulsar-experimental:processTestResources NO-SOURCE
> Task :messaging:messaging-pulsar-experimental:testClasses UP-TO-DATE
> Task :messaging:messaging-pulsar-experimental:test UP-TO-DATE
> Task :messaging:messaging-rabbit:compileTestJava UP-TO-DATE
> Task :messaging:messaging-rabbit:processTestResources NO-SOURCE
> Task :messaging:messaging-rabbit:testClasses UP-TO-DATE
> Task :messaging:messaging-rabbit:test UP-TO-DATE
> Task :messaging:messaging-reliability-api:compileTestJava NO-SOURCE
> Task :messaging:messaging-reliability-api:processTestResources NO-SOURCE
> Task :messaging:messaging-reliability-api:testClasses UP-TO-DATE
> Task :messaging:messaging-reliability-api:test NO-SOURCE
> Task :messaging:messaging-runtime-core:compileTestJava UP-TO-DATE
> Task :messaging:messaging-runtime-core:processTestResources NO-SOURCE
> Task :messaging:messaging-runtime-core:testClasses UP-TO-DATE
> Task :messaging:messaging-runtime-core:test UP-TO-DATE
> Task :messaging:messaging-schema-api:compileTestJava UP-TO-DATE
> Task :messaging:messaging-schema-api:processTestResources NO-SOURCE
> Task :messaging:messaging-schema-api:testClasses UP-TO-DATE
> Task :messaging:messaging-schema-api:test UP-TO-DATE
> Task :messaging:messaging-schema-avro:compileJava UP-TO-DATE
> Task :messaging:messaging-schema-avro:processResources NO-SOURCE
> Task :messaging:messaging-schema-avro:classes UP-TO-DATE
> Task :messaging:messaging-schema-avro:compileTestJava UP-TO-DATE
> Task :messaging:messaging-schema-avro:processTestResources UP-TO-DATE
> Task :messaging:messaging-schema-avro:testClasses UP-TO-DATE
> Task :messaging:messaging-schema-avro:test UP-TO-DATE
> Task :messaging:messaging-schema-json:compileTestJava UP-TO-DATE
> Task :messaging:messaging-schema-json:processTestResources NO-SOURCE
> Task :messaging:messaging-schema-json:testClasses UP-TO-DATE
> Task :messaging:messaging-schema-json:test UP-TO-DATE
> Task :messaging:messaging-schema-protobuf:compileJava UP-TO-DATE
> Task :messaging:messaging-schema-protobuf:processResources NO-SOURCE
> Task :messaging:messaging-schema-protobuf:classes UP-TO-DATE
> Task :messaging:messaging-schema-protobuf:compileTestJava UP-TO-DATE
> Task :messaging:messaging-schema-protobuf:processTestResources NO-SOURCE
> Task :messaging:messaging-schema-protobuf:testClasses UP-TO-DATE
> Task :messaging:messaging-schema-protobuf:test UP-TO-DATE
> Task :messaging:messaging-security:compileTestJava UP-TO-DATE
> Task :messaging:messaging-security:processTestResources NO-SOURCE
> Task :messaging:messaging-security:testClasses UP-TO-DATE
> Task :messaging:messaging-security:test UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:compileTestJava UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:processTestResources UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:testClasses UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:test UP-TO-DATE
> Task :messaging:messaging-spring-cloud-stream-bridge:compileJava UP-TO-DATE
> Task :messaging:messaging-spring-cloud-stream-bridge:processResources NO-SOURCE
> Task :messaging:messaging-spring-cloud-stream-bridge:classes UP-TO-DATE
> Task :messaging:messaging-spring-cloud-stream-bridge:compileTestJava UP-TO-DATE
> Task :messaging:messaging-spring-cloud-stream-bridge:processTestResources NO-SOURCE
> Task :messaging:messaging-spring-cloud-stream-bridge:testClasses UP-TO-DATE
> Task :messaging:messaging-spring-cloud-stream-bridge:test UP-TO-DATE
> Task :messaging:messaging-testkit:compileTestJava UP-TO-DATE
> Task :messaging:messaging-testkit:processTestResources NO-SOURCE
> Task :messaging:messaging-testkit:testClasses UP-TO-DATE
> Task :messaging:messaging-testkit:test UP-TO-DATE
> Task :messaging:messaging-transport-spi:compileTestJava UP-TO-DATE
> Task :messaging:messaging-transport-spi:processTestResources NO-SOURCE
> Task :messaging:messaging-transport-spi:testClasses UP-TO-DATE
> Task :messaging:messaging-transport-spi:test UP-TO-DATE
> Task :adapter:inbound:graphql:compileTestFixturesJava UP-TO-DATE
> Task :adapter:inbound:graphql:processTestFixturesResources NO-SOURCE
> Task :adapter:inbound:graphql:testFixturesClasses UP-TO-DATE
> Task :adapter:inbound:graphql:testFixturesJar UP-TO-DATE
> Task :adapter:inbound:graphql:compileTestJava UP-TO-DATE
> Task :adapter:inbound:graphql:processTestResources UP-TO-DATE
> Task :adapter:inbound:graphql:testClasses UP-TO-DATE
> Task :adapter:inbound:graphql:test UP-TO-DATE
> Task :adapter:inbound:grpc:compileJava UP-TO-DATE
> Task :adapter:inbound:grpc:processResources NO-SOURCE
> Task :adapter:inbound:grpc:classes UP-TO-DATE
> Task :adapter:inbound:grpc:compileTestJava UP-TO-DATE
> Task :adapter:inbound:grpc:processTestResources NO-SOURCE
> Task :adapter:inbound:grpc:testClasses UP-TO-DATE
> Task :adapter:inbound:grpc:test UP-TO-DATE
> Task :adapter:inbound:web:compileTestJava UP-TO-DATE
> Task :adapter:inbound:web:processTestResources NO-SOURCE
> Task :adapter:inbound:web:testClasses UP-TO-DATE
> Task :adapter:inbound:web:test UP-TO-DATE
> Task :adapter:inbound:websocket:compileJava UP-TO-DATE
> Task :adapter:inbound:websocket:processResources NO-SOURCE
> Task :adapter:inbound:websocket:classes UP-TO-DATE
> Task :adapter:inbound:websocket:compileTestJava UP-TO-DATE
> Task :adapter:inbound:websocket:processTestResources NO-SOURCE
> Task :adapter:inbound:websocket:testClasses UP-TO-DATE
> Task :adapter:inbound:websocket:test UP-TO-DATE
> Task :adapter:outbound:cache-redis:compileTestJava UP-TO-DATE
> Task :adapter:outbound:cache-redis:processTestResources UP-TO-DATE
> Task :adapter:outbound:cache-redis:testClasses UP-TO-DATE
> Task :adapter:outbound:cache-redis:test UP-TO-DATE
> Task :adapter:outbound:fileserver:compileTestJava UP-TO-DATE
> Task :adapter:outbound:fileserver:processTestResources NO-SOURCE
> Task :adapter:outbound:fileserver:testClasses UP-TO-DATE
> Task :adapter:outbound:fileserver:test UP-TO-DATE
> Task :adapter:outbound:httpclient:compileTestkitJava UP-TO-DATE
> Task :adapter:outbound:httpclient:processTestkitResources NO-SOURCE
> Task :adapter:outbound:httpclient:testkitClasses UP-TO-DATE
> Task :adapter:outbound:httpclient:compileTestJava UP-TO-DATE
> Task :adapter:outbound:httpclient:processTestResources NO-SOURCE
> Task :adapter:outbound:httpclient:testClasses UP-TO-DATE
> Task :adapter:outbound:httpclient:test UP-TO-DATE
> Task :adapter:outbound:identifier:compileTestJava UP-TO-DATE
> Task :adapter:outbound:identifier:compileTestGroovy UP-TO-DATE
> Task :adapter:outbound:identifier:processTestResources NO-SOURCE
> Task :adapter:outbound:identifier:testClasses UP-TO-DATE
> Task :adapter:outbound:identifier:test UP-TO-DATE
> Task :adapter:outbound:messaging:compileTestJava UP-TO-DATE
> Task :adapter:outbound:messaging:processTestResources UP-TO-DATE
> Task :adapter:outbound:messaging:testClasses UP-TO-DATE
> Task :adapter:outbound:messaging:test UP-TO-DATE
> Task :adapter:outbound:notification:compileTestJava UP-TO-DATE
> Task :adapter:outbound:notification:processTestResources UP-TO-DATE
> Task :adapter:outbound:notification:testClasses UP-TO-DATE
> Task :adapter:outbound:notification:test
> Task :adapter:outbound:objectstorage:compileTestJava UP-TO-DATE
> Task :adapter:outbound:objectstorage:processTestResources
> Task :adapter:outbound:objectstorage:testClasses
> Task :adapter:outbound:objectstorage:test
> Task :adapter:outbound:persistence-jpa:compileTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processTestResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:testClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:test
> Task :adapter:outbound:persistence-mongo:compileTestkitJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processTestkitResources NO-SOURCE
> Task :adapter:outbound:persistence-mongo:testkitClasses UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processTestResources NO-SOURCE
> Task :adapter:outbound:persistence-mongo:testClasses UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:test
> Task :adapter:outbound:support:compileTestJava UP-TO-DATE
> Task :adapter:outbound:support:processTestResources NO-SOURCE
> Task :adapter:outbound:support:testClasses UP-TO-DATE
> Task :adapter:outbound:support:test
BUILD SUCCESSFUL in 2m 38s
200 actionable tasks: 6 executed, 194 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html
exit=0
@@ -0,0 +1,54 @@
# Task 1 — the Wave 0 red set is empty, and the lanes are removed
## Command
```bash
cd src
./gradlew wave0RedReport --console=plain --no-daemon
```
`BUILD SUCCESSFUL in 43s` — but that says nothing on its own, and this is the important part of the
evidence rather than a caveat to it. Both `wave0Red` lanes set `ignoreFailures = true`, because they
were reports rather than gates: their job was to answer "what is still red from the baseline?", not
to fail a build. A green exit code from a report is not a claim about the tests.
## What the results actually say
```
app-bootstrap/build/test-results/wave0Red: 0 classes, 0 tests, 0 failures, 0 skipped
messaging/messaging-observability/build/test-results/wave0Red: 0 classes, 0 tests, 0 failures, 0 skipped
```
Zero tests, because no test carries the tag any more:
```bash
grep -rc 'wave0-red' $(git ls-files '*.java') # no match in any tracked Java source
```
The three Wave 0 characterizations — the full-`test` scanner failure, the five-switch-off bean and
resource inventory, and the local/dev boot and Compose merge reproductions — became ordinary tests as
the waves that fixed them landed, and their `@Tag("wave0-red")` markers came off with them. The red
set is empty by the only measure that matters: there is nothing left tagged.
## Why the lanes are deleted rather than kept
Two reasons, and the second is the one that generalises.
1. The plan says so, and its reason holds: a permanent lane for an empty set is a lane that stops
being read.
2. These lanes are the exact shape the Wave 5 lane convention exists to refuse. A tag filter that
matches nothing does not fail — `failOnNoDiscoveredTests` applies to discovery and a tag excludes
after discovery — so the lane runs, executes zero tests, and reports success. Here that is
harmless, because the lanes are reports and their emptiness is the answer. But leaving two
hand-rolled lanes in that shape, outside the convention that would have refused them, is leaving
a template for the next lane somebody copies.
Removed:
- `src/build.gradle` — the `wave0RedReport` aggregate
- `src/app-bootstrap/build.gradle` — the `wave0Red` lane and the `test { excludeTags 'wave0-red' }`
- `src/messaging/messaging-observability/build.gradle` — the same pair
The `excludeTags` removal matters as much as the lane removal: it was what kept tagged
characterizations out of the ordinary suite. With no tagged test left it is inert, and leaving it
would silently exclude any future test that reused the tag.
@@ -0,0 +1,28 @@
$ ./gradlew wave0RedReport --console=plain --no-daemon
run-at: 2026-08-20 (Wave 6 execution pass)
FAILURE: Build failed with an exception.
* What went wrong:
Task 'wave0RedReport' not found in root project 'ca-skeleton' and its subprojects.
exit=1
--------------------------------------------------------------------------------
READ THIS BEFORE TREATING THE LINE ABOVE AS A REGRESSION.
This is the expected end state, not a failure. Task 1's checkbox is "confirm the report is empty,
*then delete the lanes and the aggregate*". The deletion happened, so the task it names no longer
exists and the command can no longer run. `grep -rn wave0Red src --include=*.gradle` returns
nothing, which is the same fact from the other direction.
The substantive evidence — the empty red set measured while the lanes still existed — is in
`task1-wave0-red-set.md` beside this file: 0 classes / 0 tests / 0 failures in both lanes, with the
note that those lanes ran `ignoreFailures = true` and so a green exit code from them was never the
claim.
Provenance note: this file previously held the raw log of that earlier successful run. It was
overwritten during the Wave 6 execution pass by re-running the command under the same filename
without checking what was already there. The original log is not recoverable (this directory is
untracked). What was lost is the raw transcript; what the checkbox depends on survives in the
`.md` above, which was written from it.
@@ -0,0 +1,59 @@
# Task 2 — Wave 2 Task B5, the three ghost release lanes
Wave 2 offered two outcomes for `mongoShardedTest`, `mongoAtlasTest` and `mongoKmsTest`: implement
them, or demote them and stop describing an unrunnable gate. **(b) Demote was taken**, and it is
reflected in all three places the plan names.
## `src/config/mongodb/release-contracts.json`
All three moved out of the Stable blocking set into `experimental_contracts`, each carrying
`"blocking": false`, `"promotion": "experimental"` and a `not_promoted_reason` that states the
mechanism rather than an intention:
> A manifest entry pointing at an unregistered task does not fail; it is simply never run, and the
> release reports green for a capability nobody qualified. Demoted rather than implemented so the
> green means what it says.
That is the correct reading. A release manifest naming a task that no build file registers is not a
failing gate — it is an absent one, and absence is indistinguishable from success in a report that
counts failures.
## `docs/mongodb/advanced/sharding.md`
The gate row now reads:
> **Not promoted.** No `mongoShardedTest` lane is registered, and a sharded cluster is not an
> environment this repository stands up. Listed under `experimental_contracts` in
> `src/config/mongodb/release-contracts.json`; promoting it needs the lane, its required class, and
> protected-environment evidence to exist first.
## `scripts/verify-mongodb-advanced.sh`
Setting `MONGODB_SHARDED_URI` is now an explicit error rather than an invocation of a task that does
not exist:
```
sharded topology is experimental and has no registered lane;
MONGODB_SHARDED_URI was set but mongoShardedTest does not exist.
See experimental_contracts in src/config/mongodb/release-contracts.json.
```
The distinction the script draws is worth keeping: an operator who exported the URI expected a
qualification to run, so silence would be worse than failure. With the URI unset it records missing
evidence instead, which is a different statement from a pass.
## The arbiter
```bash
cd src
./gradlew :app-bootstrap:test --tests '*ReleaseManifestTaskExistenceTest' --console=plain
```
`BUILD SUCCESSFUL` — 1 test, 0 failures, 0 skipped. The manifest names no blocking task that the
build does not register.
## Registered Mongo lanes, for the record
`mongoStableContractTest`, `mongoReplicaSetTest`, `mongoFailoverTest`, `mongoMigrationTest`,
`mongoCompatibilityTest`, `mongoSecurityIntegrationTest`, `mongoPerformanceTest` — seven, and none of
the three demoted names among them, which is the state the demotion describes.
@@ -0,0 +1,181 @@
> Task :build-logic:extractPluginRequests UP-TO-DATE
> Task :build-logic:generatePluginAdapters UP-TO-DATE
> Task :build-logic:compileJava UP-TO-DATE
> Task :build-logic:compileGroovy UP-TO-DATE
> Task :build-logic:compileGroovyPlugins UP-TO-DATE
> Task :build-logic:pluginDescriptors UP-TO-DATE
> Task :build-logic:processResources UP-TO-DATE
> Task :build-logic:classes UP-TO-DATE
> Task :build-logic:jar UP-TO-DATE
> Task :shared-contract:compileJava UP-TO-DATE
> Task :shared-contract:processResources UP-TO-DATE
> Task :shared-contract:classes UP-TO-DATE
> Task :shared-contract:jar UP-TO-DATE
> Task :application-core:compileJava UP-TO-DATE
> Task :application-core:processResources NO-SOURCE
> Task :application-core:classes UP-TO-DATE
> Task :application-core:jar UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:jar UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileTestkitJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:testkitClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processTestResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:testClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:test UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processResources UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:classes UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:jar UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileTestkitJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processTestkitResources NO-SOURCE
> Task :adapter:outbound:persistence-mongo:testkitClasses UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processTestResources NO-SOURCE
> Task :adapter:outbound:persistence-mongo:testClasses UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:test UP-TO-DATE
> Task :adapter:outbound:support:compileJava UP-TO-DATE
> Task :adapter:outbound:support:processResources NO-SOURCE
> Task :adapter:outbound:support:classes UP-TO-DATE
> Task :adapter:outbound:support:jar UP-TO-DATE
> Task :adapter:outbound:messaging:compileJava UP-TO-DATE
> Task :adapter:outbound:messaging:processResources UP-TO-DATE
> Task :adapter:outbound:messaging:classes UP-TO-DATE
> Task :adapter:outbound:messaging:jar UP-TO-DATE
> Task :adapter:outbound:messaging:compileTestJava UP-TO-DATE
> Task :adapter:outbound:messaging:processTestResources UP-TO-DATE
> Task :adapter:outbound:messaging:testClasses UP-TO-DATE
> Task :adapter:outbound:messaging:test UP-TO-DATE
> Task :adapter:outbound:notification:compileJava UP-TO-DATE
> Task :adapter:outbound:notification:processResources NO-SOURCE
> Task :adapter:outbound:notification:classes UP-TO-DATE
> Task :adapter:outbound:notification:jar UP-TO-DATE
> Task :adapter:outbound:notification:compileTestJava UP-TO-DATE
> Task :adapter:outbound:notification:processTestResources UP-TO-DATE
> Task :adapter:outbound:notification:testClasses UP-TO-DATE
> Task :adapter:outbound:notification:test UP-TO-DATE
> Task :adapter:inbound:graphql:compileJava UP-TO-DATE
> Task :adapter:inbound:graphql:processResources UP-TO-DATE
> Task :adapter:inbound:graphql:classes UP-TO-DATE
> Task :adapter:inbound:graphql:jar UP-TO-DATE
> Task :adapter:inbound:graphql:compileTestFixturesJava UP-TO-DATE
> Task :adapter:inbound:graphql:processTestFixturesResources NO-SOURCE
> Task :adapter:inbound:graphql:testFixturesClasses UP-TO-DATE
> Task :adapter:inbound:graphql:testFixturesJar UP-TO-DATE
> Task :adapter:inbound:graphql:compileTestJava UP-TO-DATE
> Task :adapter:inbound:graphql:processTestResources UP-TO-DATE
> Task :adapter:inbound:graphql:testClasses UP-TO-DATE
> Task :adapter:inbound:graphql:test UP-TO-DATE
2026-08-19T04:59:21.954Z INFO 432501 --- [ionShutdownHook] o.s.boot.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
2026-08-19T04:59:21.963Z INFO 432501 --- [tomcat-shutdown] o.s.boot.tomcat.GracefulShutdown : Graceful shutdown complete
2026-08-19T04:59:21.978Z INFO 432501 --- [ionShutdownHook] o.s.boot.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
2026-08-19T04:59:21.980Z INFO 432501 --- [tomcat-shutdown] o.s.boot.tomcat.GracefulShutdown : Graceful shutdown complete
> Task :adapter:inbound:graphql:graphqlStableTest
> Task :verifyRuntimeModuleMembership
verifyRuntimeModuleMembership: 2 runtime composition(s) match the registry
> Task :domain-core:compileJava UP-TO-DATE
> Task :domain-core:processResources NO-SOURCE
> Task :domain-core:classes UP-TO-DATE
> Task :domain-core:jar UP-TO-DATE
> Task :messaging:messaging-core-api:compileJava UP-TO-DATE
> Task :messaging:messaging-observability:compileJava UP-TO-DATE
> Task :messaging:messaging-schema-api:compileJava UP-TO-DATE
> Task :messaging:messaging-policy:compileJava UP-TO-DATE
> Task :messaging:messaging-reliability-api:compileJava UP-TO-DATE
> Task :messaging:messaging-security:compileJava UP-TO-DATE
> Task :messaging:messaging-admin-api:compileJava UP-TO-DATE
> Task :messaging:messaging-transport-spi:compileJava UP-TO-DATE
> Task :messaging:messaging-admin-runtime:compileJava UP-TO-DATE
> Task :messaging:messaging-claim-check:compileJava UP-TO-DATE
> Task :messaging:messaging-cloudevents:compileJava UP-TO-DATE
> Task :messaging:messaging-inbox-jdbc-postgresql:compileJava UP-TO-DATE
> Task :messaging:messaging-kafka:compileJava UP-TO-DATE
> Task :messaging:messaging-outbox-jdbc-postgresql:compileJava UP-TO-DATE
> Task :messaging:messaging-rabbit:compileJava UP-TO-DATE
> Task :messaging:messaging-runtime-core:compileJava UP-TO-DATE
> Task :messaging:messaging-schema-json:compileJava UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:compileJava UP-TO-DATE
> Task :adapter:inbound:web:compileJava UP-TO-DATE
> Task :adapter:inbound:web:processResources NO-SOURCE
> Task :adapter:inbound:web:classes UP-TO-DATE
> Task :adapter:inbound:web:jar UP-TO-DATE
> Task :adapter:outbound:cache-redis:compileJava UP-TO-DATE
> Task :adapter:outbound:cache-redis:processResources UP-TO-DATE
> Task :adapter:outbound:cache-redis:classes UP-TO-DATE
> Task :adapter:outbound:cache-redis:jar UP-TO-DATE
> Task :adapter:outbound:fileserver:compileJava UP-TO-DATE
> Task :adapter:outbound:fileserver:processResources NO-SOURCE
> Task :adapter:outbound:fileserver:classes UP-TO-DATE
> Task :adapter:outbound:fileserver:jar UP-TO-DATE
> Task :adapter:outbound:httpclient:compileJava UP-TO-DATE
> Task :adapter:outbound:httpclient:processResources NO-SOURCE
> Task :adapter:outbound:httpclient:classes UP-TO-DATE
> Task :adapter:outbound:httpclient:jar UP-TO-DATE
> Task :adapter:outbound:identifier:compileJava UP-TO-DATE
> Task :adapter:outbound:identifier:compileGroovy NO-SOURCE
> Task :adapter:outbound:identifier:processResources NO-SOURCE
> Task :adapter:outbound:identifier:classes UP-TO-DATE
> Task :adapter:outbound:identifier:jar UP-TO-DATE
> Task :app-bootstrap:compileJava UP-TO-DATE
> Task :app-bootstrap:processResources UP-TO-DATE
> Task :app-bootstrap:classes UP-TO-DATE
> Task :adapter:inbound:grpc:compileJava UP-TO-DATE
> Task :adapter:inbound:grpc:processResources NO-SOURCE
> Task :adapter:inbound:grpc:classes UP-TO-DATE
> Task :adapter:inbound:grpc:jar UP-TO-DATE
> Task :adapter:inbound:websocket:compileJava UP-TO-DATE
> Task :adapter:inbound:websocket:processResources NO-SOURCE
> Task :adapter:inbound:websocket:classes UP-TO-DATE
> Task :adapter:inbound:websocket:jar UP-TO-DATE
> Task :app-bootstrap:compileConditionalTransportTestJava UP-TO-DATE
> Task :app-bootstrap:processConditionalTransportTestResources NO-SOURCE
> Task :app-bootstrap:conditionalTransportTestClasses UP-TO-DATE
> Task :app-bootstrap:conditionalTransportCompositionTestRequiredClasses
> Task :app-bootstrap:conditionalTransportCompositionTest
> Task :app-bootstrap:conditionalTransportCompositionTestEvidence
conditionalTransportCompositionTest: 3 tests, 0 skipped
> Task :adapter:inbound:graphql:graphqlTransportQualificationTestRequiredClasses
2026-08-19T04:59:43.275Z INFO 433478 --- [ionShutdownHook] o.s.boot.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
2026-08-19T04:59:43.288Z INFO 433478 --- [tomcat-shutdown] o.s.boot.tomcat.GracefulShutdown : Graceful shutdown complete
> Task :adapter:inbound:graphql:graphqlTransportQualificationTest
> Task :adapter:inbound:graphql:graphqlTransportQualificationTestEvidence
graphqlTransportQualificationTest: 8 tests, 0 skipped
> Task :adapter:inbound:grpc:compileTestJava UP-TO-DATE
> Task :adapter:inbound:grpc:processTestResources NO-SOURCE
> Task :adapter:inbound:grpc:testClasses UP-TO-DATE
> Task :adapter:inbound:grpc:grpcTransportQualificationTestRequiredClasses
> Task :adapter:inbound:grpc:grpcTransportQualificationTest
> Task :adapter:inbound:grpc:grpcTransportQualificationTestEvidence
grpcTransportQualificationTest: 15 tests, 0 skipped
> Task :adapter:inbound:websocket:compileTestJava UP-TO-DATE
> Task :adapter:inbound:websocket:processTestResources NO-SOURCE
> Task :adapter:inbound:websocket:testClasses UP-TO-DATE
> Task :adapter:inbound:websocket:websocketTransportQualificationTestRequiredClasses
2026-08-19T05:00:00.518Z INFO 437360 --- [ionShutdownHook] o.s.boot.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
2026-08-19T05:00:00.523Z INFO 437360 --- [tomcat-shutdown] o.s.boot.tomcat.GracefulShutdown : Graceful shutdown complete
> Task :adapter:inbound:websocket:websocketTransportQualificationTest
> Task :adapter:inbound:websocket:websocketTransportQualificationTestEvidence
websocketTransportQualificationTest: 5 tests, 0 skipped
> Task :conditionalTransportQualification
conditional-transport-graphql: 8 tests, 0 skipped
conditional-transport-grpc: 15 tests, 0 skipped
conditional-transport-websocket: 5 tests, 0 skipped
conditional-transport-composition: 3 tests, 0 skipped
BUILD SUCCESSFUL in 1m 8s
101 actionable tasks: 15 executed, 86 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html
EXIT=0
@@ -0,0 +1,61 @@
# NOT RUN — the GraphQL bootJar JWT qualification
Recorded as not-run with its reason, per this wave's rule that a gate which could not run is never
reported as passing.
## What was attempted
```bash
cd src
./gradlew :app-bootstrap:graphqlRuntimeQualification --console=plain
```
```
* What went wrong:
Cannot locate tasks that match ':app-bootstrap:graphqlRuntimeQualification' as task
'graphqlRuntimeQualification' not found in project ':app-bootstrap'.
exit=1
```
## Why it cannot run
The task does not exist, and neither does the source set the spec names for it.
`app-bootstrap/src/` holds `main`, `test`, `functionalTest`, `conditionalTransportTest` and
`sampleOffTest` — there is no `graphqlRuntimeQualificationTest`.
The spec (`…-five-adapter-runtime-remediation-review-design.md`, GraphQL section) fixes the
canonical task as `:app-bootstrap:graphqlRuntimeQualification`, depending on `bootJar`, running the
produced jar as a child process, taking a client-credentials token from a Keycloak container that
imported the same tracked realm artifact, and calling real HTTP `/graphql`. None of that machinery
was built.
## What exists instead, and why it does not substitute
| Artefact | What it actually proves | Why it is not release evidence |
| --- | --- | --- |
| `GraphqlHttpBoundaryQualificationTest` | `/graphql` and `/graphiql` answer over HTTP in a `@SpringBootTest` | authenticates with `withBasicAuth(USERNAME, PASSWORD)` and `httpBasic(Customizer.withDefaults())` — test-only Basic auth, not the JWT decoder a deployment runs |
| `ConditionalTransportCompositionContractTest` | the GraphQL types load and the leaf's runtime membership matches the registry | `assertThatCodeLoads(typeName)` is class existence; it makes no request and sees no token |
The spec anticipates exactly these two and rules both out by name: the boundary test "may remain a
module contract test but is not aggregated into release evidence", and class existence is named as
the thing the qualification exists to replace.
## Consequence for the Definition of Done
Spec §13 item — *"GraphQL blocking qualification runs the bootJar JWT composition exactly once and
uses neither class-existence nor test-only Basic Auth as release evidence"* — **cannot be ticked**.
It is the one item of the twenty-four in that state.
## This was already declared, not discovered
`.github/ci-gate-matrix.yml` registers the gate as `mechanism: delegated-pending` with the comment
that it "inherits that control's pending status rather than having none of its own", and
`verify-gate-matrix.sh` reports `49 gates, 45 verified, 4 delegated-pending`. The gap is recorded in
the repository's own control plane; this file is the Wave 6 confirmation of it, not a new finding.
## What closing it would take
A new `graphqlRuntimeQualificationTest` source set, a Gradle lane depending on `bootJar`, a child
process launcher for the jar, a Keycloak container importing `infra/keycloak/realms/
ca-skeleton-realm.json`, and the required-class / zero-discovery / stale-XML refusals the spec lists.
That is new capability, which this wave explicitly does not add.
@@ -0,0 +1,31 @@
$ ./gradlew :app-bootstrap:graphqlRuntimeQualification --console=plain
run-at: 2026-08-20T07:20:21Z
Mem: 30Gi 18Gi 1.4Gi 688Mi 12Gi 12Gi
> Task :build-logic:extractPluginRequests UP-TO-DATE
> Task :build-logic:generatePluginAdapters UP-TO-DATE
> Task :build-logic:compileJava UP-TO-DATE
> Task :build-logic:compileGroovy UP-TO-DATE
> Task :build-logic:compileGroovyPlugins UP-TO-DATE
> Task :build-logic:pluginDescriptors UP-TO-DATE
> Task :build-logic:processResources UP-TO-DATE
> Task :build-logic:classes UP-TO-DATE
> Task :build-logic:jar UP-TO-DATE
[Incubating] Problems report is available at: file:///home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/build/reports/problems/problems-report.html
FAILURE: Build failed with an exception.
* What went wrong:
Cannot locate tasks that match ':app-bootstrap:graphqlRuntimeQualification' as task 'graphqlRuntimeQualification' not found in project ':app-bootstrap'.
* Try:
> Run gradlew tasks to get a list of available tasks.
> For more on name expansion, please refer to https://docs.gradle.org/9.0.0/userguide/command_line_interface.html#sec:name_abbreviation in the Gradle documentation.
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to generate a Build Scan (Powered by Develocity).
> Get more help at https://help.gradle.org.
BUILD FAILED in 2s
8 actionable tasks: 8 up-to-date
exit=1
@@ -0,0 +1,162 @@
$ ./gradlew :adapter:inbound:graphql:graphqlStableTest conditionalTransportQualification --console=plain
run-at: 2026-08-20T07:18:59Z
total used free shared buff/cache available
Mem: 30Gi 17Gi 2.0Gi 687Mi 12Gi 13Gi
> Task :build-logic:extractPluginRequests UP-TO-DATE
> Task :build-logic:generatePluginAdapters UP-TO-DATE
> Task :build-logic:compileJava UP-TO-DATE
> Task :build-logic:compileGroovy UP-TO-DATE
> Task :build-logic:compileGroovyPlugins UP-TO-DATE
> Task :build-logic:pluginDescriptors UP-TO-DATE
> Task :build-logic:processResources UP-TO-DATE
> Task :build-logic:classes UP-TO-DATE
> Task :build-logic:jar UP-TO-DATE
> Task :shared-contract:compileJava UP-TO-DATE
> Task :shared-contract:processResources UP-TO-DATE
> Task :shared-contract:classes UP-TO-DATE
> Task :shared-contract:jar UP-TO-DATE
> Task :application-core:compileJava UP-TO-DATE
> Task :application-core:processResources NO-SOURCE
> Task :application-core:classes UP-TO-DATE
> Task :application-core:jar UP-TO-DATE
> Task :adapter:inbound:graphql:compileJava UP-TO-DATE
> Task :adapter:inbound:graphql:processResources UP-TO-DATE
> Task :adapter:inbound:graphql:classes UP-TO-DATE
> Task :adapter:inbound:graphql:jar UP-TO-DATE
> Task :adapter:inbound:graphql:compileTestFixturesJava UP-TO-DATE
> Task :adapter:inbound:graphql:processTestFixturesResources NO-SOURCE
> Task :adapter:inbound:graphql:testFixturesClasses UP-TO-DATE
> Task :adapter:inbound:graphql:testFixturesJar UP-TO-DATE
> Task :adapter:inbound:graphql:compileTestJava UP-TO-DATE
> Task :adapter:inbound:graphql:processTestResources UP-TO-DATE
> Task :adapter:inbound:graphql:testClasses UP-TO-DATE
2026-08-20T07:19:40.310Z INFO 3669807 --- [ionShutdownHook] o.s.boot.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
2026-08-20T07:19:40.317Z INFO 3669807 --- [tomcat-shutdown] o.s.boot.tomcat.GracefulShutdown : Graceful shutdown complete
2026-08-20T07:19:40.345Z INFO 3669807 --- [ionShutdownHook] o.s.boot.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
2026-08-20T07:19:40.347Z INFO 3669807 --- [tomcat-shutdown] o.s.boot.tomcat.GracefulShutdown : Graceful shutdown complete
> Task :adapter:inbound:graphql:graphqlStableTest
> Task :verifyRuntimeModuleMembership
verifyRuntimeModuleMembership: 2 runtime composition(s) match the registry
> Task :domain-core:compileJava UP-TO-DATE
> Task :domain-core:processResources NO-SOURCE
> Task :domain-core:classes UP-TO-DATE
> Task :domain-core:jar UP-TO-DATE
> Task :messaging:messaging-core-api:compileJava UP-TO-DATE
> Task :messaging:messaging-observability:compileJava UP-TO-DATE
> Task :messaging:messaging-schema-api:compileJava UP-TO-DATE
> Task :messaging:messaging-policy:compileJava UP-TO-DATE
> Task :messaging:messaging-reliability-api:compileJava UP-TO-DATE
> Task :messaging:messaging-security:compileJava UP-TO-DATE
> Task :messaging:messaging-admin-api:compileJava UP-TO-DATE
> Task :messaging:messaging-transport-spi:compileJava UP-TO-DATE
> Task :messaging:messaging-admin-runtime:compileJava UP-TO-DATE
> Task :messaging:messaging-claim-check:compileJava UP-TO-DATE
> Task :messaging:messaging-cloudevents:compileJava UP-TO-DATE
> Task :messaging:messaging-inbox-jdbc-postgresql:compileJava UP-TO-DATE
> Task :messaging:messaging-kafka:compileJava UP-TO-DATE
> Task :messaging:messaging-outbox-jdbc-postgresql:compileJava UP-TO-DATE
> Task :messaging:messaging-rabbit:compileJava UP-TO-DATE
> Task :messaging:messaging-runtime-core:compileJava UP-TO-DATE
> Task :messaging:messaging-schema-json:compileJava UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:compileJava UP-TO-DATE
> Task :adapter:inbound:web:compileJava UP-TO-DATE
> Task :adapter:inbound:web:processResources NO-SOURCE
> Task :adapter:inbound:web:classes UP-TO-DATE
> Task :adapter:inbound:web:jar UP-TO-DATE
> Task :adapter:outbound:support:compileJava UP-TO-DATE
> Task :adapter:outbound:support:processResources NO-SOURCE
> Task :adapter:outbound:support:classes UP-TO-DATE
> Task :adapter:outbound:support:jar UP-TO-DATE
> Task :adapter:outbound:cache-redis:compileJava UP-TO-DATE
> Task :adapter:outbound:cache-redis:processResources UP-TO-DATE
> Task :adapter:outbound:cache-redis:classes UP-TO-DATE
> Task :adapter:outbound:cache-redis:jar UP-TO-DATE
> Task :adapter:outbound:fileserver:compileJava UP-TO-DATE
> Task :adapter:outbound:fileserver:processResources NO-SOURCE
> Task :adapter:outbound:fileserver:classes UP-TO-DATE
> Task :adapter:outbound:fileserver:jar UP-TO-DATE
> Task :adapter:outbound:httpclient:compileJava UP-TO-DATE
> Task :adapter:outbound:httpclient:processResources NO-SOURCE
> Task :adapter:outbound:httpclient:classes UP-TO-DATE
> Task :adapter:outbound:httpclient:jar UP-TO-DATE
> Task :adapter:outbound:identifier:compileJava UP-TO-DATE
> Task :adapter:outbound:identifier:compileGroovy NO-SOURCE
> Task :adapter:outbound:identifier:processResources NO-SOURCE
> Task :adapter:outbound:identifier:classes UP-TO-DATE
> Task :adapter:outbound:identifier:jar UP-TO-DATE
> Task :adapter:outbound:messaging:compileJava UP-TO-DATE
> Task :adapter:outbound:messaging:processResources UP-TO-DATE
> Task :adapter:outbound:messaging:classes UP-TO-DATE
> Task :adapter:outbound:messaging:jar UP-TO-DATE
> Task :adapter:outbound:notification:compileJava UP-TO-DATE
> Task :adapter:outbound:notification:processResources NO-SOURCE
> Task :adapter:outbound:notification:classes UP-TO-DATE
> Task :adapter:outbound:notification:jar UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:jar UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processResources UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:classes UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:jar UP-TO-DATE
> Task :app-bootstrap:compileJava UP-TO-DATE
> Task :app-bootstrap:processResources UP-TO-DATE
> Task :app-bootstrap:classes UP-TO-DATE
> Task :adapter:inbound:grpc:compileJava UP-TO-DATE
> Task :adapter:inbound:grpc:processResources NO-SOURCE
> Task :adapter:inbound:grpc:classes UP-TO-DATE
> Task :adapter:inbound:grpc:jar UP-TO-DATE
> Task :adapter:inbound:websocket:compileJava UP-TO-DATE
> Task :adapter:inbound:websocket:processResources NO-SOURCE
> Task :adapter:inbound:websocket:classes UP-TO-DATE
> Task :adapter:inbound:websocket:jar UP-TO-DATE
> Task :app-bootstrap:compileConditionalTransportTestJava UP-TO-DATE
> Task :app-bootstrap:processConditionalTransportTestResources NO-SOURCE
> Task :app-bootstrap:conditionalTransportTestClasses UP-TO-DATE
> Task :app-bootstrap:conditionalTransportCompositionTestRequiredClasses
> Task :app-bootstrap:conditionalTransportCompositionTest
> Task :app-bootstrap:conditionalTransportCompositionTestEvidence
conditionalTransportCompositionTest: 3 tests, 0 skipped
> Task :adapter:inbound:graphql:graphqlTransportQualificationTestRequiredClasses
2026-08-20T07:19:56.193Z INFO 3670728 --- [ionShutdownHook] o.s.boot.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
2026-08-20T07:19:56.198Z INFO 3670728 --- [tomcat-shutdown] o.s.boot.tomcat.GracefulShutdown : Graceful shutdown complete
> Task :adapter:inbound:graphql:graphqlTransportQualificationTest
> Task :adapter:inbound:graphql:graphqlTransportQualificationTestEvidence
graphqlTransportQualificationTest: 8 tests, 0 skipped
> Task :adapter:inbound:grpc:compileTestJava UP-TO-DATE
> Task :adapter:inbound:grpc:processTestResources NO-SOURCE
> Task :adapter:inbound:grpc:testClasses UP-TO-DATE
> Task :adapter:inbound:grpc:grpcTransportQualificationTestRequiredClasses
> Task :adapter:inbound:grpc:grpcTransportQualificationTest
> Task :adapter:inbound:grpc:grpcTransportQualificationTestEvidence
grpcTransportQualificationTest: 15 tests, 0 skipped
> Task :adapter:inbound:websocket:compileTestJava UP-TO-DATE
> Task :adapter:inbound:websocket:processTestResources NO-SOURCE
> Task :adapter:inbound:websocket:testClasses UP-TO-DATE
> Task :adapter:inbound:websocket:websocketTransportQualificationTestRequiredClasses
2026-08-20T07:20:10.449Z INFO 3671395 --- [ionShutdownHook] o.s.boot.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
2026-08-20T07:20:10.454Z INFO 3671395 --- [tomcat-shutdown] o.s.boot.tomcat.GracefulShutdown : Graceful shutdown complete
> Task :adapter:inbound:websocket:websocketTransportQualificationTest
> Task :adapter:inbound:websocket:websocketTransportQualificationTestEvidence
websocketTransportQualificationTest: 5 tests, 0 skipped
> Task :conditionalTransportQualification
conditional-transport-graphql: 8 tests, 0 skipped
conditional-transport-grpc: 15 tests, 0 skipped
conditional-transport-websocket: 5 tests, 0 skipped
conditional-transport-composition: 3 tests, 0 skipped
BUILD SUCCESSFUL in 1m 11s
88 actionable tasks: 15 executed, 73 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html
exit=0
@@ -0,0 +1,173 @@
===== PostgreSQL 16 =====
> Task :build-logic:extractPluginRequests UP-TO-DATE
> Task :build-logic:generatePluginAdapters UP-TO-DATE
> Task :build-logic:compileJava UP-TO-DATE
> Task :build-logic:compileGroovy UP-TO-DATE
> Task :build-logic:compileGroovyPlugins UP-TO-DATE
> Task :build-logic:pluginDescriptors UP-TO-DATE
> Task :build-logic:processResources UP-TO-DATE
> Task :build-logic:classes UP-TO-DATE
> Task :build-logic:jar UP-TO-DATE
> Task :shared-contract:compileJava UP-TO-DATE
> Task :shared-contract:processResources UP-TO-DATE
> Task :shared-contract:classes UP-TO-DATE
> Task :shared-contract:jar UP-TO-DATE
> Task :application-core:compileJava UP-TO-DATE
> Task :application-core:processResources NO-SOURCE
> Task :application-core:classes UP-TO-DATE
> Task :application-core:jar UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileTestkitJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:testkitClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compilePostgresqlIntegrationTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processPostgresqlIntegrationTestResources UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:postgresqlIntegrationTestClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:jpaPlatformContractTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformFailureTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest
PostgreSqlNotificationSchemaActivationIntegrationTest > V1 to V4, disable, re-enable and an interrupted migration all recover FAILED
org.opentest4j.AssertionFailedError at PostgreSqlNotificationSchemaActivationIntegrationTest.java:184
43 tests completed, 1 failed
> Task :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':adapter:outbound:persistence-jpa:jpaPlatformMigrationTest'.
> There were failing tests. See the report at: file:///home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/reports/tests/jpaPlatformMigrationTest/index.html
* Try:
> Run with --scan to generate a Build Scan (Powered by Develocity).
BUILD FAILED in 4m 58s
21 actionable tasks: 3 executed, 18 up-to-date
EXIT(16)=1
===== PostgreSQL 17 =====
> Task :build-logic:extractPluginRequests UP-TO-DATE
> Task :build-logic:generatePluginAdapters UP-TO-DATE
> Task :build-logic:compileJava UP-TO-DATE
> Task :build-logic:compileGroovy UP-TO-DATE
> Task :build-logic:compileGroovyPlugins UP-TO-DATE
> Task :build-logic:pluginDescriptors UP-TO-DATE
> Task :build-logic:processResources UP-TO-DATE
> Task :build-logic:classes UP-TO-DATE
> Task :build-logic:jar UP-TO-DATE
> Task :shared-contract:compileJava UP-TO-DATE
> Task :shared-contract:processResources UP-TO-DATE
> Task :shared-contract:classes UP-TO-DATE
> Task :shared-contract:jar UP-TO-DATE
> Task :application-core:compileJava UP-TO-DATE
> Task :application-core:processResources NO-SOURCE
> Task :application-core:classes UP-TO-DATE
> Task :application-core:jar UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileTestkitJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:testkitClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compilePostgresqlIntegrationTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processPostgresqlIntegrationTestResources UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:postgresqlIntegrationTestClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:jpaPlatformContractTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformFailureTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest
PostgreSqlNotificationSchemaActivationIntegrationTest > V1 to V4, disable, re-enable and an interrupted migration all recover FAILED
org.opentest4j.AssertionFailedError at PostgreSqlNotificationSchemaActivationIntegrationTest.java:184
43 tests completed, 1 failed
> Task :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':adapter:outbound:persistence-jpa:jpaPlatformMigrationTest'.
> There were failing tests. See the report at: file:///home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/reports/tests/jpaPlatformMigrationTest/index.html
* Try:
> Run with --scan to generate a Build Scan (Powered by Develocity).
BUILD FAILED in 5m 42s
21 actionable tasks: 3 executed, 18 up-to-date
EXIT(17)=1
===== PostgreSQL 18 =====
> Task :build-logic:extractPluginRequests
> Task :build-logic:generatePluginAdapters
> Task :build-logic:compileJava
> Task :build-logic:compileGroovy
> Task :build-logic:compileGroovyPlugins
> Task :build-logic:pluginDescriptors
> Task :build-logic:processResources
> Task :build-logic:classes
> Task :build-logic:jar
> Task :shared-contract:compileJava UP-TO-DATE
> Task :shared-contract:processResources UP-TO-DATE
> Task :shared-contract:classes UP-TO-DATE
> Task :shared-contract:jar UP-TO-DATE
> Task :application-core:compileJava UP-TO-DATE
> Task :application-core:processResources NO-SOURCE
> Task :application-core:classes UP-TO-DATE
> Task :application-core:jar UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileTestkitJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:testkitClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compilePostgresqlIntegrationTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processPostgresqlIntegrationTestResources UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:postgresqlIntegrationTestClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:jpaPlatformContractTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformContractTest FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':adapter:outbound:persistence-jpa:jpaPlatformContractTest'.
> Multiple build operations failed.
Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.StablePostgreSqlMatrixContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.StablePostgreSqlMatrixContractTest.xml.
Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlArrayRangeContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlArrayRangeContractTest.xml.
Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlPessimisticLockContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlPessimisticLockContractTest.xml.
Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.OptimisticRetryIntegrationTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.OptimisticRetryIntegrationTest.xml.
Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlCopyLoaderIntegrationTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlCopyLoaderIntegrationTest.xml.
Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.experimental.ReadAfterWriteRoutingContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.experimental.ReadAfterWriteRoutingContractTest.xml.
Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlNotificationIdempotencyRaceIntegrationTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlNotificationIdempotencyRaceIntegrationTest.xml.
Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.experimental.TenantColumnIsolationTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.experimental.TenantColumnIsolationTest.xml.
Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.experimental.TenantPoolCapacityContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.experimental.TenantPoolCapacityContractTest.xml.
Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlWorkClaimContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlWorkClaimContractTest.xml.
...and 7 more failures.
> Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.StablePostgreSqlMatrixContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.StablePostgreSqlMatrixContractTest.xml.
> Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlArrayRangeContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlArrayRangeContractTest.xml.
> Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlPessimisticLockContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlPessimisticLockContractTest.xml.
> Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.OptimisticRetryIntegrationTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.OptimisticRetryIntegrationTest.xml.
> Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlCopyLoaderIntegrationTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlCopyLoaderIntegrationTest.xml.
> Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.experimental.ReadAfterWriteRoutingContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.experimental.ReadAfterWriteRoutingContractTest.xml.
> Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlNotificationIdempotencyRaceIntegrationTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlNotificationIdempotencyRaceIntegrationTest.xml.
> Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.experimental.TenantColumnIsolationTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.experimental.TenantColumnIsolationTest.xml.
> Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.experimental.TenantPoolCapacityContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.experimental.TenantPoolCapacityContractTest.xml.
> Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlWorkClaimContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlWorkClaimContractTest.xml.
> Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.JpaLifecycleAssociationContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.JpaLifecycleAssociationContractTest.xml.
> Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlUpsertContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlUpsertContractTest.xml.
> Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlJsonbContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlJsonbContractTest.xml.
> Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.JpaValueMappingContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.JpaValueMappingContractTest.xml.
> Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.JpaPlatformContractSupportOwnershipTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.JpaPlatformContractSupportOwnershipTest.xml.
> Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlSqlStateContractTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.platform.PostgreSqlSqlStateContractTest.xml.
> Could not write XML test results for dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlRecipientLeaseFencingIntegrationTest to file /home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-jpa/build/test-results/jpaPlatformContractTest/TEST-dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlRecipientLeaseFencingIntegrationTest.xml.
* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to generate a Build Scan (Powered by Develocity).
> Get more help at https://help.gradle.org.
BUILD FAILED in 5m 20s
19 actionable tasks: 9 executed, 10 up-to-date
@@ -0,0 +1,49 @@
$ ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformReleaseGate -Pjpa.matrix.versions=16 --console=plain
run-at: 2026-08-20T07:38:10Z
Mem: 30Gi 15Gi 8.9Gi 770Mi 8.2Gi 15Gi
Starting a Gradle Daemon, 5 stopped Daemons could not be reused, use --status for details
> Task :build-logic:extractPluginRequests UP-TO-DATE
> Task :build-logic:generatePluginAdapters UP-TO-DATE
> Task :build-logic:compileJava UP-TO-DATE
> Task :build-logic:compileGroovy UP-TO-DATE
> Task :build-logic:compileGroovyPlugins UP-TO-DATE
> Task :build-logic:pluginDescriptors UP-TO-DATE
> Task :build-logic:processResources UP-TO-DATE
> Task :build-logic:classes UP-TO-DATE
> Task :build-logic:jar UP-TO-DATE
> Task :shared-contract:compileJava UP-TO-DATE
> Task :shared-contract:processResources UP-TO-DATE
> Task :shared-contract:classes UP-TO-DATE
> Task :shared-contract:jar UP-TO-DATE
> Task :application-core:compileJava UP-TO-DATE
> Task :application-core:processResources NO-SOURCE
> Task :application-core:classes UP-TO-DATE
> Task :application-core:jar UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileTestkitJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:testkitClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compilePostgresqlIntegrationTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processPostgresqlIntegrationTestResources UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:postgresqlIntegrationTestClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:jpaPlatformContractTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformFailureTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest
> Task :adapter:outbound:persistence-jpa:compileJpaPlatformPerformanceTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processJpaPlatformPerformanceTestResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:jpaPlatformPerformanceTestClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformSecurityTest
> Task :adapter:outbound:persistence-jpa:compileTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processTestResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:testClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:test UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:jpaPlatformReleaseGate
BUILD SUCCESSFUL in 8m 14s
27 actionable tasks: 6 executed, 21 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html
exit=0
@@ -0,0 +1,48 @@
$ ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformReleaseGate -Pjpa.matrix.versions=17 --console=plain
run-at: 2026-08-20T07:46:38Z
Mem: 30Gi 19Gi 4.6Gi 782Mi 8.5Gi 11Gi
> Task :build-logic:extractPluginRequests UP-TO-DATE
> Task :build-logic:generatePluginAdapters UP-TO-DATE
> Task :build-logic:compileJava UP-TO-DATE
> Task :build-logic:compileGroovy UP-TO-DATE
> Task :build-logic:compileGroovyPlugins UP-TO-DATE
> Task :build-logic:pluginDescriptors UP-TO-DATE
> Task :build-logic:processResources UP-TO-DATE
> Task :build-logic:classes UP-TO-DATE
> Task :build-logic:jar UP-TO-DATE
> Task :shared-contract:compileJava UP-TO-DATE
> Task :shared-contract:processResources UP-TO-DATE
> Task :shared-contract:classes UP-TO-DATE
> Task :shared-contract:jar UP-TO-DATE
> Task :application-core:compileJava UP-TO-DATE
> Task :application-core:processResources NO-SOURCE
> Task :application-core:classes UP-TO-DATE
> Task :application-core:jar UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileTestkitJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:testkitClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compilePostgresqlIntegrationTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processPostgresqlIntegrationTestResources UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:postgresqlIntegrationTestClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:jpaPlatformContractTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformFailureTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest
> Task :adapter:outbound:persistence-jpa:compileJpaPlatformPerformanceTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processJpaPlatformPerformanceTestResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:jpaPlatformPerformanceTestClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformSecurityTest
> Task :adapter:outbound:persistence-jpa:compileTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processTestResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:testClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:test UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:jpaPlatformReleaseGate
BUILD SUCCESSFUL in 7m 26s
27 actionable tasks: 6 executed, 21 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html
exit=0
@@ -0,0 +1,48 @@
$ ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformReleaseGate -Pjpa.matrix.versions=18 --console=plain
run-at: 2026-08-20T07:54:35Z
Mem: 30Gi 19Gi 4.3Gi 1.1Gi 8.2Gi 10Gi
> Task :build-logic:extractPluginRequests UP-TO-DATE
> Task :build-logic:generatePluginAdapters UP-TO-DATE
> Task :build-logic:compileJava UP-TO-DATE
> Task :build-logic:compileGroovy UP-TO-DATE
> Task :build-logic:compileGroovyPlugins UP-TO-DATE
> Task :build-logic:pluginDescriptors UP-TO-DATE
> Task :build-logic:processResources UP-TO-DATE
> Task :build-logic:classes UP-TO-DATE
> Task :build-logic:jar UP-TO-DATE
> Task :shared-contract:compileJava UP-TO-DATE
> Task :shared-contract:processResources UP-TO-DATE
> Task :shared-contract:classes UP-TO-DATE
> Task :shared-contract:jar UP-TO-DATE
> Task :application-core:compileJava UP-TO-DATE
> Task :application-core:processResources NO-SOURCE
> Task :application-core:classes UP-TO-DATE
> Task :application-core:jar UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileTestkitJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:testkitClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compilePostgresqlIntegrationTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processPostgresqlIntegrationTestResources UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:postgresqlIntegrationTestClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:jpaPlatformContractTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformFailureTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest
> Task :adapter:outbound:persistence-jpa:compileJpaPlatformPerformanceTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processJpaPlatformPerformanceTestResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:jpaPlatformPerformanceTestClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformSecurityTest
> Task :adapter:outbound:persistence-jpa:compileTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processTestResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:testClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:test UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:jpaPlatformReleaseGate
BUILD SUCCESSFUL in 5m 45s
27 actionable tasks: 6 executed, 21 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html
exit=0
@@ -0,0 +1,138 @@
===== PostgreSQL 16 =====
> Task :build-logic:extractPluginRequests UP-TO-DATE
> Task :build-logic:generatePluginAdapters UP-TO-DATE
> Task :build-logic:compileJava UP-TO-DATE
> Task :build-logic:compileGroovy UP-TO-DATE
> Task :build-logic:compileGroovyPlugins UP-TO-DATE
> Task :build-logic:pluginDescriptors UP-TO-DATE
> Task :build-logic:processResources UP-TO-DATE
> Task :build-logic:classes UP-TO-DATE
> Task :build-logic:jar UP-TO-DATE
> Task :shared-contract:compileJava UP-TO-DATE
> Task :shared-contract:processResources UP-TO-DATE
> Task :shared-contract:classes UP-TO-DATE
> Task :shared-contract:jar UP-TO-DATE
> Task :application-core:compileJava UP-TO-DATE
> Task :application-core:processResources NO-SOURCE
> Task :application-core:classes UP-TO-DATE
> Task :application-core:jar UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileTestkitJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:testkitClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compilePostgresqlIntegrationTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processPostgresqlIntegrationTestResources UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:postgresqlIntegrationTestClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:jpaPlatformContractTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformFailureTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest
> Task :adapter:outbound:persistence-jpa:compileJpaPlatformPerformanceTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processJpaPlatformPerformanceTestResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:jpaPlatformPerformanceTestClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformSecurityTest
> Task :adapter:outbound:persistence-jpa:compileTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processTestResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:testClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:test UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:jpaPlatformReleaseGate
BUILD SUCCESSFUL in 7m 26s
27 actionable tasks: 6 executed, 21 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html
EXIT(16)=0
===== PostgreSQL 17 =====
> Task :build-logic:extractPluginRequests UP-TO-DATE
> Task :build-logic:generatePluginAdapters UP-TO-DATE
> Task :build-logic:compileJava UP-TO-DATE
> Task :build-logic:compileGroovy UP-TO-DATE
> Task :build-logic:compileGroovyPlugins UP-TO-DATE
> Task :build-logic:pluginDescriptors UP-TO-DATE
> Task :build-logic:processResources UP-TO-DATE
> Task :build-logic:classes UP-TO-DATE
> Task :build-logic:jar UP-TO-DATE
> Task :shared-contract:compileJava UP-TO-DATE
> Task :shared-contract:processResources UP-TO-DATE
> Task :shared-contract:classes UP-TO-DATE
> Task :shared-contract:jar UP-TO-DATE
> Task :application-core:compileJava UP-TO-DATE
> Task :application-core:processResources NO-SOURCE
> Task :application-core:classes UP-TO-DATE
> Task :application-core:jar UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileTestkitJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:testkitClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compilePostgresqlIntegrationTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processPostgresqlIntegrationTestResources UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:postgresqlIntegrationTestClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:jpaPlatformContractTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformFailureTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest
> Task :adapter:outbound:persistence-jpa:compileJpaPlatformPerformanceTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processJpaPlatformPerformanceTestResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:jpaPlatformPerformanceTestClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformSecurityTest
> Task :adapter:outbound:persistence-jpa:compileTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processTestResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:testClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:test UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:jpaPlatformReleaseGate
BUILD SUCCESSFUL in 5m 55s
27 actionable tasks: 6 executed, 21 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html
EXIT(17)=0
===== PostgreSQL 18 =====
> Task :build-logic:extractPluginRequests UP-TO-DATE
> Task :build-logic:generatePluginAdapters UP-TO-DATE
> Task :build-logic:compileJava UP-TO-DATE
> Task :build-logic:compileGroovy UP-TO-DATE
> Task :build-logic:compileGroovyPlugins UP-TO-DATE
> Task :build-logic:pluginDescriptors UP-TO-DATE
> Task :build-logic:processResources UP-TO-DATE
> Task :build-logic:classes UP-TO-DATE
> Task :build-logic:jar UP-TO-DATE
> Task :shared-contract:compileJava UP-TO-DATE
> Task :shared-contract:processResources UP-TO-DATE
> Task :shared-contract:classes UP-TO-DATE
> Task :shared-contract:jar UP-TO-DATE
> Task :application-core:compileJava UP-TO-DATE
> Task :application-core:processResources NO-SOURCE
> Task :application-core:classes UP-TO-DATE
> Task :application-core:jar UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileTestkitJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:testkitClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compilePostgresqlIntegrationTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processPostgresqlIntegrationTestResources UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:postgresqlIntegrationTestClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:jpaPlatformContractTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformFailureTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest
> Task :adapter:outbound:persistence-jpa:compileJpaPlatformPerformanceTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processJpaPlatformPerformanceTestResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:jpaPlatformPerformanceTestClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest
> Task :adapter:outbound:persistence-jpa:jpaPlatformSecurityTest
> Task :adapter:outbound:persistence-jpa:compileTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processTestResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:testClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:test UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:jpaPlatformReleaseGate
BUILD SUCCESSFUL in 5m 58s
27 actionable tasks: 6 executed, 21 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html
EXIT(18)=0
@@ -0,0 +1,61 @@
$ ./gradlew :messaging:messaging-kafka:verifyMessagingCertificationEvidence --console=plain
run-at: 2026-08-20T08:00:43Z
Mem: 30Gi 16Gi 7.8Gi 1.0Gi 8.3Gi 14Gi
> Task :build-logic:extractPluginRequests UP-TO-DATE
> Task :build-logic:generatePluginAdapters UP-TO-DATE
> Task :build-logic:compileJava UP-TO-DATE
> Task :build-logic:compileGroovy UP-TO-DATE
> Task :build-logic:compileGroovyPlugins UP-TO-DATE
> Task :build-logic:pluginDescriptors UP-TO-DATE
> Task :build-logic:processResources UP-TO-DATE
> Task :build-logic:classes UP-TO-DATE
> Task :build-logic:jar UP-TO-DATE
> Task :messaging:messaging-core-api:compileJava UP-TO-DATE
> Task :messaging:messaging-schema-api:compileJava UP-TO-DATE
> Task :messaging:messaging-policy:compileJava UP-TO-DATE
> Task :messaging:messaging-admin-api:compileJava UP-TO-DATE
> Task :messaging:messaging-admin-api:processResources NO-SOURCE
> Task :messaging:messaging-admin-api:classes UP-TO-DATE
> Task :messaging:messaging-admin-api:jar UP-TO-DATE
> Task :messaging:messaging-observability:compileJava UP-TO-DATE
> Task :messaging:messaging-security:compileJava UP-TO-DATE
> Task :messaging:messaging-transport-spi:compileJava UP-TO-DATE
> Task :messaging:messaging-admin-runtime:compileJava UP-TO-DATE
> Task :messaging:messaging-admin-runtime:processResources NO-SOURCE
> Task :messaging:messaging-admin-runtime:classes UP-TO-DATE
> Task :messaging:messaging-admin-runtime:jar UP-TO-DATE
> Task :messaging:messaging-core-api:processResources NO-SOURCE
> Task :messaging:messaging-core-api:classes UP-TO-DATE
> Task :messaging:messaging-core-api:jar UP-TO-DATE
> Task :messaging:messaging-kafka:compileJava UP-TO-DATE
> Task :messaging:messaging-kafka:processResources NO-SOURCE
> Task :messaging:messaging-kafka:classes UP-TO-DATE
> Task :messaging:messaging-testkit:compileJava UP-TO-DATE
> Task :messaging:messaging-kafka:compileTestJava UP-TO-DATE
> Task :messaging:messaging-kafka:processTestResources NO-SOURCE
> Task :messaging:messaging-kafka:testClasses UP-TO-DATE
> Task :messaging:messaging-observability:processResources NO-SOURCE
> Task :messaging:messaging-observability:classes UP-TO-DATE
> Task :messaging:messaging-observability:jar UP-TO-DATE
> Task :messaging:messaging-policy:processResources NO-SOURCE
> Task :messaging:messaging-policy:classes UP-TO-DATE
> Task :messaging:messaging-policy:jar UP-TO-DATE
> Task :messaging:messaging-schema-api:processResources NO-SOURCE
> Task :messaging:messaging-schema-api:classes UP-TO-DATE
> Task :messaging:messaging-schema-api:jar UP-TO-DATE
> Task :messaging:messaging-security:processResources NO-SOURCE
> Task :messaging:messaging-security:classes UP-TO-DATE
> Task :messaging:messaging-security:jar UP-TO-DATE
> Task :messaging:messaging-testkit:processResources UP-TO-DATE
> Task :messaging:messaging-testkit:classes UP-TO-DATE
> Task :messaging:messaging-testkit:jar UP-TO-DATE
> Task :messaging:messaging-transport-spi:processResources NO-SOURCE
> Task :messaging:messaging-transport-spi:classes UP-TO-DATE
> Task :messaging:messaging-transport-spi:jar UP-TO-DATE
> Task :messaging:messaging-kafka:messagingCertificationTest
> Task :messaging:messaging-kafka:verifyMessagingCertificationEvidence
BUILD SUCCESSFUL in 39s
31 actionable tasks: 2 executed, 29 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html
exit=0
@@ -0,0 +1,71 @@
$ ./gradlew :messaging:messaging-kafka:cleanTest :messaging:messaging-kafka:test --tests '*IT' :messaging:messaging-rabbit:cleanTest :messaging:messaging-rabbit:test --tests '*IT' --console=plain
(cleanTest first: the previous invocation reused 6.5-hour-old XML because the task was up-to-date)
run-at: 2026-08-20T08:03:01Z
Mem: 30Gi 17Gi 6.5Gi 1.0Gi 8.5Gi 13Gi
Starting a Gradle Daemon, 1 busy and 35 stopped Daemons could not be reused, use --status for details
> Task :build-logic:extractPluginRequests UP-TO-DATE
> Task :build-logic:generatePluginAdapters UP-TO-DATE
> Task :build-logic:compileJava UP-TO-DATE
> Task :build-logic:compileGroovy UP-TO-DATE
> Task :build-logic:compileGroovyPlugins UP-TO-DATE
> Task :build-logic:pluginDescriptors UP-TO-DATE
> Task :build-logic:processResources UP-TO-DATE
> Task :build-logic:classes UP-TO-DATE
> Task :build-logic:jar UP-TO-DATE
> Task :messaging:messaging-kafka:cleanTest
> Task :messaging:messaging-core-api:compileJava UP-TO-DATE
> Task :messaging:messaging-schema-api:compileJava UP-TO-DATE
> Task :messaging:messaging-policy:compileJava UP-TO-DATE
> Task :messaging:messaging-admin-api:compileJava UP-TO-DATE
> Task :messaging:messaging-admin-api:processResources NO-SOURCE
> Task :messaging:messaging-admin-api:classes UP-TO-DATE
> Task :messaging:messaging-admin-api:jar UP-TO-DATE
> Task :messaging:messaging-observability:compileJava UP-TO-DATE
> Task :messaging:messaging-security:compileJava UP-TO-DATE
> Task :messaging:messaging-transport-spi:compileJava UP-TO-DATE
> Task :messaging:messaging-admin-runtime:compileJava UP-TO-DATE
> Task :messaging:messaging-admin-runtime:processResources NO-SOURCE
> Task :messaging:messaging-admin-runtime:classes UP-TO-DATE
> Task :messaging:messaging-admin-runtime:jar UP-TO-DATE
> Task :messaging:messaging-core-api:processResources NO-SOURCE
> Task :messaging:messaging-core-api:classes UP-TO-DATE
> Task :messaging:messaging-core-api:jar UP-TO-DATE
> Task :messaging:messaging-kafka:compileJava UP-TO-DATE
> Task :messaging:messaging-kafka:processResources NO-SOURCE
> Task :messaging:messaging-kafka:classes UP-TO-DATE
> Task :messaging:messaging-testkit:compileJava UP-TO-DATE
> Task :messaging:messaging-kafka:compileTestJava UP-TO-DATE
> Task :messaging:messaging-kafka:processTestResources NO-SOURCE
> Task :messaging:messaging-kafka:testClasses UP-TO-DATE
> Task :messaging:messaging-observability:processResources NO-SOURCE
> Task :messaging:messaging-observability:classes UP-TO-DATE
> Task :messaging:messaging-observability:jar UP-TO-DATE
> Task :messaging:messaging-policy:processResources NO-SOURCE
> Task :messaging:messaging-policy:classes UP-TO-DATE
> Task :messaging:messaging-policy:jar UP-TO-DATE
> Task :messaging:messaging-schema-api:processResources NO-SOURCE
> Task :messaging:messaging-schema-api:classes UP-TO-DATE
> Task :messaging:messaging-schema-api:jar UP-TO-DATE
> Task :messaging:messaging-security:processResources NO-SOURCE
> Task :messaging:messaging-security:classes UP-TO-DATE
> Task :messaging:messaging-security:jar UP-TO-DATE
> Task :messaging:messaging-testkit:processResources UP-TO-DATE
> Task :messaging:messaging-testkit:classes UP-TO-DATE
> Task :messaging:messaging-testkit:jar UP-TO-DATE
> Task :messaging:messaging-transport-spi:processResources NO-SOURCE
> Task :messaging:messaging-transport-spi:classes UP-TO-DATE
> Task :messaging:messaging-transport-spi:jar UP-TO-DATE
> Task :messaging:messaging-kafka:test
> Task :messaging:messaging-rabbit:cleanTest
> Task :messaging:messaging-rabbit:compileJava UP-TO-DATE
> Task :messaging:messaging-rabbit:processResources NO-SOURCE
> Task :messaging:messaging-rabbit:classes UP-TO-DATE
> Task :messaging:messaging-rabbit:compileTestJava UP-TO-DATE
> Task :messaging:messaging-rabbit:processTestResources NO-SOURCE
> Task :messaging:messaging-rabbit:testClasses UP-TO-DATE
> Task :messaging:messaging-rabbit:test
BUILD SUCCESSFUL in 1m 29s
35 actionable tasks: 4 executed, 31 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html
exit=0
@@ -0,0 +1,27 @@
$ ./gradlew :adapter:outbound:persistence-mongo:mongoCompatibilityTest --console=plain
run-at: 2026-08-20T07:31:58Z
Mem: 30Gi 19Gi 632Mi 834Mi 11Gi 11Gi
> Task :build-logic:extractPluginRequests UP-TO-DATE
> Task :build-logic:generatePluginAdapters UP-TO-DATE
> Task :build-logic:compileJava UP-TO-DATE
> Task :build-logic:compileGroovy UP-TO-DATE
> Task :build-logic:compileGroovyPlugins UP-TO-DATE
> Task :build-logic:pluginDescriptors UP-TO-DATE
> Task :build-logic:processResources UP-TO-DATE
> Task :build-logic:classes UP-TO-DATE
> Task :build-logic:jar UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processResources UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:classes UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileTestkitJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processTestkitResources NO-SOURCE
> Task :adapter:outbound:persistence-mongo:testkitClasses UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processTestResources NO-SOURCE
> Task :adapter:outbound:persistence-mongo:testClasses UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:mongoCompatibilityTest
BUILD SUCCESSFUL in 18s
13 actionable tasks: 1 executed, 12 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html
exit=0
@@ -0,0 +1,27 @@
$ ./gradlew :adapter:outbound:persistence-mongo:mongoFailoverTest --console=plain
run-at: 2026-08-20T07:33:19Z
Mem: 30Gi 19Gi 1.0Gi 839Mi 11Gi 11Gi
> Task :build-logic:extractPluginRequests UP-TO-DATE
> Task :build-logic:generatePluginAdapters UP-TO-DATE
> Task :build-logic:compileJava UP-TO-DATE
> Task :build-logic:compileGroovy UP-TO-DATE
> Task :build-logic:compileGroovyPlugins UP-TO-DATE
> Task :build-logic:pluginDescriptors UP-TO-DATE
> Task :build-logic:processResources UP-TO-DATE
> Task :build-logic:classes UP-TO-DATE
> Task :build-logic:jar UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processResources UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:classes UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileTestkitJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processTestkitResources NO-SOURCE
> Task :adapter:outbound:persistence-mongo:testkitClasses UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processTestResources NO-SOURCE
> Task :adapter:outbound:persistence-mongo:testClasses UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:mongoFailoverTest
BUILD SUCCESSFUL in 1m 17s
13 actionable tasks: 1 executed, 12 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html
exit=0
@@ -0,0 +1,43 @@
> Task :build-logic:extractPluginRequests UP-TO-DATE
> Task :build-logic:generatePluginAdapters UP-TO-DATE
> Task :build-logic:compileJava UP-TO-DATE
> Task :build-logic:compileGroovy UP-TO-DATE
> Task :build-logic:compileGroovyPlugins UP-TO-DATE
> Task :build-logic:pluginDescriptors UP-TO-DATE
> Task :build-logic:processResources UP-TO-DATE
> Task :build-logic:classes UP-TO-DATE
> Task :build-logic:jar UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processResources UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:classes UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileTestkitJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processTestkitResources NO-SOURCE
> Task :adapter:outbound:persistence-mongo:testkitClasses UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processTestResources NO-SOURCE
> Task :adapter:outbound:persistence-mongo:testClasses UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:mongoStableContractTest
> Task :adapter:outbound:persistence-mongo:mongoReplicaSetTest
> Task :adapter:outbound:persistence-mongo:mongoFailoverTest
> Task :adapter:outbound:persistence-mongo:mongoMigrationTest
MongoMigrationLaneTest > aCheckpointSurvivesTheProcessThatWroteIt() FAILED
dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException at MongoMigrationLaneTest.java:155
6 tests completed, 1 failed
> Task :adapter:outbound:persistence-mongo:mongoMigrationTest FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':adapter:outbound:persistence-mongo:mongoMigrationTest'.
> There were failing tests. See the report at: file:///home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/persistence-mongo/build/reports/tests/mongoMigrationTest/index.html
* Try:
> Run with --scan to generate a Build Scan (Powered by Develocity).
BUILD FAILED in 2m 11s
16 actionable tasks: 4 executed, 12 up-to-date
EXIT=1
@@ -0,0 +1,33 @@
> Task :build-logic:extractPluginRequests UP-TO-DATE
> Task :build-logic:generatePluginAdapters UP-TO-DATE
> Task :build-logic:compileJava UP-TO-DATE
> Task :build-logic:compileGroovy UP-TO-DATE
> Task :build-logic:compileGroovyPlugins UP-TO-DATE
> Task :build-logic:pluginDescriptors UP-TO-DATE
> Task :build-logic:processResources UP-TO-DATE
> Task :build-logic:classes UP-TO-DATE
> Task :build-logic:jar UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processResources UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:classes UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileTestkitJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processTestkitResources NO-SOURCE
> Task :adapter:outbound:persistence-mongo:testkitClasses UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processTestResources NO-SOURCE
> Task :adapter:outbound:persistence-mongo:testClasses UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:mongoStableContractTest
> Task :adapter:outbound:persistence-mongo:mongoReplicaSetTest
> Task :adapter:outbound:persistence-mongo:mongoFailoverTest
> Task :adapter:outbound:persistence-mongo:mongoMigrationTest
> Task :adapter:outbound:persistence-mongo:mongoCompatibilityTest
> Task :adapter:outbound:persistence-mongo:mongoSecurityIntegrationTest
> Task :adapter:outbound:persistence-mongo:compileMongoPerformanceTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processMongoPerformanceTestResources NO-SOURCE
> Task :adapter:outbound:persistence-mongo:mongoPerformanceTestClasses UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:mongoPerformanceTest
BUILD SUCCESSFUL in 2m 50s
20 actionable tasks: 7 executed, 13 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html
EXIT=0
@@ -0,0 +1,27 @@
$ ./gradlew :adapter:outbound:persistence-mongo:mongoMigrationTest --console=plain
run-at: 2026-08-20T07:31:22Z
Mem: 30Gi 19Gi 858Mi 817Mi 12Gi 11Gi
> Task :build-logic:extractPluginRequests UP-TO-DATE
> Task :build-logic:generatePluginAdapters UP-TO-DATE
> Task :build-logic:compileJava UP-TO-DATE
> Task :build-logic:compileGroovy UP-TO-DATE
> Task :build-logic:compileGroovyPlugins UP-TO-DATE
> Task :build-logic:pluginDescriptors UP-TO-DATE
> Task :build-logic:processResources UP-TO-DATE
> Task :build-logic:classes UP-TO-DATE
> Task :build-logic:jar UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processResources UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:classes UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileTestkitJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processTestkitResources NO-SOURCE
> Task :adapter:outbound:persistence-mongo:testkitClasses UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processTestResources NO-SOURCE
> Task :adapter:outbound:persistence-mongo:testClasses UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:mongoMigrationTest
BUILD SUCCESSFUL in 13s
13 actionable tasks: 1 executed, 12 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html
exit=0
@@ -0,0 +1,27 @@
$ ./gradlew :adapter:outbound:persistence-mongo:mongoPerformanceTest --console=plain
run-at: 2026-08-20T07:37:00Z
Mem: 30Gi 21Gi 664Mi 789Mi 9.5Gi 9.0Gi
> Task :build-logic:extractPluginRequests UP-TO-DATE
> Task :build-logic:generatePluginAdapters UP-TO-DATE
> Task :build-logic:compileJava UP-TO-DATE
> Task :build-logic:compileGroovy UP-TO-DATE
> Task :build-logic:compileGroovyPlugins UP-TO-DATE
> Task :build-logic:pluginDescriptors UP-TO-DATE
> Task :build-logic:processResources UP-TO-DATE
> Task :build-logic:classes UP-TO-DATE
> Task :build-logic:jar UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processResources UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:classes UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileTestkitJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processTestkitResources NO-SOURCE
> Task :adapter:outbound:persistence-mongo:testkitClasses UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileMongoPerformanceTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processMongoPerformanceTestResources NO-SOURCE
> Task :adapter:outbound:persistence-mongo:mongoPerformanceTestClasses UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:mongoPerformanceTest
BUILD SUCCESSFUL in 26s
13 actionable tasks: 1 executed, 12 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html
exit=0
@@ -0,0 +1,27 @@
$ ./gradlew :adapter:outbound:persistence-mongo:mongoReplicaSetTest --console=plain
run-at: 2026-08-20T07:33:01Z
Mem: 30Gi 19Gi 1.0Gi 804Mi 11Gi 11Gi
> Task :build-logic:extractPluginRequests UP-TO-DATE
> Task :build-logic:generatePluginAdapters UP-TO-DATE
> Task :build-logic:compileJava UP-TO-DATE
> Task :build-logic:compileGroovy UP-TO-DATE
> Task :build-logic:compileGroovyPlugins UP-TO-DATE
> Task :build-logic:pluginDescriptors UP-TO-DATE
> Task :build-logic:processResources UP-TO-DATE
> Task :build-logic:classes UP-TO-DATE
> Task :build-logic:jar UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processResources UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:classes UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileTestkitJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processTestkitResources NO-SOURCE
> Task :adapter:outbound:persistence-mongo:testkitClasses UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processTestResources NO-SOURCE
> Task :adapter:outbound:persistence-mongo:testClasses UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:mongoReplicaSetTest
BUILD SUCCESSFUL in 9s
13 actionable tasks: 1 executed, 12 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html
exit=0
@@ -0,0 +1,27 @@
$ ./gradlew :adapter:outbound:persistence-mongo:mongoSecurityIntegrationTest --console=plain
run-at: 2026-08-20T07:34:53Z
Mem: 30Gi 19Gi 1.6Gi 795Mi 11Gi 11Gi
> Task :build-logic:extractPluginRequests UP-TO-DATE
> Task :build-logic:generatePluginAdapters UP-TO-DATE
> Task :build-logic:compileJava UP-TO-DATE
> Task :build-logic:compileGroovy UP-TO-DATE
> Task :build-logic:compileGroovyPlugins UP-TO-DATE
> Task :build-logic:pluginDescriptors UP-TO-DATE
> Task :build-logic:processResources UP-TO-DATE
> Task :build-logic:classes UP-TO-DATE
> Task :build-logic:jar UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processResources UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:classes UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileTestkitJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processTestkitResources NO-SOURCE
> Task :adapter:outbound:persistence-mongo:testkitClasses UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileTestJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processTestResources NO-SOURCE
> Task :adapter:outbound:persistence-mongo:testClasses UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:mongoSecurityIntegrationTest
BUILD SUCCESSFUL in 1m 54s
13 actionable tasks: 1 executed, 12 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html
exit=0
@@ -0,0 +1,164 @@
Starting a Gradle Daemon, 1 busy and 61 stopped Daemons could not be reused, use --status for details
> Task :build-logic:extractPluginRequests UP-TO-DATE
> Task :build-logic:generatePluginAdapters UP-TO-DATE
> Task :build-logic:compileJava UP-TO-DATE
> Task :build-logic:compileGroovy UP-TO-DATE
> Task :build-logic:compileGroovyPlugins UP-TO-DATE
> Task :build-logic:pluginDescriptors UP-TO-DATE
> Task :build-logic:processResources UP-TO-DATE
> Task :build-logic:classes UP-TO-DATE
> Task :build-logic:jar UP-TO-DATE
> Task :shared-contract:compileJava UP-TO-DATE
> Task :shared-contract:processResources UP-TO-DATE
> Task :shared-contract:classes UP-TO-DATE
> Task :shared-contract:jar UP-TO-DATE
> Task :application-core:compileJava UP-TO-DATE
> Task :application-core:processResources NO-SOURCE
> Task :application-core:classes UP-TO-DATE
> Task :application-core:jar UP-TO-DATE
> Task :domain-core:compileJava UP-TO-DATE
> Task :domain-core:processResources NO-SOURCE
> Task :domain-core:classes UP-TO-DATE
> Task :domain-core:jar UP-TO-DATE
> Task :messaging:messaging-core-api:compileJava UP-TO-DATE
> Task :messaging:messaging-observability:compileJava UP-TO-DATE
> Task :messaging:messaging-schema-api:compileJava UP-TO-DATE
> Task :messaging:messaging-policy:compileJava UP-TO-DATE
> Task :messaging:messaging-reliability-api:compileJava UP-TO-DATE
> Task :messaging:messaging-security:compileJava UP-TO-DATE
> Task :messaging:messaging-admin-api:compileJava UP-TO-DATE
> Task :messaging:messaging-transport-spi:compileJava UP-TO-DATE
> Task :messaging:messaging-admin-runtime:compileJava UP-TO-DATE
> Task :messaging:messaging-claim-check:compileJava UP-TO-DATE
> Task :messaging:messaging-cloudevents:compileJava UP-TO-DATE
> Task :messaging:messaging-inbox-jdbc-postgresql:compileJava UP-TO-DATE
> Task :messaging:messaging-kafka:compileJava UP-TO-DATE
> Task :messaging:messaging-outbox-jdbc-postgresql:compileJava UP-TO-DATE
> Task :messaging:messaging-rabbit:compileJava UP-TO-DATE
> Task :messaging:messaging-runtime-core:compileJava UP-TO-DATE
> Task :messaging:messaging-schema-json:compileJava UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:compileJava UP-TO-DATE
> Task :adapter:inbound:graphql:compileJava UP-TO-DATE
> Task :adapter:inbound:graphql:processResources UP-TO-DATE
> Task :adapter:inbound:graphql:classes UP-TO-DATE
> Task :adapter:inbound:graphql:jar UP-TO-DATE
> Task :adapter:inbound:web:compileJava UP-TO-DATE
> Task :adapter:inbound:web:processResources NO-SOURCE
> Task :adapter:inbound:web:classes UP-TO-DATE
> Task :adapter:inbound:web:jar UP-TO-DATE
> Task :adapter:outbound:support:compileJava UP-TO-DATE
> Task :adapter:outbound:support:processResources NO-SOURCE
> Task :adapter:outbound:support:classes UP-TO-DATE
> Task :adapter:outbound:support:jar UP-TO-DATE
> Task :adapter:outbound:cache-redis:compileJava UP-TO-DATE
> Task :adapter:outbound:cache-redis:processResources UP-TO-DATE
> Task :adapter:outbound:cache-redis:classes UP-TO-DATE
> Task :adapter:outbound:cache-redis:jar UP-TO-DATE
> Task :adapter:outbound:fileserver:compileJava UP-TO-DATE
> Task :adapter:outbound:fileserver:processResources NO-SOURCE
> Task :adapter:outbound:fileserver:classes UP-TO-DATE
> Task :adapter:outbound:fileserver:jar UP-TO-DATE
> Task :adapter:outbound:httpclient:compileJava UP-TO-DATE
> Task :adapter:outbound:httpclient:processResources NO-SOURCE
> Task :adapter:outbound:httpclient:classes UP-TO-DATE
> Task :adapter:outbound:httpclient:jar UP-TO-DATE
> Task :adapter:outbound:identifier:compileJava UP-TO-DATE
> Task :adapter:outbound:identifier:compileGroovy NO-SOURCE
> Task :adapter:outbound:identifier:processResources NO-SOURCE
> Task :adapter:outbound:identifier:classes UP-TO-DATE
> Task :adapter:outbound:identifier:jar UP-TO-DATE
> Task :adapter:outbound:messaging:compileJava UP-TO-DATE
> Task :adapter:outbound:messaging:processResources UP-TO-DATE
> Task :adapter:outbound:messaging:classes UP-TO-DATE
> Task :adapter:outbound:messaging:jar UP-TO-DATE
> Task :adapter:outbound:notification:compileJava UP-TO-DATE
> Task :adapter:outbound:notification:processResources NO-SOURCE
> Task :adapter:outbound:notification:classes UP-TO-DATE
> Task :adapter:outbound:notification:jar UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processResources UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:classes UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:jar UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:compileJava UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:processResources UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:classes UP-TO-DATE
> Task :adapter:outbound:persistence-mongo:jar UP-TO-DATE
> Task :app-bootstrap:compileJava UP-TO-DATE
> Task :app-bootstrap:processResources UP-TO-DATE
> Task :app-bootstrap:classes UP-TO-DATE
> Task :sample-portfolio:compileJava UP-TO-DATE
> Task :sample-portfolio:processResources UP-TO-DATE
> Task :sample-portfolio:classes UP-TO-DATE
> Task :sample-portfolio:jar UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:compileTestkitJava UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:processTestkitResources NO-SOURCE
> Task :adapter:outbound:persistence-jpa:testkitClasses UP-TO-DATE
> Task :adapter:outbound:persistence-jpa:testkitJar UP-TO-DATE
> Task :app-bootstrap:compileTestJava UP-TO-DATE
> Task :app-bootstrap:runtimeClasspathManifest UP-TO-DATE
> Task :app-bootstrap:processTestResources UP-TO-DATE
> Task :app-bootstrap:testClasses UP-TO-DATE
> Task :messaging:messaging-admin-api:processResources NO-SOURCE
> Task :messaging:messaging-admin-api:classes UP-TO-DATE
> Task :messaging:messaging-admin-api:jar UP-TO-DATE
> Task :messaging:messaging-admin-runtime:processResources NO-SOURCE
> Task :messaging:messaging-admin-runtime:classes UP-TO-DATE
> Task :messaging:messaging-admin-runtime:jar UP-TO-DATE
> Task :messaging:messaging-claim-check:processResources NO-SOURCE
> Task :messaging:messaging-claim-check:classes UP-TO-DATE
> Task :messaging:messaging-claim-check:jar UP-TO-DATE
> Task :messaging:messaging-cloudevents:processResources NO-SOURCE
> Task :messaging:messaging-cloudevents:classes UP-TO-DATE
> Task :messaging:messaging-cloudevents:jar UP-TO-DATE
> Task :messaging:messaging-core-api:processResources NO-SOURCE
> Task :messaging:messaging-core-api:classes UP-TO-DATE
> Task :messaging:messaging-core-api:jar UP-TO-DATE
> Task :messaging:messaging-inbox-jdbc-postgresql:processResources UP-TO-DATE
> Task :messaging:messaging-inbox-jdbc-postgresql:classes UP-TO-DATE
> Task :messaging:messaging-inbox-jdbc-postgresql:jar UP-TO-DATE
> Task :messaging:messaging-kafka:processResources NO-SOURCE
> Task :messaging:messaging-kafka:classes UP-TO-DATE
> Task :messaging:messaging-kafka:jar UP-TO-DATE
> Task :messaging:messaging-observability:processResources NO-SOURCE
> Task :messaging:messaging-observability:classes UP-TO-DATE
> Task :messaging:messaging-observability:jar UP-TO-DATE
> Task :messaging:messaging-outbox-jdbc-postgresql:processResources UP-TO-DATE
> Task :messaging:messaging-outbox-jdbc-postgresql:classes UP-TO-DATE
> Task :messaging:messaging-outbox-jdbc-postgresql:jar UP-TO-DATE
> Task :messaging:messaging-policy:processResources NO-SOURCE
> Task :messaging:messaging-policy:classes UP-TO-DATE
> Task :messaging:messaging-policy:jar UP-TO-DATE
> Task :messaging:messaging-rabbit:processResources NO-SOURCE
> Task :messaging:messaging-rabbit:classes UP-TO-DATE
> Task :messaging:messaging-rabbit:jar UP-TO-DATE
> Task :messaging:messaging-reliability-api:processResources NO-SOURCE
> Task :messaging:messaging-reliability-api:classes UP-TO-DATE
> Task :messaging:messaging-reliability-api:jar UP-TO-DATE
> Task :messaging:messaging-runtime-core:processResources NO-SOURCE
> Task :messaging:messaging-runtime-core:classes UP-TO-DATE
> Task :messaging:messaging-runtime-core:jar UP-TO-DATE
> Task :messaging:messaging-schema-api:processResources NO-SOURCE
> Task :messaging:messaging-schema-api:classes UP-TO-DATE
> Task :messaging:messaging-schema-api:jar UP-TO-DATE
> Task :messaging:messaging-schema-json:processResources NO-SOURCE
> Task :messaging:messaging-schema-json:classes UP-TO-DATE
> Task :messaging:messaging-schema-json:jar UP-TO-DATE
> Task :messaging:messaging-security:processResources NO-SOURCE
> Task :messaging:messaging-security:classes UP-TO-DATE
> Task :messaging:messaging-security:jar UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:processResources UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:classes UP-TO-DATE
> Task :messaging:messaging-spring-boot-starter:jar UP-TO-DATE
> Task :messaging:messaging-transport-spi:processResources NO-SOURCE
> Task :messaging:messaging-transport-spi:classes UP-TO-DATE
> Task :messaging:messaging-transport-spi:jar UP-TO-DATE
> Task :adapter:outbound:objectstorage:compileJava UP-TO-DATE
> Task :adapter:outbound:objectstorage:processResources NO-SOURCE
> Task :adapter:outbound:objectstorage:classes UP-TO-DATE
> Task :adapter:outbound:objectstorage:jar UP-TO-DATE
> Task :app-bootstrap:test
BUILD SUCCESSFUL in 1m 6s
94 actionable tasks: 1 executed, 93 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html
EXIT=0
@@ -0,0 +1,104 @@
# Task 3 — the ten-row activation matrix
Every row below names where its evidence is, and nothing is ticked from reasoning about the code.
Rows 17 and 10 are runtime lanes from one Compose matrix run, `20260819T025416Z-200172`; rows 8 and 9
needed work this wave and are described in full.
The per-lane `activation.json` is the application's own answer about which switches are on, not the
flags the harness passed in — which is the point of reading it rather than the lane definition.
| # | Matrix | Evidence | Resolved switches (from `activation.json`) |
| --- | --- | --- | --- |
| 1 | five off | `off-local`, `off-dev`, `off-prod` lanes + `FiveAdapterOffInventoryTest` | `local` / `dev` / `prod`, **none on** |
| 2 | JPA only | `local-jpa` lane | `local`, `persistence-jpa` |
| 3 | Mongo only | `local-mongo` lane | `local`, `persistence-mongo` |
| 4 | Messaging only | `local-messaging` lane | `local`, `messaging` |
| 5 | Notification + JPA, `INGEST_ONLY` | `local-notification-ingest` + `local-notification-handoff` | `local`, `persistence-jpa`+`notification.platform`, `APP_NOTIFICATION_PLATFORM_MODE: INGEST_ONLY` |
| 6 | Notification + JPA, `SERVING` | `local-notification-serving` | same switches, `MODE: SERVING` |
| 7 | GraphQL only | `local-graphql` lane | `local`, `graphql` |
| 8 | relay on, dependency missing | **this wave** — see below | n/a: the deployment is refused |
| 9 | JPA + Mongo | `all-adapters` lane + `PortResolutionContractTest` | `local`, both persistence switches on |
| 10 | five on | `all-adapters` lane | `local`, `graphql`,`messaging`,`persistence-jpa`,`persistence-mongo`,`notification.platform` |
Row 5's exactly-once claim is not inferred from the lane passing; the smoke client says it:
```
notification-smoke: b0e083cb-06c5-4dde-b454-4c4e26f65edc delivered exactly once and stayed that way
```
## Row 8 — a defect, found by running the row
The plan is explicit that Row 8 must be checked for the *name*, not merely for a failure. Checking it
that way found that the name was not what an operator got.
`ca-skeleton.outbox.enabled=true` with the JPA switch off is a dependency error this repository
names, and `CapabilityDependencyValidator` produces the sentence that names it. But the outbox is
also a registered relational consumer, so `DataSourceRequirement` reports that a pool is required,
`JpaOffAutoConfigurationImportFilter` therefore keeps Boot's relational auto-configurations in the
candidate set, and Hibernate and Flyway were instantiated during refresh — ahead of the
`InitializingBean` that carried the check. What actually came out was:
```
Unable to obtain connection from database: Connection to localhost:5433 refused.
```
Both components were right on their own. A pool does have consumers besides JPA, and the outbox does
need the JPA switch. The disagreement was only ever visible in **which one spoke first**, and no test
could see it: the existing `CapabilityDependencyValidatorTest` calls the validator's static method
against a `MockEnvironment`, which proves the rule computes the right sentence and nothing about
whether anything runs it in time.
**Fix.** The check moved to the environment stage
(`CapabilityDependencyEnvironmentValidator`, an `EnvironmentPostProcessor` at
`LOWEST_PRECEDENCE` alongside the master-switch and profile validators), where every property
source is resolved and nothing has been instantiated. The `InitializingBean` stays: a context built
without `spring.factories` — an `ApplicationContextRunner`, a slice test — never reaches the
post-processor, and the rule should not be optional there.
**Evidence, from the built jar rather than from a test harness** — see
[task3-row8-dependency-error.log](task3-row8-dependency-error.log):
```
### outbox on, JPA off exit code: 1
This deployment enables capabilities whose dependencies are off:
- ca-skeleton.outbox.enabled=true needs relational persistence to store rows;
set ca-skeleton.persistence-jpa.enabled=true or turn the outbox off.
- ca-skeleton.outbox.enabled=true needs somewhere to publish;
set app.messaging.enabled=true or turn the outbox off.
### relay on, outbox off exit code: 1
This deployment enables capabilities whose dependencies are off:
- ca-skeleton.outbox.relay-enabled=true only starts the scheduler for a capability that is off;
set ca-skeleton.outbox.enabled=true or turn the relay off.
```
`DependencyErrorStartupContractTest` pins this at the composition-root level, including a case that
boots all-off successfully — without it, every other assertion in that class would also be satisfied
by a validator that refuses everything.
## Row 9 — the positive half runs; the conflict half is pinned as a rule
`all-adapters` starts PostgreSQL and MongoDB and the application together, and the application
reports both persistence switches on. The two adapters implement disjoint ports, so there is no
ambiguity to resolve and no `@Primary` involved.
That absence is what needs pinning, because manufacturing a conflict would test Spring's
`NoUniqueBeanDefinitionException` rather than this repository. What can silently change is the
property the row depends on: that no port is resolved by preferring one bean over another. A
`@Primary` added later to settle an ambiguity would convert a startup rejection into a silent pick,
and every existing test would stay green, because the composition would still boot.
`PortResolutionContractTest` states the rule directly — a `@Primary` on a port implementation is
permitted only when a condition decides which one is active, which makes it a selector rather than a
tiebreak. The whole repository has three `@Primary` beans:
| bean | type | guard |
| --- | --- | --- |
| `inProcessDistributedLock` | `DistributedLockPort` | `multi-instance-enabled` false or absent |
| `distributedLockProvider` | `DistributedLockPort` | `multi-instance-enabled` true |
| `applicationTaskExecutor` | `TaskExecutor` | none — Boot's own contract wants a primary executor, and it is not a port |
The two lock beans cannot coexist, so neither is being preferred; one of them simply is not there.
The rule was falsified before being trusted: removing the `@ConditionalOnProperty` from
`distributedLockProvider` fails the test, and restoring it passes.
@@ -0,0 +1,26 @@
# Task 3 — the activation matrix, and where each row's evidence comes from
The plan's matrix is ten rows; the Compose contract is fifteen lanes; the in-process activation
suite is nineteen classes. They are not three copies of one thing, so this records which artefact
answers which row before anything is run. A row whose evidence is a passing unit test is recorded as
that, not as a runtime lane.
| # | Matrix row | Evidence |
| --- | --- | --- |
| 1 | five off | `off-local`, `off-dev`, `off-prod` lanes + `FiveAdapterOffInventoryTest` (bean/thread/endpoint inventory is in-process; a container cannot count beans) |
| 2 | JPA only | `local-jpa` lane (db + app) |
| 3 | Mongo only | `local-mongo` lane (mongo + rs-init + app) |
| 4 | Messaging only | `local-messaging` lane; `local-messaging-outbox` additionally proves the relay's database dependency |
| 5 | Notification + JPA, `INGEST_ONLY` | `local-notification-ingest`, and `local-notification-handoff` for the accept → restart → deliver-once half |
| 6 | Notification + JPA, `SERVING` | `local-notification-serving` |
| 7 | GraphQL only | `local-graphql` lane (keycloak + auth-smoke + graphql-smoke) |
| 8 | relay on, dependency missing | **no lane** — a startup that must be *rejected* is an in-process contract: `DependencyErrorStartupContractTest`, `CapabilityDependencyValidatorTest`. The plan requires the *name* of the missing switch, not merely a failure. |
| 9 | JPA + Mongo | `all-adapters` covers the both-on half; the two-implementations-of-one-port rejection is in-process (`CleanArchitectureTest` plus the composition tests), because it is a context-startup outcome |
| 10 | five on | `all-adapters` lane |
Rows 8 and 9 are deliberately not Compose lanes. Both are assertions that a context *refuses* to
start for a stated reason, and a Compose lane can only observe that a container exited — which is
the same observation for a missing switch, a bad password and a typo in a YAML key.
Lanes with no matrix row of their own — `shared-infra-local`, `shared-infra-dev`, `prod-smoke`
carry Task 4's environment evidence rather than Task 3's activation evidence.
@@ -0,0 +1,17 @@
# Task 3 Row 8 — a capability on with its dependency off: the real jar, the real exit code
### outbox on, JPA off
```
java.lang.IllegalStateException: This deployment enables capabilities whose dependencies are off:
- ca-skeleton.outbox.enabled=true needs relational persistence to store rows; set ca-skeleton.persistence-jpa.enabled=true or turn the outbox off.
- ca-skeleton.outbox.enabled=true needs somewhere to publish; set app.messaging.enabled=true or turn the outbox off.
exit code: 1
```
### relay on, outbox off
```
java.lang.IllegalStateException: This deployment enables capabilities whose dependencies are off:
- ca-skeleton.outbox.relay-enabled=true only starts the scheduler for a capability that is off; set ca-skeleton.outbox.enabled=true or turn the relay off.
exit code: 1
```
@@ -0,0 +1,165 @@
# Task 6 — the five reviews' P0 findings, reconciled
The remediation design this wave executed never referenced a review id (`grep -c` over it: zero).
It took one theme from the five reviews — five adapters ship and do not run — and Waves 06 executed
that theme. So "how much of the reviews is reflected" had never been measured. This is the first
measurement, and then the work that followed it.
Three read-only audits ran in parallel, one per review family, each instructed to treat a passing
test as evidence only when it exercises the real composition. Their headline claims were then
re-checked by hand before being acted on; one was wrong and is recorded as such below.
## Where the P0 set stood when measured
| Review | P0 | CLOSED | PARTIAL | OPEN |
| --- | ---: | ---: | ---: | ---: |
| jpa | 6 | 5 | 1 | 0 |
| messaging | 5 | 2 | 3 | 0 |
| graphql | 2 | 1 | 1 | 0 |
| notification | 12 | 1 | 11 | 0 |
| mongodb | 9 | 0 | 9 | 0 |
| **total** | **34** | **9** | **25** | **0** |
Nothing was OPEN: every P0 had been worked. What the audits found instead, in three independent
voices, was the same shape — **the implementation is substantially real and the gate under it is
thin**. Six notification adapter classes were referenced by zero tests. Both Mongo executors were
referenced by zero tests. Every messaging real-broker test skips silently without Docker. And the
Compose matrix, the strongest evidence this repository produces, runs in no workflow.
## What was fixed, and how each was proven
### NTF-004 / JPA-004 — the completion write was not fenced
Two audits reached this independently from different reviews, which is why it was taken first.
The claim is fenced (a single `FOR UPDATE SKIP LOCKED` CTE that bumps `lease_fence`) and the renew
is fenced. The *completion* was `findById → mutate → saveAndFlush` with no owner or fence predicate.
A worker whose lease expired during the provider call — the one stretch the platform deliberately
spends outside a transaction — came back and wrote its outcome over the row a new holder had already
claimed. The `@Version` column does not stop that: it detects a concurrent edit, not a superseded
writer, and the late worker's read is recent enough to win.
Fixed with conditional statements in the same idiom as the renew, `saveHeldBy`/`transitionHeldBy` on
the port returning empty when the lease is gone, and all four branches of `applyNextAction` moved
onto them. Losing the lease is not an error: the new holder owns the job and will record its own
outcome.
`@Modifying(clearAutomatically = true)` on both, because a native update bypasses the persistence
context and the immediate re-read would otherwise be served the values it just replaced — a trap one
of the audits had found elsewhere in this same platform.
**Proof:** two real-PostgreSQL cases in the JPA contract lane. A superseded holder's completion
matches zero rows and the live holder's state is untouched; the current holder's completion writes.
The second exists because without it the first is satisfied by a statement that matches nothing ever.
### NTF-012 — the SSRF guard had no callers
`requireExternallyRoutable` refuses the cloud metadata service, RFC 1918, link-local, IPv6 local,
userinfo disguise and multi-answer DNS. It had an eight-case test suite, all green, and **one
occurrence in the repository: its own definition.** The two sites it was written for —
`WebhookSubscription` and `SesProviderProperties` — still called `requireSecureOrLoopback`, which
reads the scheme and nothing else.
Both now call it. The new test goes through the constructors rather than the helper, because testing
the helper again is exactly what failed to catch this.
What is not closed: `allowLoopback` is true, so a user-supplied target naming `localhost` still
passes. Closing it means the allowance becomes a decision the caller states, and the caller does not
exist — WEBHOOK has no `ProviderRuntimeAssembler`, so a webhook profile refuses to boot and nothing
in production constructs the record. Writing the policy now would mean choosing its default with no
caller to check it against. Recorded in the code at the call site.
### NTF-001 — the documented callback switch broke startup
`CallbackRequestFactory` is a constructor argument of the MVC controller, the WebFlux handler and the
WebFlux configuration, and was produced by no production code — the only instantiation was in a test.
So `APP_NOTIFICATION_PLATFORM_CALLBACKS_ENABLED=true`, a key in the env registry and in the
configuration reference, did not enable callbacks; it failed the boot on an unsatisfied dependency.
The beans now exist, conditioned on the same switch. The missing `trusted-proxies` setting came with
them, defaulting to empty — with no entry, forwarded headers are not believed, because honouring
them unconditionally lets any caller choose the URL its own signature is checked against. Registered
in `application.yml`, the env registry, `.env.example` and the configuration reference; both
notification gates pass.
### MNG-007 — a health view Actuator could not read, and a reactive half nothing built
`MongoPlatformHealthIndicator` computed topology mismatch, secondary availability and a bounded
detail map, and implemented neither `HealthIndicator` nor `HealthContributor`. The adapter carries no
Actuator dependency and should not: the delivery platform had already established the shape, where
the adapter computes the facts and the composition root maps them onto `Health`. Done the same way.
Separately, every reactive class in the leaf — executor, consistency binder, session factory, cursor
guard — was declared by no configuration. They shipped and no configuration could construct them.
Now wired in a nested configuration conditioned on a `ReactiveMongoTemplate` *bean*, not just the
class: the class is on the compile classpath unconditionally, so a class condition alone would try to
build the reactive path in a servlet-only deployment and fail a startup for a capability nobody asked
for. Both the positive and the negative case are asserted.
### MNG-005 / MNG-023 — a deadline that only produced a report
The blocking executor compared elapsed time to the declared timeout *after* the callback returned,
and said so in its own comment: a Java callback cannot be interrupted mid driver call. That is an
overrun report, not a deadline.
The scoped API is the narrowed surface where the number can actually be sent, so every method taking
a `Query` or an `Aggregation` now carries it as `maxTimeMS`, which the server enforces. `insert` has
no query to attach it to. `Duration.ZERO` is refused, because zero means "no limit" to the server and
accepting it would turn a misconfiguration into an unbounded operation.
The raw escape hatches (`rawOperations()`, `executeInternal(...)`) are genuinely used inside the
platform by the geospatial, atomic and bulk operations, and their callers live in sibling packages,
so package-private cannot express the rule. It is enforced as a boundary from the composition root —
the only place that sees both the platform and everything consuming it. The rule was falsified by
widening its scope until it fired, and a second case asserts the platform still uses them, so the
rule cannot pass by the hatches having been deleted.
### MNG-006 — a guard that compared a declaration against nothing
`LocalDateTimeMappingGuard` was constructed `withoutConverters()` and then asked to validate the
manifest. It could only ever reject `LOCAL_DATE_TIME_WITH_REGISTERED_CONVERTER`: a deployment that
*had* registered the named converter was rejected exactly as loudly as one that had not, so the check
that exists to distinguish those two cases could not tell them apart. It now reads the converters the
deployment actually registered, and a test asserts the same manifest passes with the converter and
fails without it.
### MNG-008 — a promotion gate that required five of the six categories it declares
`MongoAdvancedPromotionEvidence.REQUIRED` listed six; `MongoAdvancedPromotionGate.verify()` required
five. `migration` was missing, so a promotion could pass with no migration evidence at all.
### GQL-002 — the batch policy applied to nothing
`GraphQlBatchLoaderRegistrar` carried the chunking, the budget and the request scope, was unit
tested, and was declared by no configuration — the only file mentioning it was itself. A field
resolving through `@BatchMapping` or a `DataLoader` met none of it. The chain
(`BatchPolicyRegistry → DataLoaderFactory → BatchLoaderRegistrar`) is now assembled by the platform.
The batch ceiling reuses `maximumPageSize` rather than adding a setting: both answer how many rows
one downstream call may ask for, and a batch limit above the page limit would let one request fan out
past the bound it already accepted.
## One thing an audit got wrong, and one fix that was reverted
The mongodb audit reported that the UUID axis is "declaration-only" and mapped onto the driver
nowhere. It is mapped — `MongoClientSettingsFactory` calls `.uuidRepresentation(...)`. Verified
before acting.
Acting on the adjacent concern — the manifest and the profile describing the same fact independently
— a startup check was written to refuse a disagreement between them. Writing its test showed the
disagreement cannot occur: `MongoUuidRepresentation` has two values, only `STANDARD` is writable, and
an existing check already refuses the other. **A guard for a state that cannot arise is the same
"declaration nothing checks" this wave has been removing**, so it was reverted rather than kept with
an unfalsifiable test.
## What remains, and why
- **NTF-012 loopback residue** — belongs with the change set that gives WEBHOOK an assembler.
- **MSG-015** — the application-owned port and anti-corruption bridge between `application-core` and
the messaging platform. The review names it and specifies the target shape; it is a runtime wiring
change, and the review itself says the physical work is a separate change set.
- **MSG-003/004/005 residue** — on code no deployment can execute: the platform's outbox and inbox
migrations are applied only by tests, the production Kafka transport is publish-only, and Rabbit
declares no transport bean.
- **P1 and P2 — 100 findings, never audited.** They were not in this remediation's scope and their
state is unknown. Saying so is the honest position; the P0 audit took three parallel agents and
the P1/P2 set is three times larger.
+75
View File
@@ -0,0 +1,75 @@
#!/bin/sh
# The GraphQL transport, exercised as a request rather than as a bean inventory.
#
# auth-smoke proves the realm issues a usable token and that the application answers its public
# health path. It never sends an authenticated request, and never touches /graphql at all — so the
# JWT decoder, the security filter chain and the GraphQL execution path were each covered by their
# own tests and by nothing that put them in one line together.
#
# Three requests, in this order, because each is only meaningful given the one before:
#
# 1. an unauthenticated query, which must be refused — otherwise steps 2 and 3 prove nothing about
# authentication, they just prove the endpoint answers;
# 2. a malformed token, which must be refused without a server error — a 500 here means the
# decoder threw where it should have rejected;
# 3. the real token, which must return the schema's liveness field — the request path, the policy
# instrumentation and the resolver, in one call.
set -eu
SECRET_FILE="/run/secrets/keycloak-graphql-smoke-client-secret"
GRAPHQL_PATH="${GRAPHQL_PATH:-/graphql}"
QUERY='{"query":"{ _health }"}'
fail() { echo "graphql-smoke: $1" >&2; exit 1; }
post() {
# $1 = Authorization header value, or empty for none. Prints the status code; body to /tmp/gql.json.
if [ -n "$1" ]; then
curl -s -o /tmp/gql.json -w '%{http_code}' -X POST "${APP_BASE_URL}${GRAPHQL_PATH}" \
-H "Authorization: $1" -H 'Content-Type: application/json' -d "${QUERY}"
else
curl -s -o /tmp/gql.json -w '%{http_code}' -X POST "${APP_BASE_URL}${GRAPHQL_PATH}" \
-H 'Content-Type: application/json' -d "${QUERY}"
fi
}
# 1. no credential at all
ANON_STATUS="$(post '')"
case "${ANON_STATUS}" in
401|403) : ;;
200) fail "an unauthenticated GraphQL query was answered (${ANON_STATUS}); /graphql is not guarded" ;;
*) fail "an unauthenticated GraphQL query answered ${ANON_STATUS}: $(cat /tmp/gql.json)" ;;
esac
# 2. a credential that is not a token
BAD_STATUS="$(post 'Bearer not-a-real-token')"
[ "${BAD_STATUS}" != "500" ] \
|| fail "a malformed token produced a server error rather than a refusal"
case "${BAD_STATUS}" in
401|403) : ;;
*) fail "a malformed token answered ${BAD_STATUS}, which is neither a refusal nor a server error" ;;
esac
# 3. the real thing
[ -r "${SECRET_FILE}" ] || fail "the client secret was not mounted"
TOKEN_RESPONSE="$(curl -sf -X POST "${KEYCLOAK_ISSUER}/protocol/openid-connect/token" \
-d grant_type=client_credentials \
-d "client_id=${KEYCLOAK_CLIENT_ID}" \
--data-urlencode "client_secret=$(cat "${SECRET_FILE}")")" \
|| fail "client-credentials token request failed"
ACCESS_TOKEN="$(echo "${TOKEN_RESPONSE}" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')"
[ -n "${ACCESS_TOKEN}" ] || fail "the token response carried no access_token"
OK_STATUS="$(post "Bearer ${ACCESS_TOKEN}")"
[ "${OK_STATUS}" = "200" ] \
|| fail "an authenticated GraphQL query answered ${OK_STATUS}: $(cat /tmp/gql.json)"
# The body, not just the status. A 200 carrying a GraphQL `errors` array is how a refused or failed
# execution looks over HTTP, so a status-only check would pass on an unresolved field.
grep -q '"_health"' /tmp/gql.json \
|| fail "the response carried no _health field: $(cat /tmp/gql.json)"
if grep -q '"errors"' /tmp/gql.json; then
fail "the query returned GraphQL errors: $(cat /tmp/gql.json)"
fi
echo "graphql-smoke: /graphql refused anonymous and malformed credentials and answered the authenticated query"
+2
View File
@@ -331,6 +331,8 @@ APP_NOTIFICATION_PLATFORM_ALLOW_AMBIGUOUS_FALLBACK=false
APP_NOTIFICATION_PLATFORM_CALLBACKS_ENABLED=false APP_NOTIFICATION_PLATFORM_CALLBACKS_ENABLED=false
APP_NOTIFICATION_PLATFORM_CALLBACK_MAX_BODY_BYTES=65508 APP_NOTIFICATION_PLATFORM_CALLBACK_MAX_BODY_BYTES=65508
APP_NOTIFICATION_PLATFORM_CALLBACK_REPLAY_SKEW=5m APP_NOTIFICATION_PLATFORM_CALLBACK_REPLAY_SKEW=5m
# Empty: forwarded headers are not believed. List load-balancer peers to honour them.
APP_NOTIFICATION_PLATFORM_CALLBACK_TRUSTED_PROXIES=
# ---- Secrets — supply out of band; never commit a value here ------------------ # ---- Secrets — supply out of band; never commit a value here ------------------
APP_DATASOURCE_PASSWORD= APP_DATASOURCE_PASSWORD=
+5
View File
@@ -40,6 +40,11 @@ COPY gradlew ./
COPY gradle/ gradle/ COPY gradle/ gradle/
COPY config/ ./config/ COPY config/ ./config/
COPY --parents settings.gradle build.gradle **/build.gradle **/gradle.lockfile ./ COPY --parents settings.gradle build.gradle **/build.gradle **/gradle.lockfile ./
# The convention plugins, whole. The glob above copies files named build.gradle and gradle.lockfile,
# which picks up build-logic's own build script and misses the precompiled script plugins beside it —
# so settings.gradle's `includeBuild('build-logic')` resolved against a build that declared no
# plugins and every leaf failed on an unknown plugin id, in the dependency-resolution stage below.
COPY build-logic/ ./build-logic/
# Resolve every module configuration in STRICT mode (no --write-locks in a release build). This # Resolve every module configuration in STRICT mode (no --write-locks in a release build). This
# custom task fails on drift; Gradle's diagnostic `dependencies` report can print FAILED entries # custom task fails on drift; Gradle's diagnostic `dependencies` report can print FAILED entries
+63 -5
View File
@@ -241,6 +241,59 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지
- **`APP_ASYNC_EXECUTOR_QUEUE_CAPACITY`** — 백로그 큐 용량. **bounded(유한) 필수, unbounded 금지(D7)**. - **`APP_ASYNC_EXECUTOR_QUEUE_CAPACITY`** — 백로그 큐 용량. **bounded(유한) 필수, unbounded 금지(D7)**.
1 이상 정수. 1 이상 정수.
### 다섯 master switch (activation SSOT)
한 bootJar가 다섯 어댑터를 모두 싣고, 각각은 아래 스위치 하나로만 켜집니다. **전부 기본 `false`** 이고,
다섯이 모두 꺼진 배포는 외부 자원 없이 기동합니다. 값의 SSOT는
`dev.caskeleton.shared.activation.MasterSwitch`이며 `docs/registries/env-keys.yaml`이 같은 이름을
등록합니다.
| 환경 변수 | Spring property | 기본값 |
|---|---|---|
| `APP_PERSISTENCE_JPA_ENABLED` | `ca-skeleton.persistence-jpa.enabled` | `false` |
| `APP_PERSISTENCE_MONGO_ENABLED` | `ca-skeleton.persistence-mongo.enabled` | `false` |
| `APP_MESSAGING_ENABLED` | `app.messaging.enabled` | `false` |
| `APP_NOTIFICATION_PLATFORM_ENABLED` | `ca-skeleton.notification.platform.enabled` | `false` |
| `APP_GRAPHQL_ENABLED` | `backend.graphql.enabled` | `false` |
**어댑터가 꺼진 것과 없는 것은 다릅니다.** 다섯 어댑터의 클래스는 언제나 아티팩트 안에 있고, 운영자는
재빌드 없이 스위치만으로 켭니다. 클래스가 없으면 애초에 켤 수 없습니다.
켜진 capability가 의존하는 것이 꺼져 있으면 기동이 거부되고, **거부 메시지는 설정해야 할 정확한
property 이름을 말합니다**(`CapabilityDependencyValidator`). 예:
```
This deployment enables capabilities whose dependencies are off:
- ca-skeleton.outbox.enabled=true needs relational persistence to store rows;
set ca-skeleton.persistence-jpa.enabled=true or turn the outbox off.
```
종속 선택자 둘:
- `APP_GRAPHQL_DEPLOYMENT_MODE` — GraphQL이 켜지면 **필수**이고 기본값이 없습니다. 예전의 boolean과
enum 두 기본값이 서로 다른 말을 했기 때문에, 안전 태세는 배포가 명시적으로 고릅니다. 허용되는 값은
런타임 환경(`local`/`dev`/`prod`)마다 다릅니다.
- `APP_NOTIFICATION_PLATFORM_MODE``SERVING`(기본) 또는 `INGEST_ONLY`. 선택 사항이고, 허용 값은
`NotificationModeSsotTest`가 enum에서 파생합니다.
### Compose와 런타임 스모크
- **Docker Compose 최소 버전 `2.24.4`.** SSOT는 `src/config/runtime/compose-profile-contracts.json`
이고, 정본 스크립트가 검증합니다.
- 진입점은 둘뿐이고, 그 둘만이 증거입니다. 워크플로에 명령 일부를 인라인하면 one-shot 없이 도는 레인이
초록으로 보고됩니다.
```bash
# 정적: profile별 정확한 service set, 병합된 모델 전체, mount target 유일성
./scripts/verify-compose-profile-contracts.sh
# 동적: 15개 blocking 레인을 zero-skip으로. create → up --wait → 필수 one-shot →
# sanitized evidence → 고유 project teardown
./scripts/run-compose-runtime-smoke.sh --matrix src/config/runtime/compose-profile-contracts.json
```
`--lane <id>`는 실패 재현용이고, 레인 하나가 초록인 것은 matrix가 통과했다는 증거가 아닙니다.
### Optional integration adapters ### Optional integration adapters
선택형 Kafka / Redis / Slack / Google Email 어댑터 템플릿입니다. **기본은 전부 비활성**(비활성 = 선택 선택형 Kafka / Redis / Slack / Google Email 어댑터 템플릿입니다. **기본은 전부 비활성**(비활성 = 선택
@@ -252,13 +305,18 @@ fail-fast sentinel 이 포트를 충족합니다(Layer 3).
`disabled`(기본) | `redis`. `redis`는 canonical Redis CACHE role binding을 함께 요구합니다. `disabled`(기본) | `redis`. `redis`는 canonical Redis CACHE role binding을 함께 요구합니다.
- **`APP_CACHE_REDIS_CLIENT_MODE`** — `managed`는 내장 Lettuce runtime, `external`은 프로젝트가 - **`APP_CACHE_REDIS_CLIENT_MODE`** — `managed`는 내장 Lettuce runtime, `external`은 프로젝트가
제공한 `RedisClient` bean을 사용합니다. 제공한 `RedisClient` bean을 사용합니다.
- **`APP_MESSAGING_BROKER`** — 활성 메시지 브로커 id(예: `kafka`). 빈 값 = 메시징 비활성(사용 시 아래 세 키는 **활성화 스위치가 아니라 선택자**입니다. 어떤 capability가 켜지는지는 위의 master switch가
fail-fast). 정하고, 이 값들은 켜진 capability가 *무엇으로* 동작할지만 고릅니다. 예전에는 "빈 값 = 비활성"으로
설명돼 있었고, 그 문장이 남아 있는 동안 두 개의 활성화 모델이 공존했습니다.
- **`APP_MESSAGING_BROKER`** — 활성 메시지 브로커 id(예: `kafka`). `APP_MESSAGING_ENABLED=true`일 때
**필수**이고, 빈 값이면 기동이 거부되면서 이 키 이름을 지목합니다. 이 키를 비워도 메시징이 꺼지지는
않습니다 — 끄는 것은 master switch입니다.
- **`APP_MESSAGING_KAFKA_BROKERS`** — `host:port` CSV. `APP_MESSAGING_BROKER=kafka` 일 때만 필수, - **`APP_MESSAGING_KAFKA_BROKERS`** — `host:port` CSV. `APP_MESSAGING_BROKER=kafka` 일 때만 필수,
아니면 빈 값. 아니면 빈 값.
- **`APP_NOTIFICATION_SLACK_PROVIDER`** — 활성 Slack provider id(예: `webhook`). 빈 값 = Slack 비활성. - **`APP_NOTIFICATION_SLACK_PROVIDER`** — Slack provider id(예: `webhook`). notification 플랫폼이
- **`APP_NOTIFICATION_EMAIL_PROVIDER`** — 활성 email provider id(예: `google-email`). 빈 값 = email 켜졌을 때 어떤 provider를 쓸지 고르는 값입니다.
비활성. - **`APP_NOTIFICATION_EMAIL_PROVIDER`** — email provider id(예: `google-email`). 위와 같습니다.
### Outbound HTTP client ### Outbound HTTP client
+9 -4
View File
@@ -69,7 +69,10 @@ SSOT(`src/config/architecture/modules.json`)까지 밀어올리지 않는다는
## Allowed ## Allowed
- `:application-core`, `:domain-core`, `:shared-contract`. - `:application-core`, `:domain-core`, `:shared-contract`. `:application-core` 는 선언만이 아니라 실제
의존이다 — object 인가의 **답하는 계약**(`dev.caskeleton.application.security.ObjectAccessPolicy`)이
거기 살기 때문이다. 이 leaf 가 그 계약을 소유했다면 application 구현체가 인바운드 전송을 컴파일
의존해야 했고, 그건 의존 방향이 뒤집힌다.
- `spring-boot-starter-graphql` (Spring Boot BOM 관리 — 버전 명시 없음). - `spring-boot-starter-graphql` (Spring Boot BOM 관리 — 버전 명시 없음).
- test scope 에 한해 `spring-boot-starter-web`(random-port 전송 테스트용), - test scope 에 한해 `spring-boot-starter-web`(random-port 전송 테스트용),
`spring-boot-starter-security`(HTTP 인증/CORS qualification 용), `spring-boot-starter-security`(HTTP 인증/CORS qualification 용),
@@ -140,7 +143,9 @@ health 스키마만 소유한다.
| preparsed document cache (`execution/`) | `wired` | `GraphQlPreparsedDocumentAdapter` + 같은 테스트의 캐시 hit 케이스 | | preparsed document cache (`execution/`) | `wired` | `GraphQlPreparsedDocumentAdapter` + 같은 테스트의 캐시 hit 케이스 |
| 커스텀 scalar (`scalar/`) | `wired` | 같은 테스트의 scalar coercion 케이스 | | 커스텀 scalar (`scalar/`) | `wired` | 같은 테스트의 scalar coercion 케이스 |
| 요청 크기/Accept 협상 (`http/`) | `wired` | `GraphQlRequestBoundsTest`, `GraphQlAcceptNegotiationTest` | | 요청 크기/Accept 협상 (`http/`) | `wired` | `GraphQlRequestBoundsTest`, `GraphQlAcceptNegotiationTest` |
| DataLoader/batching (`dataloader/`) | `wired` | `runtime/GraphQlBatchLoaderRegistrar` + `dataloader/GraphQlBatchContractTest` | | 관측 tag cardinality (`observation/`) | `wired` | `runtime/GraphQlRequestObservationConventionAdapter` 가 Spring 의 `ExecutionRequestObservationConvention` 을 구현해 Boot 의 `GraphQlObservationAutoConfiguration` 이 이 컨벤션을 가져간다. `autoconfigure/GraphQlObservationWiringTest`(프레임워크가 실제로 해석), `runtime/GraphQlRequestObservationConventionAdapterTest`(실제 `MeterRegistry` 에 임의 이름 10,000개 → series 1개) |
| object 인가 (`security/`) | `modelled` | 답하는 계약은 중립 `dev.caskeleton.application.security.ObjectAccessPolicy` 가 소유하고 이 leaf 는 `ApplicationObjectAuthorization` 매핑만 가진다. 실행 경로에 연결하는 configuration 은 없다 |
| DataLoader/batching (`dataloader/`) | `wired` | `runtime/GraphQlBatchLoaderRegistrationTest` (실제 graphql-java 실행 + Spring `BatchLoaderRegistry`, 50 parent → 3 downstream 호출), `dataloader/GraphQlBatchContractTest` |
| cursor 서명 (`pagination/`) | `modelled` | `HmacGraphQlCursorCodec`·`GraphQlCursorKeyRing` 단위 테스트만. **auto-configuration 이 둘 중 무엇도 생성하지 않는다**`autoconfigure/GraphQlPolicyRequestPathTest` 가 그 사실을 고정 | | cursor 서명 (`pagination/`) | `modelled` | `HmacGraphQlCursorCodec`·`GraphQlCursorKeyRing` 단위 테스트만. **auto-configuration 이 둘 중 무엇도 생성하지 않는다**`autoconfigure/GraphQlPolicyRequestPathTest` 가 그 사실을 고정 |
| mutation 멱등성 (`mutation/`) | `modelled` | `GraphQlMutationIdempotencyInterceptor` 를 참조하는 configuration 이 없다. 같은 테스트가 고정 | | mutation 멱등성 (`mutation/`) | `modelled` | `GraphQlMutationIdempotencyInterceptor` 를 참조하는 configuration 이 없다. 같은 테스트가 고정 |
| persisted operation (`advanced/persisted/`) | `modelled` | 중립 `OperationalRecordStorePort` 기반 레지스트리 + 방향성 테스트. durable 구현체는 미제공 | | persisted operation (`advanced/persisted/`) | `modelled` | 중립 `OperationalRecordStorePort` 기반 레지스트리 + 방향성 테스트. durable 구현체는 미제공 |
@@ -190,7 +195,7 @@ cd src
`quarantine`·`graphql-performance` 태그를 제외한다: `quarantine`·`graphql-performance` 태그를 제외한다:
```bash ```bash
./gradlew :adapter:inbound:graphql:graphqlStableTest --console=plain # 551 tests ./gradlew :adapter:inbound:graphql:graphqlStableTest --console=plain # 605 tests
./gradlew :adapter:inbound:graphql:graphqlContractTest --console=plain # 9 tests ./gradlew :adapter:inbound:graphql:graphqlContractTest --console=plain # 9 tests
./gradlew :adapter:inbound:graphql:graphqlAdvancedTest --console=plain # 152 tests ./gradlew :adapter:inbound:graphql:graphqlAdvancedTest --console=plain # 152 tests
./gradlew :adapter:inbound:graphql:graphqlPerformanceTest --console=plain # 실부하 인프라 필요 ./gradlew :adapter:inbound:graphql:graphqlPerformanceTest --console=plain # 실부하 인프라 필요
@@ -199,7 +204,7 @@ cd src
`graphqlPerformanceTest``@Tag("graphql-performance")` 가 하나도 없으면 **실패한다** — 이는 `graphqlPerformanceTest``@Tag("graphql-performance")` 가 하나도 없으면 **실패한다** — 이는
버그가 아니라 "성능 증거 없음"을 통과로 위장하지 않기 위한 fail-closed 설계다. 버그가 아니라 "성능 증거 없음"을 통과로 위장하지 않기 위한 fail-closed 설계다.
위 숫자는 `build/test-results/<lane>/*.xml` 의 실제 실행 결과다(기본 `test` 703, transport 위 숫자는 `build/test-results/<lane>/*.xml` 의 실제 실행 결과다(기본 `test` 757, transport
qualification 8). 문서에 옮겨 적은 숫자는 반드시 마지막 green 실행에서 다시 읽어 갱신한다 — qualification 8). 문서에 옮겨 적은 숫자는 반드시 마지막 green 실행에서 다시 읽어 갱신한다 —
컴파일이 깨진 채로 남은 과거 숫자는 통과 증거가 아니라 통과했다는 인상일 뿐이다. 컴파일이 깨진 채로 남은 과거 숫자는 통과 증거가 아니라 통과했다는 인상일 뿐이다.
+16 -92
View File
@@ -19,12 +19,17 @@ description = 'Inbound adapter: GraphQL API (Spring for GraphQL, GraphQL executi
// the second half rather than trusting it. // the second half rather than trusting it.
apply plugin: 'java-test-fixtures' apply plugin: 'java-test-fixtures'
apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle"
apply from: "${rootProject.projectDir}/gradle/graphql-platform-conventions.gradle" apply from: "${rootProject.projectDir}/gradle/graphql-platform-conventions.gradle"
dependencies { dependencies {
implementation project(':shared-contract') implementation project(':shared-contract')
// The object-access rule this platform consults belongs to the application, and so does its
// contract: an inbound adapter that declared it would force every implementation to compile
// against this transport. The leaf keeps only the mapping from a GraphQL request context to
// the four plain values that contract speaks.
implementation project(':application-core')
// The fixtures exercise the platform through the same contracts an adopter uses. // The fixtures exercise the platform through the same contracts an adopter uses.
testFixturesImplementation project(':shared-contract') testFixturesImplementation project(':shared-contract')
testFixturesImplementation 'org.springframework.boot:spring-boot-starter-graphql' testFixturesImplementation 'org.springframework.boot:spring-boot-starter-graphql'
@@ -152,95 +157,14 @@ tasks.named('check') {
// prerequisite for shrinking it: the `api` and `spi` packages are the surface an adopter is meant // prerequisite for shrinking it: the `api` and `spi` packages are the surface an adopter is meant
// to use, and everything else in this file is a candidate for becoming internal when the leaf is // to use, and everything else in this file is a candidate for becoming internal when the leaf is
// split into capability artifacts. Until then the number cannot grow by accident. // split into capability artifacts. Until then the number cannot grow by accident.
def graphQlApiSurfaceFile = rootProject.file('../docs/architecture/graphql-api-surface.txt') apiSurface {
label = 'GraphQl'
Closure<String> renderGraphQlApiSurface = { baseline = rootProject.file('../docs/architecture/graphql-api-surface.txt')
def sourceRoot = file('src/main/java') description = 'GraphQL leaf public API surface — every public top-level type in src/main/java.'
def typePattern = ~/(?m)^public\s+(?:final\s+|abstract\s+|sealed\s+|non-sealed\s+)*(class|interface|enum|record|@interface)\s+(\w+)/ rationale = [
def packagePattern = ~/(?m)^package\s+([\w.]+)\s*;/ 'A public type in a single-jar leaf is reachable from every adopter\'s code, so',
List<String> types = [] 'additions are reviewed rather than discovered. `api` and `spi` are the intended',
sourceRoot.eachFileRecurse { candidate -> 'external surface; the rest are candidates to become internal when this leaf is',
if (!candidate.isFile() || !candidate.name.endsWith('.java')) { 'split into capability artifacts.',
return ]
}
String text = candidate.getText('UTF-8')
def packageMatcher = packagePattern.matcher(text)
if (!packageMatcher.find()) {
return
}
String packageName = packageMatcher.group(1)
def typeMatcher = typePattern.matcher(text)
while (typeMatcher.find()) {
types << "${packageName}.${typeMatcher.group(2)}".toString()
}
}
types = types.unique().toSorted()
String header =
"# GraphQL leaf public API surface — every public top-level type in src/main/java.\n" +
"# A public type in a single-jar leaf is reachable from every adopter's code, so\n" +
"# additions are reviewed rather than discovered. `api` and `spi` are the intended\n" +
"# external surface; the rest are candidates to become internal when this leaf is\n" +
"# split into capability artifacts.\n" +
"# Update only after review with:\n" +
"# ./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange\n" +
"# types: ${types.size()}\n"
header + (types.isEmpty() ? '' : types.join('\n') + '\n')
}
// The approval flag is read at configuration time and carried in, not fetched from `project`
// inside doLast. Task.project at execution time is deprecated and fails under Gradle 10, and it is
// incompatible with the configuration cache which this build will need before it can adopt one.
boolean graphQlApiSurfaceUpdateApproved = project.hasProperty('approveGraphQlApiSurfaceChange')
tasks.register('verifyGraphQlApiSurface') {
group = 'verification'
description = 'Fails without mutation when the committed GraphQL public API surface drifts.'
doLast {
if (graphQlApiSurfaceUpdateApproved) {
throw new GradleException(
'verifyGraphQlApiSurface is read-only; use updateGraphQlApiSurface to record an ' +
'approved change.')
}
String rendered = renderGraphQlApiSurface()
if (!graphQlApiSurfaceFile.isFile()) {
throw new GradleException(
"verifyGraphQlApiSurface: missing committed baseline ${graphQlApiSurfaceFile}")
}
String committed = graphQlApiSurfaceFile.getText('UTF-8')
if (committed != rendered) {
List<String> committedTypes = committed.readLines().findAll { !it.startsWith('#') }
List<String> renderedTypes = rendered.readLines().findAll { !it.startsWith('#') }
List<String> added = (renderedTypes - committedTypes).toSorted()
List<String> removed = (committedTypes - renderedTypes).toSorted()
throw new GradleException(
"verifyGraphQlApiSurface: the public API surface changed.\n" +
(added.isEmpty() ? '' : " added:\n " + added.join('\n ') + '\n') +
(removed.isEmpty() ? '' : " removed:\n " + removed.join('\n ') + '\n') +
"Review the change, then record it with:\n" +
" ./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface " +
"-PapproveGraphQlApiSurfaceChange")
}
logger.lifecycle('verifyGraphQlApiSurface: OK — the committed public API surface is unchanged.')
}
}
tasks.register('updateGraphQlApiSurface') {
group = 'verification'
description = 'Rewrites the committed GraphQL public API surface baseline after review.'
doLast {
if (!project.hasProperty('approveGraphQlApiSurfaceChange')) {
throw new GradleException(
'updateGraphQlApiSurface requires -PapproveGraphQlApiSurfaceChange: growing the ' +
'public surface is a review decision, not a build step.')
}
graphQlApiSurfaceFile.parentFile.mkdirs()
graphQlApiSurfaceFile.setText(renderGraphQlApiSurface(), 'UTF-8')
logger.lifecycle("updateGraphQlApiSurface: wrote ${graphQlApiSurfaceFile}")
}
}
tasks.named('check') {
dependsOn tasks.named('verifyGraphQlApiSurface')
} }
@@ -1,9 +1,6 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.subscription; package dev.caskeleton.adapter.inbound.graphql.advanced.subscription;
import dev.caskeleton.adapter.inbound.graphql.security.GraphQlContextCleanup; import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlCancellation;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicBoolean;
/** /**
* Propagates cancellation from the client to the source. * Propagates cancellation from the client to the source.
@@ -12,47 +9,32 @@ import java.util.concurrent.atomic.AtomicBoolean;
* consumer, the polling task and the nested publishers all continue for a subscriber that has gone, * consumer, the polling task and the nested publishers all continue for a subscriber that has gone,
* and nothing in the request path notices. * and nothing in the request path notices.
* *
* <p>Every hook runs exactly once, and one that throws does not stop the rest. The loop used to * <p>The signal itself is {@link GraphQlCancellation}, not a second copy of it. There used to be
* abandon the queue at the first failure, so a broken consumer-close left the polling task and the * two one-way cancellation state machines in this platform with the same queue, the same flag and
* nested publishers running the leak the second and third hooks existed to prevent, caused by the * the same late-registration rule, which is two places for the run-every-hook guarantee to be
* first one failing. * correct in and it was correct in one of them. Whatever the request path guarantees, a
* subscription now guarantees by construction: every hook runs exactly once, one that throws does
* not stop the rest, and the first failure carries the later ones as suppressed.
*/ */
public final class GraphQlSubscriptionCancellation { public final class GraphQlSubscriptionCancellation {
private final AtomicBoolean cancelled = new AtomicBoolean(); private final GraphQlCancellation cancellation = GraphQlCancellation.create();
private final Queue<Runnable> upstream = new ConcurrentLinkedQueue<>();
/** Registers upstream work to stop on cancellation. */ /** Registers upstream work to stop on cancellation. */
public void onCancel(Runnable stopUpstream) { public void onCancel(Runnable stopUpstream) {
if (stopUpstream == null) { if (stopUpstream == null) {
throw new IllegalArgumentException("upstream cancellation hook is required"); throw new IllegalArgumentException("upstream cancellation hook is required");
} }
upstream.add(stopUpstream); cancellation.onCancel(stopUpstream);
if (cancelled.get()) {
drain();
}
} }
/** Cancels the subscription and everything upstream of it. */ /** Cancels the subscription and everything upstream of it. */
public void cancel() { public void cancel() {
if (cancelled.compareAndSet(false, true)) { cancellation.cancel();
drain();
}
} }
/** Whether the subscription has been cancelled. */ /** Whether the subscription has been cancelled. */
public boolean cancelled() { public boolean cancelled() {
return cancelled.get(); return cancellation.cancelled();
}
private void drain() {
GraphQlContextCleanup cleanup = GraphQlContextCleanup.create();
Runnable hook = upstream.poll();
while (hook != null) {
cleanup.register(hook);
hook = upstream.poll();
}
// The same run-all-then-rethrow-with-suppressed semantics the request path already uses.
cleanup.close();
} }
} }
@@ -32,6 +32,7 @@ import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformInstrumenta
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformWebInterceptor; import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformWebInterceptor;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPreparsedDocumentAdapter; import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPreparsedDocumentAdapter;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPrincipalResolver; import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPrincipalResolver;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlRequestObservationConventionAdapter;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlWireErrorMapper; import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlWireErrorMapper;
import dev.caskeleton.adapter.inbound.graphql.runtime.servlet.GraphQlRequestBodyLimitFilter; import dev.caskeleton.adapter.inbound.graphql.runtime.servlet.GraphQlRequestBodyLimitFilter;
import dev.caskeleton.adapter.inbound.graphql.scalar.GraphQlScalarWiringConfigurer; import dev.caskeleton.adapter.inbound.graphql.scalar.GraphQlScalarWiringConfigurer;
@@ -60,6 +61,7 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered; import org.springframework.core.Ordered;
import org.springframework.graphql.execution.DataFetcherExceptionResolver; import org.springframework.graphql.execution.DataFetcherExceptionResolver;
import org.springframework.graphql.execution.GraphQlSource; import org.springframework.graphql.execution.GraphQlSource;
import org.springframework.graphql.observation.ExecutionRequestObservationConvention;
import org.springframework.graphql.server.WebGraphQlInterceptor; import org.springframework.graphql.server.WebGraphQlInterceptor;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.util.ClassUtils; import org.springframework.util.ClassUtils;
@@ -526,6 +528,23 @@ public class GraphQlPlatformAutoConfiguration {
return new GraphQlRequestObservationConvention(filter, operationNames); return new GraphQlRequestObservationConvention(filter, operationNames);
} }
/**
* Hands the bounded request tags to Spring for GraphQL's observation instrumentation.
*
* <p>The convention above is a value; this is the bean Boot's {@code
* GraphQlObservationAutoConfiguration} looks for. Without it the framework falls back to its own
* convention and the platform's cardinality policy applies to nothing that is exported the
* shape of defect where a control passes its tests and no request reaches it.
*
* @param convention the platform's bounded tag policy
*/
@Bean
@ConditionalOnMissingBean(ExecutionRequestObservationConvention.class)
public GraphQlRequestObservationConventionAdapter graphQlExecutionRequestObservationConvention(
GraphQlRequestObservationConvention convention) {
return new GraphQlRequestObservationConventionAdapter(convention);
}
/** /**
* Which operation names may become metric labels. * Which operation names may become metric labels.
* *
@@ -601,6 +620,78 @@ public class GraphQlPlatformAutoConfiguration {
builder.configureGraphQl(graphQl -> graphQl.preparsedDocumentProvider(adapter)); builder.configureGraphQl(graphQl -> graphQl.preparsedDocumentProvider(adapter));
} }
/**
* The batch policies an adopter registers, empty until one does.
*
* <p>A bean rather than something each adopter constructs, because the two things downstream of
* it the loader factory and the registrar were beans nowhere, and a platform whose N+1
* protection has to be assembled by hand is a platform whose N+1 protection is not applied.
*
* @return the registry
*/
@Bean
@ConditionalOnMissingBean(
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicyRegistry.class)
public dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicyRegistry
graphQlBatchPolicyRegistry() {
return new dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicyRegistry();
}
/**
* Supplies the per-loader batch policy and executor.
*
* @param policies the registered policies
* @param properties the platform settings, which supply the batch ceiling
* @param graphQlPlatformClock the clock deadlines are measured against
* @return the factory
*/
@Bean
@ConditionalOnMissingBean(
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory.class)
public dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory
graphQlDataLoaderFactory(
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicyRegistry policies,
GraphQlPlatformSettings properties,
Clock graphQlPlatformClock) {
// The page ceiling is the batch ceiling. Both answer the same question how many rows one
// downstream call may ask for and a batch limit larger than the page limit would let a single
// request fan out past the bound the same request already accepted for its page.
return new dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory(
policies, properties.limits().maximumPageSize(), graphQlPlatformClock);
}
/**
* Wraps an adopter's batch loader in the platform's chunking, budget and request scope.
*
* <p>This was the missing link. {@code GraphQlBatchLoaderRegistrar} existed, was tested, and was
* declared by no configuration so a field resolving through {@code @BatchMapping} or a {@code
* DataLoader} met none of the platform's batch policy. The chunking and the budget were a set of
* well-tested objects no request could reach.
*
* <p>An adopter still supplies the downstream call, because only the adopter has one. What it no
* longer supplies is the machinery around it.
*
* @param factory the loader factory
* @param blockingBridge the bounded hand-off, when a runtime provides one
* @param properties the platform settings, which declare the runtime the loaders will execute on
* @return the registrar
*/
@Bean
@ConditionalOnMissingBean(
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlBatchLoaderRegistrar.class)
public dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlBatchLoaderRegistrar
graphQlBatchLoaderRegistrar(
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory factory,
ObjectProvider<dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlBlockingBridge>
blockingBridge,
GraphQlPlatformSettings properties) {
// The profile decides where a chunk may run, so the registrar receives it rather than assuming
// the servlet answer. A reactive deployment that registers a blocking loader with no bridge is
// refused at startup instead of discovering it as event-loop starvation.
return new dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlBatchLoaderRegistrar(
factory, blockingBridge.getIfAvailable(), properties.executionProfile());
}
private static String schemaContractHash(ObjectProvider<GraphQlSource> graphQlSource) { private static String schemaContractHash(ObjectProvider<GraphQlSource> graphQlSource) {
GraphQlSource source = graphQlSource.getIfAvailable(); GraphQlSource source = graphQlSource.getIfAvailable();
if (source == null) { if (source == null) {
@@ -20,5 +20,14 @@ import org.springframework.context.annotation.Import;
@AutoConfiguration @AutoConfiguration
@ConditionalOnProperty(prefix = "backend.graphql", name = "enabled", havingValue = "true") @ConditionalOnProperty(prefix = "backend.graphql", name = "enabled", havingValue = "true")
@EnableConfigurationProperties(GraphQlPlatformSettings.class) @EnableConfigurationProperties(GraphQlPlatformSettings.class)
@Import(GraphQlPlatformAutoConfiguration.class) @Import({
GraphQlPlatformAutoConfiguration.class,
// The resolver for the schema's only field. It is a @Controller in a package the composition
// root's component scan excludes by regex the exclusion that makes this capability optional
// and no root imported it, so a deployment with GraphQL on served a schema declaring
// `_health: String!` with nothing to resolve it. Every query answered
// NullValueInNonNullableField. Its own tests passed throughout by registering the class
// themselves, which is the shape of the defect rather than a defence against it.
dev.caskeleton.adapter.inbound.graphql.HealthGraphqlController.class
})
public class GraphQlRootAutoConfiguration {} public class GraphQlRootAutoConfiguration {}
@@ -2,18 +2,23 @@ package dev.caskeleton.adapter.inbound.graphql.compat;
import graphql.language.AstPrinter; import graphql.language.AstPrinter;
import graphql.language.EnumTypeDefinition; import graphql.language.EnumTypeDefinition;
import graphql.language.EnumTypeExtensionDefinition;
import graphql.language.EnumValueDefinition; import graphql.language.EnumValueDefinition;
import graphql.language.FieldDefinition; import graphql.language.FieldDefinition;
import graphql.language.ImplementingTypeDefinition; import graphql.language.ImplementingTypeDefinition;
import graphql.language.InputObjectTypeDefinition; import graphql.language.InputObjectTypeDefinition;
import graphql.language.InputObjectTypeExtensionDefinition;
import graphql.language.InputValueDefinition; import graphql.language.InputValueDefinition;
import graphql.language.InterfaceTypeDefinition; import graphql.language.InterfaceTypeDefinition;
import graphql.language.InterfaceTypeExtensionDefinition;
import graphql.language.NonNullType; import graphql.language.NonNullType;
import graphql.language.ObjectTypeDefinition; import graphql.language.ObjectTypeDefinition;
import graphql.language.ObjectTypeExtensionDefinition;
import graphql.language.ScalarTypeDefinition; import graphql.language.ScalarTypeDefinition;
import graphql.language.Type; import graphql.language.Type;
import graphql.language.TypeDefinition; import graphql.language.TypeDefinition;
import graphql.language.UnionTypeDefinition; import graphql.language.UnionTypeDefinition;
import graphql.language.UnionTypeExtensionDefinition;
import graphql.schema.idl.ScalarInfo; import graphql.schema.idl.ScalarInfo;
import graphql.schema.idl.SchemaParser; import graphql.schema.idl.SchemaParser;
import graphql.schema.idl.TypeDefinitionRegistry; import graphql.schema.idl.TypeDefinitionRegistry;
@@ -57,26 +62,150 @@ public final class GraphQlSchemaComparator {
List<GraphQlSchemaChange> changes = new ArrayList<>(); List<GraphQlSchemaChange> changes = new ArrayList<>();
compareTypePresence(previous, candidate, changes); // Extensions folded in first. A registry keeps `extend type Query { }` in a separate map from
compareTypeKinds(previous, candidate, changes); // `type Query { }`, so a comparison that reads only the base definitions cannot see a field
compareOutputTypes(previous, candidate, changes); // an extension contributed and cannot see it disappear either. Every schema that composes
compareInputTypes(previous, candidate, changes); // from several files is exactly this shape, which made the omission a breaking change the gate
compareEnums(previous, candidate, changes); // reported as no change at all.
compareUnions(previous, candidate, changes); Map<String, TypeDefinition> previousTypes = withExtensions(previous);
Map<String, TypeDefinition> candidateTypes = withExtensions(candidate);
compareTypePresence(previousTypes, candidateTypes, changes);
compareTypeKinds(previousTypes, candidateTypes, changes);
compareOutputTypes(previousTypes, candidateTypes, changes);
compareInputTypes(previousTypes, candidateTypes, changes);
compareEnums(previousTypes, candidateTypes, changes);
compareUnions(previousTypes, candidateTypes, changes);
compareScalars(previous, candidate, changes); compareScalars(previous, candidate, changes);
compareDirectives(previous, candidate, changes); compareDirectives(previous, candidate, changes);
compareAppliedDirectives(previous, candidate, changes); compareAppliedDirectives(previousTypes, candidateTypes, changes);
return new GraphQlCompatibilityReport(changes.stream().sorted(DETERMINISTIC_ORDER).toList()); return new GraphQlCompatibilityReport(changes.stream().sorted(DETERMINISTIC_ORDER).toList());
} }
/**
* Every type definition with the members its extensions contribute already merged in.
*
* <p>The merged form is what a client sees: the engine builds one type out of the base
* declaration and every extension of it, and a field's origin is invisible on the wire.
*/
private static Map<String, TypeDefinition> withExtensions(TypeDefinitionRegistry registry) {
Map<String, TypeDefinition> merged = new LinkedHashMap<>();
registry.types().forEach((name, type) -> merged.put(name, mergeExtensions(registry, type)));
return merged;
}
private static TypeDefinition mergeExtensions(
TypeDefinitionRegistry registry, TypeDefinition type) {
String name = type.getName();
if (type instanceof ObjectTypeDefinition object) {
List<ObjectTypeExtensionDefinition> extensions =
registry.objectTypeExtensions().getOrDefault(name, List.of());
if (extensions.isEmpty()) {
return object;
}
List<FieldDefinition> fields = new ArrayList<>(object.getFieldDefinitions());
List<Type> interfaces = new ArrayList<>(object.getImplements());
List<graphql.language.Directive> directives = new ArrayList<>(object.getDirectives());
extensions.forEach(
extension -> {
fields.addAll(extension.getFieldDefinitions());
interfaces.addAll(extension.getImplements());
directives.addAll(extension.getDirectives());
});
return object.transform(
builder ->
builder.fieldDefinitions(fields).implementz(interfaces).directives(directives));
}
if (type instanceof InterfaceTypeDefinition definition) {
List<InterfaceTypeExtensionDefinition> extensions =
registry.interfaceTypeExtensions().getOrDefault(name, List.of());
if (extensions.isEmpty()) {
return definition;
}
List<FieldDefinition> fields = new ArrayList<>(definition.getFieldDefinitions());
List<Type> interfaces = new ArrayList<>(definition.getImplements());
List<graphql.language.Directive> directives = new ArrayList<>(definition.getDirectives());
extensions.forEach(
extension -> {
fields.addAll(extension.getFieldDefinitions());
interfaces.addAll(extension.getImplements());
directives.addAll(extension.getDirectives());
});
return definition.transform(
builder -> builder.definitions(fields).implementz(interfaces).directives(directives));
}
if (type instanceof InputObjectTypeDefinition input) {
List<InputObjectTypeExtensionDefinition> extensions =
registry.inputObjectTypeExtensions().getOrDefault(name, List.of());
if (extensions.isEmpty()) {
return input;
}
List<InputValueDefinition> fields = new ArrayList<>(input.getInputValueDefinitions());
List<graphql.language.Directive> directives = new ArrayList<>(input.getDirectives());
extensions.forEach(
extension -> {
fields.addAll(extension.getInputValueDefinitions());
directives.addAll(extension.getDirectives());
});
return input.transform(
builder -> builder.inputValueDefinitions(fields).directives(directives));
}
if (type instanceof EnumTypeDefinition enumeration) {
List<EnumTypeExtensionDefinition> extensions =
registry.enumTypeExtensions().getOrDefault(name, List.of());
if (extensions.isEmpty()) {
return enumeration;
}
List<EnumValueDefinition> values = new ArrayList<>(enumeration.getEnumValueDefinitions());
List<graphql.language.Directive> directives = new ArrayList<>(enumeration.getDirectives());
extensions.forEach(
extension -> {
values.addAll(extension.getEnumValueDefinitions());
directives.addAll(extension.getDirectives());
});
return enumeration.transform(
builder -> builder.enumValueDefinitions(values).directives(directives));
}
if (type instanceof UnionTypeDefinition union) {
List<UnionTypeExtensionDefinition> extensions =
registry.unionTypeExtensions().getOrDefault(name, List.of());
if (extensions.isEmpty()) {
return union;
}
List<Type> members = new ArrayList<>(union.getMemberTypes());
List<graphql.language.Directive> directives = new ArrayList<>(union.getDirectives());
extensions.forEach(
extension -> {
members.addAll(extension.getMemberTypes());
directives.addAll(extension.getDirectives());
});
return union.transform(builder -> builder.memberTypes(members).directives(directives));
}
return type;
}
/** The merged definitions of one kind. */
private static <T extends TypeDefinition> Map<String, T> typesOf(
Map<String, TypeDefinition> types, Class<T> kind) {
Map<String, T> selected = new LinkedHashMap<>();
types.forEach(
(name, type) -> {
if (kind.isInstance(type)) {
selected.put(name, kind.cast(type));
}
});
return selected;
}
private static void compareTypePresence( private static void compareTypePresence(
TypeDefinitionRegistry previous, Map<String, TypeDefinition> previous,
TypeDefinitionRegistry candidate, Map<String, TypeDefinition> candidate,
List<GraphQlSchemaChange> changes) { List<GraphQlSchemaChange> changes) {
Set<String> previousTypes = new TreeSet<>(previous.types().keySet()); Set<String> previousTypes = new TreeSet<>(previous.keySet());
Set<String> candidateTypes = new TreeSet<>(candidate.types().keySet()); Set<String> candidateTypes = new TreeSet<>(candidate.keySet());
previousTypes.stream() previousTypes.stream()
.filter(name -> !candidateTypes.contains(name)) .filter(name -> !candidateTypes.contains(name))
@@ -94,12 +223,10 @@ public final class GraphQlSchemaComparator {
* to another, or as nothing at all. * to another, or as nothing at all.
*/ */
private static void compareTypeKinds( private static void compareTypeKinds(
TypeDefinitionRegistry previous, Map<String, TypeDefinition> previousTypes,
TypeDefinitionRegistry candidate, Map<String, TypeDefinition> candidateTypes,
List<GraphQlSchemaChange> changes) { List<GraphQlSchemaChange> changes) {
Map<String, TypeDefinition> previousTypes = previous.types();
Map<String, TypeDefinition> candidateTypes = candidate.types();
for (String name : new TreeSet<>(previousTypes.keySet())) { for (String name : new TreeSet<>(previousTypes.keySet())) {
TypeDefinition after = candidateTypes.get(name); TypeDefinition after = candidateTypes.get(name);
if (after == null) { if (after == null) {
@@ -119,12 +246,10 @@ public final class GraphQlSchemaComparator {
* definition untouched. * definition untouched.
*/ */
private static void compareAppliedDirectives( private static void compareAppliedDirectives(
TypeDefinitionRegistry previous, Map<String, TypeDefinition> previousTypes,
TypeDefinitionRegistry candidate, Map<String, TypeDefinition> candidateTypes,
List<GraphQlSchemaChange> changes) { List<GraphQlSchemaChange> changes) {
Map<String, TypeDefinition> previousTypes = previous.types();
Map<String, TypeDefinition> candidateTypes = candidate.types();
for (String name : new TreeSet<>(previousTypes.keySet())) { for (String name : new TreeSet<>(previousTypes.keySet())) {
TypeDefinition before = previousTypes.get(name); TypeDefinition before = previousTypes.get(name);
TypeDefinition after = candidateTypes.get(name); TypeDefinition after = candidateTypes.get(name);
@@ -202,8 +327,8 @@ public final class GraphQlSchemaComparator {
} }
private static void compareOutputTypes( private static void compareOutputTypes(
TypeDefinitionRegistry previous, Map<String, TypeDefinition> previous,
TypeDefinitionRegistry candidate, Map<String, TypeDefinition> candidate,
List<GraphQlSchemaChange> changes) { List<GraphQlSchemaChange> changes) {
Map<String, ImplementingTypeDefinition<?>> previousTypes = implementingTypes(previous); Map<String, ImplementingTypeDefinition<?>> previousTypes = implementingTypes(previous);
@@ -349,14 +474,14 @@ public final class GraphQlSchemaComparator {
} }
private static void compareInputTypes( private static void compareInputTypes(
TypeDefinitionRegistry previous, Map<String, TypeDefinition> previous,
TypeDefinitionRegistry candidate, Map<String, TypeDefinition> candidate,
List<GraphQlSchemaChange> changes) { List<GraphQlSchemaChange> changes) {
Map<String, InputObjectTypeDefinition> previousTypes = Map<String, InputObjectTypeDefinition> previousTypes =
previous.getTypesMap(InputObjectTypeDefinition.class); typesOf(previous, InputObjectTypeDefinition.class);
Map<String, InputObjectTypeDefinition> candidateTypes = Map<String, InputObjectTypeDefinition> candidateTypes =
candidate.getTypesMap(InputObjectTypeDefinition.class); typesOf(candidate, InputObjectTypeDefinition.class);
for (String typeName : new TreeSet<>(previousTypes.keySet())) { for (String typeName : new TreeSet<>(previousTypes.keySet())) {
InputObjectTypeDefinition after = candidateTypes.get(typeName); InputObjectTypeDefinition after = candidateTypes.get(typeName);
@@ -426,13 +551,12 @@ public final class GraphQlSchemaComparator {
} }
private static void compareEnums( private static void compareEnums(
TypeDefinitionRegistry previous, Map<String, TypeDefinition> previous,
TypeDefinitionRegistry candidate, Map<String, TypeDefinition> candidate,
List<GraphQlSchemaChange> changes) { List<GraphQlSchemaChange> changes) {
Map<String, EnumTypeDefinition> previousTypes = previous.getTypesMap(EnumTypeDefinition.class); Map<String, EnumTypeDefinition> previousTypes = typesOf(previous, EnumTypeDefinition.class);
Map<String, EnumTypeDefinition> candidateTypes = Map<String, EnumTypeDefinition> candidateTypes = typesOf(candidate, EnumTypeDefinition.class);
candidate.getTypesMap(EnumTypeDefinition.class);
for (String typeName : new TreeSet<>(previousTypes.keySet())) { for (String typeName : new TreeSet<>(previousTypes.keySet())) {
EnumTypeDefinition after = candidateTypes.get(typeName); EnumTypeDefinition after = candidateTypes.get(typeName);
@@ -462,14 +586,12 @@ public final class GraphQlSchemaComparator {
} }
private static void compareUnions( private static void compareUnions(
TypeDefinitionRegistry previous, Map<String, TypeDefinition> previous,
TypeDefinitionRegistry candidate, Map<String, TypeDefinition> candidate,
List<GraphQlSchemaChange> changes) { List<GraphQlSchemaChange> changes) {
Map<String, UnionTypeDefinition> previousTypes = Map<String, UnionTypeDefinition> previousTypes = typesOf(previous, UnionTypeDefinition.class);
previous.getTypesMap(UnionTypeDefinition.class); Map<String, UnionTypeDefinition> candidateTypes = typesOf(candidate, UnionTypeDefinition.class);
Map<String, UnionTypeDefinition> candidateTypes =
candidate.getTypesMap(UnionTypeDefinition.class);
for (String typeName : new TreeSet<>(previousTypes.keySet())) { for (String typeName : new TreeSet<>(previousTypes.keySet())) {
UnionTypeDefinition after = candidateTypes.get(typeName); UnionTypeDefinition after = candidateTypes.get(typeName);
@@ -512,7 +634,7 @@ public final class GraphQlSchemaComparator {
changes.add(GraphQlSchemaChange.of("scalar " + name, GraphQlChangeKind.SCALAR_REMOVED)); changes.add(GraphQlSchemaChange.of("scalar " + name, GraphQlChangeKind.SCALAR_REMOVED));
continue; continue;
} }
if (!print(previousScalars.get(name)).equals(print(after))) { if (!declaration(previousScalars.get(name)).equals(declaration(after))) {
changes.add( changes.add(
GraphQlSchemaChange.of("scalar " + name, GraphQlChangeKind.SCALAR_DECLARATION_CHANGED)); GraphQlSchemaChange.of("scalar " + name, GraphQlChangeKind.SCALAR_DECLARATION_CHANGED));
} }
@@ -555,13 +677,26 @@ public final class GraphQlSchemaComparator {
} }
private static Map<String, ImplementingTypeDefinition<?>> implementingTypes( private static Map<String, ImplementingTypeDefinition<?>> implementingTypes(
TypeDefinitionRegistry registry) { Map<String, TypeDefinition> merged) {
Map<String, ImplementingTypeDefinition<?>> types = new LinkedHashMap<>(); Map<String, ImplementingTypeDefinition<?>> types = new LinkedHashMap<>();
registry.getTypesMap(ObjectTypeDefinition.class).forEach(types::put); typesOf(merged, ObjectTypeDefinition.class).forEach(types::put);
registry.getTypesMap(InterfaceTypeDefinition.class).forEach(types::put); typesOf(merged, InterfaceTypeDefinition.class).forEach(types::put);
return types; return types;
} }
/**
* A scalar's declaration without its description.
*
* <p>The declaration is compared because it is the only signal SDL carries about how a scalar
* coerces the {@code Coercing} implementation behind it is Java, and swapping it changes the
* wire contract without changing a character of schema. Prose is not that signal: reporting a
* reworded sentence as a possible coercion change is how a review that matters gets approved
* without being read.
*/
private static String declaration(ScalarTypeDefinition scalar) {
return print(scalar.transform(builder -> builder.description(null)));
}
private static Map<String, ScalarTypeDefinition> customScalars(TypeDefinitionRegistry registry) { private static Map<String, ScalarTypeDefinition> customScalars(TypeDefinitionRegistry registry) {
return registry.scalars().entrySet().stream() return registry.scalars().entrySet().stream()
.filter(entry -> !ScalarInfo.isGraphqlSpecifiedScalar(entry.getKey())) .filter(entry -> !ScalarInfo.isGraphqlSpecifiedScalar(entry.getKey()))
@@ -10,9 +10,9 @@ import java.util.HexFormat;
* *
* <p>The platform needs an actor identity for authorization, idempotency scoping and audit, but the * <p>The platform needs an actor identity for authorization, idempotency scoping and audit, but the
* error contract and the observability contract both forbid a raw user identifier from reaching a * error contract and the observability contract both forbid a raw user identifier from reaching a
* response or a metric label. So the context carries this reference and exposes {@link * response or a metric label. So the context carries this reference and never carries an access
* #fingerprint()} for anything that must be shared outward, and it never carries an access token, * token, cookie or raw provider claim; what may be shared outward is a keyed fingerprint from
* cookie or raw provider claim. * {@link GraphQlIdentityFingerprinter}.
* *
* @param value opaque, stable caller reference supplied by the authentication adapter * @param value opaque, stable caller reference supplied by the authentication adapter
* @param authenticated whether a credential was actually verified * @param authenticated whether a credential was actually verified
@@ -49,12 +49,19 @@ public record ActorRef(String value, boolean authenticated) {
} }
/** /**
* A stable, non-reversible fingerprint of this actor. * A stable partition token for this actor, for use inside one running request.
* *
* <p>Safe to use where the raw reference must not appear idempotency scoping, audit correlation * <p>Deliberately not called a fingerprint. An actor reference is low-entropy a numeric user
* and subscription principals. * id, a service account name and a digest of a guessable input is recovered by digesting the
* guesses, so this hides the reference from a casual reader and from nobody else. It is a
* separator for in-memory structures such as the request-scoped DataLoader cache, where the only
* requirement is that two actors never share one bucket.
*
* <p>Anything durable or outward-facing an idempotency record, an audit trail, a value handed
* to another system uses {@link GraphQlIdentityFingerprinter} instead, which is keyed and can
* rotate.
*/ */
public String fingerprint() { public String cachePartition() {
return sha256Prefix(value); return sha256Prefix(value);
} }
@@ -0,0 +1,150 @@
package dev.caskeleton.adapter.inbound.graphql.context;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/**
* Turns an actor or tenant identity into a value that may be stored outside this process.
*
* <p>A plain digest is not that value. An actor reference and a tenant name are low-entropy: they
* come from a bounded set a reader can enumerate {@code tenant-a}, {@code acme}, a numeric user
* id, an email address and a digest of a guessable input is recovered by digesting the guesses.
* An idempotency record keyed on such a digest therefore still names the caller to anyone holding
* the store, which is the one thing hashing it was meant to prevent. A keyed MAC removes the
* dictionary attack, because the attacker cannot compute the candidate values without the key.
*
* <p>Keys are addressed by identity and the identity travels with the fingerprint, so a deployment
* can rotate. Rotation matters here more than for a signature: a fingerprint is durable, it sits in
* stored idempotency records for as long as they are retained, and a key that can never change is a
* key that is compromised permanently. Records written under a retired key stay readable because
* their key identity still resolves; new ones are written under the active key.
*
* <p>The platform supplies no key and no default. A default key is public knowledge, and a
* fingerprint under a public key is a plain digest wearing a MAC's name so the deployment's
* secret source is the only way to build this, and a mutation cannot derive an idempotency scope
* without one.
*
* <p>These values are not metric labels. A fingerprint is per actor and per tenant by construction,
* which is exactly the unbounded cardinality the observability contract refuses; {@link
* dev.caskeleton.adapter.inbound.graphql.observation.GraphQlSensitiveAttributeFilter} is what
* decides what a metric may carry.
*/
public final class GraphQlIdentityFingerprinter {
/** Shortest key accepted, matching the block size a truncated key would be padded to anyway. */
public static final int MINIMUM_KEY_BYTES = 16;
private static final String ALGORITHM = "HmacSHA256";
/**
* Domain tags, so the same string used as an actor and as a tenant does not fingerprint the same.
*
* <p>Without them a service account named {@code acme} and the tenant named {@code acme} share a
* fingerprint, and an idempotency scope built from both halves collapses to one repeated value.
*/
private static final String ACTOR_DOMAIN = "actor";
private static final String TENANT_DOMAIN = "tenant";
private final Map<String, byte[]> keys;
private final String activeKeyId;
private GraphQlIdentityFingerprinter(Map<String, byte[]> keys, String activeKeyId) {
this.keys = keys;
this.activeKeyId = activeKeyId;
}
/**
* Creates a fingerprinter over a rotating key ring.
*
* @param keys secrets by key identity, from the deployment's secret source
* @param activeKeyId the key new fingerprints are computed under
*/
public static GraphQlIdentityFingerprinter of(Map<String, byte[]> keys, String activeKeyId) {
if (keys == null || keys.isEmpty()) {
throw new IllegalArgumentException("identity fingerprint key ring cannot be empty");
}
if (activeKeyId == null || activeKeyId.isBlank()) {
throw new IllegalArgumentException("an active identity fingerprint key is required");
}
if (!keys.containsKey(activeKeyId)) {
throw new IllegalArgumentException("active identity fingerprint key is not in the key ring");
}
Map<String, byte[]> copy = new LinkedHashMap<>();
keys.forEach(
(keyId, secret) -> {
if (keyId == null || keyId.isBlank() || keyId.indexOf(':') >= 0) {
// The key identity is the prefix of every fingerprint it produces, so a colon in it
// would make the prefix ambiguous and two rings could mint the same fingerprint text.
throw new IllegalArgumentException(
"identity fingerprint key id is required and opaque");
}
if (secret == null || secret.length < MINIMUM_KEY_BYTES) {
throw new IllegalArgumentException(
"identity fingerprint key " + keyId + " is too short");
}
copy.put(keyId, secret.clone());
});
return new GraphQlIdentityFingerprinter(copy, activeKeyId);
}
/** A single-key ring, for a deployment that has not rotated yet. */
public static GraphQlIdentityFingerprinter single(String keyId, byte[] secret) {
return of(Map.of(keyId, secret), keyId);
}
/** The key new fingerprints are computed under. */
public String activeKeyId() {
return activeKeyId;
}
/**
* Key identities a stored fingerprint may still be attributed to.
*
* <p>A copy, so handing the set out cannot retire a key by removing it from the live view.
*/
public Set<String> keyIds() {
return Set.copyOf(keys.keySet());
}
/** The fingerprint of an actor, safe to store alongside an idempotency record. */
public String actor(ActorRef actor) {
if (actor == null) {
throw new IllegalArgumentException("actor is required");
}
return fingerprint(ACTOR_DOMAIN, actor.value());
}
/** The fingerprint of a tenant, safe to store alongside an idempotency record. */
public String tenant(TenantContext tenant) {
if (tenant == null) {
throw new IllegalArgumentException("tenant is required");
}
return fingerprint(TENANT_DOMAIN, tenant.value());
}
private String fingerprint(String domain, String value) {
try {
Mac mac = Mac.getInstance(ALGORITHM);
mac.init(new SecretKeySpec(keys.get(activeKeyId), ALGORITHM));
// Length-framed, for the reason the canonical mutation input is: concatenating a domain and a
// caller-influenced value lets one of them absorb the other's boundary, and two different
// identities then produce one fingerprint.
byte[] digest =
mac.doFinal(
(domain.length() + ":" + domain + "|" + value.length() + ":" + value + "|")
.getBytes(StandardCharsets.UTF_8));
return activeKeyId + ":" + HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException | InvalidKeyException unavailable) {
throw new IllegalStateException(
"HMAC-SHA256 is required for identity fingerprints", unavailable);
}
}
}
@@ -51,12 +51,14 @@ public record TenantContext(String value, TenantSource source) {
} }
/** /**
* A stable, non-reversible fingerprint of this tenant. * A stable partition token for this tenant, for use inside one running request.
* *
* <p>The observability contract forbids a raw tenant identifier as a metric label; this is what * <p>A tenant name comes from a set small enough to enumerate, so a digest of it is recovered by
* goes outward instead. * digesting the candidates. That is acceptable for what this is used for keeping one tenant's
* request-scoped cache entries out of another's and not acceptable for anything stored or
* shared, which uses the keyed {@link GraphQlIdentityFingerprinter} instead.
*/ */
public String fingerprint() { public String cachePartition() {
return ActorRef.sha256Prefix(value); return ActorRef.sha256Prefix(value);
} }
} }
@@ -20,9 +20,18 @@ import java.util.function.BiFunction;
* more downstream call and wait for it however long it took, which is the case the budget exists * more downstream call and wait for it however long it took, which is the case the budget exists
* for. Bounding the call itself is the loader's job, and the deadline is handed to it for that; * for. Bounding the call itself is the loader's job, and the deadline is handed to it for that;
* this check is what stops the batch continuing past a budget that has already gone. * this check is what stops the batch continuing past a budget that has already gone.
*
* <p>Every chunk's answer goes through {@link GraphQlBatchResultMapper} before it is believed. The
* mapper is where "the loader answered a question nobody asked" and "this key has no row" are
* distinguished, and while the executor handed the loader's map straight back, neither distinction
* reached a request: a result under the wrong keys rendered every parent's child as null, and a
* loader declared as always resolving reported its absences as legitimate nulls. Both are the shape
* that produces plausible data instead of an error.
*/ */
public final class GraphQlBatchExecutor { public final class GraphQlBatchExecutor {
private static final GraphQlBatchResultMapper MAPPER = new GraphQlBatchResultMapper();
private final GraphQlBatchPolicy policy; private final GraphQlBatchPolicy policy;
private final GraphQlBatchChunker chunker; private final GraphQlBatchChunker chunker;
private final Clock clock; private final Clock clock;
@@ -61,7 +70,24 @@ public final class GraphQlBatchExecutor {
for (List<K> chunk : chunker.chunk(keys)) { for (List<K> chunk : chunker.chunk(keys)) {
requireBudget(started, budget); requireBudget(started, budget);
loaded.putAll(loadChunk.apply(chunk, context)); Map<K, V> answered = loadChunk.apply(chunk, context);
if (answered == null) {
throw new IllegalArgumentException(
"loader " + policy.loaderName().value() + " returned no result for a chunk");
}
// One outcome per requested key, so a missing row and a wrong-key answer are two different
// facts. A key whose policy is NULL_VALUE is simply absent from the returned map, which is
// how a mapped DataLoader spells "no value" without claiming one.
MAPPER
.map(chunk, answered)
.values()
.forEach(
(key, value) -> {
V resolved = MAPPER.resolve(value, policy.missingKeyPolicy());
if (resolved != null) {
loaded.put(key, resolved);
}
});
// After, too: a chunk that overran the budget must not have its result used and must not be // After, too: a chunk that overran the budget must not have its result used and must not be
// followed by another one. // followed by another one.
requireBudget(started, budget); requireBudget(started, budget);
@@ -14,6 +14,13 @@ import java.util.concurrent.atomic.AtomicBoolean;
* <p>Cancellation is one-way, and each listener runs exactly once listeners are drained from a * <p>Cancellation is one-way, and each listener runs exactly once listeners are drained from a
* queue rather than iterated, so a listener registered concurrently with cancellation is neither * queue rather than iterated, so a listener registered concurrently with cancellation is neither
* dropped nor run twice. * dropped nor run twice.
*
* <p>A listener that throws does not stop the rest. The drain used to abandon the queue at the
* first failure, which meant a downstream client that refused to close left the statement and the
* publisher behind it running the leaks the later listeners existed to prevent, caused by the
* first one failing and hidden behind the exception that stopped it. Every listener is now
* attempted; the first failure is rethrown once the queue is empty, with the later ones attached to
* it as suppressed, so a second broken listener is not invisible until the first is fixed.
*/ */
public final class GraphQlCancellation { public final class GraphQlCancellation {
@@ -65,10 +72,22 @@ public final class GraphQlCancellation {
} }
private void drain() { private void drain() {
RuntimeException firstFailure = null;
Runnable listener = listeners.poll(); Runnable listener = listeners.poll();
while (listener != null) { while (listener != null) {
listener.run(); try {
listener.run();
} catch (RuntimeException failure) {
if (firstFailure == null) {
firstFailure = failure;
} else {
firstFailure.addSuppressed(failure);
}
}
listener = listeners.poll(); listener = listeners.poll();
} }
if (firstFailure != null) {
throw firstFailure;
}
} }
} }
@@ -83,6 +83,7 @@ public enum GraphQlAdvancedModule {
"advanced.subscription", "advanced.subscription",
"advanced.security", "advanced.security",
"api", "api",
"execution",
"http", "http",
"security"), "security"),
@@ -118,6 +118,7 @@ public enum GraphQlStableModule {
"error", "error",
"execution", "execution",
"http", "http",
"observation",
"policy", "policy",
"security"), "security"),
@@ -17,8 +17,8 @@ package dev.caskeleton.adapter.inbound.graphql.mutation;
* implement replay, record storage or locking: those need transactional guarantees the transport * implement replay, record storage or locking: those need transactional guarantees the transport
* layer cannot give. * layer cannot give.
* *
* @param actorFingerprint non-reversible actor identity * @param actorFingerprint keyed actor fingerprint, from the deployment's identity fingerprinter
* @param tenantFingerprint non-reversible tenant identity * @param tenantFingerprint keyed tenant fingerprint, from the same fingerprinter
* @param coordinate the mutation the key belongs to * @param coordinate the mutation the key belongs to
* @param contractVersion the mutation contract version the key was issued under * @param contractVersion the mutation contract version the key was issued under
* @param key the client-supplied key * @param key the client-supplied key
@@ -74,10 +74,10 @@ public record GraphQlMutationIdempotencyContext(
/** /**
* The storage scope for the Application's idempotency record. * The storage scope for the Application's idempotency record.
* *
* <p>Uses fingerprints rather than the raw actor and tenant, so the scope can be persisted and * <p>Uses the keyed fingerprints rather than the raw actor and tenant, so the scope can be
* logged. Length-framed for the same reason the canonical input form is: joining five * persisted and logged without naming the caller to whoever holds the store. Length-framed for
* caller-influenced values with a separator lets one of them contain the separator and collide * the same reason the canonical input form is: joining five caller-influenced values with a
* with a different scope. * separator lets one of them contain the separator and collide with a different scope.
*/ */
public String scope() { public String scope() {
StringBuilder scope = new StringBuilder(); StringBuilder scope = new StringBuilder();
@@ -1,5 +1,6 @@
package dev.caskeleton.adapter.inbound.graphql.mutation; package dev.caskeleton.adapter.inbound.graphql.mutation;
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlIdentityFingerprinter;
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlExtensionsPolicy; import dev.caskeleton.adapter.inbound.graphql.http.GraphQlExtensionsPolicy;
import java.util.Map; import java.util.Map;
@@ -23,6 +24,7 @@ public final class GraphQlMutationIdempotencyInterceptor {
* Derives the idempotency scope for one mutation. * Derives the idempotency scope for one mutation.
* *
* @param context the request context, whose actor scopes the key * @param context the request context, whose actor scopes the key
* @param fingerprinter the deployment's keyed identity fingerprinter
* @param coordinate the mutation being executed * @param coordinate the mutation being executed
* @param extensions request extensions, which may carry the key * @param extensions request extensions, which may carry the key
* @param normalizedInput the mutation's normalised business input * @param normalizedInput the mutation's normalised business input
@@ -30,11 +32,18 @@ public final class GraphQlMutationIdempotencyInterceptor {
*/ */
public static Optional<GraphQlMutationIdempotencyContext> from( public static Optional<GraphQlMutationIdempotencyContext> from(
GraphQlRequestContext context, GraphQlRequestContext context,
GraphQlIdentityFingerprinter fingerprinter,
GraphQlMutationCoordinate coordinate, GraphQlMutationCoordinate coordinate,
String contractVersion, String contractVersion,
Map<String, Object> extensions, Map<String, Object> extensions,
Map<String, ?> normalizedInput) { Map<String, ?> normalizedInput) {
if (fingerprinter == null) {
// No key, no scope. Falling back to a plain digest here would put a recoverable actor and
// tenant into a record the Application persists, which is the failure the keyed fingerprint
// exists to stop and it would do it silently, on a deployment that never configured a key.
throw new IllegalArgumentException("an identity fingerprinter is required");
}
Object supplied = Object supplied =
extensions == null ? null : extensions.get(GraphQlExtensionsPolicy.IDEMPOTENCY_KEY); extensions == null ? null : extensions.get(GraphQlExtensionsPolicy.IDEMPOTENCY_KEY);
if (supplied == null) { if (supplied == null) {
@@ -45,8 +54,8 @@ public final class GraphQlMutationIdempotencyInterceptor {
} }
return Optional.of( return Optional.of(
GraphQlMutationIdempotencyContext.of( GraphQlMutationIdempotencyContext.of(
context.actor().fingerprint(), fingerprinter.actor(context.actor()),
context.tenant().fingerprint(), fingerprinter.tenant(context.tenant()),
coordinate, coordinate,
contractVersion, contractVersion,
new GraphQlIdempotencyKey(key), new GraphQlIdempotencyKey(key),
@@ -57,6 +57,26 @@ public final class GraphQlOperationNameCardinality {
return registered.contains(operationName.value()) ? operationName.value() : UNREGISTERED; return registered.contains(operationName.value()) ? operationName.value() : UNREGISTERED;
} }
/**
* The bounded label for one name as it arrived on the wire.
*
* <p>The wire carries a string, and it is a wider string than {@link GraphQlOperationName}
* accepts: graphql-java admits any GraphQL {@code Name}, so {@code ab} and {@code _internal}
* reach execution and would make the value type throw. A policy that can only be asked about
* names it already considers well formed is not a bound on what a client can send, so the
* question is answered against the raw string and a raw string becomes a label only by being
* exactly one the deployment declared.
*
* @param wireOperationName the client-supplied name, or {@code null} for an anonymous operation
* @return the registered name, the anonymous value, or {@link #UNREGISTERED}
*/
public String labelForWireName(String wireOperationName) {
if (wireOperationName == null || wireOperationName.isBlank()) {
return GraphQlOperationName.ANONYMOUS_OBSERVATION_VALUE;
}
return registered.contains(wireOperationName) ? wireOperationName : UNREGISTERED;
}
/** /**
* The number of distinct labels this policy can ever produce. * The number of distinct labels this policy can ever produce.
* *
@@ -89,8 +89,67 @@ public final class GraphQlRequestObservationConvention {
GraphQlComplexityResult complexity, GraphQlComplexityResult complexity,
int depth) { int depth) {
return tags(
operationNames.labelFor(operationName),
operationType,
clientProfile,
persisted,
outcome,
errorCategory,
complexity,
depth);
}
/**
* Builds the request tags from the operation name as it arrived on the wire.
*
* <p>The overload exists for the runtime seam, which sees the raw request rather than a validated
* name. Routing it through the same cardinality policy is what keeps one bound instead of two: a
* second place that decides which names become labels is a second place for the bound to be
* missing.
*
* @param wireOperationName client-supplied operation name, or {@code null} when anonymous
* @param operationType root operation type
* @param clientProfile bounded client profile
* @param persisted whether the operation came from the persisted registry
* @param outcome bounded outcome name
* @param errorCategory bounded error category, or {@code null}
* @param complexity computed complexity, or {@code null} when the request never reached costing
* @param depth measured selection depth
*/
public Map<String, String> tagsForWireName(
String wireOperationName,
GraphQlOperationType operationType,
GraphQlClientProfile clientProfile,
boolean persisted,
String outcome,
String errorCategory,
GraphQlComplexityResult complexity,
int depth) {
return tags(
operationNames.labelForWireName(wireOperationName),
operationType,
clientProfile,
persisted,
outcome,
errorCategory,
complexity,
depth);
}
private Map<String, String> tags(
String operationNameLabel,
GraphQlOperationType operationType,
GraphQlClientProfile clientProfile,
boolean persisted,
String outcome,
String errorCategory,
GraphQlComplexityResult complexity,
int depth) {
Map<String, String> tags = new LinkedHashMap<>(); Map<String, String> tags = new LinkedHashMap<>();
tags.put("graphql.operation.name", operationNames.labelFor(operationName)); tags.put("graphql.operation.name", operationNameLabel);
tags.put("graphql.operation.type", operationType.name()); tags.put("graphql.operation.type", operationType.name());
tags.put("graphql.client.profile", clientProfile.value()); tags.put("graphql.client.profile", clientProfile.value());
tags.put("graphql.persisted", Boolean.toString(persisted)); tags.put("graphql.persisted", Boolean.toString(persisted));
@@ -6,6 +6,8 @@ import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchExecutor;
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchTimeoutException; import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchTimeoutException;
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory; import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory;
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderName; import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderName;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfile;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfileException;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
@@ -24,11 +26,20 @@ import org.springframework.graphql.execution.BatchLoaderRegistry;
* <p>The chunking, budget and scope arrive as a decorator around the adopter's loader rather than * <p>The chunking, budget and scope arrive as a decorator around the adopter's loader rather than
* as something the adopter has to remember. What the adopter supplies is the downstream call; what * as something the adopter has to remember. What the adopter supplies is the downstream call; what
* this adds is everything that makes it safe to run on a shared request budget. * this adds is everything that makes it safe to run on a shared request budget.
*
* <p>Where the chunk actually runs is a property of the runtime, so the registrar is told which one
* it is. On a servlet stack the answer is "here": Spring already put the request on a thread, and a
* second pool would only add a queue and a wait. On a reactive stack the same inline call runs on
* the event loop, where one slow downstream stalls every request the loop is serving so a
* reactive deployment either supplies the bounded bridge or does not get to register a blocking
* loader at all. Refusing at registration makes that a startup failure rather than a latency
* mystery under load.
*/ */
public final class GraphQlBatchLoaderRegistrar { public final class GraphQlBatchLoaderRegistrar {
private final GraphQlDataLoaderFactory factory; private final GraphQlDataLoaderFactory factory;
private final GraphQlBlockingBridge blockingBridge; private final GraphQlBlockingBridge blockingBridge;
private final GraphQlExecutionProfile profile;
/** /**
* Creates a registrar that runs loaders on the calling thread. * Creates a registrar that runs loaders on the calling thread.
@@ -37,9 +48,11 @@ public final class GraphQlBatchLoaderRegistrar {
* a second pool adds a queue, a wait and a context hop, and buys nothing on a servlet stack. * a second pool adds a queue, a wait and a context hop, and buys nothing on a servlet stack.
* *
* @param factory supplies the per-loader batch policy and executor * @param factory supplies the per-loader batch policy and executor
* @param profile the runtime this deployment declared
*/ */
public GraphQlBatchLoaderRegistrar(GraphQlDataLoaderFactory factory) { public GraphQlBatchLoaderRegistrar(
this(factory, null); GraphQlDataLoaderFactory factory, GraphQlExecutionProfile profile) {
this(factory, null, profile);
} }
/** /**
@@ -47,11 +60,15 @@ public final class GraphQlBatchLoaderRegistrar {
* *
* @param factory supplies the per-loader batch policy and executor * @param factory supplies the per-loader batch policy and executor
* @param blockingBridge the bounded hand-off, for a runtime where blocking in place is unsafe * @param blockingBridge the bounded hand-off, for a runtime where blocking in place is unsafe
* @param profile the runtime this deployment declared
*/ */
public GraphQlBatchLoaderRegistrar( public GraphQlBatchLoaderRegistrar(
GraphQlDataLoaderFactory factory, GraphQlBlockingBridge blockingBridge) { GraphQlDataLoaderFactory factory,
GraphQlBlockingBridge blockingBridge,
GraphQlExecutionProfile profile) {
this.factory = Objects.requireNonNull(factory, "data loader factory is required"); this.factory = Objects.requireNonNull(factory, "data loader factory is required");
this.blockingBridge = blockingBridge; this.blockingBridge = blockingBridge;
this.profile = Objects.requireNonNull(profile, "execution profile is required");
} }
/** /**
@@ -72,6 +89,12 @@ public final class GraphQlBatchLoaderRegistrar {
GraphQlDataLoaderName loaderName, GraphQlDataLoaderName loaderName,
BiFunction<List<K>, GraphQlBatchContext, Map<K, V>> loadChunk) { BiFunction<List<K>, GraphQlBatchContext, Map<K, V>> loadChunk) {
if (profile != GraphQlExecutionProfile.BLOCKING_MVC && blockingBridge == null) {
throw new GraphQlExecutionProfileException(
"loader "
+ loaderName.value()
+ " would block the event loop; declare a bounded blocking bridge");
}
GraphQlBatchExecutor executor = factory.executorFor(loaderName); GraphQlBatchExecutor executor = factory.executorFor(loaderName);
registry registry
@@ -36,6 +36,16 @@ public record GraphQlExecutionContext(
GraphQlDocumentShape shape, GraphQlDocumentShape shape,
GraphQlComplexityResult complexity) { GraphQlComplexityResult complexity) {
/**
* Key under which the settled pipeline state is published for the rest of the request.
*
* <p>The measured shape and the scored complexity are computed once, by the cost stage, and were
* then discarded when the chain returned. Anything later in the request that wants to describe
* how large this request was an observation convention, a diagnostic had no way to ask, so it
* either re-measured the document or reported a number it had not measured.
*/
public static final String CONTEXT_KEY = "dev.caskeleton.graphql.executionContext";
public GraphQlExecutionContext { public GraphQlExecutionContext {
Objects.requireNonNull(request, "request is required"); Objects.requireNonNull(request, "request is required");
Objects.requireNonNull(requestContext, "request context is required"); Objects.requireNonNull(requestContext, "request context is required");
@@ -95,6 +95,9 @@ public final class GraphQlPlatformInstrumentation extends SimplePerformantInstru
execution execution
.getGraphQLContext() .getGraphQLContext()
.put(GraphQlRequestContext.CONTEXT_KEY, completed.requestContext()); .put(GraphQlRequestContext.CONTEXT_KEY, completed.requestContext());
// The whole settled state, not only the context: the cost stage has measured this document's
// depth and scored its complexity, and those numbers exist nowhere else once the chain returns.
execution.getGraphQLContext().put(GraphQlExecutionContext.CONTEXT_KEY, completed);
return SimpleInstrumentationContext.noOp(); return SimpleInstrumentationContext.noOp();
} }
} }
@@ -0,0 +1,176 @@
package dev.caskeleton.adapter.inbound.graphql.runtime;
import dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile;
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext;
import dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorCategory;
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlRequestObservationConvention;
import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlOperationType;
import graphql.ExecutionResult;
import graphql.GraphQLError;
import graphql.execution.ExecutionContext;
import io.micrometer.common.KeyValue;
import io.micrometer.common.KeyValues;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import org.springframework.graphql.observation.ExecutionRequestObservationContext;
import org.springframework.graphql.observation.ExecutionRequestObservationConvention;
/**
* Makes the platform's bounded request tags the ones a metrics backend actually receives.
*
* <p>The convention was a well-tested object with no consumer. Spring for GraphQL emits the {@code
* graphql.request} observation from its own instrumentation and asks an {@link
* ExecutionRequestObservationConvention} bean what to tag it with; the platform's convention did
* not implement that interface, so the cardinality bound it computes was never applied to a series
* anybody stored. A tag policy that no exporter consults bounds nothing.
*
* <p>The adapter lives here rather than in the observation package because the bound has to be
* framework-free to be testable without a running application, and the seam that applies it has to
* speak Spring and graphql-java. So the decision stays a value, and this class translates.
*
* <p>Everything it reports is bounded by construction. The operation name goes through the
* deployment's registry, the type is an enum, the profile is a validated identity, the outcome and
* the error category are closed sets, and the size numbers are buckets. The per-request identity
* that a debugger needs the execution id is reported as a high-cardinality value, which
* Micrometer carries on the trace and keeps off the meter.
*/
public final class GraphQlRequestObservationConventionAdapter
implements ExecutionRequestObservationConvention {
/** Outcome of a request that produced no error. */
public static final String OUTCOME_SUCCESS = "SUCCESS";
/** Outcome of a request the caller could have avoided. */
public static final String OUTCOME_REQUEST_ERROR = "REQUEST_ERROR";
/** Outcome of a request that failed for a reason the caller cannot fix. */
public static final String OUTCOME_INTERNAL_ERROR = "INTERNAL_ERROR";
private static final GraphQlClientProfile ANONYMOUS_PROFILE =
new GraphQlClientProfile("anonymous");
private final GraphQlRequestObservationConvention convention;
/**
* Creates the adapter.
*
* @param convention the platform's bounded tag policy
*/
public GraphQlRequestObservationConventionAdapter(
GraphQlRequestObservationConvention convention) {
this.convention = Objects.requireNonNull(convention, "observation convention is required");
}
@Override
public String getName() {
return convention.name();
}
@Override
public String getContextualName(ExecutionRequestObservationContext context) {
// The span name, and therefore as bounded as a tag: the operation type, never the client's
// chosen operation name.
return "graphql " + operationType(context).name().toLowerCase(Locale.ROOT);
}
@Override
public KeyValues getLowCardinalityKeyValues(ExecutionRequestObservationContext context) {
GraphQlExecutionContext settled = settled(context);
Map<String, String> tags =
convention.tagsForWireName(
context.getExecutionInput().getOperationName(),
operationType(context),
clientProfile(context),
// False rather than unknown: the platform's persisted-operation stage is not on this
// execution path, so no request reaching here came out of the persisted registry.
false,
outcome(context),
errorCategory(context),
settled == null ? null : settled.complexity(),
depth(settled));
KeyValues keyValues = KeyValues.empty();
for (Map.Entry<String, String> tag : tags.entrySet()) {
keyValues = keyValues.and(KeyValue.of(tag.getKey(), tag.getValue()));
}
return keyValues;
}
@Override
public KeyValues getHighCardinalityKeyValues(ExecutionRequestObservationContext context) {
Object executionId = context.getExecutionInput().getExecutionId();
return executionId == null
? KeyValues.empty()
: KeyValues.of("graphql.execution.id", executionId.toString());
}
private static GraphQlOperationType operationType(ExecutionRequestObservationContext context) {
ExecutionContext execution = context.getExecutionContext();
if (execution == null || execution.getOperationDefinition() == null) {
// The observation is stopped even when parsing never selected an operation. Reporting the
// read type is what keeps the series shape stable across a request that failed early.
return GraphQlOperationType.QUERY;
}
return switch (execution.getOperationDefinition().getOperation()) {
case QUERY -> GraphQlOperationType.QUERY;
case MUTATION -> GraphQlOperationType.MUTATION;
case SUBSCRIPTION -> GraphQlOperationType.SUBSCRIPTION;
};
}
private static GraphQlClientProfile clientProfile(ExecutionRequestObservationContext context) {
GraphQlRequestContext requestContext =
context.getExecutionInput().getGraphQLContext().get(GraphQlRequestContext.CONTEXT_KEY);
// Anonymous is a profile, not a missing value: a request that proved nothing is still one the
// operator has to be able to count.
return requestContext == null ? ANONYMOUS_PROFILE : requestContext.clientProfile();
}
private static GraphQlExecutionContext settled(ExecutionRequestObservationContext context) {
return context.getExecutionInput().getGraphQLContext().get(GraphQlExecutionContext.CONTEXT_KEY);
}
private static int depth(GraphQlExecutionContext settled) {
return settled == null || settled.shape() == null ? 0 : settled.shape().depth();
}
private static String outcome(ExecutionRequestObservationContext context) {
if (context.getError() != null) {
return OUTCOME_INTERNAL_ERROR;
}
ExecutionResult result = context.getExecutionResult();
if (result == null) {
return OUTCOME_INTERNAL_ERROR;
}
List<GraphQLError> errors = result.getErrors();
if (errors == null || errors.isEmpty()) {
return OUTCOME_SUCCESS;
}
return internal(errors) ? OUTCOME_INTERNAL_ERROR : OUTCOME_REQUEST_ERROR;
}
private static String errorCategory(ExecutionRequestObservationContext context) {
if (context.getError() != null) {
return GraphQlErrorCategory.INTERNAL.name();
}
ExecutionResult result = context.getExecutionResult();
List<GraphQLError> errors = result == null ? null : result.getErrors();
if (errors == null || errors.isEmpty()) {
// Absent rather than a "none" label: an outcome tag already says the request succeeded, and a
// second tag saying the same thing doubles the series for no extra answer.
return null;
}
return internal(errors)
? GraphQlErrorCategory.INTERNAL.name()
: GraphQlErrorCategory.REQUEST.name();
}
private static boolean internal(List<GraphQLError> errors) {
// A resolver that threw is the server's problem; a document the schema refused is the caller's.
// Splitting them is the difference between an alert and a client-side bug report.
return errors.stream()
.anyMatch(error -> error.getErrorType() == graphql.ErrorType.DataFetchingException);
}
}
@@ -0,0 +1,84 @@
package dev.caskeleton.adapter.inbound.graphql.security;
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlCommandAttribution;
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext;
import dev.caskeleton.application.security.ObjectAccessDecision;
import dev.caskeleton.application.security.ObjectAccessPolicy;
import dev.caskeleton.application.security.ObjectAccessRequest;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Asks the application's object-access rule on the platform's behalf.
*
* <p>The direction is what this class is for. The platform decides <em>when</em> an object needs an
* access check; the application decides the answer, because the answer depends on domain state.
* Those two facts used to be expressed by a port declared in this leaf whose method took a {@link
* GraphQlRequestContext} a contract the application layer could not implement without depending
* on the inbound GraphQL adapter, which the dependency gate forbids and which would have dragged
* the transport into every persistence adapter behind the use case.
*
* <p>So the contract moved to {@code application-core} in terms of plain values, and what stays
* here is the mapping: request context in, four strings out, decision back. It is the same shape
* the persisted-operation registry uses against a neutral store contract, for the same reason.
*/
public final class ApplicationObjectAuthorization implements GraphQlObjectAuthorizationPort {
private final ObjectAccessPolicy policy;
/**
* Creates the bridge.
*
* @param policy the application's object-access rule
*/
public ApplicationObjectAuthorization(ObjectAccessPolicy policy) {
this.policy = Objects.requireNonNull(policy, "object access policy is required");
}
@Override
public GraphQlAuthorizationDecision authorize(
GraphQlRequestContext context, String objectType, String objectId) {
GraphQlCommandAttribution attribution = GraphQlCommandAttribution.from(context);
return map(
policy.decide(
new ObjectAccessRequest(
attribution.actorId(), attribution.tenantId(), objectType, objectId)));
}
@Override
public Map<String, GraphQlAuthorizationDecision> authorizeAll(
GraphQlRequestContext context, String objectType, List<String> objectIds) {
GraphQlCommandAttribution attribution = GraphQlCommandAttribution.from(context);
Map<String, ObjectAccessDecision> decided =
policy.decideAll(
attribution.actorId(), attribution.tenantId(), objectType, List.copyOf(objectIds));
Map<String, GraphQlAuthorizationDecision> mapped = new LinkedHashMap<>();
objectIds.forEach(
objectId -> {
ObjectAccessDecision decision = decided.get(objectId);
if (decision == null) {
// A missing answer is a denial, never an omission: a caller that dropped ids from its
// response would leave the loader with no decision for those objects, and "no decision"
// is the one state that must not read as permission.
mapped.put(objectId, GraphQlAuthorizationDecision.deny("OBJECT_NOT_AUTHORIZED"));
return;
}
mapped.put(objectId, map(decision));
});
return Map.copyOf(mapped);
}
private static GraphQlAuthorizationDecision map(ObjectAccessDecision decision) {
if (decision.allowed()) {
return GraphQlAuthorizationDecision.allow();
}
return decision.hideExistence()
? GraphQlAuthorizationDecision.denyHidingExistence(decision.code())
: GraphQlAuthorizationDecision.deny(decision.code());
}
}
@@ -37,9 +37,12 @@ public record GraphQlBatchContext(ActorRef actor, TenantContext tenant, GraphQlD
/** /**
* The cache-key prefix that keeps one tenant's loaded values out of another's. * The cache-key prefix that keeps one tenant's loaded values out of another's.
* *
* <p>A fingerprint rather than the raw tenant, so the key is safe if it is ever logged. * <p>Partition tokens rather than the raw identities, so a cache key that reaches a debug log
* does not read as a list of who called and for whom. The tokens are not a privacy control the
* identities behind them are guessable but this key never leaves the request that built it, and
* what does leave uses the keyed fingerprinter.
*/ */
public String cacheScope() { public String cacheScope() {
return actor.fingerprint() + ":" + tenant.fingerprint(); return actor.cachePartition() + ":" + tenant.cachePartition();
} }
} }
@@ -5,12 +5,18 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* The Application port that answers "may this caller see this object?". * The platform-side seam for "may this caller see this object?".
* *
* <p>A port rather than a repository call from platform code: whether a caller may see an object * <p>A seam rather than a repository call from platform code: whether a caller may see an object
* depends on domain state ownership, membership, workflow status which the transport layer has * depends on domain state ownership, membership, workflow status which the transport layer has
* no business querying. The platform decides <em>when</em> to ask; the Application decides the * no business querying. The platform decides <em>when</em> to ask.
* answer. *
* <p>It does not decide who answers. The contract that answers is {@code
* dev.caskeleton.application.security.ObjectAccessPolicy}, phrased in plain values so the
* application layer can own it; {@link ApplicationObjectAuthorization} maps between the two. This
* interface used to describe itself as the Application port, which no application code could have
* implemented its method signature named a GraphQL request context, so implementing it required
* depending on this transport adapter.
* *
* <p>The batch method exists because object authorization inside a DataLoader would otherwise * <p>The batch method exists because object authorization inside a DataLoader would otherwise
* reintroduce the N+1 the loader was added to remove. * reintroduce the N+1 the loader was added to remove.
@@ -0,0 +1,54 @@
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicyRegistry;
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlBatchLoaderRegistrar;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
/**
* The batch-loader chain is assembled by the platform, not by each adopter.
*
* <p>{@code GraphQlBatchLoaderRegistrar} carried the chunking, the budget and the request scope,
* was unit tested, and was declared by no configuration the only file in the repository that
* mentioned it was itself. A field resolving through {@code @BatchMapping} or a {@code DataLoader}
* therefore met none of it: the platform's N+1 protection existed as a set of objects no request
* could reach.
*
* <p>An adopter still supplies the downstream call, because only the adopter has one. What it no
* longer supplies is the machinery around it, which is what this asserts.
*/
class GraphQlBatchLoaderWiringTest {
private final ApplicationContextRunner runner =
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(GraphQlRootAutoConfiguration.class))
.withPropertyValues(
"backend.graphql.enabled=true", "backend.graphql.deployment-mode=LOCAL");
@Test
@DisplayName("the platform supplies the whole batch chain when GraphQL is on")
void theChainIsSupplied() {
runner.run(
context ->
assertThat(context)
.hasNotFailed()
.hasSingleBean(GraphQlBatchPolicyRegistry.class)
.hasSingleBean(GraphQlDataLoaderFactory.class)
.hasSingleBean(GraphQlBatchLoaderRegistrar.class));
}
@Test
@DisplayName("an adopter's own registry replaces the platform's, rather than colliding with it")
void anAdopterRegistryWins() {
runner
.withBean(GraphQlBatchPolicyRegistry.class, GraphQlBatchPolicyRegistry::new)
.run(
context ->
assertThat(context).hasNotFailed().hasSingleBean(GraphQlBatchPolicyRegistry.class));
}
}
@@ -0,0 +1,62 @@
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlRequestObservationConventionAdapter;
import io.micrometer.observation.ObservationRegistry;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.graphql.autoconfigure.observation.GraphQlObservationAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.graphql.observation.ExecutionRequestObservationConvention;
import org.springframework.graphql.observation.GraphQlObservationInstrumentation;
/**
* The tag policy has to reach the instrumentation that emits the observation.
*
* <p>Spring for GraphQL resolves one {@link ExecutionRequestObservationConvention} bean and falls
* back to its own when it finds none. The platform's cardinality policy used to be a bean of a type
* nothing looked for, so the framework took the fallback and the bounded operation-name label was
* computed for nobody a control with tests, no consumer, and no way to tell from the metrics.
*/
class GraphQlObservationWiringTest {
private final ApplicationContextRunner runner =
new ApplicationContextRunner()
.withConfiguration(
AutoConfigurations.of(
GraphQlRootAutoConfiguration.class, GraphQlObservationAutoConfiguration.class))
.withBean(ObservationRegistry.class, ObservationRegistry::create)
.withPropertyValues(
"backend.graphql.enabled=true", "backend.graphql.deployment-mode=LOCAL");
@Test
void theFrameworkInstrumentationResolvesThePlatformConvention() {
runner.run(
context -> {
assertThat(context).hasNotFailed();
assertThat(context).hasSingleBean(GraphQlObservationInstrumentation.class);
assertThat(context)
.getBean(ExecutionRequestObservationConvention.class)
.as("the only convention the framework can resolve must be the bounded one")
.isInstanceOf(GraphQlRequestObservationConventionAdapter.class);
});
}
@Test
void anAdopterConventionKeepsTheFrameworkFromTakingThePlatformOne() {
runner
.withBean(
"adopterConvention",
ExecutionRequestObservationConvention.class,
org.springframework.graphql.observation.DefaultExecutionRequestObservationConvention
::new)
.run(
context -> {
assertThat(context).hasNotFailed();
assertThat(context)
.as("a platform default that cannot be replaced is not a default")
.doesNotHaveBean(GraphQlRequestObservationConventionAdapter.class);
});
}
}
@@ -7,6 +7,69 @@ import org.junit.jupiter.api.Test;
/** Schema compatibility diff and breaking policy (Stable plan Task 10). */ /** Schema compatibility diff and breaking policy (Stable plan Task 10). */
class GraphQlSchemaComparatorTest { class GraphQlSchemaComparatorTest {
@Test
void aFieldRemovedFromATypeExtensionIsBreaking() {
GraphQlCompatibilityReport report =
GraphQlSchemaComparator.compare(
"type Query { a: String } extend type Query { b: String }", "type Query { a: String }");
assertThat(report.changesOf(GraphQlChangeKind.OUTPUT_FIELD_REMOVED))
.as("a field an extension contributed is on the wire like any other")
.hasSize(1);
}
@Test
void aFieldAddedByATypeExtensionIsReported() {
GraphQlCompatibilityReport report =
GraphQlSchemaComparator.compare(
"type Query { a: String }", "type Query { a: String } extend type Query { b: String }");
assertThat(report.changesOf(GraphQlChangeKind.OUTPUT_FIELD_ADDED_NULLABLE)).hasSize(1);
}
@Test
void anExtensionThatStrengthensAnInputIsBreaking() {
GraphQlCompatibilityReport report =
GraphQlSchemaComparator.compare(
"input OrderFilter { status: String } extend input OrderFilter { region: String }",
"input OrderFilter { status: String } extend input OrderFilter { region: String! }");
assertThat(report.changesOf(GraphQlChangeKind.INPUT_FIELD_STRENGTHENED)).hasSize(1);
}
@Test
void anEnumValueRemovedFromAnExtensionIsReported() {
GraphQlCompatibilityReport report =
GraphQlSchemaComparator.compare(
"enum Status { NEW } extend enum Status { ARCHIVED }", "enum Status { NEW }");
assertThat(report.changesOf(GraphQlChangeKind.ENUM_VALUE_REMOVED)).hasSize(1);
}
@Test
void aUnionMemberRemovedFromAnExtensionIsReported() {
GraphQlCompatibilityReport report =
GraphQlSchemaComparator.compare(
"type A { a: String } type B { b: String } union Result = A extend union Result = B",
"type A { a: String } type B { b: String } union Result = A");
assertThat(report.changesOf(GraphQlChangeKind.UNION_MEMBER_REMOVED)).hasSize(1);
}
@Test
void rewordingAScalarsDescriptionIsNotACoercionChange() {
GraphQlCompatibilityReport report =
GraphQlSchemaComparator.compare(
"\"An ISO-8601 instant.\" scalar Instant type Query { at: Instant }",
"\"An instant, in ISO-8601.\" scalar Instant type Query { at: Instant }");
assertThat(report.changesOf(GraphQlChangeKind.SCALAR_DECLARATION_CHANGED))
.as(
"prose is not a coercion; a review triggered by an edited sentence trains people to "
+ "approve the report without reading it")
.isEmpty();
}
@Test @Test
void requiredArgumentAdditionIsBreaking() { void requiredArgumentAdditionIsBreaking() {
GraphQlCompatibilityReport report = GraphQlCompatibilityReport report =
@@ -0,0 +1,135 @@
package dev.caskeleton.adapter.inbound.graphql.context;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
/**
* The fingerprint that leaves this process has to survive someone guessing the identity behind it.
*
* <p>The interesting case is the low-entropy one, so the tests use the identifiers a real
* deployment has: {@code user-42}, {@code tenant-a}. A plain digest of either is recovered in the
* time it takes to hash a wordlist, which is what the dictionary test measures directly.
*/
class GraphQlIdentityFingerprinterTest {
private static final ActorRef ACTOR = ActorRef.authenticated("user-42");
private static final TenantContext TENANT = TenantContext.fromTrustedSession("tenant-a");
@Test
void aGuessableIdentityIsNotRecoverableFromItsFingerprint() {
GraphQlIdentityFingerprinter fingerprinter =
GraphQlIdentityFingerprinter.single("k1", randomKey());
String fingerprint = fingerprinter.actor(ACTOR);
Map<String, String> dictionary = new LinkedHashMap<>();
for (int candidate = 0; candidate < 100; candidate++) {
dictionary.put(sha256Prefix("user-" + candidate), "user-" + candidate);
}
assertThat(dictionary.keySet())
.as("a digest of an enumerable identifier names its owner to anyone who enumerates it")
.contains(sha256Prefix("user-42"));
assertThat(dictionary)
.as("the same enumeration must not resolve the keyed fingerprint")
.doesNotContainKey(fingerprint.substring(fingerprint.indexOf(':') + 1));
assertThat(fingerprint).doesNotContain("user-42");
}
@Test
void twoKeysProduceTwoFingerprintsForOneIdentity() {
assertThat(GraphQlIdentityFingerprinter.single("k1", randomKey()).actor(ACTOR))
.as("a fingerprint a deployment cannot change is a fingerprint it cannot revoke")
.isNotEqualTo(GraphQlIdentityFingerprinter.single("k2", randomKey()).actor(ACTOR));
}
@Test
void theSameKeyProducesTheSameFingerprint() {
byte[] secret = randomKey();
assertThat(GraphQlIdentityFingerprinter.single("k1", secret).actor(ACTOR))
.as("a retry has to fingerprint to the stored value or idempotency never matches")
.isEqualTo(GraphQlIdentityFingerprinter.single("k1", secret.clone()).actor(ACTOR));
}
@Test
void theFingerprintNamesTheKeyItWasComputedUnder() {
byte[] retired = randomKey();
byte[] active = randomKey();
GraphQlIdentityFingerprinter rotated =
GraphQlIdentityFingerprinter.of(Map.of("k1", retired, "k2", active), "k2");
assertThat(rotated.activeKeyId()).isEqualTo("k2");
assertThat(rotated.actor(ACTOR)).startsWith("k2:");
assertThat(rotated.keyIds())
.as("records written under the retired key stay attributable while they are retained")
.containsExactlyInAnyOrder("k1", "k2");
}
@Test
void anActorAndATenantOfTheSameNameFingerprintDifferently() {
GraphQlIdentityFingerprinter fingerprinter =
GraphQlIdentityFingerprinter.single("k1", randomKey());
assertThat(fingerprinter.actor(ActorRef.authenticated("acme")))
.isNotEqualTo(fingerprinter.tenant(TenantContext.fromAuthenticatedCredential("acme")));
}
@Test
void aTenantFingerprintNeverCarriesTheTenantName() {
assertThat(GraphQlIdentityFingerprinter.single("k1", randomKey()).tenant(TENANT))
.doesNotContain("tenant-a");
}
@Test
void aRingWithoutAUsableKeyIsRefused() {
byte[] usable = randomKey();
assertThatThrownBy(() -> GraphQlIdentityFingerprinter.of(Map.of(), "k1"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> GraphQlIdentityFingerprinter.of(Map.of("k1", usable), "k2"))
.as("an active key that is not in the ring cannot fingerprint anything")
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> GraphQlIdentityFingerprinter.single("k1", new byte[8]))
.as("a short key is the part of a MAC an attacker attacks first")
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> GraphQlIdentityFingerprinter.single("k:1", usable))
.as("the key id is the fingerprint's prefix, so it cannot contain the separator")
.isInstanceOf(IllegalArgumentException.class);
}
/**
* One generator for the whole class.
*
* <p>Seeding a fresh {@code SecureRandom} per call is the wasteful shape SpotBugs reports as
* {@code DMI_RANDOM_USED_ONLY_ONCE}: each instance pays for seeding and then produces a single
* value. The keys still never appear in this file, which is the property that matters.
*/
private static final SecureRandom KEYS = new SecureRandom();
private static byte[] randomKey() {
byte[] secret = new byte[32];
KEYS.nextBytes(secret);
return secret;
}
/** The unkeyed form, reproduced here only so the dictionary attack on it can be demonstrated. */
private static String sha256Prefix(String source) {
try {
byte[] digest =
MessageDigest.getInstance("SHA-256").digest(source.getBytes(StandardCharsets.UTF_8));
byte[] prefix = new byte[16];
System.arraycopy(digest, 0, prefix, 0, prefix.length);
return HexFormat.of().formatHex(prefix);
} catch (Exception unavailable) {
throw new IllegalStateException(unavailable);
}
}
}
@@ -48,12 +48,12 @@ class GraphQlRequestContextTest {
} }
@Test @Test
void actorAndTenantExposeFingerprintsInsteadOfRawIdentifiers() { void actorAndTenantExposePartitionTokensInsteadOfRawIdentifiers() {
ActorRef actor = ActorRef.authenticated("user-42"); ActorRef actor = ActorRef.authenticated("user-42");
TenantContext tenant = TenantContext.fromTrustedSession("tenant-a"); TenantContext tenant = TenantContext.fromTrustedSession("tenant-a");
assertThat(actor.fingerprint()).doesNotContain("user-42").hasSize(32); assertThat(actor.cachePartition()).doesNotContain("user-42").hasSize(32);
assertThat(tenant.fingerprint()).doesNotContain("tenant-a").hasSize(32); assertThat(tenant.cachePartition()).doesNotContain("tenant-a").hasSize(32);
assertThat(ActorRef.anonymous().authenticated()).isFalse(); assertThat(ActorRef.anonymous().authenticated()).isFalse();
} }
@@ -4,7 +4,10 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.inbound.graphql.context.ActorRef;
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlIdentityFingerprinter;
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext;
import dev.caskeleton.adapter.inbound.graphql.context.TenantContext;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlExtensionsPolicy; import dev.caskeleton.adapter.inbound.graphql.http.GraphQlExtensionsPolicy;
import dev.caskeleton.adapter.inbound.graphql.testkit.GraphQlRequestContexts; import dev.caskeleton.adapter.inbound.graphql.testkit.GraphQlRequestContexts;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@@ -15,11 +18,33 @@ import org.junit.jupiter.api.Test;
/** Mutation idempotency scope and fingerprint (Stable plan Task 43). */ /** Mutation idempotency scope and fingerprint (Stable plan Task 43). */
class GraphQlMutationIdempotencyContextTest { class GraphQlMutationIdempotencyContextTest {
/**
* One generator for the whole class, declared before the keys that draw from it.
*
* <p>Static initialisers run in textual order, so a generator declared below the two key
* constants would still be null when they are built. Seeding a fresh {@code SecureRandom} per
* call is also what SpotBugs reports as {@code DMI_RANDOM_USED_ONLY_ONCE}.
*/
private static final java.security.SecureRandom KEYS = new java.security.SecureRandom();
private static final String TENANT = "tenant-fingerprint"; private static final String TENANT = "tenant-fingerprint";
private static final String VERSION = "v1"; private static final String VERSION = "v1";
private static final GraphQlMutationCoordinate CREATE = private static final GraphQlMutationCoordinate CREATE =
new GraphQlMutationCoordinate("Mutation.createOrder"); new GraphQlMutationCoordinate("Mutation.createOrder");
// Two rings standing for one deployment before and after a rotation. Both secrets are generated
// here rather than written down, so nothing in this file is a key anyone could reuse.
private static final GraphQlIdentityFingerprinter FIRST_KEY =
GraphQlIdentityFingerprinter.single("k1", randomKey());
private static final GraphQlIdentityFingerprinter ROTATED_KEY =
GraphQlIdentityFingerprinter.single("k2", randomKey());
private static byte[] randomKey() {
byte[] secret = new byte[32];
KEYS.nextBytes(secret);
return secret;
}
@Test @Test
void sameKeyWithDifferentFingerprintIsConflict() { void sameKeyWithDifferentFingerprintIsConflict() {
var first = context(CREATE, new GraphQlMutationFingerprint("sha256:a")); var first = context(CREATE, new GraphQlMutationFingerprint("sha256:a"));
@@ -189,6 +214,7 @@ class GraphQlMutationIdempotencyContextTest {
var derived = var derived =
GraphQlMutationIdempotencyInterceptor.from( GraphQlMutationIdempotencyInterceptor.from(
context, context,
FIRST_KEY,
CREATE, CREATE,
VERSION, VERSION,
Map.of(GraphQlExtensionsPolicy.IDEMPOTENCY_KEY, "request-1"), Map.of(GraphQlExtensionsPolicy.IDEMPOTENCY_KEY, "request-1"),
@@ -198,16 +224,68 @@ class GraphQlMutationIdempotencyContextTest {
.hasValueSatisfying( .hasValueSatisfying(
scope -> { scope -> {
assertThat(scope.key().value()).isEqualTo("request-1"); assertThat(scope.key().value()).isEqualTo("request-1");
assertThat(scope.actorFingerprint()).isEqualTo(context.actor().fingerprint()); assertThat(scope.actorFingerprint()).isEqualTo(FIRST_KEY.actor(context.actor()));
assertThat(scope.tenantFingerprint()).isEqualTo(context.tenant().fingerprint()); assertThat(scope.tenantFingerprint()).isEqualTo(FIRST_KEY.tenant(context.tenant()));
assertThat(scope.contractVersion()).isEqualTo(VERSION); assertThat(scope.contractVersion()).isEqualTo(VERSION);
}); });
assertThat( assertThat(
GraphQlMutationIdempotencyInterceptor.from( GraphQlMutationIdempotencyInterceptor.from(
context, CREATE, VERSION, Map.of(), Map.of())) context, FIRST_KEY, CREATE, VERSION, Map.of(), Map.of()))
.isEmpty(); .isEmpty();
} }
@Test
void rotatingTheFingerprintKeyChangesTheStoredScope() {
GraphQlRequestContext context = GraphQlRequestContexts.testContext("tenant-a");
String first = derivedScope(context, FIRST_KEY);
String rotated = derivedScope(context, ROTATED_KEY);
assertThat(first)
.as(
"an unkeyed digest of a guessable actor is recovered by digesting the guesses, and a "
+ "deployment that suspects its stored scopes have been read has no way to change "
+ "them unless the fingerprint depends on a key it controls")
.isNotEqualTo(rotated);
}
@Test
void anActorAndATenantOfTheSameNameDoNotShareAFingerprint() {
assertThat(FIRST_KEY.actor(ActorRef.authenticated("acme")))
.as("a service account and a tenant that happen to share a name are two identities")
.isNotEqualTo(FIRST_KEY.tenant(TenantContext.fromAuthenticatedCredential("acme")));
}
@Test
void derivingAScopeWithoutAFingerprintKeyIsRefused() {
GraphQlRequestContext context = GraphQlRequestContexts.testContext("tenant-a");
assertThatThrownBy(
() ->
GraphQlMutationIdempotencyInterceptor.from(
context,
null,
CREATE,
VERSION,
Map.of(GraphQlExtensionsPolicy.IDEMPOTENCY_KEY, "request-1"),
Map.of("customerId", "c-1")))
.as("a deployment with no key must not silently fall back to a recoverable digest")
.isInstanceOf(IllegalArgumentException.class);
}
private static String derivedScope(
GraphQlRequestContext context, GraphQlIdentityFingerprinter fingerprinter) {
return GraphQlMutationIdempotencyInterceptor.from(
context,
fingerprinter,
CREATE,
VERSION,
Map.of(GraphQlExtensionsPolicy.IDEMPOTENCY_KEY, "request-1"),
Map.of("customerId", "c-1"))
.orElseThrow()
.scope();
}
@Test @Test
void fingerprintsAndScopesNeverCarryRawInputOrActor() { void fingerprintsAndScopesNeverCarryRawInputOrActor() {
var fingerprint = GraphQlMutationFingerprint.of(Map.of("card", "4111111111111111")); var fingerprint = GraphQlMutationFingerprint.of(Map.of("card", "4111111111111111"));
@@ -0,0 +1,431 @@
package dev.caskeleton.adapter.inbound.graphql.runtime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlDeadline;
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext;
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchContext;
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchErrorPolicy;
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicy;
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicyRegistry;
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory;
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderName;
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlMissingKeyPolicy;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfile;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfileException;
import dev.caskeleton.adapter.inbound.graphql.security.GraphQlContextPropagator;
import dev.caskeleton.adapter.inbound.graphql.testkit.GraphQlRequestContexts;
import graphql.ExecutionInput;
import graphql.ExecutionResult;
import graphql.GraphQL;
import graphql.schema.DataFetcher;
import graphql.schema.GraphQLSchema;
import graphql.schema.idl.RuntimeWiring;
import graphql.schema.idl.SchemaGenerator;
import graphql.schema.idl.SchemaParser;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import org.dataloader.DataLoaderRegistry;
import org.junit.jupiter.api.Test;
import org.springframework.graphql.execution.DefaultBatchLoaderRegistry;
/**
* The batch policy has to be reached by an executing query, not only by a unit test.
*
* <p>Everything the platform says about N+1 lived in objects that a request never met: the registry
* held {@code Object} and was connected to neither Spring's {@link
* org.springframework.graphql.execution.BatchLoaderRegistry} nor java-dataloader, and the chunk
* counts the contract suite asserted were numbers the caller had passed in. A suite that measures
* its own argument passes whatever the runtime does.
*
* <p>So this drives the real path the registrar registers with Spring's registry, Spring builds
* the {@code DataLoaderRegistry} for one execution, graphql-java dispatches through it and counts
* calls on a fake downstream. Fifty parents that produce fifty calls, or a batch that outlives the
* request budget, fail here and nowhere else.
*/
class GraphQlBatchLoaderRegistrationTest {
private static final GraphQlDataLoaderName LOADER = new GraphQlDataLoaderName("customer-by-id");
private static final String SCHEMA =
"""
type Query { orders: [Order!]! }
type Order { id: ID!, customer: Customer }
type Customer { id: ID!, name: String! }
""";
@Test
void fiftyParentsBecomeABoundedNumberOfDownstreamCalls() {
RecordingLoader downstream = new RecordingLoader();
Fixture fixture = fixture(chunkSize(20), Clock.systemUTC());
ExecutionResult result = fixture.execute(orders(50, 50), downstream);
assertThat(result.getErrors()).isEmpty();
assertThat(resolvedCustomers(result)).hasSize(50);
assertThat(downstream.calls)
.as("fifty parents resolving one child each must not be fifty downstream calls")
.hasValue(3);
}
@Test
void oneRequestLoadsARepeatedKeyOnce() {
RecordingLoader downstream = new RecordingLoader();
Fixture fixture = fixture(chunkSize(20), Clock.systemUTC());
ExecutionResult result = fixture.execute(orders(50, 5), downstream);
assertThat(result.getErrors()).isEmpty();
assertThat(downstream.requestedKeys)
.as("the loader dedupes within a request; fifty orders share five customers")
.hasSize(5);
assertThat(downstream.calls).hasValue(1);
}
@Test
void oneRequestsCacheIsNotHandedToTheNext() {
RecordingLoader downstream = new RecordingLoader();
Fixture fixture = fixture(chunkSize(20), Clock.systemUTC());
fixture.execute(orders(5, 5), downstream);
fixture.execute(orders(5, 5), downstream);
assertThat(downstream.calls)
.as(
"a DataLoader cache that outlived its request would serve one caller's rows to the next")
.hasValue(2);
}
@Test
void everyChunkRunsUnderTheRequestsOwnActorAndTenant() {
RecordingLoader downstream = new RecordingLoader();
Fixture tenantA = fixture(chunkSize(20), Clock.systemUTC());
Fixture tenantB = fixture(chunkSize(20), Clock.systemUTC(), "tenant-b");
tenantA.execute(orders(50, 50), downstream);
Set<String> afterTenantA = Set.copyOf(downstream.observedScopes);
tenantB.execute(orders(50, 50), downstream);
assertThat(afterTenantA)
.as("one request's chunks all carry the same scope, or a chunk read for someone else")
.hasSize(1);
assertThat(downstream.observedScopes)
.as("two tenants must not share one batch scope")
.hasSize(2);
}
@Test
void aLoaderWithoutThePlatformContextFailsClosedRatherThanLoading() {
RecordingLoader downstream = new RecordingLoader();
Fixture fixture = fixture(chunkSize(20), Clock.systemUTC());
ExecutionResult result = fixture.executeWithoutRequestContext(orders(5, 5), downstream);
assertThat(result.getErrors())
.as("a batch with no deadline, tenant or actor has nothing to bound or scope it")
.isNotEmpty();
assertThat(downstream.calls).hasValue(0);
}
@Test
void aBatchThatOverrunsTheRequestBudgetStopsAtTheChunkThatSpentIt() {
MutableClock clock = new MutableClock(Instant.parse("2026-08-14T00:00:00Z"));
RecordingLoader downstream = new RecordingLoader();
downstream.onCall = () -> clock.advance(Duration.ofMinutes(1));
Fixture fixture = fixture(chunkSize(10), clock);
ExecutionResult result = fixture.execute(orders(50, 50), downstream);
assertThat(result.getErrors()).isNotEmpty();
assertThat(downstream.calls)
.as("the chunk after an exhausted budget must never reach the downstream")
.hasValue(1);
}
@Test
void aLoaderThatAnswersAKeyNobodyAskedForIsRefused() {
RecordingLoader downstream = new RecordingLoader();
downstream.extraKey = "c-from-another-question";
Fixture fixture = fixture(chunkSize(20), Clock.systemUTC());
ExecutionResult result = fixture.execute(orders(5, 5), downstream);
assertThat(result.getErrors())
.as("matching cardinality is not matching keys, and the platform declares that a violation")
.isNotEmpty();
}
@Test
void aRowTheLoaderReportsAsNullRendersAsAMissingChild() {
RecordingLoader downstream = new RecordingLoader();
downstream.nullValueKey = "c-2";
Fixture fixture = fixture(chunkSize(20), Clock.systemUTC());
ExecutionResult result = fixture.execute(orders(5, 5), downstream);
assertThat(result.getErrors())
.as("a nullable relation with no row is data, not a failure of the whole batch")
.isEmpty();
assertThat(resolvedCustomers(result)).containsNull();
}
@Test
void aMissingRowUnderTheFieldErrorPolicyFailsTheField() {
RecordingLoader downstream = new RecordingLoader();
downstream.omittedKey = "c-2";
Fixture fixture =
fixture(
new GraphQlBatchPolicy(
LOADER,
20,
Duration.ofSeconds(5),
true,
GraphQlMissingKeyPolicy.FIELD_ERROR,
GraphQlBatchErrorPolicy.PER_KEY),
Clock.systemUTC());
ExecutionResult result = fixture.execute(orders(5, 5), downstream);
assertThat(result.getErrors())
.as("a loader declared as always resolving must not render its absence as legitimate null")
.isNotEmpty();
}
@Test
void aReactiveRuntimeWithoutABridgeRefusesTheLoader() {
GraphQlBatchLoaderRegistrar registrar =
new GraphQlBatchLoaderRegistrar(
factory(chunkSize(20), Clock.systemUTC()), GraphQlExecutionProfile.REACTIVE_WEBFLUX);
assertThatThrownBy(
() ->
registrar.<String, Map<String, Object>>register(
new DefaultBatchLoaderRegistry(), LOADER, (keys, batchContext) -> Map.of()))
.as("an inline blocking chunk on an event loop stalls every request that loop is serving")
.isInstanceOf(GraphQlExecutionProfileException.class);
}
@Test
void aBridgedLoaderRunsOffTheCallingThreadAndStillSeesTheRequest() {
RecordingLoader downstream = new RecordingLoader();
try (GraphQlBlockingBridge bridge = GraphQlBlockingBridge.bounded(1, 4)) {
Fixture fixture =
new Fixture(
new GraphQlBatchLoaderRegistrar(
factory(chunkSize(20), Clock.systemUTC()),
bridge,
GraphQlExecutionProfile.REACTIVE_WEBFLUX),
"tenant-a",
Clock.systemUTC());
ExecutionResult result = fixture.execute(orders(5, 5), downstream);
assertThat(result.getErrors()).isEmpty();
assertThat(downstream.observedThreads)
.as("the bridge exists to move the blocking call off the thread that subscribed")
.doesNotContain(Thread.currentThread().getName());
assertThat(downstream.observedBoundContexts)
.as("a batch that arrives on the bridge thread with no context has no tenant")
.containsExactly(Boolean.TRUE);
}
}
@Test
void aServletRuntimeLoadsOnTheThreadItWasGiven() {
RecordingLoader downstream = new RecordingLoader();
fixture(chunkSize(20), Clock.systemUTC()).execute(orders(5, 5), downstream);
assertThat(downstream.observedThreads)
.as("a second pool on a servlet stack is a queue and a wait that buys nothing")
.containsExactly(Thread.currentThread().getName());
}
private static GraphQlBatchPolicy chunkSize(int size) {
return new GraphQlBatchPolicy(
LOADER,
size,
Duration.ofSeconds(5),
true,
GraphQlMissingKeyPolicy.NULL_VALUE,
GraphQlBatchErrorPolicy.PER_KEY);
}
private static Fixture fixture(GraphQlBatchPolicy policy, Clock clock) {
return fixture(policy, clock, "tenant-a");
}
private static Fixture fixture(GraphQlBatchPolicy policy, Clock clock, String tenant) {
return new Fixture(
new GraphQlBatchLoaderRegistrar(
factory(policy, clock), GraphQlExecutionProfile.BLOCKING_MVC),
tenant,
clock);
}
private static GraphQlDataLoaderFactory factory(GraphQlBatchPolicy policy, Clock clock) {
return new GraphQlDataLoaderFactory(
new GraphQlBatchPolicyRegistry().register(policy), 100, clock);
}
/** {@code count} orders spread over {@code customers} distinct customer ids. */
private static List<Order> orders(int count, int customers) {
List<Order> orders = new ArrayList<>();
for (int index = 0; index < count; index++) {
orders.add(new Order("o-" + index, "c-" + (index % customers)));
}
return orders;
}
@SuppressWarnings("unchecked")
private static List<Map<String, Object>> resolvedCustomers(ExecutionResult result) {
Map<String, Object> data = result.getData();
return ((List<Map<String, Object>>) data.get("orders"))
.stream().map(order -> (Map<String, Object>) order.get("customer")).toList();
}
private record Order(String id, String customerId) {}
/** The adopter's downstream call, counting what the platform actually asked it for. */
private static final class RecordingLoader {
private final AtomicInteger calls = new AtomicInteger();
private final Set<String> requestedKeys = new LinkedHashSet<>();
private final Set<String> observedScopes = new LinkedHashSet<>();
private Runnable onCall = () -> {};
private final Set<String> observedThreads = new LinkedHashSet<>();
private final Set<Boolean> observedBoundContexts = new LinkedHashSet<>();
private String extraKey;
private String nullValueKey;
private String omittedKey;
Map<String, Map<String, Object>> load(List<String> keys, GraphQlBatchContext context) {
calls.incrementAndGet();
requestedKeys.addAll(keys);
observedScopes.add(context.cacheScope());
observedThreads.add(Thread.currentThread().getName());
observedBoundContexts.add(GraphQlContextPropagator.current().isPresent());
onCall.run();
Map<String, Map<String, Object>> rows = new LinkedHashMap<>();
for (String key : keys) {
if (key.equals(omittedKey)) {
continue;
}
rows.put(
key, key.equals(nullValueKey) ? null : Map.of("id", key, "name", "customer " + key));
}
if (extraKey != null) {
rows.put(extraKey, Map.of("id", extraKey, "name", "customer " + extraKey));
}
return rows;
}
}
/** Registrar, schema and execution wired the way Spring wires them at runtime. */
private static final class Fixture {
private final GraphQlBatchLoaderRegistrar registrar;
private final String tenant;
private final Clock clock;
Fixture(GraphQlBatchLoaderRegistrar registrar, String tenant, Clock clock) {
this.registrar = registrar;
this.tenant = tenant;
this.clock = clock;
}
ExecutionResult execute(List<Order> orders, RecordingLoader downstream) {
GraphQlRequestContext context =
GraphQlRequestContexts.testContext(tenant)
.withDeadline(GraphQlDeadline.after(Duration.ofSeconds(5), clock));
return execute(orders, downstream, context);
}
ExecutionResult executeWithoutRequestContext(List<Order> orders, RecordingLoader downstream) {
return execute(orders, downstream, null);
}
private ExecutionResult execute(
List<Order> orders, RecordingLoader downstream, GraphQlRequestContext context) {
DefaultBatchLoaderRegistry batchLoaders = new DefaultBatchLoaderRegistry();
registrar.<String, Map<String, Object>>register(
batchLoaders, LOADER, (keys, batchContext) -> downstream.load(keys, batchContext));
ExecutionInput input =
ExecutionInput.newExecutionInput("{ orders { id customer { id name } } }")
.graphQLContext(
builder -> {
if (context != null) {
builder.of(GraphQlRequestContext.CONTEXT_KEY, context);
}
})
.build();
// A registry per execution, exactly as Spring builds one per request: this is what makes the
// loader's cache request-scoped rather than a process-wide store of other people's rows.
DataLoaderRegistry dataLoaders = new DataLoaderRegistry();
batchLoaders.registerDataLoaders(dataLoaders, input.getGraphQLContext());
return GraphQL.newGraphQL(schema(orders))
.build()
.execute(input.transform(builder -> builder.dataLoaderRegistry(dataLoaders)));
}
private static GraphQLSchema schema(List<Order> orders) {
DataFetcher<?> customer =
environment -> {
Order order = environment.getSource();
return environment
.<String, Map<String, Object>>getDataLoader(LOADER.value())
.load(order.customerId());
};
return new SchemaGenerator()
.makeExecutableSchema(
new SchemaParser().parse(SCHEMA),
RuntimeWiring.newRuntimeWiring()
.type("Query", builder -> builder.dataFetcher("orders", environment -> orders))
.type("Order", builder -> builder.dataFetcher("customer", customer))
.build());
}
}
/** A clock the test advances itself, so elapsed time is caused by the work under test. */
private static final class MutableClock extends Clock {
private Instant current;
MutableClock(Instant start) {
this.current = start;
}
void advance(Duration step) {
current = current.plus(step);
}
@Override
public ZoneId getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(ZoneId zone) {
return this;
}
@Override
public Instant instant() {
return current;
}
}
}
@@ -0,0 +1,172 @@
package dev.caskeleton.adapter.inbound.graphql.runtime;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile;
import dev.caskeleton.adapter.inbound.graphql.api.GraphQlOperationId;
import dev.caskeleton.adapter.inbound.graphql.context.ActorRef;
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlDeadline;
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext;
import dev.caskeleton.adapter.inbound.graphql.context.TenantContext;
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlObservationNames;
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlOperationNameCardinality;
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlRequestObservationConvention;
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlSensitiveAttributeFilter;
import graphql.ExecutionInput;
import graphql.ExecutionResult;
import io.micrometer.core.instrument.Meter;
import io.micrometer.core.instrument.observation.DefaultMeterObservationHandler;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.graphql.observation.ExecutionRequestObservationContext;
/**
* The bound, measured where a metrics backend would see it.
*
* <p>The platform's tag policy was a value object with no consumer: Spring for GraphQL emits the
* {@code graphql.request} observation and asks its own convention what to tag it with, so the
* cardinality policy applied to nothing that was ever exported. These cases drive the observation
* through a real {@code ObservationRegistry} and a registry that actually stores series, and count
* the meters afterwards.
*/
class GraphQlRequestObservationConventionAdapterTest {
private static final int ARBITRARY_NAMES = 10_000;
private final SimpleMeterRegistry meters = new SimpleMeterRegistry();
private final ObservationRegistry observations = ObservationRegistry.create();
GraphQlRequestObservationConventionAdapterTest() {
observations.observationConfig().observationHandler(new DefaultMeterObservationHandler(meters));
}
@Test
void tenThousandArbitraryNamesProduceOneExportedSeries() {
var adapter = new GraphQlRequestObservationConventionAdapter(collapsingConvention());
for (int index = 0; index < ARBITRARY_NAMES; index++) {
observe(adapter, "Query" + index, null);
}
assertThat(requestSeries())
.as("a client that renames its operation per request must not rename the time series")
.hasSize(1);
assertThat(operationNameLabels()).containsExactly(GraphQlOperationNameCardinality.UNREGISTERED);
}
@Test
void aNameTheValueTypeWouldRejectIsCollapsedRatherThanThrown() {
var adapter = new GraphQlRequestObservationConventionAdapter(collapsingConvention());
// Valid GraphQL names, invalid platform operation names: too short, and leading underscore.
observe(adapter, "ab", null);
observe(adapter, "_internal", null);
assertThat(requestSeries())
.as("an observation convention that throws takes the request down with it")
.hasSize(1);
assertThat(operationNameLabels()).containsExactly(GraphQlOperationNameCardinality.UNREGISTERED);
}
@Test
void aRegisteredOperationKeepsItsOwnSeriesAndTheRestCollapse() {
var convention =
new GraphQlRequestObservationConvention(
GraphQlSensitiveAttributeFilter.standard(),
new GraphQlOperationNameCardinality(Set.of("OrderById")));
var adapter = new GraphQlRequestObservationConventionAdapter(convention);
observe(adapter, "OrderById", null);
for (int index = 0; index < 100; index++) {
observe(adapter, "Query" + index, null);
}
assertThat(operationNameLabels())
.containsExactlyInAnyOrder("OrderById", GraphQlOperationNameCardinality.UNREGISTERED);
}
@Test
void theExportedTagsCarryTheRequestProfileAndOutcome() {
var adapter = new GraphQlRequestObservationConventionAdapter(collapsingConvention());
observe(adapter, "Anything", requestContext("partner"));
Meter.Id id = requestSeries().get(0);
assertThat(id.getTag("graphql.client.profile")).isEqualTo("partner");
assertThat(id.getTag("graphql.outcome"))
.isEqualTo(GraphQlRequestObservationConventionAdapter.OUTCOME_SUCCESS);
assertThat(id.getTag("graphql.operation.type")).isEqualTo("QUERY");
assertThat(id.getTag("graphql.document"))
.as("the document is the unbounded value the allowlist exists to drop")
.isNull();
}
@Test
void anUnauthenticatedRequestIsCountedUnderTheAnonymousProfile() {
var adapter = new GraphQlRequestObservationConventionAdapter(collapsingConvention());
observe(adapter, "Anything", null);
assertThat(requestSeries().get(0).getTag("graphql.client.profile")).isEqualTo("anonymous");
}
@Test
void theObservationNameMatchesTheOneSpringAlreadyEmits() {
var adapter = new GraphQlRequestObservationConventionAdapter(collapsingConvention());
assertThat(adapter.getName()).isEqualTo(GraphQlObservationNames.REQUEST);
}
private static GraphQlRequestObservationConvention collapsingConvention() {
return GraphQlRequestObservationConvention.standard();
}
private void observe(
GraphQlRequestObservationConventionAdapter adapter,
String operationName,
GraphQlRequestContext requestContext) {
ExecutionInput input =
ExecutionInput.newExecutionInput("{ __typename }").operationName(operationName).build();
if (requestContext != null) {
input.getGraphQLContext().put(GraphQlRequestContext.CONTEXT_KEY, requestContext);
}
var context = new ExecutionRequestObservationContext(input);
context.setExecutionResult(ExecutionResult.newExecutionResult().data(Map.of()).build());
Observation.createNotStarted(adapter, () -> context, observations).observe(() -> {});
}
private static GraphQlRequestContext requestContext(String profile) {
return new GraphQlRequestContext(
ActorRef.anonymous(),
TenantContext.system("public"),
new GraphQlClientProfile(profile),
Locale.ENGLISH,
new GraphQlOperationId("order.by-id"),
"trace-1",
new GraphQlDeadline(Instant.now().plus(Duration.ofSeconds(5))));
}
private List<Meter.Id> requestSeries() {
return meters.getMeters().stream()
.map(Meter::getId)
.filter(id -> GraphQlObservationNames.REQUEST.equals(id.getName()))
.toList();
}
private List<String> operationNameLabels() {
return requestSeries().stream()
.map(id -> id.getTag("graphql.operation.name"))
.distinct()
.sorted()
.toList();
}
}
@@ -0,0 +1,156 @@
package dev.caskeleton.adapter.inbound.graphql.security;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile;
import dev.caskeleton.adapter.inbound.graphql.api.GraphQlOperationId;
import dev.caskeleton.adapter.inbound.graphql.context.ActorRef;
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlDeadline;
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext;
import dev.caskeleton.adapter.inbound.graphql.context.TenantContext;
import dev.caskeleton.application.security.ObjectAccessDecision;
import dev.caskeleton.application.security.ObjectAccessPolicy;
import dev.caskeleton.application.security.ObjectAccessRequest;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.junit.jupiter.api.Test;
/**
* The GraphQL side of object access is a mapping, and nothing more.
*
* <p>What the application receives has to be four plain values, because a contract that names this
* leaf's request context cannot be implemented from application-core at all the dependency gate
* refuses the edge, and the transport would arrive in every persistence adapter behind the use
* case. These cases pin the mapping: the actor and tenant come from the request context, the
* decision comes back unaltered in meaning, and an answer the application failed to give is a
* denial rather than an omission.
*/
class ApplicationObjectAuthorizationTest {
@Test
void theApplicationSeesTheActorAndTenantFromTheRequestContext() {
List<ObjectAccessRequest> asked = new ArrayList<>();
var bridge =
new ApplicationObjectAuthorization(
request -> {
asked.add(request);
return ObjectAccessDecision.allow();
});
bridge.authorize(context(ActorRef.authenticated("actor-1"), "tenant-a"), "Order", "order-1");
assertThat(asked).hasSize(1);
assertThat(asked.get(0).actor()).contains("actor-1");
assertThat(asked.get(0).tenantId()).isEqualTo("tenant-a");
assertThat(asked.get(0).objectType()).isEqualTo("Order");
assertThat(asked.get(0).objectId()).isEqualTo("order-1");
}
@Test
void anUnauthenticatedCallerReachesTheApplicationWithNoActor() {
List<ObjectAccessRequest> asked = new ArrayList<>();
var bridge =
new ApplicationObjectAuthorization(
request -> {
asked.add(request);
return ObjectAccessDecision.allow();
});
bridge.authorize(context(ActorRef.anonymous(), "public"), "Order", "order-1");
assertThat(asked.get(0).actor())
.as("an anonymous reference is not an identity the rule may match on")
.isEmpty();
}
@Test
void aHiddenDenialStaysHiddenAcrossTheBoundary() {
var bridge =
new ApplicationObjectAuthorization(
request -> ObjectAccessDecision.denyHidingExistence("OBJECT_NOT_FOUND"));
GraphQlAuthorizationDecision decision =
bridge.authorize(context(ActorRef.authenticated("actor-1"), "tenant-a"), "Order", "o-1");
assertThat(decision.allowed()).isFalse();
assertThat(decision.code()).isEqualTo("OBJECT_NOT_FOUND");
assertThat(decision.hideExistence())
.as("dropping this flag turns a not-found into a forbidden, which discloses the object")
.isTrue();
}
@Test
void theBatchFormAsksOnceAndAnswersEveryId() {
List<String> batches = new ArrayList<>();
ObjectAccessPolicy policy =
new ObjectAccessPolicy() {
@Override
public ObjectAccessDecision decide(ObjectAccessRequest request) {
throw new AssertionError("the batch form must not fall back to one call per object");
}
@Override
public Map<String, ObjectAccessDecision> decideAll(
String actorId, String tenantId, String objectType, List<String> objectIds) {
batches.add(String.join(",", objectIds));
return Map.of(
"order-1", ObjectAccessDecision.allow(),
"order-2", ObjectAccessDecision.deny("OBJECT_NOT_AUTHORIZED"));
}
};
Map<String, GraphQlAuthorizationDecision> decisions =
new ApplicationObjectAuthorization(policy)
.authorizeAll(
context(ActorRef.authenticated("actor-1"), "tenant-a"),
"Order",
List.of("order-1", "order-2"));
assertThat(batches).containsExactly("order-1,order-2");
assertThat(decisions.get("order-1").allowed()).isTrue();
assertThat(decisions.get("order-2").allowed()).isFalse();
}
@Test
void anObjectTheApplicationDidNotAnswerForIsDenied() {
ObjectAccessPolicy policy =
new ObjectAccessPolicy() {
@Override
public ObjectAccessDecision decide(ObjectAccessRequest request) {
return ObjectAccessDecision.allow();
}
@Override
public Map<String, ObjectAccessDecision> decideAll(
String actorId, String tenantId, String objectType, List<String> objectIds) {
return Map.of("order-1", ObjectAccessDecision.allow());
}
};
Map<String, GraphQlAuthorizationDecision> decisions =
new ApplicationObjectAuthorization(policy)
.authorizeAll(
context(ActorRef.authenticated("actor-1"), "tenant-a"),
"Order",
List.of("order-1", "order-2"));
assertThat(decisions.get("order-2").allowed())
.as("no decision is the one state that must never read as permission")
.isFalse();
}
private static GraphQlRequestContext context(ActorRef actor, String tenant) {
return new GraphQlRequestContext(
actor,
TenantContext.system(tenant),
new GraphQlClientProfile("first-party"),
Locale.ENGLISH,
new GraphQlOperationId("order.by-id"),
"trace-1",
new GraphQlDeadline(Instant.now().plus(Duration.ofSeconds(5))));
}
}
@@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionCancellation; import dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionCancellation;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlCancellation;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -63,6 +64,61 @@ class GraphQlCancellationAggregationTest {
assertThat(suppressed).isInstanceOf(IllegalStateException.class))); assertThat(suppressed).isInstanceOf(IllegalStateException.class)));
} }
@Test
void aFailingRequestListenerNeverStopsTheOtherWorkFromStopping() {
List<String> stopped = new ArrayList<>();
var cancellation = GraphQlCancellation.create();
cancellation.onCancel(() -> stopped.add("statement"));
cancellation.onCancel(
() -> {
stopped.add("downstream-call");
throw new IllegalStateException("the downstream client refused to close");
});
cancellation.onCancel(() -> stopped.add("publisher"));
assertThatThrownBy(cancellation::cancel).isInstanceOf(IllegalStateException.class);
assertThat(stopped)
.as("a deadline that reaches only the first listener is not a cancellation")
.containsExactlyInAnyOrder("statement", "downstream-call", "publisher");
assertThat(cancellation.cancelled()).isTrue();
}
@Test
void aRequestListenerFailingLateIsAttachedToTheFirstFailure() {
var cancellation = GraphQlCancellation.create();
cancellation.onCancel(
() -> {
throw new IllegalStateException("registered-first");
});
cancellation.onCancel(
() -> {
throw new IllegalArgumentException("registered-second");
});
assertThatThrownBy(cancellation::cancel)
.isInstanceOf(IllegalStateException.class)
.satisfies(
thrown ->
assertThat(thrown.getSuppressed())
.hasSize(1)
.allSatisfy(
suppressed ->
assertThat(suppressed).isInstanceOf(IllegalArgumentException.class)));
}
@Test
void aRequestListenerRegisteredAfterCancellationStillRunsExactlyOnce() {
List<String> stopped = new ArrayList<>();
var cancellation = GraphQlCancellation.create();
cancellation.cancel();
cancellation.onCancel(() -> stopped.add("late"));
cancellation.cancel();
assertThat(stopped).containsExactly("late");
}
@Test @Test
void aFailingUpstreamHookNeverStopsTheOthersFromStopping() { void aFailingUpstreamHookNeverStopsTheOthersFromStopping() {
List<String> stopped = new ArrayList<>(); List<String> stopped = new ArrayList<>();
-1
View File
@@ -9,7 +9,6 @@
// catalog, so the grpc-bom + protobuf-bom platforms are imported HERE (module scope) using the root // catalog, so the grpc-bom + protobuf-bom platforms are imported HERE (module scope) using the root
// `ext.grpcVersion` / `ext.protobufVersion` SSOT this keeps the strict-locking blast radius to // `ext.grpcVersion` / `ext.protobufVersion` SSOT this keeps the strict-locking blast radius to
// this module (the shared root dependencyManagement block stays io.grpc-free). // this module (the shared root dependencyManagement block stays io.grpc-free).
apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle"
dependencyManagement { dependencyManagement {
imports { imports {
+22 -31
View File
@@ -27,44 +27,35 @@ dependencies {
testImplementation 'io.projectreactor:reactor-test' testImplementation 'io.projectreactor:reactor-test'
} }
tasks.register('jpaPersistenceRedactionContractTest', Test) {
group = 'verification'
description = 'Runs the exact persistence error log/trace redaction contract used by JPA evidence.'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform()
filter {
includeTestsMatching(
'dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandlerTest.persistenceFailureObservabilityDoesNotCarryRawDatabaseDetails')
includeTestsMatching(
'dev.caskeleton.adapter.inbound.web.error.SpanErrorRecorderHookTest.persistenceFailureHandlerRecordsSanitizedExceptionWithClassifiedCode')
}
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.named('test') { tasks.named('test') {
useJUnitPlatform { useJUnitPlatform {
excludeTags 'security-boundary' excludeTags 'security-boundary'
} }
} }
tasks.register('webSecurityBoundaryTest', Test) { strictTestLanes {
group = 'verification' lane('jpaPersistenceRedactionContractTest') {
description = 'Runs hermetic JWT/JWKS and CORS filter-boundary contracts with no skips.' description = 'Runs the exact persistence error log/trace redaction contract used by JPA evidence.'
testClassesDirs = sourceSets.test.output.classesDirs // Named rather than tagged. This lane is JPA evidence's proof that a database failure never
classpath = sourceSets.test.runtimeClasspath // reaches a log line or a span, and it must stay exactly these two contracts a tag would
useJUnitPlatform { // let a later test opt itself in and change what the evidence covers.
includeTags 'security-boundary' requires(
'dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandlerTest.persistenceFailureObservabilityDoesNotCarryRawDatabaseDetails',
'dev.caskeleton.adapter.inbound.web.error.SpanErrorRecorderHookTest.persistenceFailureHandlerRecordsSanitizedExceptionWithClassifiedCode')
} }
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false } lane('webSecurityBoundaryTest') {
shouldRunAfter tasks.named('test') tag = 'security-boundary'
jvmArgs '-Duser.timezone=UTC' description = 'Runs hermetic JWT/JWKS and CORS filter-boundary contracts with no skips.'
afterSuite { descriptor, result -> customize = { test ->
if (descriptor.parent == null && result.skippedTestCount > 0) { test.shouldRunAfter test.project.tasks.named('test')
throw new GradleException( test.jvmArgs '-Duser.timezone=UTC'
"webSecurityBoundaryTest forbids skipped tests: ${result.skippedTestCount}") test.afterSuite { descriptor, result ->
if (descriptor.parent == null && result.skippedTestCount > 0) {
throw new GradleException(
"webSecurityBoundaryTest forbids skipped tests: ${result.skippedTestCount}")
}
}
} }
} }
} }
@@ -0,0 +1,65 @@
package dev.caskeleton.adapter.inbound.web.notification.platform.callback;
import java.time.Clock;
import java.util.Set;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* The two beans the callback endpoints need, and neither of which existed.
*
* <p>{@code NotificationCallbackMvcController}, {@code NotificationCallbackWebFluxHandler} and
* {@code CallbackWebFluxConfiguration} all take {@code CallbackRequestFactory} as a constructor
* argument, and it was produced nowhere in production code the only instantiation in the
* repository was inside a test. So the documented, env-registered switch {@code
* APP_NOTIFICATION_PLATFORM_CALLBACKS_ENABLED=true} did not enable callbacks; it made the
* application fail to start on an unsatisfied dependency. An operator following the configuration
* reference got a deployment that would not boot.
*
* <p>Conditioned on the same switch as the controller, so a deployment that leaves callbacks off
* carries no URL resolver and no clock binding for them.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(
prefix = "ca-skeleton.notification.platform.callbacks",
name = "enabled",
havingValue = "true")
public class CallbackRequestConfiguration {
/**
* Resolves the URL a provider actually called.
*
* <p>The trusted-proxy set is empty by default, and that default is the safe one rather than the
* convenient one: with no entry, forwarded headers are never honoured and the resolver uses what
* the container observed. Honouring them unconditionally would let any caller choose the URL that
* gets signature-verified, which defeats the signature. A deployment behind a load balancer names
* its proxies explicitly.
*
* @param trustedProxies peers whose forwarded headers may be believed
* @return the resolver
*/
@Bean
@ConditionalOnMissingBean(ExternalRequestUrlResolver.class)
public ExternalRequestUrlResolver externalRequestUrlResolver(
@Value("${ca-skeleton.notification.platform.callbacks.trusted-proxies:}")
Set<String> trustedProxies) {
return new ExternalRequestUrlResolver(trustedProxies);
}
/**
* Builds the canonical callback request both transports share.
*
* @param urlResolver the external URL resolver
* @param clock the clock timestamps are read from
* @return the factory
*/
@Bean
@ConditionalOnMissingBean(CallbackRequestFactory.class)
public CallbackRequestFactory callbackRequestFactory(
ExternalRequestUrlResolver urlResolver, Clock clock) {
return new CallbackRequestFactory(urlResolver, clock);
}
}
@@ -0,0 +1,70 @@
package dev.caskeleton.adapter.inbound.web.notification.platform.callback;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Clock;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Turning callbacks on produces the beans the endpoints need.
*
* <p>The endpoints already existed and were already conditioned on this switch; what did not exist
* was any producer of {@code CallbackRequestFactory}. Every reference to it in production code was
* a constructor parameter, so the switch documented in the configuration reference did not enable a
* feature it failed the startup. Nothing caught that because the controller's own test builds the
* factory by hand, which is precisely the dependency a running application has to supply.
*/
class CallbackRequestConfigurationTest {
private final ApplicationContextRunner runner =
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(CallbackRequestConfiguration.class))
.withUserConfiguration(ClockConfiguration.class);
@Test
@DisplayName("callbacks on supplies both beans the endpoints take")
void callbacksOnSuppliesTheBeans() {
runner
.withPropertyValues("ca-skeleton.notification.platform.callbacks.enabled=true")
.run(
context ->
assertThat(context)
.hasNotFailed()
.hasSingleBean(ExternalRequestUrlResolver.class)
.hasSingleBean(CallbackRequestFactory.class));
}
@Test
@DisplayName("callbacks off supplies neither, so an off deployment carries nothing")
void callbacksOffSuppliesNothing() {
runner.run(
context ->
assertThat(context)
.hasNotFailed()
.doesNotHaveBean(ExternalRequestUrlResolver.class)
.doesNotHaveBean(CallbackRequestFactory.class));
}
@Test
@DisplayName("no trusted proxy is configured by default")
void noTrustedProxyByDefault() {
// The safe default rather than the convenient one: with no entry, a forwarded header cannot
// choose the URL that gets signature-verified.
runner
.withPropertyValues("ca-skeleton.notification.platform.callbacks.enabled=true")
.run(context -> assertThat(context).hasSingleBean(ExternalRequestUrlResolver.class));
}
@Configuration(proxyBeanMethods = false)
static class ClockConfiguration {
@Bean
Clock clock() {
return Clock.systemUTC();
}
}
}
@@ -278,6 +278,16 @@ class NotificationCallbackMvcControllerTest {
byProviderRequestId(ProviderProfileId profileId, String providerRequestIdHash) { byProviderRequestId(ProviderProfileId profileId, String providerRequestIdHash) {
return java.util.Optional.empty(); return java.util.Optional.empty();
} }
@Override
public java.util.Optional<
dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot>
byProviderRequestIdHash(
ProviderProfileId profileId,
dev.caskeleton.application.notification.platform.callback.ProviderRequestIdHash
providerRequestIdHash) {
return java.util.Optional.empty();
}
} }
/** Never reached: projection runs only after a signature has been accepted. */ /** Never reached: projection runs only after a signature has been accepted. */
@@ -397,5 +407,10 @@ class NotificationCallbackMvcControllerTest {
dev.caskeleton.application.notification.platform.api.DeliveryAttemptId attemptId) { dev.caskeleton.application.notification.platform.api.DeliveryAttemptId attemptId) {
return java.util.List.of(); return java.util.List.of();
} }
@Override
public boolean bindAttempt(ProviderEventRecordId eventId, DeliveryAttemptId attemptId) {
throw new UnsupportedOperationException();
}
} }
} }
@@ -10,7 +10,6 @@
// io.grpc coordinates the BOM does not manage). // io.grpc coordinates the BOM does not manage).
description = 'Inbound adapter: WebSocket (STOMP over SockJS, skeleton machinery)' description = 'Inbound adapter: WebSocket (STOMP over SockJS, skeleton machinery)'
apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle"
dependencies { dependencies {
implementation project(':domain-core') implementation project(':domain-core')
@@ -1,6 +1,6 @@
// Redis SDK leaf see docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md. // Redis SDK leaf see docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md.
// //
// The design models the SDK as separate Gradle modules. This repository's fail-closed 19-leaf // The design models the SDK as separate Gradle modules. This repository's fail-closed module
// registry outranks that layout, so the module boundaries are packages under // registry outranks that layout, so the module boundaries are packages under
// dev.caskeleton.adapter.outbound.cache.redis.sdk and RedisSdkModuleBoundaryTest enforces them. // dev.caskeleton.adapter.outbound.cache.redis.sdk and RedisSdkModuleBoundaryTest enforces them.
dependencies { dependencies {
+57 -89
View File
@@ -3,7 +3,7 @@
// docs/httpclient/repository-adaptation.md (how the design's 19 library modules map here). // docs/httpclient/repository-adaptation.md (how the design's 19 library modules map here).
// //
// The design models the platform as 19 separate Gradle modules. This repository's fail-closed // The design models the platform as 19 separate Gradle modules. This repository's fail-closed
// 19-leaf registry (src/config/architecture/modules.json) outranks that layout, so the module // module registry (src/config/architecture/modules.json) outranks that layout, so the module
// boundaries are packages under dev.caskeleton.adapter.outbound.httpclient and // boundaries are packages under dev.caskeleton.adapter.outbound.httpclient and
// HttpClientModuleBoundaryTest enforces the design's module dependency table. // HttpClientModuleBoundaryTest enforces the design's module dependency table.
description = 'Outbound adapter: HTTP client platform (typed clients, profiles, evidence-based retry)' description = 'Outbound adapter: HTTP client platform (typed clients, profiles, evidence-based retry)'
@@ -73,33 +73,12 @@ dependencies {
// Performance certification and JMH benchmarks are separate source sets for their own reason: they // Performance certification and JMH benchmarks are separate source sets for their own reason: they
// are slow, they assert on resource bounds rather than behaviour, and they must never be part of // are slow, they assert on resource bounds rather than behaviour, and they must never be part of
// the default unit lane. // the default unit lane.
sourceSets { strictTestLanes {
testkit { // The testkit compiles against exactly what a test does: `implementation` inheritance runs
java.srcDir 'src/testkit/java' // through testImplementation, so this is the module's own dependencies plus the test libraries.
compileClasspath += sourceSets.main.output sourceSet('testkit') { compilesAgainst 'main' }
runtimeClasspath += output + compileClasspath sourceSet('httpClientPerformanceTest') { compilesAgainst 'main', 'testkit' }
} sourceSet('jmh') { compilesAgainst 'main', 'testkit' }
httpClientPerformanceTest {
java.srcDir 'src/httpClientPerformanceTest/java'
compileClasspath += sourceSets.main.output + sourceSets.testkit.output
runtimeClasspath += output + compileClasspath
}
jmh {
java.srcDir 'src/jmh/java'
compileClasspath += sourceSets.main.output + sourceSets.testkit.output
runtimeClasspath += output + compileClasspath
}
}
configurations {
// The testkit compiles against exactly what a test does: testImplementation already extends
// implementation, so this is the module's own dependencies plus the test libraries.
testkitImplementation.extendsFrom testImplementation
testkitRuntimeOnly.extendsFrom testRuntimeOnly
httpClientPerformanceTestImplementation.extendsFrom testImplementation
httpClientPerformanceTestRuntimeOnly.extendsFrom testRuntimeOnly
jmhImplementation.extendsFrom testImplementation
jmhRuntimeOnly.extendsFrom testRuntimeOnly
} }
// Every test lane compiles and runs against the testkit. // Every test lane compiles and runs against the testkit.
@@ -166,69 +145,58 @@ tasks.named('test', Test) {
} }
} }
tasks.register('httpClientBlockHoundTest', Test) { // Four tag-selected lanes, declared rather than assembled.
group = 'verification' //
description = 'Proves no platform code blocks a Reactor event loop (design §18.2, §28.6).' // Two of them the stable contract suite and the security suite did not carry
testClassesDirs = sourceSets.test.output.classesDirs // failOnNoDiscoveredTests at all. Five lanes were written by copying the block above, and the
classpath = sourceSets.test.runtimeClasspath // property that makes a lane mean anything was lost on two of the copies, so the cross-transport
useJUnitPlatform { includeTags 'httpclient-blockhound' } // contract suite and the SSRF/credential-leak suite would each have reported success on discovering
applyContractSelection(it) // nothing. Declaring the lanes removes the opportunity: the convention has no opt-out.
// BlockHound instruments already-loaded JDK classes; Java 13+ needs this to redefine them. strictTestLanes {
jvmArgs '-XX:+AllowRedefinitionToAddDeleteMethods' lane('httpClientBlockHoundTest') {
// The lane exists to run BlockHound. Discovering nothing means it did not, which is a failure. tag = 'httpclient-blockhound'
failOnNoDiscoveredTests = true description = 'Proves no platform code blocks a Reactor event loop (design §18.2, §28.6).'
outputs.upToDateWhen { false } customize = { test ->
applyContractSelection(test)
// BlockHound instruments already-loaded JDK classes; Java 13+ needs this to redefine them.
test.jvmArgs '-XX:+AllowRedefinitionToAddDeleteMethods'
}
}
lane('httpClientStableContractTest') {
tag = 'httpclient-contract'
description = 'Runs the cross-transport stable contract suite (design §28.2, §33).'
customize = { test -> applyContractSelection(test) }
}
lane('httpClientSecurityTest') {
tag = 'httpclient-security'
description = 'Runs the SSRF, credential-leak, and cardinality suite (design §28.5, §28.7).'
customize = { test -> applyContractSelection(test) }
}
// Its own source set rather than a tag, so the source set is the selection.
lane('httpClientPerformanceTest') {
sourceSet = 'httpClientPerformanceTest'
description = 'Certifies pool, streaming, retry, and rotation resource bounds (design §28.8).'
customize = { test ->
applyContractSelection(test)
test.systemProperty 'performance.assertions.enabled',
(project.findProperty('performance.assertions.enabled') ?: 'false').toString()
}
}
lane('httpClientFailureInjectionTest') {
tag = 'httpclient-fault'
description = 'Runs the Toxiproxy fault-injection suite; fails closed without Docker ' +
'(design §28.3).'
customize = { test ->
applyContractSelection(test)
// The upstream image is mutable by default. Passing a digest here is what makes a red
// fault run attributable to this repository rather than to someone else's image push.
test.systemProperty 'httpclient.fault.httpbin.image',
(project.findProperty('httpclient.fault.httpbin.image')
?: 'kennethreitz/httpbin:latest').toString()
}
}
} }
tasks.register('httpClientStableContractTest', Test) {
group = 'verification'
description = 'Runs the cross-transport stable contract suite (design §28.2, §33).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'httpclient-contract' }
applyContractSelection(it)
outputs.upToDateWhen { false }
}
tasks.register('httpClientSecurityTest', Test) {
group = 'verification'
description = 'Runs the SSRF, credential-leak, and cardinality suite (design §28.5, §28.7).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'httpclient-security' }
applyContractSelection(it)
outputs.upToDateWhen { false }
}
tasks.register('httpClientFailureInjectionTest', Test) {
group = 'verification'
description = 'Runs the Toxiproxy fault-injection suite; fails closed without Docker (design §28.3).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'httpclient-fault' }
applyContractSelection(it)
// The upstream image is mutable by default. Passing a digest here is what makes a red fault
// run attributable to this repository rather than to someone else's image push.
systemProperty 'httpclient.fault.httpbin.image',
(project.findProperty('httpclient.fault.httpbin.image') ?: 'kennethreitz/httpbin:latest').toString()
// A fault suite that never injected a fault must not report success, so a selected lane with no
// discovered test is an error rather than an empty pass.
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.register('httpClientPerformanceTest', Test) {
group = 'verification'
description = 'Certifies pool, streaming, retry, and rotation resource bounds (design §28.8).'
testClassesDirs = sourceSets.httpClientPerformanceTest.output.classesDirs
classpath = sourceSets.httpClientPerformanceTest.runtimeClasspath
useJUnitPlatform()
applyContractSelection(it)
systemProperty 'performance.assertions.enabled',
(project.findProperty('performance.assertions.enabled') ?: 'false').toString()
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.register('jmh', JavaExec) { tasks.register('jmh', JavaExec) {
group = 'verification' group = 'verification'

Some files were not shown because too many files have changed in this diff Show More