feat: jpa, messaging, notification, mongo, graphql 어댑터터 구현체 추가

This commit is contained in:
DongHyeonka
2026-08-15 13:01:58 +09:00
parent ac874e49e6
commit 2f5d2fc219
909 changed files with 62510 additions and 6354 deletions
+6 -6
View File
@@ -24,15 +24,15 @@ readonly EXPECTED_WORKFLOW_LOCK=(
'58e28f3358d794ca08f4aa8df4516e03f50a9ee58488b3f0d2619998e069ef14 .github/workflows/httpclient-contract.yml'
'823bc346e58a58b2c0814cd1e3e55ec90d360c138419ec3d8f05deb59c62c7eb .github/workflows/httpclient-nightly.yml'
'ad84000efc438ee7439517b8f85819e62b13dab0aa4f94066c2905060f3bb581 .github/workflows/httpclient-release.yml'
'e0cb998969b8f4b8531f50d38413ee4640931839be7ad50509e1d0e1a84f919e .github/workflows/jpa-next-hibernate8.yml'
'6f577e71cd10d74facdf76353f211132d2e2ba04363b8025affb45873f349d9f .github/workflows/jpa-next-jpa4.yml'
'f69e174cd0e5a2451078ea23d52efefc13fb27d2c120f5fe30dc93ffc4d532aa .github/workflows/jpa-next-postgresql19.yml'
'053593c3f1b5acdc98f01f1986ffbe74163d61c58384d16e27949b879757bef1 .github/workflows/jpa-nightly.yml'
'3be84c9f15fa3b2ac5a085f8d725ec6d05e7007ae0b433da9e79b3bf340d57ea .github/workflows/jpa-next-hibernate8.yml'
'a2b74bfb3af12d6d03cd2ea8a5e48490dd131afb89b79694d498c5798387ac53 .github/workflows/jpa-next-jpa4.yml'
'cd955ef4af895df477896dad9577810f010b2beea8570b09b008f9e94e928bd0 .github/workflows/jpa-next-postgresql19.yml'
'b56b548a867b74eaeccb42e7df4f4e52cf7ce657ab27f91e2c8d7ea9944d64af .github/workflows/jpa-nightly.yml'
'04851f44ba94533bfbc8fabe2b3a2b408726a9996e86ed3864986d1499d16b50 .github/workflows/jpa-pr.yml'
'59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml'
'ea7f8214a3cc9ec3e7ba3183a2201fd26a05a61f0b0fdcb1f041b71efca3e81c .github/workflows/jpa-release.yml'
'4748f2ba0a0b77dc1a858ebcfa7db6e41627d97843df5f0aa978bc2facccaad2 .github/workflows/jpa-release.yml'
'5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml'
'3d5afcef6bf1c65dcd8cad3d1687f07c2cfbb15d360f41251e46f9eb8950baac .github/workflows/notification-platform.yml'
'4e4ccfa267ecd63b9369803d49f2dbdb2fa899517ad4cf23ab11d29104557a91 .github/workflows/notification-platform.yml'
'64245586cd5936f1a5647b57f2cd9acd316f96fd75f713b1890decb812e7d5fe .github/workflows/object-storage-qualification.yml'
'cbc104ea486c746229895e804e3be7716e056a02cce0588c537bce9f442f8b38 .github/workflows/redis-sdk-topology.yml'
)
+21
View File
@@ -41,3 +41,24 @@ jobs:
:adapter:outbound:persistence-jpa:test --tests '*HibernateCompatibilityPolicyTest'
--no-daemon
--stacktrace
- name: Record what this lane did and did not execute
if: always()
run: |
mkdir -p compatibility-evidence
{
echo "target=Hibernate 8"
echo "target-coordinate=org.hibernate.orm:hibernate-core:8.x"
echo "status=NOT_EXECUTABLE"
echo "reason=Hibernate 8 is not resolvable from this build, so nothing has been compiled or run against it"
echo "what-ran=the current runtime's own policy and lane-definition tests"
echo "sha=${{ github.sha }}"
} > compatibility-evidence/status.properties
echo "::notice::Hibernate 8 compatibility is NOT_EXECUTABLE: Hibernate 8 is not resolvable from this build, so nothing has been compiled or run against it"
- name: Upload the compatibility status
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: compatibility-status-hibernate-8
path: compatibility-evidence/status.properties
retention-days: 30
if-no-files-found: error
+21
View File
@@ -41,3 +41,24 @@ jobs:
:adapter:outbound:persistence-jpa:test --tests '*CompatibilityLaneDefinitionTest'
--no-daemon
--stacktrace
- name: Record what this lane did and did not execute
if: always()
run: |
mkdir -p compatibility-evidence
{
echo "target=Jakarta Persistence 4"
echo "target-coordinate=jakarta.persistence:jakarta.persistence-api:4.x"
echo "status=NOT_EXECUTABLE"
echo "reason=the JPA 4 API is not on any configuration this build resolves, so nothing has been compiled against it"
echo "what-ran=the current runtime's own policy and lane-definition tests"
echo "sha=${{ github.sha }}"
} > compatibility-evidence/status.properties
echo "::notice::Jakarta Persistence 4 compatibility is NOT_EXECUTABLE: the JPA 4 API is not on any configuration this build resolves, so nothing has been compiled against it"
- name: Upload the compatibility status
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: compatibility-status-jakarta-persistence-4
path: compatibility-evidence/status.properties
retention-days: 30
if-no-files-found: error
@@ -2,6 +2,13 @@ name: jpa-next-postgresql19
# PostgreSQL 19 compatibility lane (experimental plan Task 9).
#
# This lane is NOT_EXECUTABLE against its target.
#
# It runs the current runtime's policy and lane-definition tests; it does not resolve the target
# dependency or start a container of the target version. A green run therefore says "the target is
# absent from this build", which is not the same claim as "we are compatible with the target" — and
# the workflow's name reads as the second one. The status artifact says which it is.
#
# Promotion needs evidence, not availability. Two supported patch runs with no unresolved semantic
# regression, plus a reviewed ADR, before the Stable support matrix changes — which is what
# ExperimentalPromotionGate encodes.
@@ -40,3 +47,24 @@ jobs:
:adapter:outbound:persistence-jpa:test --tests '*ExperimentalPromotionGateTest'
--no-daemon
--stacktrace
- name: Record what this lane did and did not execute
if: always()
run: |
mkdir -p compatibility-evidence
{
echo "target=PostgreSQL 19"
echo "target-coordinate=postgres:19-alpine"
echo "status=NOT_EXECUTABLE"
echo "reason=no PostgreSQL 19 image is published yet, so no container of that major has ever been started by this lane"
echo "what-ran=the current runtime's own policy and lane-definition tests"
echo "sha=${{ github.sha }}"
} > compatibility-evidence/status.properties
echo "::notice::PostgreSQL 19 compatibility is NOT_EXECUTABLE: no PostgreSQL 19 image is published yet, so no container of that major has ever been started by this lane"
- name: Upload the compatibility status
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: compatibility-status-postgresql-19
path: compatibility-evidence/status.properties
retention-days: 30
if-no-files-found: error
+1 -2
View File
@@ -125,7 +125,6 @@ jobs:
# noisy shared runner does not produce a red build that means nothing.
run: >-
./gradlew
:adapter:outbound:persistence-jpa:jpaPlatformPerformanceTest
-Pperformance.assertions.enabled=false
:adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest
--no-daemon
--stacktrace
+74 -2
View File
@@ -18,9 +18,24 @@ concurrency:
cancel-in-progress: false
jobs:
# One job per PostgreSQL major, because one job for three majors was one job for one major.
#
# `-Pjpa.matrix.versions=16,17,18` reached JpaPlatformContractSupport.start(), which started
# selectedVersions().get(0) — so twenty-eight integration classes ran against PG16 and nothing
# ran against 17 or 18, while docs/jpa/support-matrix.md recorded all three as "full contract
# suite, release lane". A JSONB mapping, a Hibernate dialect difference or a Flyway upgrade that
# only breaks on 18 shipped with a green release.
#
# start() now fails closed on a multi-version selection, so the fan-out is not optional: the
# matrix is the only way the three majors get covered, and removing a major from it removes the
# evidence rather than quietly reusing another major's.
jpa-release-gate:
runs-on: ubuntu-latest
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
postgresql: ["16", "17", "18"]
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
@@ -35,14 +50,71 @@ jobs:
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run the full JPA release gate
- name: Run the full JPA release gate on PostgreSQL ${{ matrix.postgresql }}
working-directory: src
run: >-
./gradlew
jpaReleaseGate
-Pjpa.matrix.versions=16,17,18
-Pjpa.matrix.versions=${{ matrix.postgresql }}
--no-daemon
--stacktrace
- name: Record which major this evidence covers
if: always()
working-directory: src
run: |
mkdir -p build/jpa-release-evidence
{
echo "sha=${{ github.sha }}"
echo "ref=${{ github.ref }}"
echo "postgresql-major=${{ matrix.postgresql }}"
echo "task=jpaReleaseGate"
} > "build/jpa-release-evidence/manifest-${{ matrix.postgresql }}.properties"
- name: Upload the release evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: jpa-release-evidence-pg${{ matrix.postgresql }}
path: |
src/build/jpa-release-evidence/manifest-${{ matrix.postgresql }}.properties
src/adapter/outbound/persistence-jpa/build/test-results/**/*.xml
retention-days: 30
if-no-files-found: error
# The promotion decision. Three majors' evidence, and all three must come from this SHA — an
# aggregate that accepted a re-run artifact from another commit would promote a release on
# evidence produced by different code.
jpa-release-promotion:
runs-on: ubuntu-latest
timeout-minutes: 15
needs: jpa-release-gate
steps:
- name: Download every major's evidence
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # actions/download-artifact@v4.3.0
with:
pattern: jpa-release-evidence-pg*
path: evidence
- name: Require all three majors, all from this SHA
run: |
set -euo pipefail
missing=0
for major in 16 17 18; do
manifest=$(find evidence -name "manifest-${major}.properties" -print -quit)
if [ -z "${manifest}" ]; then
echo "::error::no release evidence for PostgreSQL ${major}"
missing=1
continue
fi
sha=$(sed -n 's/^sha=//p' "${manifest}")
if [ "${sha}" != "${{ github.sha }}" ]; then
echo "::error::PostgreSQL ${major} evidence is from ${sha}, not ${{ github.sha }}"
missing=1
fi
done
if [ "${missing}" -ne 0 ]; then
echo "::error::the release gate covers three PostgreSQL majors; promotion needs all three"
exit 1
fi
echo "PostgreSQL 16, 17 and 18 evidence all present and all from ${{ github.sha }}."
jpa-architecture-and-docs:
runs-on: ubuntu-latest
+87 -6
View File
@@ -14,10 +14,20 @@ name: notification-platform
on:
pull_request:
paths:
- 'src/application-core/src/**/notification/platform/**'
# The filter used to stop at the four notification source trees, so a change to the
# composition root, the settings binding, the schema migrations, or the evidence manifest
# ran none of this — and those are exactly the surfaces that decide whether the platform
# assembles, binds and migrates at all.
- 'src/application-core/src/**/notification/**'
- 'src/adapter/outbound/notification/**'
- 'src/adapter/outbound/persistence-jpa/src/**/notification/**'
- 'src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/notification-platform/**'
- 'src/adapter/inbound/web/src/**/notification/**'
- 'src/app-bootstrap/src/**/notification/**'
- 'src/app-bootstrap/src/main/resources/application*.yml'
- 'src/gradle/notification-*.gradle'
- 'src/config/architecture/modules.json'
- 'src/.env'
- 'docs/notification/**'
- 'infra/notification/**'
- '.github/workflows/notification-platform.yml'
@@ -66,6 +76,16 @@ jobs:
- name: Persistence and web
working-directory: src
run: ./gradlew :adapter:outbound:persistence-jpa:test :adapter:inbound:web:test --console=plain
# The PR tier never touched a database, so every claim about migrations, claim atomicity and
# lease fencing rested on a fake. Docker is available on this runner; the lane fails closed
# when the container cannot start, because a skipped contract reports success for a database
# nobody tested.
- name: Notification schema and claim contracts (real PostgreSQL)
working-directory: src
run: ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformContractTest --console=plain
- name: Notification migration upgrade (real PostgreSQL)
working-directory: src
run: ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest --console=plain
- name: Architecture gates
working-directory: src
run: |
@@ -73,7 +93,15 @@ jobs:
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --tests '*NotificationArchitectureTest' --console=plain
- name: Configuration surface
working-directory: src
run: ./gradlew verifyEnvKeys verifyPublicPathSnapshot --console=plain
run: |
./gradlew verifyEnvKeys verifyPublicPathSnapshot --console=plain
./gradlew verifyNotificationApiSurface verifyNotificationConfiguration --console=plain
# A support grade is a promise about production behaviour. This refuses one the pipeline
# cannot back — the check that would have caught five channels reading "Stable" while no
# request had ever left the process.
- name: Evidence manifest
working-directory: src
run: ./gradlew verifyNotificationEvidence --console=plain
- name: Static analysis
working-directory: src
run: ./gradlew :adapter:outbound:notification:check -x test --console=plain
@@ -97,12 +125,33 @@ jobs:
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
# This job is named for ambiguity, restart recovery and callback burst. It used to run a
# unit-test filter and then `test` — neither of which restarts anything or bursts anything —
# so the job name was the only place those three properties existed.
- name: Ambiguity and fault harness
working-directory: src
run: ./gradlew :adapter:outbound:notification:test --tests '*ChaosSecurity*' --tests '*CrossProviderContractSuite*' --console=plain
- name: Concurrency and rotation races
working-directory: src
run: ./gradlew :adapter:outbound:notification:test --tests '*ConcurrencyTest' --tests '*ProviderRuntimeStateTest' --console=plain
- name: Restart recovery and lease fencing (real PostgreSQL)
working-directory: src
run: ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformContractTest :adapter:outbound:persistence-jpa:jpaPlatformFailureTest --console=plain
- name: Full suite
working-directory: src
run: ./gradlew test --console=plain
# A filter that matches nothing passes. Each --tests filter above names a class that exists
# today; if one is renamed the job must fail rather than quietly stop covering it.
- name: Every named suite actually ran
working-directory: src
run: |
set -euo pipefail
for suite in ChaosSecurity CrossProviderContractSuite ConcurrencyTest ProviderRuntimeStateTest; do
if ! find . -path '*/build/test-results/*' -name "*${suite}*.xml" | grep -q .; then
echo "no test results for ${suite}: the filter matched nothing and the job passed vacuously" >&2
exit 1
fi
done
provider-sandbox:
name: provider sandbox smoke (secret-protected, non-blocking)
@@ -110,12 +159,44 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
environment: notification-provider-sandbox
continue-on-error: true
# Not a required check: an external outage must not block a merge. But not continue-on-error
# either — a job that cannot fail produces no evidence, and this job's entire previous body was
# two echo statements, which is what let five channels be graded Stable on nothing.
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: Refuse to report a pass with no credentials
env:
NOTIFICATION_SANDBOX_CREDENTIALS: ${{ secrets.NOTIFICATION_SANDBOX_CREDENTIALS }}
run: |
set -euo pipefail
if [ -z "${NOTIFICATION_SANDBOX_CREDENTIALS:-}" ]; then
echo "provider sandbox credentials are not configured for this environment." >&2
echo "The job stops here rather than reporting a green run that called nothing." >&2
exit 1
fi
- name: Smoke test against real provider sandboxes
working-directory: src
env:
NOTIFICATION_SANDBOX_ENABLED: 'true'
run: |
echo "Runs only where provider sandbox credentials are configured."
echo "Never a required check: an external outage must not block a merge."
NOTIFICATION_SANDBOX_CREDENTIALS: ${{ secrets.NOTIFICATION_SANDBOX_CREDENTIALS }}
run: ./gradlew :adapter:outbound:notification:test --tests '*ProviderSandbox*' --console=plain
- name: Upload the wire evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: notification-provider-sandbox-evidence
path: src/adapter/outbound/notification/build/test-results/test/
if-no-files-found: error
retention-days: 90
+1
View File
@@ -1,2 +1,3 @@
.vscode/
src/**/bin/
.claude/
+5 -4
View File
@@ -49,9 +49,10 @@ root `CLAUDE.md`는 이 목록의 동기화된 요약이다. 두 문서가 어
## Gradle 정책 권위
- `src/config/architecture/modules.json`: 정확히 19개 leaf의 ID, repository-relative 소스 경로,
- `src/config/architecture/modules.json`: 등록된 모든 leaf의 ID, repository-relative 소스 경로,
Gradle path, 허용 production project dependency edge, 두 composition root의 실제 runtime
membership
membership. leaf 목록과 그 개수의 SSOT는 registry다. 문서는 개수를 복제하지 않는다 —
산문에 적힌 숫자는 leaf가 추가되는 순간 drift한다. `verifyDocumentedLeafCount`가 이를 강제한다.
- `src/settings.gradle`: registry를 fail-closed로 검증하고 등록된 Gradle project를 include/mapping
- `src/build.gradle`: 같은 registry를 읽는 `verifyCleanArchitectureDependencies`와 그 밖의
architecture-wide verification task
@@ -102,7 +103,7 @@ commit 정책은 모든 플랫폼에서 `human-only`이며 agent는 stage/commit
## 모듈 책임
19개 leaf 모듈의 ID, 실제 소스 경로, Gradle path, 허용 production 의존성, runtime membership은
모든 leaf 모듈의 ID, 실제 소스 경로, Gradle path, 허용 production 의존성, runtime membership은
`src/config/architecture/modules.json`이 SSOT다. focused test는 소유 leaf의 `gradle_path`에서
파생한다. 이 문서는 leaf 목록을 복제하지 않고 family 책임만 정의한다. 작업 파일에서는 가장 가까운
`src/**/CLAUDE.md`를 함께 읽는다.
@@ -182,7 +183,7 @@ cd src
```
소유 leaf의 정확한 Gradle path는 `src/config/architecture/modules.json`에서 읽고 focused test
명령을 파생한다. root 문서에 19개 명령 목록을 복제하지 않는다.
명령을 파생한다. root 문서에 leaf별 명령 목록을 복제하지 않는다.
## 설정과 런타임
+6 -4
View File
@@ -21,9 +21,10 @@ If this summary drifts from `AGENTS.md`, `AGENTS.md` wins and this summary must
## Gradle policy authorities
- `src/config/architecture/modules.json`: exactly 19 leaf identities, repository-relative source
- `src/config/architecture/modules.json`: every registered leaf identity, repository-relative source
paths, Gradle paths, allowed production project dependency edges, and the exact runtime
memberships of both composition roots.
memberships of both composition roots. The registry owns the leaf list and its size; no document
restates the count, because a number written in prose drifts the moment a leaf is added.
- `src/settings.gradle`: fail-closed registry validation, project inclusion, and directory mapping.
- `src/build.gradle`: `verifyCleanArchitectureDependencies` and the other architecture-wide
verification tasks.
@@ -43,8 +44,9 @@ count.
## Module families
`src/config/architecture/modules.json` owns the complete 19-leaf list. Root guidance summarizes
families; the nearest `src/**/CLAUDE.md` owns local rules.
`src/config/architecture/modules.json` owns the complete leaf list. Root guidance summarizes
families; the nearest `src/**/CLAUDE.md` owns local rules. `verifyDocumentedLeafCount` fails the
build when a policy document states a leaf count that the registry does not agree with.
| Family | Responsibility | Stable dependency direction |
| --- | --- | --- |
+399
View File
@@ -0,0 +1,399 @@
# GraphQL 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` and `spi` are the intended
# external surface; the rest are candidates to become internal when this leaf is
# split into capability artifacts.
# Update only after review with:
# ./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange
# types: 391
dev.caskeleton.adapter.inbound.graphql.HealthGraphqlController
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlAdminPrincipal
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAdminAuthorization
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAdminDeniedException
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAdminPort
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAdminService
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAudit
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationBlockCommand
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationRemovalGate
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationRemovalRejectedException
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationUsage
dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapability
dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapabilityDisabledException
dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapabilityGrade
dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedDependencyRules
dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedFeatureFlags
dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedModuleGuard
dev.caskeleton.adapter.inbound.graphql.advanced.chaining.GraphQlChainedDataLoaderPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.chaining.GraphQlChainedDispatchConfigurer
dev.caskeleton.adapter.inbound.graphql.advanced.chaining.GraphQlChainedLoaderMetrics
dev.caskeleton.adapter.inbound.graphql.advanced.chaining.GraphQlDataLoaderCycleDetector
dev.caskeleton.adapter.inbound.graphql.advanced.chaining.GraphQlDataLoaderDependencyCycleException
dev.caskeleton.adapter.inbound.graphql.advanced.chaining.GraphQlDataLoaderDependencyGraph
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlClientOperationGenerator
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlCodegenBoundaryException
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlCodegenProfile
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlGeneratedCompatibilityGate
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlGeneratedSourceBoundary
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlOperationValidator
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlScalarMapping
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlTransportTypeGenerator
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationCompositionGate
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationCompositionResult
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationDeploymentOrder
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationLatencyBudget
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationReleaseEvidence
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationReleaseRejectedException
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationUsageReport
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlSubgraphContract
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationBatchResolver
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationCapability
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationEntityKey
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationEntityResolver
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationProperties
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationRepresentationException
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationSchemaFactory
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpDraftCompatibilityReport
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpGetCachePolicy
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpGetCsrfPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpGetOperationPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpGetProfile
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpGetRejectedException
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpGetRequestParser
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalCancellation
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalCompatibilityGate
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalDeliveryCapability
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalDeliveryProfile
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalDeliveryRejectedException
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalPatch
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalTransportPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperation
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationConflictException
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationId
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationInterceptor
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationLookup
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationNotFoundException
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationRecordMapping
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationRegistry
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationRejectedException
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationRequest
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationStatus
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationTransition
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedPreparsedBridge
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.OperationalStoreGraphQlPersistedOperationRegistry
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedCompatibilityMatrix
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedPromotionDecision
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedReleaseEvidence
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedReleaseFailure
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedReleaseGate
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedRunbookIndex
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedSoakScenario
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlReplayAuthorization
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlReplayAuthorizationException
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlReplayGapException
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlReplayHistoryLostException
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlReplayPosition
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlReplaySource
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlSnapshotLiveHandoff
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlSubscriptionCursor
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketAdmission
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketAuthentication
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketCapability
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketErrorMapper
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketProperties
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketRoutePolicy
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketRouteRejectedException
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlSubscriptionAuthorizationPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketAuthenticationException
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketAuthenticationInterceptor
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketCloseReason
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketCredentialExpiry
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketPrincipal
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketRevocationSignal
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseAdmission
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseConnectionPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseHeartbeat
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseProperties
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseRejectedException
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseTermination
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSlowConsumerPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionBufferPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionCancellation
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionContext
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionDispatcher
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionDrainCoordinator
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionDrainPhase
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionDrainingException
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionEvent
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionExecutionPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionLease
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionMetrics
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionOrderingProfile
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionSource
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionState
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionTermination
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketAdmission
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketConnectionId
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketLifecycle
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProperties
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProtocol
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProtocolError
dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile
dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfileName
dev.caskeleton.adapter.inbound.graphql.api.GraphQlOperationId
dev.caskeleton.adapter.inbound.graphql.api.GraphQlOperationName
dev.caskeleton.adapter.inbound.graphql.api.GraphQlSchemaCoordinate
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlAsyncReturnShape
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlControllerContractException
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlControllerInspector
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlControllerTransactionRule
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlInputTypePolicy
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlResolverBoundaryRules
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlReturnTypePolicy
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlTransportTypeRules
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlTypeGraph
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformActuatorEndpoint
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformAutoConfiguration
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformConfigurationException
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformConfigurationReport
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformEnvironment
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformProperties
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformRuntime
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformStartupValidator
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlRuntimeTransport
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlChangeKind
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlClientOwnerApproval
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlCompatibilityImpact
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlCompatibilityPolicy
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlCompatibilityReport
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlDeprecationGate
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlRemovalDecision
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlRemovalRequest
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlSchemaChange
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlSchemaComparator
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlSchemaUsage
dev.caskeleton.adapter.inbound.graphql.context.ActorRef
dev.caskeleton.adapter.inbound.graphql.context.GraphQlCommandAttribution
dev.caskeleton.adapter.inbound.graphql.context.GraphQlDeadline
dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext
dev.caskeleton.adapter.inbound.graphql.context.TenantContext
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityCalculator
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityRejectedException
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityResult
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlCostCatalog
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentComplexityScorer
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentShape
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentShapeAnalyzer
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlFieldCostDescriptor
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlParserLimitPolicy
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlParserLimits
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlParserOptionsFactory
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlParserRejectedException
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlResolverWeight
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlResponseByteLimiter
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlResponseNodeCounter
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlRuntimeBudget
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlRuntimeBudgetExceededException
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlRuntimeBudgetTracker
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimitPolicy
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimitViolation
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimits
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchChunker
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchContext
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchErrorPolicy
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchExecutor
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchLoadException
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchObservation
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicy
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicyRegistry
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchResult
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchResultMapper
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchTimeoutException
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchValue
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderName
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderRequestRegistry
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlMissingKeyException
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlMissingKeyPolicy
dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorCategory
dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorCode
dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorContext
dev.caskeleton.adapter.inbound.graphql.error.GraphQlExceptionResolver
dev.caskeleton.adapter.inbound.graphql.error.GraphQlFailureBoundary
dev.caskeleton.adapter.inbound.graphql.error.GraphQlInternalErrorMasker
dev.caskeleton.adapter.inbound.graphql.error.GraphQlNullabilityContract
dev.caskeleton.adapter.inbound.graphql.error.GraphQlRequestErrorMapper
dev.caskeleton.adapter.inbound.graphql.error.GraphQlSubscriptionExceptionResolver
dev.caskeleton.adapter.inbound.graphql.error.GraphQlWireError
dev.caskeleton.adapter.inbound.graphql.execution.BoundedPreparsedDocumentProvider
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlAnonymousOperationException
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlCancellation
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlDeadlinePropagator
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipeline
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipelineException
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipelineValidator
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfile
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfileException
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfileValidator
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionStage
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlOperationNameInterceptor
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlOperationNamePolicy
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlOperationSelection
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlPreparsedCacheKey
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlPreparsedCacheMetrics
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlPreparsedCachePolicy
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlRequestCancelledException
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlResolverBudget
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlResolverCatalog
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlResolverDescriptor
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlTimeoutPolicy
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlFetchProfile
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlFetchProfileClassifier
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlFetchProfileName
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlFetchProfileRegistry
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlFetchProfileRule
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlFetchProfileValidationException
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlSelectionCoordinate
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlSelectionSetView
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlSelectionSignature
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlUnmappedSelectionException
dev.caskeleton.adapter.inbound.graphql.http.GraphQlAcceptHeader
dev.caskeleton.adapter.inbound.graphql.http.GraphQlExecutionOutcome
dev.caskeleton.adapter.inbound.graphql.http.GraphQlExtensionsPolicy
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpContractException
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpExecutor
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpOutcome
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpProfile
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpRequestEnvelope
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponse
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponseFactory
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponsePolicy
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpStatusMapper
dev.caskeleton.adapter.inbound.graphql.http.GraphQlJsonStructurePolicy
dev.caskeleton.adapter.inbound.graphql.http.GraphQlJsonValues
dev.caskeleton.adapter.inbound.graphql.http.GraphQlMediaTypes
dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestEnvelopeValidator
dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestFormatException
dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestSize
dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestTooLargeException
dev.caskeleton.adapter.inbound.graphql.moduleboundary.GraphQlAdvancedModule
dev.caskeleton.adapter.inbound.graphql.moduleboundary.GraphQlModuleBoundary
dev.caskeleton.adapter.inbound.graphql.moduleboundary.GraphQlModulePurity
dev.caskeleton.adapter.inbound.graphql.moduleboundary.GraphQlStableModule
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlBatchMutationItemResult
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlBusinessResult
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlCanonicalInput
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlExpectedVersion
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlIdempotencyConflictException
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlIdempotencyKey
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationContractException
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationContractValidator
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationCoordinate
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationFingerprint
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationIdempotencyContext
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationIdempotencyInterceptor
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationPayload
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationResultMapper
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlDataLoaderObservationConvention
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlMetricCardinalityPolicy
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlObservationContractException
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlObservationNames
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlOperationNameCardinality
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlProfilerAccessPolicy
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlRequestObservationConvention
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlResolverObservationConvention
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlSensitiveAttributeFilter
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlConnection
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlConnectionAssembler
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlConnectionException
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlConnectionPolicy
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlConnectionRequest
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorCodec
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorException
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorFraming
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorKeyRing
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorKeyset
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorPayload
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorScope
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorVersion
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlEdge
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlKeysetWindow
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlPageInfo
dev.caskeleton.adapter.inbound.graphql.pagination.HmacGraphQlCursorCodec
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicyManifest
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlOperationCatalog
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlOperationPolicy
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlOperationType
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlPolicyViolation
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlUnknownClientProfileException
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlUnknownOperationException
dev.caskeleton.adapter.inbound.graphql.policy.ResolverExecutionType
dev.caskeleton.adapter.inbound.graphql.release.GraphQlCompatibilityMatrix
dev.caskeleton.adapter.inbound.graphql.release.GraphQlFaultScenario
dev.caskeleton.adapter.inbound.graphql.release.GraphQlPerformanceScenario
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseEvidence
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseFailure
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseGate
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseOverride
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseReportWriter
dev.caskeleton.adapter.inbound.graphql.release.GraphQlStableCapabilityManifest
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlBatchLoaderRegistrar
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlBlockingBridge
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlBlockingBridgeFullException
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlCostBudgetHandler
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlDataFetcherExceptionResolver
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlDocumentAuthorizationHandler
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlExecutionChain
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlExecutionContext
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlExecutionHandler
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlExecutionRequest
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlOperationSelectionHandler
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformInstrumentation
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformRejectionMapper
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformWebInterceptor
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPreparsedDocumentAdapter
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPrincipalResolver
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlWireErrorMapper
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlWireErrors
dev.caskeleton.adapter.inbound.graphql.runtime.servlet.GraphQlRequestBodyLimitFilter
dev.caskeleton.adapter.inbound.graphql.scalar.BigDecimalScalar
dev.caskeleton.adapter.inbound.graphql.scalar.DateScalar
dev.caskeleton.adapter.inbound.graphql.scalar.GraphQlDecimalBounds
dev.caskeleton.adapter.inbound.graphql.scalar.GraphQlScalarWiringConfigurer
dev.caskeleton.adapter.inbound.graphql.scalar.InstantScalar
dev.caskeleton.adapter.inbound.graphql.scalar.LongScalar
dev.caskeleton.adapter.inbound.graphql.scalar.UuidScalar
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlContractVersion
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlMappingInspectionGate
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlMappingIssue
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlMappingPolicy
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlOneOfInputValidator
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlOneOfPolicy
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlOneOfSchemaGate
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlOneOfViolationException
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlScalarDefinition
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlScalarManifest
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlScalarPolicy
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaAssembler
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaAssemblyException
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaAssemblyResult
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaContract
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaHash
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaMappingException
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaOwnership
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaResource
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticatedPrincipal
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationContextFactory
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationException
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationDecision
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationDeniedException
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationInterceptor
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationPolicy
dev.caskeleton.adapter.inbound.graphql.security.GraphQlBatchContext
dev.caskeleton.adapter.inbound.graphql.security.GraphQlClientProfileResolver
dev.caskeleton.adapter.inbound.graphql.security.GraphQlContextCleanup
dev.caskeleton.adapter.inbound.graphql.security.GraphQlContextPropagator
dev.caskeleton.adapter.inbound.graphql.security.GraphQlObjectAuthorizationPort
dev.caskeleton.adapter.inbound.graphql.security.GraphQlTenantIsolationException
dev.caskeleton.adapter.inbound.graphql.security.GraphQlTenantIsolationPolicy
+349
View File
@@ -0,0 +1,349 @@
# MongoDB 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-mongo:updateMongoApiSurface -PapproveMongoApiSurfaceChange
# types: 341
dev.caskeleton.adapter.outbound.mongo.MongoOptInAutoConfigurationImportFilter
dev.caskeleton.adapter.outbound.mongo.MongoPersistenceConfig
dev.caskeleton.adapter.outbound.mongo.MongoPersistenceProperties
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedCapabilityFlags
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedCapabilityGuard
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedEntryPoint
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedPolicy
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedPromotionEvidence
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedPromotionGate
dev.caskeleton.adapter.outbound.mongo.advanced.autoconfigure.MongoAdvancedConfiguration
dev.caskeleton.adapter.outbound.mongo.advanced.autoconfigure.MongoAdvancedProperties
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoBridgeCheckpointPolicy
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoBridgeOutboxPolicy
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoChangeMessagingBridge
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoChangeToIntegrationEventMapper
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoIntegrationEventEnvelope
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoIntegrationEventPublisher
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoPublishResult
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle.MongoCsfleClientFactory
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle.MongoCsfleFieldPolicy
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle.MongoCsfleMode
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle.MongoCsfleProfile
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle.MongoDataKeyResolver
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoEncryptedFieldDescriptor
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoEncryptionMetadataOwnership
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoQueryShape
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoQueryShapeSupport
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoQueryableEncryptionCollectionManager
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoQueryableEncryptionProfile
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoQueryableEncryptionQueryType
dev.caskeleton.adapter.outbound.mongo.advanced.gridfs.MongoGridFsCompatibilityReader
dev.caskeleton.adapter.outbound.mongo.advanced.gridfs.MongoGridFsMigrationCheckpoint
dev.caskeleton.adapter.outbound.mongo.advanced.gridfs.MongoGridFsMigrationJob
dev.caskeleton.adapter.outbound.mongo.advanced.gridfs.MongoGridFsObjectReference
dev.caskeleton.adapter.outbound.mongo.advanced.search.MongoSearchIndexDescriptor
dev.caskeleton.adapter.outbound.mongo.advanced.search.MongoSearchIndexState
dev.caskeleton.adapter.outbound.mongo.advanced.search.MongoSearchOperations
dev.caskeleton.adapter.outbound.mongo.advanced.search.MongoSearchQuery
dev.caskeleton.adapter.outbound.mongo.advanced.search.MongoSearchReadinessGate
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.MongoRoutingClassification
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.ShardAwareQueryValidator
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.ShardKeyDescriptor
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.ShardKeyPart
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.ShardStrategy
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.admin.MongoShardingAdminGateway
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.admin.ReshardApproval
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.admin.ShardKeyAnalyzer
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.admin.ShardKeyReadinessReport
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database.MongoTenantClientRegistry
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database.MongoTenantDatabaseResolver
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database.MongoTenantLifecyclePolicy
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database.MongoTenantMigrationCheckpointStore
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database.MongoTenantMigrationCoordinator
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared.MongoTenantContext
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared.MongoTenantManifestValidator
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared.MongoTenantPredicateInjector
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared.TenantScopedMongoOperations
dev.caskeleton.adapter.outbound.mongo.advanced.timeseries.MongoTimeSeriesCapability
dev.caskeleton.adapter.outbound.mongo.advanced.timeseries.MongoTimeSeriesCapabilityValidator
dev.caskeleton.adapter.outbound.mongo.advanced.timeseries.MongoTimeSeriesDescriptor
dev.caskeleton.adapter.outbound.mongo.advanced.timeseries.MongoTimeSeriesGranularity
dev.caskeleton.adapter.outbound.mongo.advanced.timeseries.MongoTimeSeriesOperations
dev.caskeleton.adapter.outbound.mongo.advanced.timeseries.MongoTimeSeriesSupport
dev.caskeleton.adapter.outbound.mongo.advanced.vector.MongoEmbedding
dev.caskeleton.adapter.outbound.mongo.advanced.vector.MongoVectorIndexDescriptor
dev.caskeleton.adapter.outbound.mongo.advanced.vector.MongoVectorQuery
dev.caskeleton.adapter.outbound.mongo.advanced.vector.MongoVectorSearchBenchmarkGate
dev.caskeleton.adapter.outbound.mongo.advanced.vector.MongoVectorSearchOperations
dev.caskeleton.adapter.outbound.mongo.aggregation.MongoAggregationPlan
dev.caskeleton.adapter.outbound.mongo.aggregation.MongoAggregationProfile
dev.caskeleton.adapter.outbound.mongo.aggregation.MongoAggregationRisk
dev.caskeleton.adapter.outbound.mongo.aggregation.MongoAggregationStageDescriptor
dev.caskeleton.adapter.outbound.mongo.aggregation.PolicyAwareMongoAggregationExecutor
dev.caskeleton.adapter.outbound.mongo.api.CollectionProfileName
dev.caskeleton.adapter.outbound.mongo.api.DatabaseProfileName
dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext
dev.caskeleton.adapter.outbound.mongo.api.MongoOperationName
dev.caskeleton.adapter.outbound.mongo.api.MongoOperationScope
dev.caskeleton.adapter.outbound.mongo.api.MongoOperationType
dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapability
dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapabilitySet
dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapabilitySupport
dev.caskeleton.adapter.outbound.mongo.api.capability.MongoServerVersion
dev.caskeleton.adapter.outbound.mongo.api.capability.MongoSupportLevel
dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyDescriptor
dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyGuarantee
dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyProfile
dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyRegistry
dev.caskeleton.adapter.outbound.mongo.api.error.MongoBulkPartialFailureException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoConnectionException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoCursorException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoDataSchemaUnsupportedException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoDocumentTooLargeException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoDuplicateKeyException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoEncryptionException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoExecutionOutcome
dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureCategory
dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureContext
dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoOptimisticConflictException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoPersistenceException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoReadConcernException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoResumeException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoRetryScope
dev.caskeleton.adapter.outbound.mongo.api.error.MongoSchemaValidationException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoServerSelectionException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoShardRoutingException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoTimeoutException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoTransactionCommitUnknownException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoTransactionTransientException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoUnclassifiedFailureException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoWriteConcernException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoWriteConflictException
dev.caskeleton.adapter.outbound.mongo.api.mapping.DomainDocumentId
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoBigIntegerRepresentation
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoDecimalRepresentation
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoEnumRepresentation
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoIdRepresentation
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoTemporalRepresentation
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoTypeMetadataPolicy
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoTypeRepresentationManifest
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoUuidRepresentation
dev.caskeleton.adapter.outbound.mongo.api.observation.MongoOperationObservation
dev.caskeleton.adapter.outbound.mongo.api.observation.MongoOperationObserver
dev.caskeleton.adapter.outbound.mongo.api.profile.MongoClientPlane
dev.caskeleton.adapter.outbound.mongo.api.profile.MongoRuntimeProfile
dev.caskeleton.adapter.outbound.mongo.api.profile.MongoStableApiProfile
dev.caskeleton.adapter.outbound.mongo.api.profile.MongoTopology
dev.caskeleton.adapter.outbound.mongo.api.profile.MongoTopologyRequirement
dev.caskeleton.adapter.outbound.mongo.api.schema.DocumentSchemaVersion
dev.caskeleton.adapter.outbound.mongo.api.schema.MongoSchemaVersionPolicy
dev.caskeleton.adapter.outbound.mongo.api.schema.MongoSchemaVersionRange
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoClientGeneration
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoClientGenerationRegistry
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoDriverObservabilityAutoConfiguration
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformAutoConfiguration
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformHealthIndicator
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformProperties
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.MongoTopologyProbe
dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeEventIdentity
dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamPipeline
dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamState
dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamSubscription
dev.caskeleton.adapter.outbound.mongo.changestream.MongoClusterTime
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpoint
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpointStore
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumePosition
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeClaim
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeDeduplicationStore
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeProjectionResult
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeProjector
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeStreamRunner
dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoChangeHistoryLostException
dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoChangeStreamRecoveryDecision
dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoChangeStreamRecoveryPolicy
dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoInvalidateRecovery
dev.caskeleton.adapter.outbound.mongo.failure.DefaultMongoFailureClassifier
dev.caskeleton.adapter.outbound.mongo.failure.DefaultMongoFailureTranslator
dev.caskeleton.adapter.outbound.mongo.failure.MongoDriverFailureView
dev.caskeleton.adapter.outbound.mongo.failure.MongoFailureClassification
dev.caskeleton.adapter.outbound.mongo.failure.MongoFailureClassifier
dev.caskeleton.adapter.outbound.mongo.failure.MongoFailureExtractor
dev.caskeleton.adapter.outbound.mongo.failure.MongoFailurePhase
dev.caskeleton.adapter.outbound.mongo.failure.MongoFailureTranslator
dev.caskeleton.adapter.outbound.mongo.geo.MongoGeoDistance
dev.caskeleton.adapter.outbound.mongo.geo.MongoGeoPoint
dev.caskeleton.adapter.outbound.mongo.geo.MongoGeoQuery
dev.caskeleton.adapter.outbound.mongo.geo.MongoGeospatialOperations
dev.caskeleton.adapter.outbound.mongo.geo.SpringMongoGeospatialOperations
dev.caskeleton.adapter.outbound.mongo.imperative.DefaultMongoImperativeExecutor
dev.caskeleton.adapter.outbound.mongo.imperative.MongoCollectionAccess
dev.caskeleton.adapter.outbound.mongo.imperative.MongoCollectionProfileRegistry
dev.caskeleton.adapter.outbound.mongo.imperative.MongoCompletion
dev.caskeleton.adapter.outbound.mongo.imperative.MongoConsistencyBinder
dev.caskeleton.adapter.outbound.mongo.imperative.MongoImperativeCallback
dev.caskeleton.adapter.outbound.mongo.imperative.MongoImperativeExecutor
dev.caskeleton.adapter.outbound.mongo.imperative.MongoOperationResult
dev.caskeleton.adapter.outbound.mongo.imperative.MongoPlatformCallback
dev.caskeleton.adapter.outbound.mongo.imperative.MongoPlatformCollectionAccess
dev.caskeleton.adapter.outbound.mongo.imperative.MongoTemplateSupportContract
dev.caskeleton.adapter.outbound.mongo.imperative.ScopedMongoOperations
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicFilter
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicUpdate
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicUpdateResult
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicOperations
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicOperationsTemplate
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicy
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoUpdateOperator
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.ReturnDocumentMode
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkExecutor
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkItemFailure
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkItemOutcome
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkMode
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkResult
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkWritePlan
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.SpringDataBulkFailureExtractor
dev.caskeleton.adapter.outbound.mongo.imperative.revision.MongoDocumentNotFoundException
dev.caskeleton.adapter.outbound.mongo.imperative.revision.MongoOptimisticConflictTranslator
dev.caskeleton.adapter.outbound.mongo.imperative.revision.MongoRevision
dev.caskeleton.adapter.outbound.mongo.imperative.revision.VersionedMongoUpdater
dev.caskeleton.adapter.outbound.mongo.imperative.revision.VersionedUpdateCommand
dev.caskeleton.adapter.outbound.mongo.mapping.BigDecimalToDecimal128Converter
dev.caskeleton.adapter.outbound.mongo.mapping.BigIntegerRepresentationConverters
dev.caskeleton.adapter.outbound.mongo.mapping.Decimal128ToBigDecimalConverter
dev.caskeleton.adapter.outbound.mongo.mapping.DomainIdReadConverter
dev.caskeleton.adapter.outbound.mongo.mapping.DomainIdWriteConverter
dev.caskeleton.adapter.outbound.mongo.mapping.LocalDateTimeMappingGuard
dev.caskeleton.adapter.outbound.mongo.mapping.MongoCustomConversionsFactory
dev.caskeleton.adapter.outbound.mongo.mapping.MongoMappingConfiguration
dev.caskeleton.adapter.outbound.mongo.mapping.MongoTypeMetadataConfigurer
dev.caskeleton.adapter.outbound.mongo.mapping.type.LongLivedMongoDocument
dev.caskeleton.adapter.outbound.mongo.mapping.type.MongoTypeMetadataDescriptor
dev.caskeleton.adapter.outbound.mongo.mapping.type.MongoTypeMetadataRegistry
dev.caskeleton.adapter.outbound.mongo.mapping.type.PolicyAwareMongoTypeMapper
dev.caskeleton.adapter.outbound.mongo.migration.MongoCollectionMigrationLedger
dev.caskeleton.adapter.outbound.mongo.migration.MongoCollectionMigrationLock
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigration
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationCheckpoint
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationChecksum
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationContext
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationHeartbeat
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationId
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationLedger
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationLock
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationPostcondition
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationPrecondition
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationResult
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationRunner
dev.caskeleton.adapter.outbound.mongo.migration.flamingock.FlamingockChangeUnitView
dev.caskeleton.adapter.outbound.mongo.migration.flamingock.FlamingockLedgerAdapter
dev.caskeleton.adapter.outbound.mongo.migration.flamingock.FlamingockLockAdapter
dev.caskeleton.adapter.outbound.mongo.migration.flamingock.FlamingockMigrationConfiguration
dev.caskeleton.adapter.outbound.mongo.migration.flamingock.FlamingockMongoMigrationAdapter
dev.caskeleton.adapter.outbound.mongo.nativecap.ApprovedMongoNativeOperation
dev.caskeleton.adapter.outbound.mongo.nativecap.MongoNativeCapabilityGateway
dev.caskeleton.adapter.outbound.mongo.nativecap.MongoNativeCommandCategory
dev.caskeleton.adapter.outbound.mongo.nativecap.MongoNativeOperationPolicy
dev.caskeleton.adapter.outbound.mongo.nativecap.PolicyAwareMongoNativeGateway
dev.caskeleton.adapter.outbound.mongo.observation.MicrometerMongoOperationObserver
dev.caskeleton.adapter.outbound.mongo.observation.MongoCommandObservationListener
dev.caskeleton.adapter.outbound.mongo.observation.MongoDriverObservabilityConfiguration
dev.caskeleton.adapter.outbound.mongo.observation.MongoObservationConvention
dev.caskeleton.adapter.outbound.mongo.observation.MongoObservationRedactor
dev.caskeleton.adapter.outbound.mongo.observation.MongoPoolObservationListener
dev.caskeleton.adapter.outbound.mongo.observation.MongoSdamObservationListener
dev.caskeleton.adapter.outbound.mongo.query.MongoFieldDescriptor
dev.caskeleton.adapter.outbound.mongo.query.MongoOperator
dev.caskeleton.adapter.outbound.mongo.query.MongoQueryPolicy
dev.caskeleton.adapter.outbound.mongo.query.MongoRegexPolicy
dev.caskeleton.adapter.outbound.mongo.query.MongoSortDescriptor
dev.caskeleton.adapter.outbound.mongo.query.PolicyAwareMongoQueryBuilder
dev.caskeleton.adapter.outbound.mongo.query.budget.MongoBudgetEnforcer
dev.caskeleton.adapter.outbound.mongo.query.budget.MongoBudgetPolicyRegistry
dev.caskeleton.adapter.outbound.mongo.query.budget.MongoOperationBudget
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoKeysetCursor
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoKeysetCursorCodec
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoKeysetPageRequest
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoKeysetQueryBuilder
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoKeysetSlice
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoKeysetSort
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoNullSortOrdering
dev.caskeleton.adapter.outbound.mongo.reactive.DefaultReactiveMongoExecutor
dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveMongoCallback
dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveMongoCollectionAccess
dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveMongoConsistencyBinder
dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveMongoContextKeys
dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveMongoExecutor
dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveScopedMongoOperations
dev.caskeleton.adapter.outbound.mongo.reactive.cursor.MongoCursorGuard
dev.caskeleton.adapter.outbound.mongo.reactive.cursor.MongoCursorLease
dev.caskeleton.adapter.outbound.mongo.reactive.cursor.MongoCursorTermination
dev.caskeleton.adapter.outbound.mongo.reactive.cursor.MongoReactiveCursorPublisher
dev.caskeleton.adapter.outbound.mongo.reactive.cursor.MongoResultBudgetTracker
dev.caskeleton.adapter.outbound.mongo.schema.index.MongoIndexApplyPolicy
dev.caskeleton.adapter.outbound.mongo.schema.index.MongoIndexDescriptorView
dev.caskeleton.adapter.outbound.mongo.schema.index.MongoIndexDiff
dev.caskeleton.adapter.outbound.mongo.schema.index.MongoIndexDiffEngine
dev.caskeleton.adapter.outbound.mongo.schema.index.MongoIndexRetirementPlan
dev.caskeleton.adapter.outbound.mongo.schema.index.MongoIndexRetirementState
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoCollectionManifest
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoIndexDirection
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoIndexKey
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoIndexManifest
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoManifestRegistry
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoMetadataOwnership
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoSchemaManifest
dev.caskeleton.adapter.outbound.mongo.schema.model.EmbeddedCollectionDescriptor
dev.caskeleton.adapter.outbound.mongo.schema.model.MongoBinaryFieldDescriptor
dev.caskeleton.adapter.outbound.mongo.schema.model.MongoDocumentModelManifest
dev.caskeleton.adapter.outbound.mongo.schema.model.MongoDocumentModelValidator
dev.caskeleton.adapter.outbound.mongo.schema.model.MongoDocumentSizeBudget
dev.caskeleton.adapter.outbound.mongo.schema.model.MongoReferenceDescriptor
dev.caskeleton.adapter.outbound.mongo.schema.model.MongoReferenceLifecycle
dev.caskeleton.adapter.outbound.mongo.schema.ttl.MongoExpirationAccessPolicy
dev.caskeleton.adapter.outbound.mongo.schema.ttl.MongoTtlIndexDescriptor
dev.caskeleton.adapter.outbound.mongo.schema.ttl.MongoTtlPolicy
dev.caskeleton.adapter.outbound.mongo.schema.ttl.MongoTtlPolicyValidator
dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidationAction
dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidationLevel
dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidatorApplyPolicy
dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidatorDescriptor
dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidatorDiff
dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidatorDiffEngine
dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialReference
dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialResolver
dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialRotationPolicy
dev.caskeleton.adapter.outbound.mongo.security.MongoPrincipalRole
dev.caskeleton.adapter.outbound.mongo.security.MongoSecurityProfile
dev.caskeleton.adapter.outbound.mongo.security.MongoSecurityProfileValidator
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminApproval
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminAuditPhase
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminAuditRecord
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminAuthorization
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminCommand
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminGateway
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminOperation
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminRuntimeGuard
dev.caskeleton.adapter.outbound.mongo.transaction.MongoTransactionExecutor
dev.caskeleton.adapter.outbound.mongo.transaction.MongoTransactionProfile
dev.caskeleton.adapter.outbound.mongo.transaction.MongoTransactionScope
dev.caskeleton.adapter.outbound.mongo.transaction.MongoTransactionSession
dev.caskeleton.adapter.outbound.mongo.transaction.MongoTransactionSessionFactory
dev.caskeleton.adapter.outbound.mongo.transaction.ReactiveMongoTransactionExecutor
dev.caskeleton.adapter.outbound.mongo.transaction.ReactiveMongoTransactionSession
dev.caskeleton.adapter.outbound.mongo.transaction.ReactiveMongoTransactionSessionFactory
dev.caskeleton.adapter.outbound.mongo.transaction.SpringMongoTransactionExecutor
dev.caskeleton.adapter.outbound.mongo.transaction.SpringMongoTransactionSessionFactory
dev.caskeleton.adapter.outbound.mongo.transaction.SpringReactiveMongoTransactionExecutor
dev.caskeleton.adapter.outbound.mongo.transaction.SpringReactiveMongoTransactionSessionFactory
dev.caskeleton.adapter.outbound.mongo.transaction.retry.MongoCommitReconciler
dev.caskeleton.adapter.outbound.mongo.transaction.retry.MongoRetryBudget
dev.caskeleton.adapter.outbound.mongo.transaction.retry.MongoRetryDecision
dev.caskeleton.adapter.outbound.mongo.transaction.retry.MongoTransactionRetryCoordinator
dev.caskeleton.adapter.outbound.mongo.transaction.session.MongoCausalSessionContext
dev.caskeleton.adapter.outbound.mongo.transaction.session.MongoCausalSessionExecutor
dev.caskeleton.adapter.outbound.mongo.transaction.session.ReactiveMongoCausalSessionExecutor
dev.caskeleton.adapter.outbound.mongo.transaction.session.SpringMongoCausalSessionExecutor
+13 -2
View File
@@ -35,8 +35,19 @@ This is the same adaptation already applied to the HTTP client platform
| `jpa-spring-boot-starter` | `:app-bootstrap` (`dev.caskeleton.bootstrap.autoconfigure.jpa`) | This repository's composition root owns wiring, startup validation, and actuator surface; an adapter leaf must not auto-configure itself. `AGENTS.md` assigns composition to `app-bootstrap`. |
| `jpa-testkit`, `jpa-testkit-postgresql`, `jpa-testkit-migration`, `jpa-testkit-queryplan` | `:adapter:outbound:persistence-jpa` `src/testkit/java/**/testkit` | The plan forbids production modules depending on the testkit. A source set whose dependencies are declared only on test configurations gives the same guarantee without a new Gradle project, and more than one lane consumes it. |
The package boundary is enforced by `JpaModuleBoundaryTest`, which reproduces the plan's
§3 module dependency map as package rules.
The package boundary is enforced by `JpaModuleBoundaryTest`. It holds a closed catalog of the
production root's direct child packages, compares that catalog against the tree for exact equality,
checks every observed top-level edge against the declared ones, and rejects cycles.
This used to be a stronger claim than the test. The catalog listed thirteen packages while the tree
held twenty-two, so nine — `audit`, `config`, `failure`, `fileserver`, `h2`, `idempotency`, `lock`,
`notification`, `outbox` — were governed by nothing, and a `transaction → postgresql` /
`postgresql → transaction` cycle passed. Both are closed now, and the catalog's exact-equality check
is what keeps a new package from being green by omission.
**Known gap.** The catalog governs top-level packages. Sub-package edges inside one top-level
package are not checked, and the target tree in the review's JPA-023 (a `capability/*` layout) is
not implemented — the notification configuration facade is the first step toward it.
## 2. Package mapping
+40 -11
View File
@@ -1,23 +1,52 @@
# JPA Persistence Platform — Support Matrix
The machine-readable source for `JpaReleaseManifest`. A release gate parses this file, so a version
or gate that stops being named here stops being claimed — and the build fails rather than the
document quietly drifting from the code.
**This document is a rendering. The machine-readable source is
[`src/config/jpa/release-registry.json`](../../src/config/jpa/release-registry.json).**
`JpaReleaseManifest` used to parse this file with regular expressions: every `PostgreSQL NN` it
mentioned became a supported version, whatever table or sentence produced the match. An Experimental
major joined the Stable list, a version named once in prose counted as supported, and demoting a
major changed nothing so long as the string survived somewhere in the document. Now the registry
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.
## Database
| Database | Support | Evidence |
|---|---|---|
| PostgreSQL 16 | Stable | full contract suite, release lane |
| PostgreSQL 17 | Stable | full contract suite, release lane |
| PostgreSQL 18 | Stable | full contract suite, release lane |
| PostgreSQL 16 | 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 19 | Experimental | compatibility lane only; promotion requires an ADR |
| H2 | Local convenience | **never** evidence of PostgreSQL behaviour |
H2 is not a second production target. It reports different SQLSTATEs for the same violation, has no
`SKIP LOCKED` guarantee the platform relies on, no JSONB operators, no range types, and no
concurrent index builds. A green H2 run is evidence that the code compiles and runs, and nothing
more.
Each major gets its **own release job**, because for a while it did not. The release lane passed
`-Pjpa.matrix.versions=16,17,18` to a `JpaPlatformContractSupport.start()` that used
`selectedVersions().get(0)`, so the whole integration suite ran against PostgreSQL 16 and this table
recorded 17 and 18 as fully covered on the strength of a three-assertion smoke test. `start()` now
refuses a multi-version selection outright, `jpa-release.yml` fans out to one job per major, and a
promotion job requires all three majors' evidence to carry the same commit SHA — so a removed major
removes the release, not the evidence for it.
**Provider baseline.** The gates run against the Hibernate version the Spring Boot BOM resolves —
**7.1.8.Final** — which the registry records as `stable-tested-baseline`. This document previously
called 7.4 the Stable baseline and the pagination gate was named `hibernate-7.4-fetch-pagination`,
so every run of that gate produced evidence labelled with a provider it had never executed against.
7.4 is recorded as `compatibility-target`; it becomes the baseline when a full lane has actually run
on it.
H2 is not a second production target. It reports different SQLSTATEs for the same violation, no JSONB
operators, no range types, and no concurrent index builds. A green H2 run is evidence that the code
compiles and runs, and nothing more.
`SKIP LOCKED` needs its own sentence, because two documents said different things about it. The
module's `CLAUDE.md` records a measurement: H2 2.4.240 accepts `FOR UPDATE SKIP LOCKED` and does
genuinely skip locked rows, which is why the outbox claim SQL is identical on both vendors. This
document previously said H2 has no such guarantee. Both are right about different questions, and
the distinction is the point: **observed behaviour in the version we measured is not a production
guarantee, and it is never PostgreSQL contract evidence.** The measurement is why the claim SQL
needs no vendor branch; the absence of a guarantee is why every concurrency contract still runs
against a real PostgreSQL.
## Specification and provider
@@ -67,7 +96,7 @@ Each row is a way the platform could pass its tests and still be wrong in produc
| `osiv-disabled` | gate | lazy loading from the view layer, one query per rendered row |
| `flyway-validate` | gate | Hibernate mutating a deployed schema, or running against one it was not built for |
| `runtime-role-no-ddl` | gate | the application's own credential being able to alter or drop schema objects |
| `hibernate-7.4-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 |
## Explicitly unsupported
+20 -6
View File
@@ -1,9 +1,18 @@
# 설정 레퍼런스
> **Prefix.** Every property below binds under `app.messaging`, which is the prefix the deployed
> runtime and the `APP_MESSAGING_*` environment variables already use. Earlier revisions of this
> page documented a bare `messaging` prefix and the starter bound `backend.messaging`; neither
> bound what this page describes, so a deployment configured from it changed nothing. A key under
> either of the old prefixes now fails startup with a message naming the key — see
> `MessagingPrefixMigrationValidator`.
## Destination profile
```yaml
messaging:
app:
messaging:
destinations:
order-events:
broker: kafka-primary
@@ -77,7 +86,8 @@ messaging:
### Kafka
```yaml
messaging:
app:
messaging:
brokers:
kafka-primary:
type: kafka
@@ -96,7 +106,8 @@ messaging:
### RabbitMQ
```yaml
messaging:
app:
messaging:
brokers:
rabbit-primary:
type: rabbitmq
@@ -117,7 +128,8 @@ messaging:
## 보안
```yaml
messaging:
app:
messaging:
security:
kafka-primary:
producer: { type: SASL_SCRAM, credential-id: kafka-producer }
@@ -135,7 +147,8 @@ messaging:
기본값은 전부 `false`다.
```yaml
messaging:
app:
messaging:
experimental:
kafka-share: false
pulsar: false
@@ -147,7 +160,8 @@ messaging:
## Backpressure
```yaml
messaging:
app:
messaging:
backpressure:
global-limit: 512
per-destination-limit: 64 # global-limit 이하여야 한다
+34
View File
@@ -0,0 +1,34 @@
# 기존 runtime → 신규 messaging platform cutover (MSG-015)
## 왜 기계적 매핑이 안 되는가
두 outbox 모델의 enum 이름이 겹치는데 의미가 반대다.
| 모델 | retryable | terminal |
|---|---|---|
| 기존 `OutboxEventStatus` | `FAILED` (`next_attempt_at` 보유) | `DEAD` |
| 신규 `OutboxStatus` | `AMBIGUOUS` | `FAILED`, `EXHAUSTED` |
이름으로 매핑하면 **확정 거절이 무한 재시도**가 되고 **불확정이 park**된다. 그래서 application은
자기 어휘(`OutboxPublishOutcome`)만 쓰고, 변환은 bridge adapter가 한다.
## 지금 반영된 것
- `OutboxPublishOutcome``CONFIRMED` / `AMBIGUOUS` / `REJECTED_BEFORE_SEND` /
`REJECTED_AFTER_BROKER`. application이 소유하는 canonical 결과 타입이며, "리턴 or throw"만 가능한
기존 어댑터를 위해 `OutboxMessagePublishPort.publishForOutcome`의 default가 `CONFIRMED`를 돌려준다.
- `APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM` ArchUnit 규칙 — application-core가
`dev.caskeleton.messaging..`를 import하면 빌드가 깨진다.
- 반대 방향(신규 `PublishResult` → application outcome) 매핑 규칙을 테스트로 고정.
## 남은 것
- `messaging-platform-bridge` outbound leaf: validated application event → platform envelope,
`PublishResult``OutboxPublishOutcome`. registry에 leaf를 추가하는 변경이라 별도 커밋.
- golden contract 테스트: event/message ID, type, schema revision, partition/order/correlation/
causation/tenant/trace, payload digest, wire version이 bytes 단위로 보존되는지.
- 단일 publication authority: 기존 `OutboxPublicationAuthority` fence를 재사용해 writer/relay가
동시에 ACTIVE가 되지 않도록. **dual write/publish는 금지** — 한 business fact가 두 durable store와
두 relay로 나가는 상태가 cutover에서 가장 위험하다.
- 첫 cutover 범위는 **transport만** 교체(저장소는 기존 유지). storage migration은 shadow read →
authority switch → old backlog drain 순서로 별도 release.
+18 -1
View File
@@ -41,9 +41,26 @@ failed로 표시하면 broker가 이미 가지고 있을 수 있는 메시지를
### lease
```text
status IN ('PENDING','AMBIGUOUS') AND (lease_expires_at IS NULL OR lease_expires_at <= now)
status IN ('PENDING','AMBIGUOUS','IN_FLIGHT')
AND (lease_expires_at IS NULL OR lease_expires_at <= now)
AND next_attempt_at <= now
AND attempts < maxAttempts
```
`IN_FLIGHT`가 목록에 있는 것이 핵심이다. relay가 publish 도중 죽으면 row는 `IN_FLIGHT`로 남는데,
이를 제외하면 그 메시지는 **영원히** 발행되지 않는다 — outbox가 막으려던 바로 그 실패다. 대신
lease가 만료됐을 때만 회수하므로, 살아 있는 relay가 들고 있는 row는 회수되지 않는다.
회수는 **같은 `message_id`로** 이루어지고 `lease_token`이 1 증가한다. 새 id를 발급하면 "전달됐을
수도 있는 메시지"가 "확실히 두 번째"가 되기 때문이다 (위의 AMBIGUOUS 논의와 같은 이유).
이 문단의 근거는 실제 PostgreSQL 컨테이너 레인이다:
- `OutboxPostgresIT#anExpiredLeaseBecomesClaimableAgain` — 만료된 lease의 재회수
- `OutboxPostgresIT#anExpiryReclaimKeepsTheMessageIdAndAdvancesTheToken` — 같은 id, 증가한 token
- `OutboxPostgresIT#aLeasedRowIsInvisibleToASecondRelayInstance` — 살아 있는 lease는 회수 불가
- `OutboxPostgresIT#aSupersededRelayCannotOverwriteTheOutcomeOfTheOneThatReplacedIt` — fencing
partial index `ix_messaging_outbox_claimable`이 이 쿼리를 backlog 크기에 비례하게 유지한다.
PUBLISHED row는 retention job이 지울 때까지 쌓이기 때문이다.
+18 -1
View File
@@ -3,11 +3,28 @@
플랫폼이 **무엇을 보장하는지**와 **무엇을 보장하지 않는지**를 브로커별로 고정한다.
여기 없는 조합은 지원되지 않는다.
> **인증 근거.** 이 표의 버전은 이 저장소의 컨테이너 레인이 실제로 실행한 이미지다. 이전 판은
> Kafka 4.2/4.3을 선언했지만 fixture는 `apache/kafka:4.1.0`, lockfile client는 4.1.1이었다 — 표와
> 코드 상수가 서로 일치했을 뿐 어느 쪽도 실행된 적이 없었다. 장애 시나리오 커버리지도 마찬가지로
> `BrokerFailureMatrix.shipped()` 하드코딩이 아니라 레인이 낸 증거(`BrokerCertificationEvidence`)에서
> 나온다. 증거가 없는 조합은 `NOT_COVERED`다 (MSG-014).
> **모듈 이름과 런타임 편입.** `messaging-outbox-jdbc-postgresql` / `messaging-inbox-jdbc-postgresql`은
> 이전에 `-jpa`로 불렸다. 구현은 Spring JDBC이고 SQL은 PostgreSQL 전용(`?::jsonb`,
> `FOR UPDATE SKIP LOCKED`, `ON CONFLICT`, `TIMESTAMPTZ`)이므로, 그 이름은 쓰지 않는 기술을
> 광고하고 vendor 중립 port(`messaging-reliability-api`)의 위치를 가렸다 (MSG-023).
>
> 또한 registry의 messaging leaf는 모두 `runtime_memberships`가 비어 있다. 이는 **build-only /
> incubating** — 어느 composition root에도 편입되지 않았다는 뜻이며, 아래의 등급과는 다른 축이다.
> 등급은 "무엇이 증명되었는가", membership은 "무엇이 실행되는가"를 말한다. 애플리케이션에 배선하려면
> registry를 먼저 바꾸고 `verifyRuntimeModuleMembership`을 통과시켜야 한다. 자세한 규칙은
> `src/messaging/CLAUDE.md`가 소유한다.
## 브로커 등급
| 브로커 | 등급 | 인증 기준 | Stable 기능 | 제한 |
|---|---|---|---|---|
| Kafka | Stable | 4.2+ / 4.3.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 미지원 |
| 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 없음(플랫폼이 대행), 기본 비활성 |
+14
View File
@@ -0,0 +1,14 @@
# Advanced capability sign-off
`scripts/verify-mongodb-advanced.sh` treats a file in this directory as the evidence that a review
happened:
- `security.md` — per-capability privilege review, naming the roles granted and by whom.
- `migration.md` — per-capability migration path, naming what an existing deployment has to do.
These were previously appended to the gate's missing-evidence list unconditionally, so the gate had
no passing state at all. A gate that can never pass is one nobody can act on, and the thing it was
waiting for — a human review — has an artefact. This is that artefact.
A file here asserts the review was done. Adding one without doing it is the failure mode; that is a
review-process problem, and no script can tell the difference.
+10 -2
View File
@@ -21,8 +21,16 @@ Gradle projects would violate HARD-STOP #5 in `AGENTS.md`.
Therefore the design's 31 modules become **package boundaries inside the registered leaf**
`:adapter:outbound:persistence-mongo`, following the precedent already set by
[docs/httpclient/repository-adaptation.md](../httpclient/repository-adaptation.md). The design's
module dependency table (§6.3) is reproduced as ten ArchUnit rules in `MongoModuleBoundaryTest`, so
a forbidden edge fails the build the same way a missing Gradle dependency would.
module dependency table (§6.3) is enforced by `MongoModuleBoundaryTest` as a **closed edge matrix**:
every top-level package is declared with the packages it may import, the matrix is compared against
the tree for exact equality, and every observed edge must appear in it. A forbidden edge fails the
build the same way a missing Gradle dependency would, and so does a new package nobody registered.
This used to be a stronger claim than the test. The rules forbade a handful of reverse dependencies
and said nothing about the rest, so four edges outside the design's DAG existed and passed:
`reactive → imperative`, `reactive → query`, `transaction → reactive` and `geo → imperative`. They
are declared in the matrix now rather than removed — each is a real coupling the code relies on, and
the point of recording them is that the next one is a decision instead of an accident.
## 2. Package mapping
@@ -0,0 +1,69 @@
# NOTIF-ADR-005 — 어느 notification API가 canonical인가
## 상태
Accepted (2026-08-15). NTF-018 대응.
## 문제
같은 저장소에 notification 모델이 **두 개** 있다.
| 세대 | 위치 | 규모 |
| --- | --- | --- |
| R0 legacy | `adapter:outbound:notification`의 router/provider seam | 삭제 예정 |
| R1 | `dev.caskeleton.application.notification` (직속) | public type 100개 |
| Platform | `dev.caskeleton.application.notification.platform..` | 신규 |
`application-core/CLAUDE.md`는 R1을 "R1 canonical"이라 부르고,
`docs/notification/migration-guide.md`는 R0 → platform 이행만 설명하며 R1의 처분을 전혀 다루지 않는다.
`Channel`, plan, dispatch, receipt/evidence 모델이 두 namespace에 중복 존재하고 둘 사이에 production
bridge도 import도 없다.
**실패 모드는 "무엇이 깨지는가"가 아니라 "무엇을 써야 하는가"다.** 새 consumer가 어느 API를 쓸지 알 수
없고, 두 모델이 각자 진화하며, R0를 지운 뒤에도 R1 graph가 고아로 남거나 platform이 R1 정책을 우회하는
이중 canonical이 된다.
## 결정
**Platform이 canonical이다.** R1은 유지되지만 새 production consumer를 받지 않는다.
이유는 능력이 아니라 증거다. platform은 durable acceptance, fenced claim, event ledger, projection,
reconciliation을 실제 PostgreSQL 레인으로 증명한다(NOTIF-ADR-001~003). R1은 fake로 증명된 R1 계약이며
스스로 그렇게 선언한다 — `application-core/CLAUDE.md`가 "R1 application contract proven with fakes.
It does not claim PostgreSQL schema/locking, provider protocol, cryptographic verifier, or runtime
wiring qualification"이라고 적어 둔 그대로다.
## Disposition
R1 public type 100개의 처분은 네 가지 중 하나다.
| 처분 | 의미 | 대상 |
| --- | --- | --- |
| `replace` | platform에 동등물이 있다. 새 consumer는 platform을 쓴다 | `Channel`, plan/dispatch/receipt/evidence 계열 |
| `bridge` | 변환이 필요하다. 변환은 ACL 한 곳에만 둔다 | writer-cutover / receipt 적용 경로 |
| `retain` | platform이 다루지 않는 관심사다. 그대로 둔다 | consent/quiescence verifier port |
| `delete` | R0와 함께 사라진다 | R0 router가 쓰던 seam |
전수 분류표는 이 ADR이 아니라 `docs/notification/module-mapping.md`가 소유한다. ADR은 규칙을,
mapping 문서는 목록을 소유한다 — 목록을 두 곳에 복제하면 드리프트하는 쪽이 늘어난다.
## 강제
두 namespace 사이의 production dependency는 **0건**이며, 이것은 문서가 아니라 ArchUnit 규칙이 지킨다
(`CleanArchitectureTest``NOTIFICATION_R1_AND_PLATFORM_DO_NOT_DEPEND_ON_EACH_OTHER`).
변환이 필요해지면 `dev.caskeleton.application.notification.compatibility.r1` 한 패키지에만 두고, 그
패키지만 규칙에서 예외로 인정한다. 예외를 한 곳으로 모으는 것이 목적이다 — 두 모델이 서로를 아는
지점이 여러 곳이면 "어느 쪽이 canonical인가"라는 질문에 코드가 답하지 못한다.
## 결과
- 새 production consumer는 `..notification.platform..`만 쓴다.
- R1 type은 남지만, 새 코드가 그것을 import하면 ArchUnit이 막는다.
- R0 삭제는 이 ADR과 무관하게 진행된다. R1은 R0와 함께 사라지지 않는다.
## 하지 않은 것
R1 100개 type에 `@Deprecated(forRemoval = true)`를 붙이지 않았다. 제거 시점이 정해지지 않았고,
`forRemoval`은 "이 릴리스 이후 사라진다"는 약속이라 시점 없이 붙이면 그 자체가 거짓 신호다. 경계는
ArchUnit이 강제하고, deprecation은 제거 계획이 생길 때 붙인다.
+572
View File
@@ -0,0 +1,572 @@
# NTF-022 — public type surface of the notification platform.
# Every top-level public type under the platform packages. Growth is a reviewed
# change: ./gradlew updateNotificationApiSurface -PapproveNotificationApiChange
dev.caskeleton.adapter.outbound.notification.NotificationConfig
dev.caskeleton.adapter.outbound.notification.NotificationRoutesSettings
dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding
dev.caskeleton.adapter.outbound.notification.catalog.NotificationBindingCompiler
dev.caskeleton.adapter.outbound.notification.catalog.NotificationCanonicalRouteCatalog
dev.caskeleton.adapter.outbound.notification.catalog.NotificationCatalogException
dev.caskeleton.adapter.outbound.notification.catalog.NotificationCutoverRouteCatalog
dev.caskeleton.adapter.outbound.notification.catalog.NotificationPlanAdapter
dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderCapabilityCard
dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderCapabilityDescriptorSource
dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderDescriptor
dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderRuntimeProfile
dev.caskeleton.adapter.outbound.notification.catalog.NotificationRouteDescriptor
dev.caskeleton.adapter.outbound.notification.catalog.NotificationTemplateDescriptor
dev.caskeleton.adapter.outbound.notification.core.FailOpenNotificationProvider
dev.caskeleton.adapter.outbound.notification.core.NotificationProvider
dev.caskeleton.adapter.outbound.notification.core.RoutingNotifier
dev.caskeleton.adapter.outbound.notification.email.google.GoogleEmailClient
dev.caskeleton.adapter.outbound.notification.email.google.GoogleEmailNotificationAdapterConfig
dev.caskeleton.adapter.outbound.notification.email.google.GoogleEmailProvider
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.AssembledProvider
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformAutoConfiguration
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformMode
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformSettings
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationProviderAssembly
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.ProviderRuntimeAssembler
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.ProviderType
dev.caskeleton.adapter.outbound.notification.platform.dispatch.AttemptPermit
dev.caskeleton.adapter.outbound.notification.platform.dispatch.CapabilityReconciliationGateway
dev.caskeleton.adapter.outbound.notification.platform.dispatch.ConfiguredProfileCatalog
dev.caskeleton.adapter.outbound.notification.platform.dispatch.CredentialProbe
dev.caskeleton.adapter.outbound.notification.platform.dispatch.CredentialValidationException
dev.caskeleton.adapter.outbound.notification.platform.dispatch.JacksonRoutingPlanCodec
dev.caskeleton.adapter.outbound.notification.platform.dispatch.LeaseRecoveryService
dev.caskeleton.adapter.outbound.notification.platform.dispatch.LoggingInboxSignalPublisher
dev.caskeleton.adapter.outbound.notification.platform.dispatch.MapTemplateRendererRegistry
dev.caskeleton.adapter.outbound.notification.platform.dispatch.NotificationBackgroundWorkers
dev.caskeleton.adapter.outbound.notification.platform.dispatch.NotificationDispatchProperties
dev.caskeleton.adapter.outbound.notification.platform.dispatch.NotificationSchedulerWorker
dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderAttemptLimiter
dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderEventReplayWorker
dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderRuntime
dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderRuntimeRegistry
dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderRuntimeRotator
dev.caskeleton.adapter.outbound.notification.platform.dispatch.ReconciliationJobWorker
dev.caskeleton.adapter.outbound.notification.platform.dispatch.RegistryProviderDispatchGateway
dev.caskeleton.adapter.outbound.notification.platform.dispatch.RegistryProviderRuntimeControl
dev.caskeleton.adapter.outbound.notification.platform.dispatch.RuntimeDrainCoordinator
dev.caskeleton.adapter.outbound.notification.platform.dispatch.SingleTenantContext
dev.caskeleton.adapter.outbound.notification.platform.dispatch.UuidV7Generator
dev.caskeleton.adapter.outbound.notification.platform.observation.LoggingNotificationAudit
dev.caskeleton.adapter.outbound.notification.platform.observation.LoggingNotificationMetrics
dev.caskeleton.adapter.outbound.notification.platform.observation.NotificationHealthReporter
dev.caskeleton.adapter.outbound.notification.platform.observation.NotificationHealthSnapshot
dev.caskeleton.adapter.outbound.notification.platform.observation.NotificationServingThresholds
dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults
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.ApnsNotificationProviderAdapter
dev.caskeleton.adapter.outbound.notification.platform.provider.apns.ApnsProviderProperties
dev.caskeleton.adapter.outbound.notification.platform.provider.apns.ApnsRequestMapper
dev.caskeleton.adapter.outbound.notification.platform.provider.fcm.FcmBatchCoordinator
dev.caskeleton.adapter.outbound.notification.platform.provider.fcm.FcmBatchResult
dev.caskeleton.adapter.outbound.notification.platform.provider.fcm.FcmContactPointUpdater
dev.caskeleton.adapter.outbound.notification.platform.provider.fcm.FcmFailureClassifier
dev.caskeleton.adapter.outbound.notification.platform.provider.fcm.FcmGateway
dev.caskeleton.adapter.outbound.notification.platform.provider.fcm.FcmMessageMapper
dev.caskeleton.adapter.outbound.notification.platform.provider.fcm.FcmNotificationProviderAdapter
dev.caskeleton.adapter.outbound.notification.platform.provider.fcm.FcmProviderProperties
dev.caskeleton.adapter.outbound.notification.platform.provider.fcm.FcmTargetMapper
dev.caskeleton.adapter.outbound.notification.platform.provider.fcm.FcmWireTarget
dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway
dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationEndpoints
dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway
dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest
dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse
dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.AwsSignatureV4Signer
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesCallbackAdapter
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesDeliveryProjector
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesEventNormalizer
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesFailureClassifier
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesNotificationProviderAdapter
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesProviderProperties
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesRequestMapper
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.SnsSignatureVerifier
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.SmtpFailureClassifier
dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpMimeMessageFactory
dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpNotificationProviderAdapter
dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpProviderProperties
dev.caskeleton.adapter.outbound.notification.platform.provider.twilio.TwilioCallbackAdapter
dev.caskeleton.adapter.outbound.notification.platform.provider.twilio.TwilioDeliveryProjector
dev.caskeleton.adapter.outbound.notification.platform.provider.twilio.TwilioFailureClassifier
dev.caskeleton.adapter.outbound.notification.platform.provider.twilio.TwilioProviderProperties
dev.caskeleton.adapter.outbound.notification.platform.provider.twilio.TwilioReconciliationCapability
dev.caskeleton.adapter.outbound.notification.platform.provider.twilio.TwilioRequestMapper
dev.caskeleton.adapter.outbound.notification.platform.provider.twilio.TwilioSignatureValidator
dev.caskeleton.adapter.outbound.notification.platform.provider.twilio.TwilioSmsProviderAdapter
dev.caskeleton.adapter.outbound.notification.platform.provider.twilio.TwilioStatusNormalizer
dev.caskeleton.adapter.outbound.notification.platform.provider.webhook.WebhookNotificationProviderAdapter
dev.caskeleton.adapter.outbound.notification.platform.provider.webhook.WebhookSignatureStrategy
dev.caskeleton.adapter.outbound.notification.platform.provider.webhook.WebhookSubscription
dev.caskeleton.adapter.outbound.notification.platform.provider.webpush.EncryptedWebPushPayload
dev.caskeleton.adapter.outbound.notification.platform.provider.webpush.Rfc8291Aes128GcmEncryptor
dev.caskeleton.adapter.outbound.notification.platform.provider.webpush.VapidAuthorizationProvider
dev.caskeleton.adapter.outbound.notification.platform.provider.webpush.VapidJwtSigner
dev.caskeleton.adapter.outbound.notification.platform.provider.webpush.VapidKeyRegistry
dev.caskeleton.adapter.outbound.notification.platform.provider.webpush.WebPushFailureClassifier
dev.caskeleton.adapter.outbound.notification.platform.provider.webpush.WebPushNotificationProviderAdapter
dev.caskeleton.adapter.outbound.notification.platform.provider.webpush.WebPushProviderProperties
dev.caskeleton.adapter.outbound.notification.platform.provider.webpush.WebPushReceiptCapability
dev.caskeleton.adapter.outbound.notification.platform.provider.webpush.WebPushRequestMapper
dev.caskeleton.adapter.outbound.notification.platform.reactor.ReactiveNotificationOrchestrator
dev.caskeleton.adapter.outbound.notification.platform.reactor.ReactorContextBridge
dev.caskeleton.adapter.outbound.notification.platform.reactor.ReactorNotificationOrchestrator
dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmCallbackPayloadProtection
dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector
dev.caskeleton.adapter.outbound.notification.platform.security.CredentialGeneration
dev.caskeleton.adapter.outbound.notification.platform.security.HmacProviderRequestIdHasher
dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager
dev.caskeleton.adapter.outbound.notification.platform.security.SettingsSecretMaterialProvider
dev.caskeleton.adapter.outbound.notification.platform.template.CanonicalNotificationRenderer
dev.caskeleton.adapter.outbound.notification.platform.template.JacksonInboxContentCodec
dev.caskeleton.adapter.outbound.notification.platform.template.JacksonNotificationVariablesCodec
dev.caskeleton.adapter.outbound.notification.platform.template.JacksonTemplateContentCodec
dev.caskeleton.adapter.outbound.notification.platform.template.JsonSchemaVariableValidator
dev.caskeleton.adapter.outbound.notification.platform.template.NotificationDigest
dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper
dev.caskeleton.adapter.outbound.notification.platform.template.NotificationTemplateEngine
dev.caskeleton.adapter.outbound.notification.platform.template.PlaceholderTemplateEngine
dev.caskeleton.adapter.outbound.notification.platform.template.Sha256MessageDigestAdapter
dev.caskeleton.adapter.outbound.notification.platform.template.TemplateSlotMode
dev.caskeleton.adapter.outbound.notification.platform.template.ThymeleafNotificationRenderer
dev.caskeleton.adapter.outbound.notification.platform.template.ThymeleafStringTemplateEngine
dev.caskeleton.adapter.outbound.notification.provider.AttemptCorrelationId
dev.caskeleton.adapter.outbound.notification.provider.InlineNotificationAttemptAdapter
dev.caskeleton.adapter.outbound.notification.provider.NotificationAdmissionReadinessAdapter
dev.caskeleton.adapter.outbound.notification.provider.NotificationAttemptContext
dev.caskeleton.adapter.outbound.notification.provider.NotificationProviderAttemptAdapter
dev.caskeleton.adapter.outbound.notification.provider.NotificationProviderAttemptClient
dev.caskeleton.adapter.outbound.notification.provider.NotificationProviderRateAdmission
dev.caskeleton.adapter.outbound.notification.provider.NotificationProviderReadinessProbe
dev.caskeleton.adapter.outbound.notification.provider.NotificationProviderReadinessSnapshot
dev.caskeleton.adapter.outbound.notification.provider.NotificationProviderSecretMaterialProvider
dev.caskeleton.adapter.outbound.notification.provider.NotificationReconciliationAdapter
dev.caskeleton.adapter.outbound.notification.provider.NotificationSecretMaterialHandle
dev.caskeleton.adapter.outbound.notification.provider.PreparedNotificationAttempt
dev.caskeleton.adapter.outbound.notification.provider.ProviderMessageReference
dev.caskeleton.adapter.outbound.notification.provider.ReconciliationLookupMode
dev.caskeleton.adapter.outbound.notification.slack.webhook.SlackClient
dev.caskeleton.adapter.outbound.notification.slack.webhook.SlackNotificationAdapterConfig
dev.caskeleton.adapter.outbound.notification.slack.webhook.SlackWebhookProvider
dev.caskeleton.adapter.outbound.notification.template.LocalEmailRenderer
dev.caskeleton.adapter.outbound.notification.template.NotificationTemplateCatalog
dev.caskeleton.adapter.outbound.notification.template.NotificationTemplateManifest
dev.caskeleton.adapter.outbound.notification.template.NotificationTemplateRenderer
dev.caskeleton.adapter.outbound.notification.template.RenderedNotification
dev.caskeleton.adapter.outbound.notification.template.SlackBlockKitRenderer
dev.caskeleton.adapter.outbound.notification.template.TemplateRenderingException
dev.caskeleton.application.notification.ApplyNotificationReceiptCommand
dev.caskeleton.application.notification.ApplyNotificationReceiptResult
dev.caskeleton.application.notification.ApplyNotificationReceiptUseCase
dev.caskeleton.application.notification.Channel
dev.caskeleton.application.notification.ConsentCheckMode
dev.caskeleton.application.notification.EmailRecipientReference
dev.caskeleton.application.notification.InitializeNotificationWriterFencesCommand
dev.caskeleton.application.notification.InitializeNotificationWriterFencesOperation
dev.caskeleton.application.notification.InitializeNotificationWriterFencesResult
dev.caskeleton.application.notification.InitializeNotificationWriterFencesUseCase
dev.caskeleton.application.notification.InlineNotificationAttemptPort
dev.caskeleton.application.notification.NormalizedNotificationReceiptCommand
dev.caskeleton.application.notification.Notification
dev.caskeleton.application.notification.NotificationAdmissionClass
dev.caskeleton.application.notification.NotificationAdmissionGateCommand
dev.caskeleton.application.notification.NotificationAdmissionGateUseCase
dev.caskeleton.application.notification.NotificationAdmissionReadinessPort
dev.caskeleton.application.notification.NotificationAppendResult
dev.caskeleton.application.notification.NotificationApplicationException
dev.caskeleton.application.notification.NotificationAttemptId
dev.caskeleton.application.notification.NotificationCanonicalWriterFenceGuard
dev.caskeleton.application.notification.NotificationCanonicalWriterFencePort
dev.caskeleton.application.notification.NotificationCanonicalWriterRouteSet
dev.caskeleton.application.notification.NotificationCapabilityCompatibilityValidator
dev.caskeleton.application.notification.NotificationChannel
dev.caskeleton.application.notification.NotificationDeliveryId
dev.caskeleton.application.notification.NotificationDeliveryStorePort
dev.caskeleton.application.notification.NotificationDispatchCommand
dev.caskeleton.application.notification.NotificationDispatchResult
dev.caskeleton.application.notification.NotificationDispatchUseCase
dev.caskeleton.application.notification.NotificationEvidenceTrustSnapshot
dev.caskeleton.application.notification.NotificationFaultScope
dev.caskeleton.application.notification.NotificationFrozenPlan
dev.caskeleton.application.notification.NotificationIntentAppendPort
dev.caskeleton.application.notification.NotificationIntentDraft
dev.caskeleton.application.notification.NotificationIntentId
dev.caskeleton.application.notification.NotificationKindId
dev.caskeleton.application.notification.NotificationKindPolicy
dev.caskeleton.application.notification.NotificationLegacyWriterPermitCommand
dev.caskeleton.application.notification.NotificationLegacyWriterPermitResult
dev.caskeleton.application.notification.NotificationLegacyWriterPermitUseCase
dev.caskeleton.application.notification.NotificationMaintenanceCommand
dev.caskeleton.application.notification.NotificationMaintenanceResult
dev.caskeleton.application.notification.NotificationMaintenanceStorePort
dev.caskeleton.application.notification.NotificationMaintenanceUseCase
dev.caskeleton.application.notification.NotificationMode
dev.caskeleton.application.notification.NotificationOperationsSnapshot
dev.caskeleton.application.notification.NotificationOperationsSnapshotPort
dev.caskeleton.application.notification.NotificationOperationsSnapshotQuery
dev.caskeleton.application.notification.NotificationOperationsSnapshotUseCase
dev.caskeleton.application.notification.NotificationPlanPort
dev.caskeleton.application.notification.NotificationPlanningResult
dev.caskeleton.application.notification.NotificationPort
dev.caskeleton.application.notification.NotificationProviderAttemptPort
dev.caskeleton.application.notification.NotificationProviderCapabilityDescriptor
dev.caskeleton.application.notification.NotificationReasonCode
dev.caskeleton.application.notification.NotificationReceiptEventId
dev.caskeleton.application.notification.NotificationReceiptFact
dev.caskeleton.application.notification.NotificationReceiptIngressCapabilityDescriptor
dev.caskeleton.application.notification.NotificationReceiptProjection
dev.caskeleton.application.notification.NotificationReceiptStorePort
dev.caskeleton.application.notification.NotificationRecipientReference
dev.caskeleton.application.notification.NotificationReconciliationPort
dev.caskeleton.application.notification.NotificationRequestResult
dev.caskeleton.application.notification.NotificationRouteId
dev.caskeleton.application.notification.NotificationRouteStrategy
dev.caskeleton.application.notification.NotificationSignedEvidenceHeader
dev.caskeleton.application.notification.NotificationStoreCapabilityDescriptor
dev.caskeleton.application.notification.NotificationTechnicalSuppressionPort
dev.caskeleton.application.notification.NotificationTemplateParameters
dev.caskeleton.application.notification.NotificationTemplateRef
dev.caskeleton.application.notification.NotificationTemplateValue
dev.caskeleton.application.notification.NotificationWriterCutoverPort
dev.caskeleton.application.notification.NotificationWriterInventoryEvidence
dev.caskeleton.application.notification.NotificationWriterInventoryEvidenceVerifierPort
dev.caskeleton.application.notification.NotificationWriterOwnership
dev.caskeleton.application.notification.NotificationWriterQuiescenceAttestationPort
dev.caskeleton.application.notification.NotificationWriterRouteSet
dev.caskeleton.application.notification.ProviderAttemptOutcome
dev.caskeleton.application.notification.ReconcileNotificationDeliveriesCommand
dev.caskeleton.application.notification.ReconcileNotificationDeliveriesResult
dev.caskeleton.application.notification.ReconcileNotificationDeliveriesUseCase
dev.caskeleton.application.notification.RecordNotificationWriterQuiescenceAttestationCommand
dev.caskeleton.application.notification.RecordNotificationWriterQuiescenceAttestationOperation
dev.caskeleton.application.notification.RecordNotificationWriterQuiescenceAttestationResult
dev.caskeleton.application.notification.RecordNotificationWriterQuiescenceAttestationUseCase
dev.caskeleton.application.notification.RetryDisposition
dev.caskeleton.application.notification.SignedNotificationWriterInventoryManifest
dev.caskeleton.application.notification.SignedNotificationWriterQuiescenceManifest
dev.caskeleton.application.notification.SlackAudienceReference
dev.caskeleton.application.notification.SubmissionCertainty
dev.caskeleton.application.notification.SwitchNotificationWriterOwnershipCommand
dev.caskeleton.application.notification.SwitchNotificationWriterOwnershipOperation
dev.caskeleton.application.notification.SwitchNotificationWriterOwnershipResult
dev.caskeleton.application.notification.SwitchNotificationWriterOwnershipUseCase
dev.caskeleton.application.notification.TargetAttemptOutcome
dev.caskeleton.application.notification.TerminalizeExpiredNotificationWriterPermitsCommand
dev.caskeleton.application.notification.TerminalizeExpiredNotificationWriterPermitsOperation
dev.caskeleton.application.notification.TerminalizeExpiredNotificationWriterPermitsResult
dev.caskeleton.application.notification.TerminalizeExpiredNotificationWriterPermitsUseCase
dev.caskeleton.application.notification.platform.admin.AdminAccessDeniedException
dev.caskeleton.application.notification.platform.admin.AdminActor
dev.caskeleton.application.notification.platform.admin.AdminAuthorizationGuard
dev.caskeleton.application.notification.platform.admin.AdminOperationClaim
dev.caskeleton.application.notification.platform.admin.AdminOperationResult
dev.caskeleton.application.notification.platform.admin.AdminOperationStorePort
dev.caskeleton.application.notification.platform.admin.DuplicateRiskApprovalRequiredException
dev.caskeleton.application.notification.platform.admin.DuplicateRiskGuard
dev.caskeleton.application.notification.platform.admin.NotificationAdminApplicationService
dev.caskeleton.application.notification.platform.admin.NotificationAdminAuthority
dev.caskeleton.application.notification.platform.admin.NotificationAdminService
dev.caskeleton.application.notification.platform.admin.ProviderRuntimeControlPort
dev.caskeleton.application.notification.platform.admin.ReconcileCommand
dev.caskeleton.application.notification.platform.admin.RedriveCommand
dev.caskeleton.application.notification.platform.admin.SetProviderStateCommand
dev.caskeleton.application.notification.platform.admin.SuppressCommand
dev.caskeleton.application.notification.platform.api.CallbackIngestionResult
dev.caskeleton.application.notification.platform.api.CallbackRequest
dev.caskeleton.application.notification.platform.api.CancelCommand
dev.caskeleton.application.notification.platform.api.CancelResult
dev.caskeleton.application.notification.platform.api.ChannelPreferenceOverride
dev.caskeleton.application.notification.platform.api.CollapseScope
dev.caskeleton.application.notification.platform.api.CollapseSpec
dev.caskeleton.application.notification.platform.api.ContactPointId
dev.caskeleton.application.notification.platform.api.ContactPointSelector
dev.caskeleton.application.notification.platform.api.CorrelationId
dev.caskeleton.application.notification.platform.api.DeduplicationAction
dev.caskeleton.application.notification.platform.api.DeduplicationSpec
dev.caskeleton.application.notification.platform.api.DeliveryAttemptId
dev.caskeleton.application.notification.platform.api.EncodedNotificationPlan
dev.caskeleton.application.notification.platform.api.IdempotencyKey
dev.caskeleton.application.notification.platform.api.NotificationAcceptance
dev.caskeleton.application.notification.platform.api.NotificationId
dev.caskeleton.application.notification.platform.api.NotificationOrchestrator
dev.caskeleton.application.notification.platform.api.NotificationPlan
dev.caskeleton.application.notification.platform.api.NotificationReceipt
dev.caskeleton.application.notification.platform.api.NotificationSnapshot
dev.caskeleton.application.notification.platform.api.NotificationVariable
dev.caskeleton.application.notification.platform.api.ProviderEventId
dev.caskeleton.application.notification.platform.api.ProviderId
dev.caskeleton.application.notification.platform.api.ProviderProfileId
dev.caskeleton.application.notification.platform.api.RecipientDeliveryId
dev.caskeleton.application.notification.platform.api.RecipientSpec
dev.caskeleton.application.notification.platform.api.RequestStatus
dev.caskeleton.application.notification.platform.api.TemplateSelection
dev.caskeleton.application.notification.platform.api.TenantId
dev.caskeleton.application.notification.platform.api.content.AttachmentDisposition
dev.caskeleton.application.notification.platform.api.content.AttachmentRef
dev.caskeleton.application.notification.platform.api.content.EmailContent
dev.caskeleton.application.notification.platform.api.content.EmailOptions
dev.caskeleton.application.notification.platform.api.content.InAppAction
dev.caskeleton.application.notification.platform.api.content.InAppContent
dev.caskeleton.application.notification.platform.api.content.MobilePushContent
dev.caskeleton.application.notification.platform.api.content.NotificationContent
dev.caskeleton.application.notification.platform.api.content.PushPresentation
dev.caskeleton.application.notification.platform.api.content.SmsContent
dev.caskeleton.application.notification.platform.api.content.SmsOptions
dev.caskeleton.application.notification.platform.api.content.WebPushContent
dev.caskeleton.application.notification.platform.api.content.WebPushOptions
dev.caskeleton.application.notification.platform.api.delivery.AttemptConfirmation
dev.caskeleton.application.notification.platform.api.delivery.DeliveryOutcome
dev.caskeleton.application.notification.platform.api.delivery.EvidenceLevel
dev.caskeleton.application.notification.platform.api.delivery.RecipientDeliveryState
dev.caskeleton.application.notification.platform.api.delivery.SubmissionOutcome
dev.caskeleton.application.notification.platform.api.error.AmbiguousSubmissionException
dev.caskeleton.application.notification.platform.api.error.AttachmentIntegrityException
dev.caskeleton.application.notification.platform.api.error.AttachmentUnavailableException
dev.caskeleton.application.notification.platform.api.error.CallbackProjectionException
dev.caskeleton.application.notification.platform.api.error.CallbackValidationException
dev.caskeleton.application.notification.platform.api.error.FailureCategory
dev.caskeleton.application.notification.platform.api.error.IdempotencyConflictException
dev.caskeleton.application.notification.platform.api.error.InvalidContactPointException
dev.caskeleton.application.notification.platform.api.error.NotificationCapacityException
dev.caskeleton.application.notification.platform.api.error.NotificationException
dev.caskeleton.application.notification.platform.api.error.NotificationExpiredException
dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode
dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor
dev.caskeleton.application.notification.platform.api.error.NotificationSuppressedException
dev.caskeleton.application.notification.platform.api.error.NotificationValidationException
dev.caskeleton.application.notification.platform.api.error.ProviderAuthenticationException
dev.caskeleton.application.notification.platform.api.error.ProviderAuthorizationException
dev.caskeleton.application.notification.platform.api.error.ProviderConfigurationException
dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException
dev.caskeleton.application.notification.platform.api.error.ProviderPermanentException
dev.caskeleton.application.notification.platform.api.error.ProviderRejectedException
dev.caskeleton.application.notification.platform.api.error.ProviderThrottledException
dev.caskeleton.application.notification.platform.api.error.ProviderTransientException
dev.caskeleton.application.notification.platform.api.error.ProviderUnavailableException
dev.caskeleton.application.notification.platform.api.error.ReconciliationException
dev.caskeleton.application.notification.platform.api.error.TemplateNotFoundException
dev.caskeleton.application.notification.platform.api.error.TemplateRenderingException
dev.caskeleton.application.notification.platform.api.error.TemplateVariableValidationException
dev.caskeleton.application.notification.platform.api.routing.Channel
dev.caskeleton.application.notification.platform.api.routing.DeliveryStrategy
dev.caskeleton.application.notification.platform.api.routing.ExplicitChannel
dev.caskeleton.application.notification.platform.api.routing.OrderedFallback
dev.caskeleton.application.notification.platform.callback.AppendEventResult
dev.caskeleton.application.notification.platform.callback.CallbackLimits
dev.caskeleton.application.notification.platform.callback.CallbackPayloadProtectionPort
dev.caskeleton.application.notification.platform.callback.CallbackVerificationResult
dev.caskeleton.application.notification.platform.callback.DeliveryAttemptResolverPort
dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot
dev.caskeleton.application.notification.platform.callback.DeliveryProjection
dev.caskeleton.application.notification.platform.callback.DeliveryProjectionStorePort
dev.caskeleton.application.notification.platform.callback.EngagementFacts
dev.caskeleton.application.notification.platform.callback.IngestProviderCallbackApplicationUseCase
dev.caskeleton.application.notification.platform.callback.NormalizedEventType
dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent
dev.caskeleton.application.notification.platform.callback.NotificationSideEffectPort
dev.caskeleton.application.notification.platform.callback.ProjectionResult
dev.caskeleton.application.notification.platform.callback.ProjectionStatus
dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapter
dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapterRegistry
dev.caskeleton.application.notification.platform.callback.ProviderEventLedger
dev.caskeleton.application.notification.platform.callback.ProviderEventProjectionService
dev.caskeleton.application.notification.platform.callback.ProviderEventProjector
dev.caskeleton.application.notification.platform.callback.ProviderEventProjectorRegistry
dev.caskeleton.application.notification.platform.callback.ProviderEventRecord
dev.caskeleton.application.notification.platform.callback.ProviderEventRecordId
dev.caskeleton.application.notification.platform.callback.ProviderEventSource
dev.caskeleton.application.notification.platform.callback.StandardDeliveryProjector
dev.caskeleton.application.notification.platform.callback.SuppressionFacts
dev.caskeleton.application.notification.platform.callback.VerifiedCallback
dev.caskeleton.application.notification.platform.callback.VerifiedProviderEvent
dev.caskeleton.application.notification.platform.contact.ApnsDeviceToken
dev.caskeleton.application.notification.platform.contact.ApnsEnvironment
dev.caskeleton.application.notification.platform.contact.ContactPointStatus
dev.caskeleton.application.notification.platform.contact.ContactPointType
dev.caskeleton.application.notification.platform.contact.ContactPointValue
dev.caskeleton.application.notification.platform.contact.EmailAddress
dev.caskeleton.application.notification.platform.contact.FcmInstallationId
dev.caskeleton.application.notification.platform.contact.InAppRecipientRef
dev.caskeleton.application.notification.platform.contact.LegacyFcmRegistrationToken
dev.caskeleton.application.notification.platform.contact.MobilePushTarget
dev.caskeleton.application.notification.platform.contact.PhoneNumber
dev.caskeleton.application.notification.platform.contact.WebPushSubscriptionValue
dev.caskeleton.application.notification.platform.dispatch.ApplicationReceiptServiceImpl
dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard
dev.caskeleton.application.notification.platform.dispatch.CancelNotificationApplicationUseCase
dev.caskeleton.application.notification.platform.dispatch.CanonicalNotificationPlanEncoder
dev.caskeleton.application.notification.platform.dispatch.CanonicalNotificationPlanWriter
dev.caskeleton.application.notification.platform.dispatch.ContactPointRecord
dev.caskeleton.application.notification.platform.dispatch.ContactPointStorePort
dev.caskeleton.application.notification.platform.dispatch.DeliveryAttemptFactory
dev.caskeleton.application.notification.platform.dispatch.DeliveryAttemptRecord
dev.caskeleton.application.notification.platform.dispatch.DeliveryAttemptStorePort
dev.caskeleton.application.notification.platform.dispatch.DispatchGuardOutcome
dev.caskeleton.application.notification.platform.dispatch.DispatchOutcomeRecorder
dev.caskeleton.application.notification.platform.dispatch.DispatchPipeline
dev.caskeleton.application.notification.platform.dispatch.DuplicateIdempotencyKeyException
dev.caskeleton.application.notification.platform.dispatch.GetNotificationApplicationUseCase
dev.caskeleton.application.notification.platform.dispatch.MessageDigestPort
dev.caskeleton.application.notification.platform.dispatch.NotificationDispatchService
dev.caskeleton.application.notification.platform.dispatch.NotificationIdGeneratorPort
dev.caskeleton.application.notification.platform.dispatch.NotificationRequestInsertOutcome
dev.caskeleton.application.notification.platform.dispatch.NotificationRequestRecord
dev.caskeleton.application.notification.platform.dispatch.NotificationRequestStatusPolicy
dev.caskeleton.application.notification.platform.dispatch.NotificationRequestStorePort
dev.caskeleton.application.notification.platform.dispatch.NotificationRoutePlannerPort
dev.caskeleton.application.notification.platform.dispatch.NotificationRoutingPlanCodecPort
dev.caskeleton.application.notification.platform.dispatch.NotificationSubmissionService
dev.caskeleton.application.notification.platform.dispatch.NotificationVariablesCodecPort
dev.caskeleton.application.notification.platform.dispatch.PolicyRoutePlanner
dev.caskeleton.application.notification.platform.dispatch.ProviderDispatchGatewayPort
dev.caskeleton.application.notification.platform.dispatch.ProviderProfileCatalogPort
dev.caskeleton.application.notification.platform.dispatch.ProviderRequestIdHasherPort
dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryRecord
dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryStorePort
dev.caskeleton.application.notification.platform.dispatch.RecipientLease
dev.caskeleton.application.notification.platform.dispatch.RecipientLeaseStorePort
dev.caskeleton.application.notification.platform.dispatch.ReconciliationGatewayPort
dev.caskeleton.application.notification.platform.dispatch.ReconciliationJob
dev.caskeleton.application.notification.platform.dispatch.ReconciliationJobStorePort
dev.caskeleton.application.notification.platform.dispatch.ReconciliationService
dev.caskeleton.application.notification.platform.dispatch.RequestFingerprint
dev.caskeleton.application.notification.platform.dispatch.ScheduleNotificationApplicationUseCase
dev.caskeleton.application.notification.platform.dispatch.SubmitNotificationApplicationUseCase
dev.caskeleton.application.notification.platform.dispatch.SyntheticEventFingerprint
dev.caskeleton.application.notification.platform.dispatch.TemplateRendererRegistry
dev.caskeleton.application.notification.platform.dispatch.TenantContextPort
dev.caskeleton.application.notification.platform.email.EmailNotification
dev.caskeleton.application.notification.platform.email.EmailNotifier
dev.caskeleton.application.notification.platform.inbox.CreateInboxItemCommand
dev.caskeleton.application.notification.platform.inbox.InboxContentCodecPort
dev.caskeleton.application.notification.platform.inbox.InboxCursor
dev.caskeleton.application.notification.platform.inbox.InboxItem
dev.caskeleton.application.notification.platform.inbox.InboxItemCreated
dev.caskeleton.application.notification.platform.inbox.InboxItemId
dev.caskeleton.application.notification.platform.inbox.InboxItemState
dev.caskeleton.application.notification.platform.inbox.InboxMutationResult
dev.caskeleton.application.notification.platform.inbox.InboxPage
dev.caskeleton.application.notification.platform.inbox.InboxPrincipal
dev.caskeleton.application.notification.platform.inbox.InboxQuery
dev.caskeleton.application.notification.platform.inbox.MarkAllReadCommand
dev.caskeleton.application.notification.platform.inbox.NotificationInbox
dev.caskeleton.application.notification.platform.inbox.NotificationInboxSignalPort
dev.caskeleton.application.notification.platform.observation.CardinalityGuard
dev.caskeleton.application.notification.platform.observation.IllegalMetricTagException
dev.caskeleton.application.notification.platform.observation.NotificationAuditEvent
dev.caskeleton.application.notification.platform.observation.NotificationAuditPort
dev.caskeleton.application.notification.platform.observation.NotificationMetricName
dev.caskeleton.application.notification.platform.observation.NotificationMetricsPort
dev.caskeleton.application.notification.platform.observation.NotificationSecurityAuditPort
dev.caskeleton.application.notification.platform.observation.NotificationServingState
dev.caskeleton.application.notification.platform.observation.NotificationServingStatePort
dev.caskeleton.application.notification.platform.observation.SensitiveValueDetector
dev.caskeleton.application.notification.platform.policy.CompositeNotificationEligibilityPolicy
dev.caskeleton.application.notification.platform.policy.ConsentRecord
dev.caskeleton.application.notification.platform.policy.ConsentStorePort
dev.caskeleton.application.notification.platform.policy.DeduplicationResult
dev.caskeleton.application.notification.platform.policy.DeduplicationService
dev.caskeleton.application.notification.platform.policy.DeduplicationStorePort
dev.caskeleton.application.notification.platform.policy.DefaultNotificationRetryPolicy
dev.caskeleton.application.notification.platform.policy.EligibilityResult
dev.caskeleton.application.notification.platform.policy.JitterSource
dev.caskeleton.application.notification.platform.policy.NotificationContext
dev.caskeleton.application.notification.platform.policy.NotificationEligibilityPolicy
dev.caskeleton.application.notification.platform.policy.NotificationRetryPolicy
dev.caskeleton.application.notification.platform.policy.PreferenceRecord
dev.caskeleton.application.notification.platform.policy.PreferenceStorePort
dev.caskeleton.application.notification.platform.policy.RecipientIdentity
dev.caskeleton.application.notification.platform.policy.RetryBackoff
dev.caskeleton.application.notification.platform.policy.RetryBudget
dev.caskeleton.application.notification.platform.policy.RetryContext
dev.caskeleton.application.notification.platform.policy.RetryDecision
dev.caskeleton.application.notification.platform.policy.RouteCandidate
dev.caskeleton.application.notification.platform.policy.RoutingContext
dev.caskeleton.application.notification.platform.policy.RoutingDecision
dev.caskeleton.application.notification.platform.policy.RoutingDecisionEngine
dev.caskeleton.application.notification.platform.policy.SuppressionEntry
dev.caskeleton.application.notification.platform.policy.SuppressionId
dev.caskeleton.application.notification.platform.policy.SuppressionReason
dev.caskeleton.application.notification.platform.policy.SuppressionScope
dev.caskeleton.application.notification.platform.policy.SuppressionSource
dev.caskeleton.application.notification.platform.policy.SuppressionStorePort
dev.caskeleton.application.notification.platform.port.in.CancelNotificationCommand
dev.caskeleton.application.notification.platform.port.in.CancelNotificationUseCase
dev.caskeleton.application.notification.platform.port.in.GetNotificationQuery
dev.caskeleton.application.notification.platform.port.in.GetNotificationUseCase
dev.caskeleton.application.notification.platform.port.in.IngestProviderCallbackCommand
dev.caskeleton.application.notification.platform.port.in.IngestProviderCallbackUseCase
dev.caskeleton.application.notification.platform.port.in.ScheduleNotificationCommand
dev.caskeleton.application.notification.platform.port.in.ScheduleNotificationUseCase
dev.caskeleton.application.notification.platform.port.in.SubmitNotificationCommand
dev.caskeleton.application.notification.platform.port.in.SubmitNotificationUseCase
dev.caskeleton.application.notification.platform.provider.AttachmentAccessContext
dev.caskeleton.application.notification.platform.provider.AttachmentResolver
dev.caskeleton.application.notification.platform.provider.BatchNotificationProviderAdapter
dev.caskeleton.application.notification.platform.provider.CollapseCapability
dev.caskeleton.application.notification.platform.provider.EvidenceCertainty
dev.caskeleton.application.notification.platform.provider.EvidenceFact
dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter
dev.caskeleton.application.notification.platform.provider.ProviderCallNotStartedException
dev.caskeleton.application.notification.platform.provider.ProviderCapabilities
dev.caskeleton.application.notification.platform.provider.ProviderCollapseMapping
dev.caskeleton.application.notification.platform.provider.ProviderExecutionEvidence
dev.caskeleton.application.notification.platform.provider.ProviderFailure
dev.caskeleton.application.notification.platform.provider.ProviderProfileSnapshot
dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState
dev.caskeleton.application.notification.platform.provider.ProviderSubmission
dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult
dev.caskeleton.application.notification.platform.provider.ReconciliationCapability
dev.caskeleton.application.notification.platform.provider.ReconciliationResult
dev.caskeleton.application.notification.platform.provider.ResolvedAttachment
dev.caskeleton.application.notification.platform.provider.TraceContext
dev.caskeleton.application.notification.platform.push.ApplicationIdentity
dev.caskeleton.application.notification.platform.push.ApplicationReceipt
dev.caskeleton.application.notification.platform.push.ApplicationReceiptService
dev.caskeleton.application.notification.platform.push.MobilePushNotification
dev.caskeleton.application.notification.platform.push.MobilePushNotifier
dev.caskeleton.application.notification.platform.push.ReceiptAuthorizationException
dev.caskeleton.application.notification.platform.push.ReceiptKind
dev.caskeleton.application.notification.platform.push.ReceiptResult
dev.caskeleton.application.notification.platform.security.AccessContext
dev.caskeleton.application.notification.platform.security.ContactPointProtector
dev.caskeleton.application.notification.platform.security.NotificationRedactor
dev.caskeleton.application.notification.platform.security.ProtectedContactPoint
dev.caskeleton.application.notification.platform.security.SafeDiagnosticContext
dev.caskeleton.application.notification.platform.security.SecretKeyMaterial
dev.caskeleton.application.notification.platform.security.SecretMaterialProvider
dev.caskeleton.application.notification.platform.security.SecretPurpose
dev.caskeleton.application.notification.platform.security.SensitiveValueClassifier
dev.caskeleton.application.notification.platform.security.SensitiveValueKind
dev.caskeleton.application.notification.platform.security.UnsafeDiagnosticFieldException
dev.caskeleton.application.notification.platform.sms.E164PhoneNumberParser
dev.caskeleton.application.notification.platform.sms.GsmAlphabet
dev.caskeleton.application.notification.platform.sms.InvalidPhoneNumberException
dev.caskeleton.application.notification.platform.sms.SmsEncoding
dev.caskeleton.application.notification.platform.sms.SmsEstimate
dev.caskeleton.application.notification.platform.sms.SmsNotification
dev.caskeleton.application.notification.platform.sms.SmsNotifier
dev.caskeleton.application.notification.platform.sms.SmsSegmentEstimator
dev.caskeleton.application.notification.platform.template.NotificationTemplateRenderer
dev.caskeleton.application.notification.platform.template.NotificationTemplateVersion
dev.caskeleton.application.notification.platform.template.RenderCommand
dev.caskeleton.application.notification.platform.template.RenderedNotificationContent
dev.caskeleton.application.notification.platform.template.TemplateContentCodecPort
dev.caskeleton.application.notification.platform.template.TemplateContentDefinition
dev.caskeleton.application.notification.platform.template.TemplateRegistry
dev.caskeleton.application.notification.platform.template.TemplateSlot
dev.caskeleton.application.notification.platform.template.TemplateStatus
dev.caskeleton.application.notification.platform.template.TemplateVariableValidator
dev.caskeleton.application.notification.platform.template.TemplateVersionConflictException
dev.caskeleton.application.notification.platform.template.VariableSchema
dev.caskeleton.application.notification.platform.webpush.WebPushNotification
dev.caskeleton.application.notification.platform.webpush.WebPushNotifier
+70 -12
View File
@@ -1,23 +1,73 @@
# Configuration reference
The notification delivery platform binds under `ca-skeleton.notification.platform`. The tree lives in
`src/app-bootstrap/src/main/resources/application.yml`, disabled by default, and every value carries
an inline default so a deployment that leaves the platform off supplies nothing.
Until 2026-08-15 this page named properties the binding did not have — `max-retry-concurrency`,
`scheduler-poll-interval`, `callback-worker-concurrency` — and omitted three it did. There was no
tree in `application.yml` at all, so the only way to configure the platform was to guess environment
variable names from Boot's relaxed binding. `./gradlew verifyNotificationConfiguration` now fails
when this page, the YAML tree and `docs/registries/env-keys.yaml` disagree.
## Master switch
| Property | Environment variable | Default | Meaning |
|---|---|---|---|
| `enabled` | `APP_NOTIFICATION_PLATFORM_ENABLED` | `false` | Binds nothing at all while false: no runtime, no schema check, no scheduler thread, no secret required |
| `mode` | `APP_NOTIFICATION_PLATFORM_MODE` | `SERVING` | `SERVING` refuses to start without a working provider; `ACCEPT_ONLY` stores requests and does not dispatch |
## Dispatch
| Property | Meaning | Bound |
|---|---|---|
| `claim-batch-size` | Rows claimed per scheduler tick | 1..1000 |
| `lease-duration` | How long a claimed job stays owned | positive, finite |
| `max-global-concurrency` | Ceiling across all providers | positive |
| `max-queue-age` | Age at which a job is escalated | positive |
| `max-retry-concurrency` | Ceiling for retry work | positive |
| `scheduler-poll-interval` | Queue poll cadence | positive |
| `callback-worker-concurrency` | Callback projection workers | positive |
| Property | Environment variable | Default | Bound |
|---|---|---|---|
| `dispatch.claim-batch-size` | `APP_NOTIFICATION_PLATFORM_CLAIM_BATCH_SIZE` | `50` | 1..1000 |
| `dispatch.lease-duration` | `APP_NOTIFICATION_PLATFORM_LEASE_DURATION` | `2m` | positive, finite |
| `dispatch.poll-interval` | `APP_NOTIFICATION_PLATFORM_POLL_INTERVAL` | `1s` | positive, finite |
| `dispatch.max-global-concurrency` | `APP_NOTIFICATION_PLATFORM_MAX_CONCURRENCY` | `64` | positive |
| `dispatch.max-additional-attempts` | `APP_NOTIFICATION_PLATFORM_MAX_ADDITIONAL_ATTEMPTS` | `4` | non-negative |
| `dispatch.max-queue-age` | `APP_NOTIFICATION_PLATFORM_MAX_QUEUE_AGE` | `24h` | positive |
| `dispatch.allow-ambiguous-fallback` | `APP_NOTIFICATION_PLATFORM_ALLOW_AMBIGUOUS_FALLBACK` | `false` | boolean |
Every value is bounded. "Unlimited" is not an accepted configuration.
The lease must outlast a provider call plus its timeout. Below that, a delivery a live worker is
still waiting on gets claimed by a second worker, and the recipient receives the notification twice.
## Callbacks
| Property | Environment variable | Default | Bound |
|---|---|---|---|
| `callbacks.enabled` | `APP_NOTIFICATION_PLATFORM_CALLBACKS_ENABLED` | `false` | boolean |
| `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 |
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
`CHECK` constraint after the callback had already been acknowledged to the provider.
## Provider profiles
A profile pins provider type, environment, credential profile, timeouts, concurrency, rate limit,
retry policy and callback profile. Sender identity and credential profile are separate concerns.
Profiles are a map under `providers`, keyed by profile id. There are no environment variables for
them, because the keys are deployment-chosen; supply them as YAML or as
`CA_SKELETON_NOTIFICATION_PLATFORM_PROVIDERS_<ID>_<FIELD>`.
| Field | Meaning |
|---|---|
| `type` | `APNS`, `FCM`, `SES`, `SMTP`, `TWILIO`, `WEB_PUSH`, `WEBHOOK` — a closed enum, so an unknown value fails binding rather than assembling into nothing |
| `enabled` | A disabled profile is bound and validated but contributes no runtime |
| `primary-for-channel` | Exactly one primary per channel |
| `environment` | Required when enabled |
| `credential-profile` | Resolved through `SecretMaterialProvider`; never an inline secret |
| `topic` | APNs bundle id |
| `vapid-public-key` | Web Push application server key |
| `callback-signing-secret-ref` | Reference, not material |
| `timeout` | Positive and finite |
| `max-concurrency` | Positive |
| `rate-per-second` | Positive |
A profile pins provider type, environment, credential profile, timeouts, concurrency and rate limit.
Sender identity and credential profile are separate concerns.
## Startup failures
@@ -32,10 +82,18 @@ Startup fails rather than degrading when:
- a Web Push profile is missing its VAPID key
- two provider profiles share an id
- a route points only at disabled providers
- ambiguous fallback is enabled by default
- `mode` is `SERVING` and no provider profile is enabled
- the notification schema stream is not applied and promoted
## Secrets
All key material arrives through `SecretMaterialProvider`. Nothing is read from source, from a
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.
## Readiness
The platform contributes a `notifications` actuator endpoint and a health indicator. It reports DOWN
when a provider's credentials were rejected, when a configured provider has no channel route, and
when the measured backlog, stuck-lease count, projection lag or reconciliation lag passes the
thresholds in `NotificationServingThresholds`. See [operations.md](operations.md).
+77
View File
@@ -0,0 +1,77 @@
{
"$comment": [
"NTF-024 — what each support grade requires, as executable artifacts rather than prose.",
"A grade in support-matrix.md is a promise about production behaviour. The workflow that was",
"supposed to back those promises ran a unit subset on PR, a job named 'restart recovery' that",
"ran no restart, and a provider sandbox job whose entire body was two echo statements behind",
"continue-on-error. So the strongest claim in the document rested on the weakest evidence in",
"the pipeline, and nothing connected the two.",
"verifyNotificationEvidence reads this file, checks that every claim marked satisfied names",
"test classes that exist, and refuses a grade whose claims are not all satisfied."
],
"claims": {
"durable": {
"requires": "PostgreSQL migration, CRUD against the real schema, and survival of a restart",
"status": "satisfied",
"lane": ":adapter:outbound:persistence-jpa:jpaPlatformContractTest",
"evidence": [
"adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/RecipientClaimContractTest.java",
"adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/ProjectionFactDurabilityContractTest.java",
"adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/ServingStateContractTest.java"
]
},
"multi-worker-safe": {
"requires": "two workers racing the same claim against a real database, with lease fencing",
"status": "satisfied",
"lane": ":adapter:outbound:persistence-jpa:jpaPlatformContractTest",
"evidence": [
"adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/RecipientClaimContractTest.java",
"adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlRecipientLeaseFencingIntegrationTest.java"
]
},
"callback-supported": {
"requires": "signature verification, duplicate suppression, and an event that arrives before its attempt is stored",
"status": "satisfied",
"lane": ":adapter:outbound:persistence-jpa:jpaPlatformContractTest",
"evidence": [
"adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/LateEventBindingContractTest.java",
"application-core/src/test/java/dev/caskeleton/application/notification/platform/callback/CallbackIngestionAtomicityTest.java"
]
},
"recoverable": {
"requires": "a process-kill matrix covering each dispatch phase, proving no delivery is lost or duplicated",
"status": "satisfied",
"lane": ":adapter:outbound:persistence-jpa:jpaPlatformContractTest",
"evidence": [
"adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/WorkerCrashRecoveryContractTest.java"
]
},
"provider-wire-qualified": {
"requires": "a real provider sandbox call producing an immutable, uploaded evidence artifact with a correlation id",
"status": "unsatisfied",
"lane": null,
"evidence": [],
"gap": "The provider-sandbox job runs two echo statements behind continue-on-error. No request has ever left the process, so no provider protocol is qualified against its real endpoint."
}
},
"grades": {
"Stable": [
"durable",
"multi-worker-safe",
"callback-supported",
"recoverable",
"provider-wire-qualified"
],
"Contract implemented / runtime unqualified": [
"durable",
"multi-worker-safe",
"callback-supported"
],
"Optional stable": [
"durable"
],
"Extension": [],
"Experimental": []
},
"matrixDocument": "docs/notification/support-matrix.md"
}
+13
View File
@@ -29,3 +29,16 @@ platform exists to avoid.
Registration tokens keep working through `LegacyFcmRegistrationToken`. New registrations should use
`FcmInstallationId`. The two are distinct types, so a migration is a compile-time task rather than a
runtime guess.
## R1의 처분 (NOTIF-ADR-005)
이 문서는 R0 router → platform 이행만 설명해 왔고, `dev.caskeleton.application.notification` 직속의
R1 public type 100개를 어떻게 할 것인지 다루지 않았다. 그래서 새 consumer가 어느 API를 써야 하는지
문서 어디에도 답이 없었다.
- **canonical은 `..notification.platform..`이다.** NOTIF-ADR-005가 근거와 함께 정한다.
- **R1은 남지만 새 production consumer를 받지 않는다.** 삭제 계획은 별개이며, R0 삭제와 함께
사라지지 않는다.
- **전수 분류표는 `docs/notification/module-mapping.md`에 있다** (replace / bridge / retain / delete).
- **두 namespace 간 production dependency는 0건이며 ArchUnit이 강제한다.** 변환이 필요하면
`dev.caskeleton.application.notification.compatibility.r1` 한 곳에만 둔다.
+22
View File
@@ -92,3 +92,25 @@ Two plan edges cannot be expressed as project edges in this repository, and are
`AGENTS.md` pins commit policy to `human-only`. Step 5 (`git add` / `git commit`) of every plan task
is therefore intentionally **not** executed by the agent; the working tree carries the change and the
human owner commits.
## R1 public type disposition (NOTIF-ADR-005)
NOTIF-ADR-005 makes `..notification.platform..` canonical and keeps the R1 namespace in place
without new consumers. The ADR owns the rule; this table owns the list, so the two do not drift
apart by being written twice.
`NOTIFICATION_R1_AND_PLATFORM_DO_NOT_DEPEND_ON_EACH_OTHER` in `CleanArchitectureTest` enforces the
boundary: production dependencies between the two namespaces are zero, and the only permitted
exception is `dev.caskeleton.application.notification.compatibility.r1`.
| Disposition | Meaning | Types |
| --- | --- | --- |
| `replace` (27) | the platform has an equivalent; new consumers use it | `ApplyNotificationReceiptCommand`, `ApplyNotificationReceiptResult`, `ApplyNotificationReceiptUseCase`, `Channel`, `InlineNotificationAttemptPort`, `NormalizedNotificationReceiptCommand`, `NotificationAttemptId`, `NotificationDeliveryId`, `NotificationDeliveryStorePort`, `NotificationDispatchCommand`, `NotificationDispatchResult`, `NotificationDispatchUseCase`, `NotificationEvidenceTrustSnapshot`, `NotificationFrozenPlan`, `NotificationPlanPort`, `NotificationPlanningResult`, `NotificationProviderAttemptPort`, `NotificationReceiptEventId`, `NotificationReceiptFact`, `NotificationReceiptIngressCapabilityDescriptor`, `NotificationReceiptProjection`, `NotificationReceiptStorePort`, `NotificationSignedEvidenceHeader`, `NotificationWriterInventoryEvidence`, `NotificationWriterInventoryEvidenceVerifierPort`, `ProviderAttemptOutcome`, `TargetAttemptOutcome` |
| `bridge` (28) | conversion needed if an R1 caller remains; conversion lives only in the ACL | `InitializeNotificationWriterFencesCommand`, `InitializeNotificationWriterFencesOperation`, `InitializeNotificationWriterFencesResult`, `InitializeNotificationWriterFencesUseCase`, `NotificationAdmissionGateCommand`, `NotificationAdmissionGateUseCase`, `NotificationAppendResult`, `NotificationLegacyWriterPermitCommand`, `NotificationLegacyWriterPermitResult`, `NotificationLegacyWriterPermitUseCase`, `NotificationMaintenanceCommand`, `NotificationMaintenanceResult`, `NotificationMaintenanceUseCase`, `NotificationOperationsSnapshot`, `NotificationOperationsSnapshotQuery`, `NotificationOperationsSnapshotUseCase`, `NotificationRequestResult`, `ReconcileNotificationDeliveriesCommand`, `ReconcileNotificationDeliveriesResult`, `ReconcileNotificationDeliveriesUseCase`, `SwitchNotificationWriterOwnershipCommand`, `SwitchNotificationWriterOwnershipOperation`, `SwitchNotificationWriterOwnershipResult`, `SwitchNotificationWriterOwnershipUseCase`, `TerminalizeExpiredNotificationWriterPermitsCommand`, `TerminalizeExpiredNotificationWriterPermitsOperation`, `TerminalizeExpiredNotificationWriterPermitsResult`, `TerminalizeExpiredNotificationWriterPermitsUseCase` |
| `retain` (45) | a concern the platform does not cover; left as it is | `ConsentCheckMode`, `EmailRecipientReference`, `Notification`, `NotificationAdmissionClass`, `NotificationAdmissionReadinessPort`, `NotificationApplicationException`, `NotificationCanonicalWriterFenceGuard`, `NotificationCanonicalWriterFencePort`, `NotificationCanonicalWriterRouteSet`, `NotificationCapabilityCompatibilityValidator`, `NotificationChannel`, `NotificationFaultScope`, `NotificationIntentAppendPort`, `NotificationIntentDraft`, `NotificationIntentId`, `NotificationKindId`, `NotificationKindPolicy`, `NotificationMaintenanceStorePort`, `NotificationMode`, `NotificationOperationsSnapshotPort`, `NotificationPort`, `NotificationProviderCapabilityDescriptor`, `NotificationReasonCode`, `NotificationRecipientReference`, `NotificationReconciliationPort`, `NotificationRouteId`, `NotificationRouteStrategy`, `NotificationStoreCapabilityDescriptor`, `NotificationTechnicalSuppressionPort`, `NotificationTemplateParameters`, `NotificationTemplateRef`, `NotificationTemplateValue`, `NotificationWriterCutoverPort`, `NotificationWriterOwnership`, `NotificationWriterQuiescenceAttestationPort`, `NotificationWriterRouteSet`, `RecordNotificationWriterQuiescenceAttestationCommand`, `RecordNotificationWriterQuiescenceAttestationOperation`, `RecordNotificationWriterQuiescenceAttestationResult`, `RecordNotificationWriterQuiescenceAttestationUseCase`, `RetryDisposition`, `SignedNotificationWriterInventoryManifest`, `SignedNotificationWriterQuiescenceManifest`, `SlackAudienceReference`, `SubmissionCertainty` |
Total: 100 public types, every one classified.
No type carries `@Deprecated(forRemoval = true)`: no removal release is fixed, and
`forRemoval` without a date is a promise the codebase cannot keep. The boundary is
enforced by the ArchUnit rule instead.
+35 -5
View File
@@ -2,18 +2,48 @@
What each channel can actually prove, and what the platform refuses to claim.
The grade column is not an opinion. `docs/notification/evidence-manifest.json` declares which claims
each grade requires and which executable artifact proves each claim, and
`./gradlew verifyNotificationEvidence` refuses a grade whose claims are not all backed by a file that
exists. Raising a grade means adding the artifact first.
Five channels read `Stable` until 2026-08-15. Nothing in the pipeline had ever sent a request to a
provider — the sandbox job's whole body was two `echo` statements behind `continue-on-error` — and
no test killed a worker mid-dispatch. The protocols are implemented and their contracts are proven
against real PostgreSQL; the wire and the crash are not. That is what the grade now says.
## Channels
| Channel | Reference implementation | Grade | Strongest evidence the platform records by default |
|---|---|---|---|
| Email | SMTP, Amazon SES API | Stable | Provider acceptance; recipient mail-server delivery, bounce and complaint when the provider publishes events |
| SMS | Twilio Programmable Messaging | Stable | `accepted`/`queued`, `sent`, and carrier-DLR `delivered`/`undelivered` |
| Mobile push (Android and cross-platform) | FCM, FID-first with legacy registration token compatibility | Stable | FCM acceptance and explicit failures |
| Mobile push (Apple) | APNs HTTP/2 provider API | Stable | APNs acceptance |
| Web Push | RFC 8030, RFC 8291, RFC 8292 | Stable | Push-service acceptance; user-agent acknowledgement only where the service offers receipts |
| Email | SMTP, Amazon SES API | Contract implemented / runtime unqualified | Provider acceptance; recipient mail-server delivery, bounce and complaint when the provider publishes events |
| SMS | Twilio Programmable Messaging | Contract implemented / runtime unqualified | `accepted`/`queued`, `sent`, and carrier-DLR `delivered`/`undelivered` |
| Mobile push (Android and cross-platform) | FCM, FID-first with legacy registration token compatibility | Contract implemented / runtime unqualified | FCM acceptance and explicit failures |
| Mobile push (Apple) | APNs HTTP/2 provider API | Contract implemented / runtime unqualified | APNs acceptance |
| Web Push | RFC 8030, RFC 8291, RFC 8292 | Contract implemented / runtime unqualified | Push-service acceptance; user-agent acknowledgement only where the service offers receipts |
| In-app inbox | Own database | Optional stable | `PERSISTED`, `SEEN`, `READ` |
| Webhook | HTTP client platform | Extension | Whatever the receiving HTTP contract states |
## What each grade requires
| Grade | Requires |
|---|---|
| Stable | durable, multi-worker-safe, callback-supported, recoverable, provider-wire-qualified |
| Contract implemented / runtime unqualified | durable, multi-worker-safe, callback-supported |
| Optional stable | durable |
| Extension | nothing; the receiving contract owns its own guarantees |
| Experimental | nothing; the grade is the warning |
`recoverable` was met on 2026-08-15 by `WorkerCrashRecoveryContractTest`, which walks the four
phases a dispatch passes through — claimed, attempt written, request started, body committed — and
asserts for each that the delivery becomes claimable again or becomes a question for the provider,
never both and never neither.
The remaining unmet claim, and what would meet it:
- **provider-wire-qualified** — a real provider sandbox call producing an immutable, uploaded
artifact with a correlation id. No request has ever left the process in CI.
## Evidence levels
`NONE``PLATFORM_QUEUED``PROVIDER_ACCEPTED``NETWORK_OR_CARRIER_ACCEPTED`
+161
View File
@@ -4244,3 +4244,164 @@ env_keys:
validation: positive_int_bounded
compatibility_impact: behavior-change
required_test: async-contract:executor-queue-bounded
# === Notification delivery platform (NTF-025 — configuration surface) ===
# The tree exists in application.yml as ca-skeleton.notification.platform, disabled by
# default. Every key carries an inline default so a deployment that leaves the platform off
# supplies nothing. Reference: docs/notification/configuration.md.
- name: APP_NOTIFICATION_PLATFORM_ENABLED
# source: NTF-025 — master switch for the notification delivery platform; false binds nothing at all
type: boolean
default: false
allowed_values: [true, false]
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: boolean
compatibility_impact: behavior-change
required_test: adapter-contract:notification-platform-disabled-safe
- name: APP_NOTIFICATION_PLATFORM_MODE
# source: NTF-025 — SERVING refuses to start without a working provider; ACCEPT_ONLY stores and does not dispatch
type: enum
default: SERVING
allowed_values: [SERVING, ACCEPT_ONLY]
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: enum_of_notification_platform_mode
compatibility_impact: behavior-change
required_test: adapter-contract:notification-platform-mode
- name: APP_NOTIFICATION_PLATFORM_CLAIM_BATCH_SIZE
# source: NTF-025 — how many recipient deliveries one scheduler pass claims; 1..1000, refused outside that at binding
type: integer
default: 50
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: integer_1_to_1000
compatibility_impact: behavior-change
required_test: adapter-contract:notification-dispatch-bounds
- name: APP_NOTIFICATION_PLATFORM_LEASE_DURATION
# source: NTF-025 — must outlast a provider call plus its timeout, or a live worker's delivery is claimed by a second one
type: duration
default: 2m
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: positive_duration
compatibility_impact: behavior-change
required_test: adapter-contract:notification-lease-fencing
- name: APP_NOTIFICATION_PLATFORM_POLL_INTERVAL
# source: NTF-025 — how often the scheduler asks for work when the last pass claimed nothing
type: duration
default: 1s
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: positive_duration
compatibility_impact: behavior-change
required_test: adapter-contract:notification-dispatch-bounds
- name: APP_NOTIFICATION_PLATFORM_MAX_CONCURRENCY
# source: NTF-025 — ceiling on in-flight provider calls across the whole process
type: integer
default: 64
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: positive_integer
compatibility_impact: behavior-change
required_test: adapter-contract:notification-dispatch-bounds
- name: APP_NOTIFICATION_PLATFORM_MAX_ADDITIONAL_ATTEMPTS
# source: NTF-025 — retries after the first attempt; 0 means one attempt and no retry
type: integer
default: 4
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: non_negative_integer
compatibility_impact: behavior-change
required_test: adapter-contract:notification-retry-policy
- name: APP_NOTIFICATION_PLATFORM_MAX_QUEUE_AGE
# source: NTF-025 — after this, a queued delivery expires rather than being sent late
type: duration
default: 24h
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: positive_duration
compatibility_impact: behavior-change
required_test: adapter-contract:notification-expiry
- name: APP_NOTIFICATION_PLATFORM_ALLOW_AMBIGUOUS_FALLBACK
# source: NTF-025 — an ambiguous attempt reached the provider with an unread outcome; falling back risks a duplicate send
type: boolean
default: false
allowed_values: [true, false]
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: boolean
compatibility_impact: behavior-change
required_test: adapter-contract:notification-ambiguity
- name: APP_NOTIFICATION_PLATFORM_CALLBACKS_ENABLED
# source: NTF-025 — whether the platform exposes provider callback endpoints
type: boolean
default: false
allowed_values: [true, false]
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: boolean
compatibility_impact: behavior-change
required_test: adapter-contract:notification-callback-ingestion
- name: APP_NOTIFICATION_PLATFORM_CALLBACK_MAX_BODY_BYTES
# source: NTF-025 — ceiling is 65508 = ciphertext column minus AES-GCM nonce and tag; larger is refused at binding
type: integer
default: 65508
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: integer_1_to_65508
compatibility_impact: behavior-change
required_test: adapter-contract:notification-callback-body-bound
- 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
type: duration
default: 5m
allowed_values: null
classification: security-relevant
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: positive_duration
compatibility_impact: behavior-change
required_test: adapter-contract:notification-callback-replay
@@ -0,0 +1,889 @@
# GraphQL 인바운드 모듈 상세 코드·아키텍처 리뷰
- 기준 일자: 2026-08-14
- 기준 Git HEAD: `ac874e49e608b35429f82aa098574b52a68f2069`
- 대상 Gradle leaf: `:adapter:inbound:graphql`
- 주 대상 경로: `src/adapter/inbound/graphql`
- 교차 확인 경로: `src/config/architecture/modules.json`, `src/gradle/graphql-platform-conventions.gradle`, `src/.gitignore`
- 판정: **CHANGES REQUIRED / 현재 컴파일 불가**
- 검토 방식: 전체 파일·import·production reference inventory, 핵심 실행 경로 정독, 세 개의 독립 병렬 리뷰, Gradle focused/architecture 검증
- 변경 범위: 이 리뷰 문서만 추가했다. production/test 코드는 수정하지 않았다.
리뷰 도중 HEAD가 `c3043e530a604315c4df341b87b5470c7617ea03`에서 위 commit으로 이동했지만,
GraphQL tree hash는 두 revision 모두 `bd8307364e2312814995e5f3bb1386cee37b1498`이고
`src/.gitignore`, GraphQL convention, architecture registry에도 delta가 없음을 확인했다.
## 1. 결론
현재 GraphQL leaf는 373개 production Java 파일과 75개 test Java 파일을 가진 큰 실행 플랫폼 후보지만,
두 층의 문제가 겹쳐 있다.
첫 번째는 즉시 고쳐야 하는 **빌드 차단**이다. `src/.gitignore`의 unanchored `build/` 규칙이 Gradle
산출물뿐 아니라 Java source package인 `...graphql.build`까지 무시한다. 문서와 production code는
`GraphQlBuildModel`, `GraphQlStableModule`, `GraphQlAdvancedModule`, `GraphQlModuleBoundaryTest`가 있다고
주장하지만 실제 checkout에는 없다. 그 결과 focused test는 test 실행 전에 `compileJava`에서 7개
오류로 실패하고, 단일 leaf 안의 Stable/Advanced 경계를 지킨다는 핵심 안전망도 함께 사라졌다.
두 번째는 더 근본적인 **런타임 진실성 문제**다. cost, authorization, DataLoader, cursor,
idempotency, observation, persisted operation, subscription 등 많은 정책과 값 객체가 구현되어 있지만,
대부분 Spring GraphQL이 실제 `/graphql` 요청을 처리하는 extension point에 연결되지 않는다. 현재 HTTP
qualification은 Spring Boot 기본 endpoint와 health controller/error resolver를 검증할 뿐, 이 플랫폼의
pipeline을 거치지 않는다. 따라서 unit test가 복구되어 green이 되더라도 “정책 객체가 맞다”는 증거와
“실제 요청에 정책이 강제된다”는 증거를 분리해야 한다.
즉시 적용할 원칙은 다음과 같다.
1. GQL-001을 단독 PR로 먼저 처리해 compile과 내부 경계 검사를 복구한다.
2. 복구 전후 모두 현재 artifact를 `runtime-ready GraphQL execution platform`으로 승격하지 않는다.
3. Spring 기본 `/graphql`을 canonical transport로 정하고, 정책을 공식 extension point에 연결한다.
4. 자체 MVC/WebFlux adapter를 실제 endpoint로 만들 계획이 없다면 제거한다. 평행 실행 경로를 두지 않는다.
5. repository 자동 노출은 Advanced라도 제거한다. application use case를 우회하는 예외를 만들지 않는다.
6. correctness/security red test를 먼저 고정한 뒤 public API와 Gradle leaf를 단계적으로 분리한다.
7. 실제 random-port request가 정책에 의해 거부되고 resolver/use case가 0회 호출됨을 promotion 증거로 삼는다.
## 2. 범위와 증거 경계
### 2.1 현재 규모
| 항목 | 현재 값 |
|---|---:|
| production Java 파일 | 373 |
| production Java LOC | 20,155 |
| test Java 파일 | 75 |
| test Java LOC | 8,424 |
| test annotation (`@Test`, `@ParameterizedTest`) | 524 |
| 최상위 production package | 21 |
| main resource | `graphql/skeleton.graphqls` 1개 |
| test resource | qualification schema 1개 |
| 외부 Spring/GraphQL/Reactor import를 가진 production Java | 19 |
최상위 package는 `advanced`, `api`, `architecture`, `autoconfigure`, `compat`, `context`, `cost`,
`dataloader`, `error`, `execution`, `fetch`, `http`, `mutation`, `observation`, `pagination`, `policy`,
`release`, `scalar`, `schema`, `security`, `testkit`이다.
373개 중 354개가 Spring/GraphQL Java/Reactor type을 직접 import하지 않는다는 점은 framework-free policy
model을 추출할 여지가 크다는 뜻이다. 동시에 거의 모든 최상위 type이 public이어서 현재 한 jar가
사실상 수백 개의 API를 노출한다.
### 2.2 검토 깊이
| Path | Status | Evidence | Extracted facts |
|---|---|---|---|
| `src/adapter/inbound/graphql/build.gradle` | READ_FULL | 1-54 | servlet runtime 의존, WebFlux compile-only, test lane 등록 |
| `src/gradle/graphql-platform-conventions.gradle` | READ_FULL | 1-100 | Stable/contract/Advanced/performance lane과 누락된 boundary model 주장 |
| `src/adapter/inbound/graphql/CLAUDE.md` | READ_FULL | 1-143 | 단일 leaf 내부 28 bounded package, runtime opt-in, 실행 범위·검증 주장 |
| `src/adapter/inbound/graphql/README.md` | READ_FULL | 1-189 | health endpoint, error mapping, 설정·경계·Advanced 설계 근거 |
| `src/config/architecture/modules.json` GraphQL record | READ_FULL | GraphQL leaf record | 허용 project edge와 빈 runtime membership |
| `src/.gitignore` | READ_FULL | 1-16 + `git check-ignore` | `build/`가 Java source package까지 무시하는 직접 원인 |
| root controller/error resolver/schema | READ_FULL | production + 대응 tests | 현재 실제 Spring GraphQL endpoint 표면 |
| `autoconfigure`, `http`, `execution`, `architecture` | READ_FULL | production 핵심 경로 + 대응 tests | auto-config 등록, 실행 연결, transport, 경계 검사 |
| `cost`, `security`, `dataloader`, `pagination`, `mutation` | READ_FULL | 핵심 policy/codec/executor + 대응 tests | 구조 제한, tenant/auth, batch, cursor, idempotency correctness |
| `schema`, `compat`, `scalar`, `error`, `observation` | READ_FULL | production 핵심 경로 + 대응 tests | schema 조립/호환, scalar, wire error, cardinality |
| `advanced/**` | READ_PARTIAL | public entry/state transition/production reference scan + 주요 tests | persisted/admin/codegen/subscription/federation/transport seam |
| `release/**`, `testkit/**` | READ_PARTIAL | public contract/lane/reference scan + suite tests | self-reported evidence와 production artifact 오염 |
| production 373개/test 75개 전체 | READ_PARTIAL | inventory/import/reference/public-surface scan | 파일·package·사용처·실행 연결의 전수 정적 탐색 |
이 문서는 28,579 LOC의 모든 method를 line-by-line 승인한 결과가 아니다. 전체 inventory와 reference scan을
바탕으로 실행 seam과 고위험 policy를 정독한 구조·correctness 리뷰다. `advanced/**`, release/testkit의
세부 알고리즘은 명시한 범위 밖에서 `UNVERIFIED`이며, 실제 adopter/runtime·load·fault evidence도 없다.
## 3. 유지할 설계
리팩터링 과정에서 다음은 보존할 가치가 있다.
- registry상 GraphQL leaf의 production project dependency가 Clean Architecture 방향을 벗어나지 않는다.
- `runtime_memberships`가 비어 있어 현재 app-bootstrap/sample runtime에 조용히 유입되지 않는다.
- 실제 `HealthGraphqlController`는 얇고 feature/domain/repository 지식이 없다.
- 실제 Spring exception resolver는 shared error code만 노출하고 raw exception message를 사용하지 않는다.
- schema compatibility를 SDL 문자열 diff가 아니라 AST로 비교하고 결과를 결정적으로 정렬한다.
- partial data map에 null을 허용하는 defensive copy를 사용한다. 이를 `Map.copyOf`로 바꾸면 안 된다.
- cursor HMAC을 `MessageDigest.isEqual`로 비교하고 query/filter에 bind하려는 방향은 맞다.
- DataLoader 결과에서 `Present`, `Missing`, `Failed`를 구분하려는 결과 algebra는 유용하다.
- document traversal은 fragment cycle과 방문 node budget을 고려한다.
- Advanced capability가 기본 비활성이고 experimental production activation을 명시적으로 거부한다.
- test lane이 빈 performance evidence를 success로 위장하지 않으려는 fail-closed 의도는 좋다.
- broad static import scan에서 Stable package가 `...graphql.advanced`를 직접 import하는 edge와
production repository/JPA/Spring Data 직접 사용은 발견되지 않았다.
## 4. 우선순위 요약
| ID | 우선순위 | 주제 | 완료 조건 |
|---|---|---|---|
| GQL-001 | P0 | ignored `build` source package 때문에 compile 및 경계 모델 소실 | 비-ignore package로 모델 복구, compile/test/boundary negative fixture 통과 |
| GQL-002 | P0 | 플랫폼 정책이 실제 `/graphql` 실행 경로에 미연결 | real interceptor/instrumentation/DataLoader/wiring E2E에서 정책 거부 증명 |
| GQL-003 | P1 | auto-configuration 등록·binding default·실제 bean 검증 불일치 | 무설정 boot, imports metadata, 실제 override bean validation 통과 |
| GQL-004 | P1 | servlet artifact가 reactive profile도 표방 | MVC/WebFlux runtime classpath와 context가 별도 leaf에서 독립 통과 |
| GQL-005 | P1 | request byte 제한 미강제와 valid null variable 거부 | decode 전 body cap, null/omitted/value E2E 통과 |
| GQL-006 | P1 | `Accept` q-value/q=0 무시 | quality/specificity 기반 negotiation contract 통과 |
| GQL-007 | P1 | named fragment introspection 우회와 variable nesting 공백 | reachable fragment/variable JSON budget 거부 E2E 통과 |
| GQL-008 | P1 | resolver 경계 검사가 generic/JAR/subpackage를 놓치고 reactive type을 오판 | actual controller graph와 recursive generic negative fixture 통과 |
| GQL-009 | P1 | Advanced repository 자동 노출이 canonical hard-stop과 충돌 | repository exposure API 제거, application handler만 허용 |
| GQL-010 | P1 | cursor framing·rotation·direction·tenant binding 결함 | versioned codec property tests와 active-key/scope rejection 통과 |
| GQL-011 | P1 | mutation fingerprint collision과 tenant 없는 idempotency scope | typed canonical serialization과 tenant/version scope 테스트 통과 |
| GQL-012 | P1 | error resolver와 category contract가 두 벌 | 하나의 mapper를 모든 Spring/transport path가 사용 |
| GQL-013 | P1 | persisted-operation admin 상태·감사·인가가 durable하지 않음 | authenticated principal, CAS state machine, atomic audit contract 통과 |
| GQL-014 | P1 | codegen이 operation을 검증하지 않고 generator도 code를 생성하지 않음 | executable document validation 또는 정직한 planner 명명 |
| GQL-015 | P1 | schema comparator가 kind/default/extension/applied directive를 놓침 | breaking matrix와 extension ownership tests 통과 |
| GQL-016 | P1 | custom DataLoader와 timeout이 실제 loader 실행을 강제하지 않음 | Spring registry 연결과 real query-count/deadline test 통과 |
| GQL-017 | P1 | MVC concurrency/context와 WebFlux blocking bridge가 안전하지 않음 | bounded admission, context propagation, event-loop nonblocking 증명 |
| GQL-018 | P1 | pipeline stage 순서가 필요한 정보와 모순 | authenticate→parse/select→authorize→cost→execute executable chain |
| GQL-019 | P1 | subscription/replay/drain lifecycle의 race와 scope 공백 | atomic state/lease, actor+tenant+subscription binding 경쟁 test 통과 |
| GQL-020 | P2 | preparsed cache expiry 미사용·global miss serialization | expiry/single-flight/parallel-key test 통과 |
| GQL-021 | P2 | cancellation hook 하나가 나머지 cleanup을 막음 | all-hooks-once + suppressed exception contract 통과 |
| GQL-022 | P2 | scalar input/output bounds와 expansion limit 불일치 | BigDecimal/Long 양방향 boundary test 통과 |
| GQL-023 | P2 | raw operation name metric cardinality와 실제 Observation 미연결 | registered-name/`other` bound와 real MeterRegistry test 통과 |
| GQL-024 | P2 | testkit/fixed secret/in-memory 구현이 main jar에 포함 | test fixtures/optional leaf 분리 및 jar surface gate 통과 |
| GQL-025 | P2 | 373개 type의 과도한 public surface와 한 leaf의 낮은 응집도 | api/spi allowlist와 6~8 capability leaf 독립 compile/test |
| GQL-026 | P2 | GraphQL context/storage SPI ownership이 dependency 방향과 충돌 | inbound-local mapping과 transport-neutral operational port로 분리 |
| GQL-027 | P3 | 문서·설정 namespace·test count·runtime 지원 주장 drift | generated metadata/runtime adoption test 기반 문서 동기화 |
## 5. 상세 발견 사항과 구현 명세
### GQL-001 — `build` Java package가 `.gitignore`에 걸려 compile과 경계 검사가 함께 사라졌다
**근거**
- `src/.gitignore:2`는 root에 고정되지 않은 `build/` 패턴이다.
- `git check-ignore -v --no-index
src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/build/GraphQlBuildModel.java`
는 `src/.gitignore:2:build/`를 반환한다.
- `GraphQlPlatformAutoConfiguration.java:3,107-108`과
`advanced/bootstrap/GraphQlAdvancedDependencyRules.java:3,26-28,43`은 존재하지 않는
`dev...graphql.build.GraphQlBuildModel`을 참조한다.
- `CLAUDE.md:36-40`, `README.md:108-115`, `graphql-platform-conventions.gradle:11-13`은
`GraphQlStableModule`, `GraphQlAdvancedModule`, `GraphQlBuildModel`,
`GraphQlModuleBoundaryTest`가 실제 tree를 검사한다고 기록하지만 네 파일은 main/test tree에 없다.
- focused `:test`는 `compileJava`에서 해당 package/class 관련 7개 오류로 실패했다.
**실패 모드**
로컬 작성자가 ignored package 아래 파일을 생성하면 파일이 보이므로 잠시 compile될 수 있지만 commit에
들어가지 않는다. fresh checkout/CI에서는 소스가 사라져 compile이 깨진다. 더 위험한 변형은 production
참조를 지웠을 때다. build는 green이 될 수 있지만 Stable→Advanced/core purity/등록 package 검사가 없는
false green이 된다.
**구현 결정**
1. source package 이름을 `...graphql.build`가 아니라 `...graphql.moduleboundary`로 바꾼다. `.gitignore`
예외보다 역할이 분명하고 다른 도구의 `build` 디렉터리 규칙과 충돌하지 않는다.
2. 세 production model을 복원한다. 단, source-tree scanner가 runtime에 필요하지 않으면
`GraphQlBuildModel`을 test/build logic으로 이동하고 `GraphQlPlatformAutoConfiguration`의 runtime
source scan을 제거한다.
3. `GraphQlModuleBoundaryTest`는 실제 source/import graph를 검사하며 다음 세 negative fixture를 가진다.
Stable→Advanced import, core package의 Spring/GraphQL/Reactor import, 등록되지 않은 package.
4. `graphqlStableTest`가 해당 FQCN을 명시적으로 포함하고, test가 0개면 실패하게 유지한다.
5. repository-level gate에 `src/**/src/{main,test}/java/**/build/**` 같은 ignored source-package를
탐지하는 검사를 추가한다. Git에 존재하지 않는 파일을 CI가 찾을 수 없으므로, package naming rule과
required boundary-class existence 검사를 함께 둔다.
**필수 테스트/검증**
```bash
cd src
./gradlew :adapter:inbound:graphql:compileJava --console=plain
./gradlew :adapter:inbound:graphql:test --console=plain
./gradlew :adapter:inbound:graphql:graphqlStableTest --console=plain
./gradlew :adapter:inbound:graphql:test \
--tests '*GraphQlModuleBoundaryTest' --rerun-tasks --console=plain
```
### GQL-002 — 정책 카탈로그는 크지만 실제 `/graphql` 요청에는 실행되지 않는다
**근거**
- `GraphQlPlatformAutoConfiguration.java:43-103`은 startup validator, stage 목록, mapping gate,
observation convention POJO를 bean으로 만들지만 실제 request hook을 등록하지 않는다.
- `GraphQlExecutionPipeline`은 실행 가능한 Chain of Responsibility가 아니라 enum stage 순서 record다.
- MVC/WebFlux transport adapter의 `handle()`은 controller/router/filter가 아니며 production 호출처가 없다.
- production에는 `WebGraphQlInterceptor`, GraphQL Java `Instrumentation`, Spring
`BatchLoaderRegistry`, 실제 `PreparsedDocumentProvider` 연결이 없다.
- `GraphQlScalarWiringConfigurer`는 올바른 `RuntimeWiringConfigurer` 구현이지만 production bean이 아니다.
- HTTP qualification은 Spring Boot 기본 `/graphql`, health controller, root exception resolver와
test-only security를 검증한다. platform auto-configuration과 custom adapters를 import하지 않는다.
**실패 모드**
adopter가 최대 depth/complexity, introspection, authorization, timeout, DataLoader policy를 설정하고
안전하다고 판단해도, Spring 기본 endpoint는 이 객체들을 호출하지 않는다. unit test는 각 policy 함수가
정상임만 증명하고 endpoint adoption을 증명하지 못한다.
**구현 결정: Spring-native 단일 실행 경로**
1. Spring 기본 `/graphql`을 canonical HTTP transport로 유지한다.
2. `GraphQlPlatformWebInterceptor implements WebGraphQlInterceptor`에서 인증 principal을 검증된
request context로 매핑하고 GraphQL/Reactor context에 넣는다.
3. `GraphQlPlatformInstrumentation` 또는 `ExecutionGraphQlService` decorator에서 document
parse/selection, introspection, authorization, cost, deadline/cancellation을 실행한다.
4. `RuntimeWiringConfigurer`, `BatchLoaderRegistry` 등록/decorator, actual preparsed document provider,
canonical exception resolver를 auto-configuration이 bean으로 조립한다.
5. 현재 MVC/WebFlux custom adapters는 제거한다. 자체 transport가 반드시 필요하다면 Spring 기본
handler를 끄고 실제 route를 소유하게 하며, 두 경로를 동시에 두지 않는다.
6. 모든 policy stage는 `GraphQlExecutionRequest`와 `GraphQlExecutionContext`를 입력·출력하는 실행 가능한
handler로 바꾼다. 단순 stage catalog는 문서/검증 view로만 파생한다.
**필수 E2E**
- random-port servlet `/graphql`에서 depth/cost/alias/introspection/oversize/authz/timeout 거부.
- 각 거부에서 controller, use case, batch loader 호출 횟수 0.
- actor/tenant/deadline이 controller와 DataLoader에 동일하게 전달됨.
- custom scalar를 포함한 schema boot 및 실제 coercion.
- 같은 document cache hit, request별 DataLoader cache 격리.
- reactive artifact를 유지한다면 동일 contract를 reactive random-port에서도 실행.
Spring GraphQL이 제공하는 공식 연결점은
[`WebGraphQlInterceptor`](https://docs.spring.io/spring-graphql/reference/1.3/request-execution.html),
[`RuntimeWiringConfigurer`](https://docs.spring.io/spring-graphql/docs/current/api/org/springframework/graphql/execution/RuntimeWiringConfigurer.html),
[`BatchLoaderRegistry`](https://docs.spring.io/spring-graphql/docs/current/api/org/springframework/graphql/execution/BatchLoaderRegistry.html)다.
구현 시 repository lock의 Spring GraphQL 2.0.0/Boot 4.0.0 API signature로 다시 확인한다.
### GQL-003 — auto-configuration, binding default, 실제 override 검증이 각각 다른 계약이다
**근거**
- `GraphQlPlatformAutoConfiguration`은 이름과 달리 `@Configuration`이며 auto-configuration imports
metadata가 없다. main resource는 schema 한 개뿐이다.
- `GraphQlPlatformProperties` primitive binding default는 `maximumPageSize=0`,
`maximumComplexity=0`인데 startup validator는 양수만 허용한다.
- `productionDefaults()` factory는 Spring binder default가 아니다.
- custom `backend.graphql.graphiql-enabled/introspection-enabled`와 실제
`spring.graphql.*` framework flags가 분리되어 있다.
- startup check는 주입된 override pipeline이 아니라 `GraphQlExecutionPipeline.stable()` 상수를 검증한다.
**구현 결정**
1. 재사용 starter라면 `@AutoConfiguration(after = GraphQlAutoConfiguration.class)`과
`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`를 추가한다.
내부 composition 전용이면 이름을 `GraphQlPlatformConfiguration`으로 바꾸고 app-bootstrap에서 명시 import한다.
2. properties를 nested record/class로 나누고 binder가 실제로 사용하는 default를 선언한다.
3. framework `GraphQlProperties`를 SSOT로 삼거나 custom flag와의 불일치를 startup failure로 만든다.
4. startup validator는 실제 주입된 pipeline, scalar manifest, client policies, key ring을 검증한다.
5. `ApplicationContextRunner`로 enabled/disabled/servlet/reactive/unsafe override matrix를 고정한다.
**필수 테스트**
- 아무 `backend.graphql.*`도 없는 context가 safe default로 부팅한다.
- custom/framework GraphiQL·introspection 값이 모순되면 부팅 실패한다.
- unsafe custom pipeline override가 startup에서 거부된다.
- auto-configuration imports와 configuration metadata에 모든 property가 존재한다.
### GQL-004 — 하나의 artifact가 servlet runtime을 강제하면서 reactive profile도 표방한다
**근거**
- `build.gradle:19-20`은 `spring-boot-starter-web`을 production implementation으로 둔다.
- WebFlux는 `compileOnly`라 reactive runtime에는 없다.
- `GraphQlWebFluxAutoConfiguration`은 application이 이미 reactive일 때만 활성화된다.
- reactive config는 blocking executor를 `Mono.fromCallable`로 감싸며 scheduler를 바꾸지 않는다.
- request validator 기본 bean은 MVC config 안에 있어 reactive context에서 기본 생성되지 않는다.
**구현 결정**
공통 artifact에 두 runtime을 넣지 말고 다음처럼 나눈다.
- `graphql-spring-execution`: Spring GraphQL execution/interceptor/wiring. servlet/reactive server 없음.
- `graphql-transport-mvc`: 위 leaf + `starter-web`.
- `graphql-transport-webflux`: 위 leaf + `starter-webflux`.
reactive profile은 `GraphQlReactiveExecutor` 전용 interface를 필수로 한다. blocking bridge가 필요하면 명시적
opt-in, bounded scheduler, bulkhead, lifecycle bean과 thread assertion을 함께 둔다.
### GQL-005 — request limit은 역직렬화 전에 강제되지 않고 valid null variable은 NPE가 된다
**근거**
- size policy 메서드는 존재하지만 custom adapters는 `validateEnvelope()`만 호출한다.
- 이미 materialized된 `GraphQlHttpRequestEnvelope`를 받으므로 JSON allocation 전 body cap을 적용할 수 없다.
- `GraphQlHttpRequestEnvelope:23-25`는 variables/extensions에 `Map.copyOf`를 사용해 null value를 거부한다.
- nested map/list는 shallow copy여서 생성 뒤 mutation 가능한 TOCTOU도 남는다.
**구현 결정**
1. servlet filter/reactive web filter 또는 bounded decoder에서 raw HTTP body byte cap을 먼저 적용한다.
2. JSON decode 후 query/variables/extensions를 UTF-8 byte 기준으로 검증한다.
3. variables/extensions는 null-preserving deep immutable JSON value copy를 사용한다.
4. JSON nesting, object key 수, list length에도 별도 bound를 둔다.
**필수 테스트**
- ASCII와 다중바이트 UTF-8의 exact limit/limit+1.
- variables의 omitted/explicit null/non-null 세 의미가 actual coercion까지 보존됨.
- nested original map/list 변경이 envelope에 반영되지 않음.
- oversize body는 decoder/controller/use case 0회와 413.
### GQL-006 — `Accept` 협상에서 client priority와 명시적 거부를 무시한다
**근거**
`GraphQlMediaTypes:45-63`은 parameter를 제거하고 server preference를 먼저 순회한다. 따라서
`application/graphql-response+json;q=0, application/json;q=1`에도 q=0인 첫 media type을 반환한다.
**구현 결정**
Spring `MediaType.parseMediaTypes`로 parse하고 quality/specificity를 정렬한 뒤 q=0을 제외한다. GraphQL
over HTTP profile이 생산 가능한 두 type과 client order의 교집합을 선택하고, malformed/empty/wildcard
정책을 명시한다. GraphQL over HTTP draft도 client가 제시한 우선순위를 존중하도록 요구한다
([GraphQL over HTTP draft](https://graphql.github.io/graphql-over-http/draft/)).
### GQL-007 — named fragment가 custom introspection gate를 우회하고 variable 입력 구조는 측정하지 않는다
**근거**
- `GraphQlDocumentShapeAnalyzer:96-132`의 introspection walk는 Field/InlineFragment만 처리한다.
- 같은 class의 일반 shape walk는 `FragmentSpread`와 cycle path를 처리한다.
- input nesting은 document literal만 보며 variables JSON은 보지 않는다.
- analyzer는 selected operation이 아니라 document의 모든 operation을 합산한다.
**실패 입력**
```graphql
query Q { ...I }
fragment I on Query { __schema { types { name } } }
```
**구현 결정**
operationName으로 선택한 operation과 reachable fragment graph만 하나의 budgeted walker가 순회하도록
합친다. introspection은 custom walker 하나만 신뢰하지 말고 engine validation/field visibility에서도
차단한다. variables는 streaming JSON constraint로 별도 제한한다.
### GQL-008 — resolver 경계 검사는 실제 adopter graph를 보지 못하고 valid reactive query도 거부한다
**근거**
- controller inspector와 boundary rules는 raw `Class<?>`만 검사해 `List<Entity>`, `Mono<Entity>`,
`Optional<Repository>`의 generic 내부 타입을 놓친다.
- package scan은 `file:` protocol, 직접 자식 `.class`만 지원해 JAR/subpackage를 건너뛴다.
- tests는 고정 fixture package만 직접 호출하고 production startup caller가 없다.
- `Publisher`를 subscription 외에서 모두 거부하지만 Spring GraphQL controller는 Query/Mutation에서
`Mono<T>`와 async return을 지원한다
([Spring GraphQL annotated controllers](https://docs.spring.io/spring-graphql/reference/controllers.html)).
**구현 결정**
1. build-time에는 ArchUnit/bytecode scan으로 실제 configured controller packages를 재귀 검사한다.
2. runtime에는 ApplicationContext의 실제 GraphQL controller bean/method를 startup 검사한다.
3. Java `Type`을 재귀 순회해 parameterized/array/wildcard/type-variable bound를 본다.
4. Query/Mutation에는 single-value async(`Mono`, `CompletionStage`)를 허용하고 multi-value publisher만
Subscription에 제한한다.
5. suffix-only `Repository/Dao` 휴리스틱은 보조 신호로 낮추고 package/assignability/annotation 증거를 쓴다.
### GQL-009 — repository 자동 노출은 Advanced여도 이 저장소의 Clean Architecture를 위반한다
**근거**
`advanced/compat/GraphQlRepositoryExposureValidator`와 `GraphQlRepositoryAllowlist`는 allowlisted
repository가 GraphQL field를 직접 back하는 경로를 지원하고 test도 이를 정상으로 고정한다. 현재 실제
controller가 repository를 직접 호출하는 위반은 없지만, 지원 계약 자체가 root HARD-STOP과 충돌한다.
**구현 결정**
- `SPRING_DATA_COMPAT`, repository exposure API와 정상 test를 제거한다.
- 자동 resolver 대상은 application query/use-case handler로 한정한다.
- 생성 resolver의 constructor/field/method generic graph에 repository, Spring Data interface,
persistence entity가 있으면 allowlist와 무관하게 실패한다.
- business transaction과 authorization은 application use case에 남긴다.
### GQL-010 — cursor는 서명되지만 정상 payload가 round-trip되지 않고 rotation/scope 검증도 불완전하다
**근거**
- keyset은 `;`, `=`, `|`, `\`를 escape하지만 decoder는 escape-aware하지 않은 `split`을 먼저 한다.
- queryProfile/filterFingerprint/keyId는 escape조차 하지 않는다.
- `GraphQlCursorKeyRing.activeKeyId()`는 production/test에서 사용처가 없고 기본 factory는 항상
`cursor-key-1`을 payload에 넣는다.
- connection request decode는 payload direction과 request direction을 비교하지 않는다.
- cursor는 tenant/actor scope를 bind하지 않는다.
- `keyIds()`는 mutable backing key set을 반환한다.
**구현 결정: versioned Codec Strategy**
1. v2 payload를 canonical JSON/CBOR 또는 length-prefixed typed framing으로 만든다.
2. codec이 active key id를 선택하고 envelope에 기록한다. caller payload가 signing key를 선택하지 않는다.
3. decode 입력에 expected query/filter/direction/tenant-scope fingerprint를 포함한다.
4. v1 decode를 migration 기간에만 유지하고 v2만 발급한다.
5. key ring map/key set을 완전 불변으로 만들고 secret clone은 유지한다.
6. `forTests()`와 fixed secret은 test fixtures로 이동한다.
**필수 property tests**
- 모든 string field의 delimiter/backslash/unicode round-trip.
- active key2로 신규 발급, key1 과거 cursor 검증, unknown/retired key 거부.
- forward↔backward, tenant A↔B, filter/query 변경 거부.
- tamper, truncation, oversized token, malformed Base64 거부.
### GQL-011 — mutation fingerprint canonical form이 충돌하고 tenant를 scope에 포함하지 않는다
**근거**
`GraphQlMutationFingerprint:29-33`은 top-level key만 정렬해 `key=value;`를 연결한다. 예를 들어
`{a:"b;c=d"}``{a:"b", c:"d"}`가 같은 canonical text가 된다. nested map은 재귀 정렬되지 않는다.
idempotency scope는 actor/coordinate/key만 포함하고 tenant와 contract version은 없다.
**구현 결정**
- recursive key sorting, JSON type, length framing, null/number normalization을 가진 canonical serializer를
하나의 port/service로 둔다.
- tenant fingerprint와 contract version을 scope에 포함한다.
- actor/tenant의 단순 SHA-256 prefix를 비가역이라고 부르지 않는다. 저엔트로피 identifier에는
rotation 가능한 HMAC fingerprint를 사용하고 metric label에는 넣지 않는다.
- `requireSingleUseCase`는 정확히 1을 요구하거나 실제 architecture gate로 교체한다.
### GQL-012 — error contract가 두 resolver와 여러 category vocabulary로 분기한다
**근거**
- root `GraphqlExceptionResolver`만 실제 Spring `DataFetcherExceptionResolverAdapter``@Component`다.
- `error/GraphQlExceptionResolver`는 richer masking/mapping을 제공하지만 Spring path에 연결되지 않는다.
- auth/cursor/idempotency/batch/timeout 예외의 code/category/retryable/executionId 계약이 경로마다 다르다.
- 대소문자만 다른 두 class 이름은 import 실수를 유발한다.
**구현 결정: Mapper + Adapter**
`GraphQlWireErrorMapper`를 canonical pure mapper로 두고 `GraphQlDataFetcherExceptionResolver`가 Spring
`GraphQLError`로 adapt한다. request-level HTTP failure와 field failure는 별도 strategy를 쓰되 code,
category, retryability, masking catalog는 공유한다. unknown failure의 raw message는 어떤 path에서도
노출하지 않는다.
### GQL-013 — persisted operation admin은 존재하지 않는 변경을 성공으로 audit할 수 있다
**근거**
- in-memory registry의 absent `updateStatus`는 no-op인데 admin service는 `ABSENT→BLOCKED/DEPRECATED` audit을 남긴다.
- `remove()`는 실제 삭제가 아니라 BLOCKED 전환이다.
- BLOCKED에서 DEPRECATED로 바꿔 다시 executable하게 만들 수 있는 transition guard가 없다.
- raw operator 문자열 allowlist를 받고 credential kind 거부 메서드는 service가 호출하지 않는다.
- registry 변경과 in-memory `ArrayList` audit은 원자적이지 않고 thread-safe하지 않다.
**구현 결정: State + authenticated command + durable transaction**
1. transport가 만든 `GraphQlAdminPrincipal`만 service에 전달한다.
2. lifecycle transition table을 두고 BLOCKED는 explicit audited unblock 전까지 terminal로 취급한다.
3. registry command는 updated record/version을 반환하거나 not-found/conflict를 던진다.
4. mutation과 audit append를 하나의 durable transactional port로 묶는다.
5. soft delete가 의도면 `remove``retireAndBlock`으로 이름 바꾼다.
### GQL-014 — codegen validator는 operation document를 읽지 않고 generator는 source를 만들지 않는다
**근거**
`GraphQlClientOperationGenerator.validateOperation`은 nonblank만 확인한 뒤 schema를 자기 자신과 비교한다.
`operationDocument`는 검증에 쓰지 않는다. invalid syntax나 unknown field operation이 통과한다. 다른
generator/factory도 실제 handler/source가 아니라 metadata set/report만 반환하는 사례가 많다.
**구현 결정**
- schema를 executable schema로 만들고 GraphQL Java parser/validator로 selected operation을 검증한다.
- 실제 source writer/Gradle task가 없다면 class/package를 `codegen-plan` 또는 `compatibility-policy`
정직하게 이름 바꾼다.
- invalid syntax, unknown field/argument/type, operation name ambiguity, valid fragment operation을 테스트한다.
### GQL-015 — schema compatibility와 ownership이 breaking change를 놓친다
**근거**
- 동일 이름의 `type Foo``input Foo` 같은 kind change를 먼저 비교하지 않는다.
- 기존 argument/input field의 default 추가·제거·변경을 비교하지 않는다.
- `extend type/interface/input/enum/union`의 field/member ownership과 duplicate를 충분히 기록하지 않는다.
- applied directive 변경이 아니라 directive definition만 비교한다.
- scalar SDL print 차이를 coercion change라 부르지만 실제 `Coercing` 구현 교체는 보지 못하고 description
변화는 오탐할 수 있다.
**구현 결정**
1. registry를 extension까지 normalize하거나 executable schema로 compile한 canonical model을 비교한다.
2. `TYPE_KIND_CHANGED`, `INPUT_DEFAULT_REMOVED/CHANGED/ADDED`, applied-directive change를 명시한다.
3. nested list/non-null 변화는 input/output position별 방향성을 재귀 분류한다.
4. scalar coercion compatibility는 SDL이 아니라 scalar manifest codec/version 계약으로 분리한다.
### GQL-016 — custom DataLoader contract는 실제 N+1과 timeout을 보장하지 않는다
**근거**
- `GraphQlDataLoaderRequestRegistry``Object` map이며 Spring/Java DataLoader registry에 연결되지 않는다.
- contract suite는 caller가 전달한 observed query count를 검사하고 test는 임의 숫자 1/2를 넘긴다.
- batch executor는 synchronous chunk 호출 전에만 시간을 보고 long/final chunk를 중단하지 못한다.
- mapped loader의 null은 `Present(null)`, ordered loader의 null은 `Missing`으로 해석되어 의미가 다르다.
- result cardinality가 같아도 requested key 대신 다른 key가 들어간 map을 검출하지 못한다.
**구현 결정: Spring registry adapter + Decorator**
Spring `BatchLoaderRegistry`에 실제 loader를 등록하고 chunk/timeout/auth scope/observation을 loader decorator로
적용한다. loader는 `CompletionStage`/`Mono`로 deadline/cancellation을 전달한다. null 의미는 하나로
정하고 requested key set/cardinality를 검증한다.
**필수 E2E**
- 50개 parent/child query의 fake application port 호출이 1회 또는 bounded chunk 수.
- request 간 cache 비공유, 같은 request duplicate key dedupe, actor/tenant scope 분리.
- never-completing loader timeout/cancel, 첫 chunk budget 소진 뒤 다음 chunk 0회.
- missing/failed/null/wrong-key map 계약.
### GQL-017 — MVC는 bounded라고 설명하지만 concurrency/queue가 unbounded이고 context도 전달하지 않는다
**근거**
- virtual-thread-per-task executor는 task admission을 제한하지 않는다.
- fixed thread pool은 기본 unbounded `LinkedBlockingQueue`를 사용한다.
- MVC adapter가 submit한 task를 `GraphQlContextPropagator.wrap`으로 감싸지 않는다.
- WebFlux blocking fallback은 `subscribeOn`이 없어 subscriber/event-loop thread에서 실행될 수 있다.
**구현 결정**
- executor 앞에 semaphore/bulkhead 또는 bounded `ThreadPoolExecutor` queue/rejection을 둔다.
- Spring GraphQL annotated controller executor를 canonical하게 구성해 double scheduling/wait을 피한다.
- context는 ThreadLocal만 믿지 말고 GraphQLContext/Reactor Context를 SSOT로 삼고 blocking bridge에서만
snapshot/wrap한다.
- timeout은 interrupt가 아니라 downstream deadline propagation과 함께 검증한다.
### GQL-018 — pipeline stage 순서는 authorization에 필요한 정보를 만들기 전에 authorize한다
**근거**
pipeline은 `AUTHORIZATION``PARSE_VALIDATE`보다 앞에 두지만 field authorization은 schema coordinate와
selected operation을 필요로 한다. 현재 pipeline이 실행되지 않아 장애는 잠복해 있지만 그대로 wiring할
수 없는 순서다.
**구현 결정: 실제 Chain of Responsibility**
```text
authenticate transport principal
→ create request context
→ persisted lookup / raw document admission
→ parse + validate + select operation
→ document/coordinate authorization
→ structural + complexity budget
→ execute + field/object authorization + DataLoader
→ map errors + observe + cleanup
```
각 handler는 입력 상태와 산출 상태를 typed record로 표현하고, 필요한 이전 stage가 없으면 compile-time 또는
startup validation에서 실패하게 한다.
### GQL-019 — subscription/replay/drain policy는 concurrent runtime state machine이 아니다
**근거**
- replay cursor는 expected subscription과 tenant를 검증하지 않는다.
- subscription event byte estimate는 실제 serialized bytes가 아니라 `payload.toString()`을 사용한다.
- drain coordinator는 draining check와 registration increment 사이 race가 있고, state publication 순서에
따라 startedAt을 null로 볼 수 있다.
- cancellation/listener collections와 protocol lifecycle의 thread-safety/ownership이 명시되지 않았다.
- WebSocket/SSE/RSocket “handler factory”는 실제 Spring transport handler가 아니라 policy 객체를 반환한다.
**구현 결정**
atomic immutable state 또는 lock-protected State pattern으로 `ACCEPTING→DRAINING→CLOSED`를 모델링한다.
registration은 lease를 받아 close 시 release한다. replay cursor는 actor+tenant+subscription profile에
bind한다. queue byte bound는 실제 serializer 결과로 계산한다. 실제 handler가 없으면 factory 명명과
지원 등급을 policy/catalog로 낮춘다.
### GQL-020 — preparsed cache의 expiry policy가 사용되지 않고 unrelated miss가 직렬화된다
`GraphQlPreparsedCachePolicy`의 expire-after-access 값은 provider에서 사용되지 않는다. cache miss parse가
synchronized block 안에서 실행되어 서로 다른 document도 직렬화된다. injected Clock/Ticker를 쓰는 bounded
cache와 per-key single-flight를 적용하고 expiry/access-refresh/same-key-once/different-key-parallel을 테스트한다.
### GQL-021 — cancellation hook 하나의 실패가 나머지 cleanup을 막는다
request/subscription cancellation listener loop가 exception을 aggregate하지 않는다. 세 hook 중 두 번째가
throw해도 세 개 모두 정확히 한 번 실행하고 첫 실패에 나머지를 suppressed로 붙이는 공통 cancellation
primitive로 합친다. 이미 `GraphQlContextCleanup`이 가진 all-cleanups 실행 의미를 재사용한다.
### GQL-022 — scalar input/output limit이 대칭이 아니고 작은 입력이 큰 출력을 만들 수 있다
- BigDecimal은 precision/scale/exponent/serialized length 제한 없이 parse 후 `toPlainString()`을 사용한다.
작은 `1E+1000000`이 매우 큰 output allocation을 만들 수 있다.
- custom Long scalar는 parse에 configured min/max를 적용하지만 serialize/valueToLiteral은 그 범위를 무시한다.
lexical length, precision, absolute scale, output length를 먼저 제한하고 Long의 input/output에 같은 range를
적용한다. coercion error에는 raw input을 포함하지 않는 기존 원칙을 유지한다.
### GQL-023 — operation name을 low-cardinality tag라고 가정할 수 없다
operation name은 길이/문법만 제한되어 client가 매번 임의 이름을 만들 수 있고 observation convention은 raw
name을 tag로 사용한다. 실제 Micrometer/Spring Observation interface 연결도 없다. persisted/registered
operation만 이름 tag로 사용하고 나머지는 `other`로 collapse하거나 production에서 anonymous/unregistered
operation을 거부한다. 10,000개 임의 name을 actual MeterRegistry에 넣어 series bound를 검증한다.
### GQL-024 — production jar가 testkit, fixed secret, in-memory development 구현을 함께 배포한다
main source에는 `testkit` 12개 class, `GraphQlConnectionAssembler.forTests()`의 fixed signing secret,
`GraphQlAuthenticationContextFactory.testContext`, test error context, in-memory persisted registry가 있다.
`java-test-fixtures` 또는 별도 `graphql-testkit` leaf로 옮기고 production jar에 `.testkit.`, `forTests`,
`testContext`, fixed secret이 없는 jar content gate를 둔다.
### GQL-025 — 373개 public 중심 type과 Stable/Advanced/testkit/release의 한 jar 결합은 변경 비용이 크다
package import graph에 명백한 cycle이 없는 방향성은 좋지만 package만으로 외부 API와 classpath isolation을
보장하지 못한다. 이번 ignored boundary package가 그 취약성을 실제로 보여 줬다. package-private를 default로
하고 explicit `api`/`spi`만 public으로 허용하는 API surface snapshot을 둔다. Gradle 분리는 28개를 한 번에
늘리지 않고 §7의 6~8개 capability 단위로 진행한다.
### GQL-026 — GraphQL request context와 storage SPI가 inbound에 있어 downstream 구현 방향과 충돌한다
문서는 `GraphQlRequestContext`/deadline을 application/JPA/Mongo/HTTP client까지 전달하고 persisted registry를
외부 durable store가 구현한다고 설명한다. application/outbound가 inbound leaf type을 구현하면 의존 방향이
뒤집힌다.
- GraphQL context는 inbound-local로 유지하고 application command의 actor/tenant/deadline 값으로 명시 매핑한다.
- object authorization은 application-core의 transport-neutral use case로 두고 GraphQL bridge가 호출한다.
- persisted operation 저장은 generic operational store/cache port를 neutral contract owner에 두고 GraphQL
adapter가 key/value mapping만 소유한다. inbound→outbound 직접 edge는 만들지 않는다.
- composition root는 연결만 하고 business/storage policy를 소유하지 않는다.
### GQL-027 — 문서와 실제 설정·테스트·지원 등급이 drift했다
- README는 custom `@ConfigurationProperties`가 없다고 하지만 `backend.graphql` properties가 있다.
- build comment는 `spring.graphql.platform.*`를 언급하지만 실제 prefix는 `backend.graphql`이다.
- CLAUDE/README는 누락된 boundary classes/tests가 있다고 기록한다.
- “더 이상 미구현이 아니다”라는 표현은 policy object 존재와 runtime integration을 구분하지 않는다.
- test count는 compile이 깨진 현재 실행 증거가 아니라 과거/문서 count다.
generated configuration metadata, actual bean inventory, random-port adoption test, task JUnit XML에서 문서를
생성/검증한다. capability마다 `modelled`, `wired`, `integration-verified`, `production-verified`를 분리하고
현재 수준 이상으로 표현하지 않는다.
## 6. 디자인 패턴 적용 제안
### 6.1 적용할 패턴
| 위치 | 패턴 | 적용 형태 | 해결하는 문제 |
|---|---|---|---|
| 실행 pipeline | Chain of Responsibility | typed stage handler + actual Spring execution decorator | stage 목록만 있고 실행되지 않는 문제 |
| Spring integration | Adapter | pure policy를 interceptor/instrumentation/wiring으로 변환 | framework-free core와 runtime 연결 분리 |
| transport | Strategy | MVC/WebFlux leaf별 transport strategy | 두 runtime classpath와 blocking policy 혼합 제거 |
| DataLoader | Decorator | loader에 chunk/deadline/auth/observation을 조합 | 병렬 custom framework와 정책 중복 제거 |
| error | Mapper + Adapter | pure wire-error mapper + Spring resolver | 두 resolver/category drift 제거 |
| cursor | Versioned Codec Strategy | v1 read/v2 write codec과 key-ring signer | framing migration과 rotation 분리 |
| persisted/admin/subscription | State | 허용 transition과 CAS version 명시 | blocked 재활성, drain race, 허위 audit 제거 |
| application 경계 | Anti-Corruption Mapper | GraphQL context/input → command/context | transport DTO/application leakage 방지 |
| configuration | Validated Plan/Builder | bind → aggregate validate → immutable runtime plan | resource 생성 뒤 validation과 inert setting 제거 |
### 6.2 피할 패턴
- `Factory`, `Generator`, `Interceptor`라는 이름만 붙이고 metadata/policy 객체만 반환하지 않는다.
- 28개 설계상 “모듈”을 근거 없이 28개 Gradle leaf로 기계 분해하지 않는다.
- controller/router와 Spring 기본 endpoint를 병렬로 유지하지 않는다.
- custom DataLoader, custom preparsed cache, custom transport를 framework가 제공하는 extension point와 경쟁시키지 않는다.
- architecture rule을 runtime reflection suffix 검사 하나로만 강제하지 않는다.
- Advanced라는 이유로 repository/use-case 경계를 완화하지 않는다.
## 7. 권장 Gradle·폴더 구조
### 7.1 대안 비교
| 대안 | 장점 | 단점 | 판정 |
|---|---|---|---|
| A. 현재 단일 leaf 유지 + 경계 test 복구 | 가장 빠름, registry 변경 최소 | public/classpath/runtime 결합 유지 | GQL-001 응급 복구용 |
| B. 6~8 capability leaf로 단계 분리 | 실제 runtime 책임과 dependency를 격리 | registry/settings/lock/CI 갱신 필요 | **권장** |
| C. 설계의 28 package를 28 leaf로 분리 | 가장 강한 compile boundary | Gradle/lock/CI 비용과 빈 facade 증가 | 현재 과도함 |
### 7.2 권장 target
```text
graphql-platform-core
src/main/java/.../graphql/core/api
src/main/java/.../graphql/core/policy
# pure Java, framework/transport/application type 없음
graphql-schema
src/main/java/.../graphql/schema
src/main/java/.../graphql/scalar
src/main/java/.../graphql/compat
# GraphQL Java AST/wiring, no web server
graphql-spring-execution
src/main/java/.../graphql/execution
src/main/java/.../graphql/security
src/main/java/.../graphql/error
src/main/java/.../graphql/dataloader
src/main/java/.../graphql/autoconfigure
# application-core bridge + Spring GraphQL extension points
graphql-transport-mvc
src/main/java/.../graphql/http/mvc
# starter-web only
graphql-transport-webflux
src/main/java/.../graphql/http/webflux
# starter-webflux only
graphql-advanced
src/main/java/.../graphql/advanced/{persisted,subscription,federation,...}
# 실제 wired capability만 opt-in; feature가 커지면 사용 단위별 추가 분리
graphql-testkit
src/testFixtures/java 또는 전용 leaf
graphql-release-verification
# Gradle/build logic와 evidence manifest, production runtime에 포함하지 않음
```
허용 방향의 기본안은 다음과 같다.
```text
transport-mvc/webflux → spring-execution → schema → platform-core
spring-execution → application-core → domain-core
advanced → spring-execution/schema/platform-core
testkit → 공개 api/spi만
release-verification → 각 leaf의 test/evidence artifact만
```
실제 edge와 runtime membership은 반드시 `src/config/architecture/modules.json`에 먼저 등록하고 같은 SSOT의
Gradle gate로 검증한다. `graphql-persisted-<database>`가 inbound contract에 역의존하는 구조는 만들지 않는다.
### 7.3 package visibility
- public 허용: 외부 resolver/adopter가 구현·호출해야 하는 `api`, `spi`, configuration properties.
- package-private/internal: calculator, parser walker, state transition, mapper implementation, factory implementation.
- test-only: fixture, fake/in-memory, fixed key/principal/context, contract assertion helper.
- public API snapshot에는 FQCN, constructor/method signature, stability level을 기록한다.
## 8. 구현 순서 — 그대로 issue/PR로 분리 가능한 단위
### Wave 0 — build와 증거 복구
1. **PR GQL-001A**: ignored package red test와 `moduleboundary` package 복구.
2. **PR GQL-001B**: module boundary negative fixtures와 required FQCN/lane 연결.
3. focused test, Stable/contract/Advanced lane을 실행한다. 여기서 발견되는 test failure는 다음 wave의
characterization backlog로 분리한다.
### Wave 1 — 실제 endpoint baseline
1. 현재 기본 `/graphql`에 health query를 보내는 full configuration test를 만든다.
2. cost/auth/DataLoader/custom adapter bean이 존재하지만 호출되지 않는 현재 상태를 failing test로 증명한다.
3. Spring-native endpoint를 canonical로 확정하고 custom transport dead path를 제거한다.
4. auto-configuration imports, binder defaults, framework property cross-check를 추가한다.
### Wave 2 — executable pipeline
1. request context interceptor.
2. parse/select/introspection/cost/auth instrumentation/decorator.
3. scalar/preparsed/DataLoader/error wiring.
4. actual observation과 cleanup/cancellation.
5. servlet random-port qualification을 platform adoption test로 교체한다.
### Wave 3 — correctness/security
서로 독립인 작은 PR로 다음을 처리한다.
- null-preserving request JSON + byte/nesting limit.
- Accept negotiation.
- fragment introspection/selected-operation analyzer.
- cursor v2/rotation/direction/tenant.
- mutation canonical fingerprint/tenant.
- schema kind/default/extensions/directives.
- scalar bounds.
- persisted admin/subscription state machines.
각 PR은 먼저 failing unit/property/integration test를 추가한다.
### Wave 4 — architecture와 모듈 분리
1. actual controller generic/bytecode gate와 application import gate.
2. repository exposure capability 제거.
3. testkit/fixed/in-memory API를 test fixtures로 이동.
4. `platform-core`, `schema`, `spring-execution` 추출.
5. MVC/WebFlux leaf 분리와 runtime classpath tests.
6. Advanced/release verification을 runtime jar에서 분리.
7. public API snapshot과 package-private 축소.
### Wave 5 — Advanced promotion
각 capability는 다음 네 증거가 모두 있을 때만 `wired` 이상으로 승격한다.
1. 실제 Spring handler/extension point가 존재한다.
2. real request 또는 protocol-level integration test가 해당 path를 호출한다.
3. disabled 상태에서 bean/resource/route가 0개다.
4. restart/concurrency/fault가 필요한 stateful capability는 durable evidence가 있다.
codegen/federation/subscription/persisted operation이 이 기준을 못 채우면 policy/catalog로 이름과 문서를
낮추고 production support claim을 하지 않는다. Spring GraphQL은 federation에 `@EntityMapping`을 포함한
공식 통합을 제공하므로 별도 facade보다 이를 우선 검토한다
([Spring GraphQL federation](https://docs.spring.io/spring-graphql/reference/federation.html)).
## 9. 테스트 전략과 Definition of Done
### 9.1 최소 테스트 피라미드
| 계층 | 테스트 | 핵심 assertion |
|---|---|---|
| pure policy | unit + property | canonicalization, bounds, transition, deterministic output |
| Spring composition | `ApplicationContextRunner` | enabled/disabled, bean exact set, unsafe config failure |
| schema/execution | `ExecutionGraphQlServiceTester` | scalar, parse, validation, error path, DataLoader |
| transport | random-port MVC/WebFlux | media type, auth, body cap, actual policy rejection |
| architecture | ArchUnit/bytecode + Gradle edge | generic DTO/entity/repository, package/leaf edge, public API |
| stateful Advanced | concurrency/restart/store integration | CAS/fencing/audit/replay/durable transition |
| release | same-SHA evidence manifest | 실행한 lane/version/scenario와 지원 문서 일치 |
### 9.2 전체 완료 조건
- `:adapter:inbound:graphql:compileJava`, focused `test`, Stable/contract/Advanced lane이 실행되고 green이다.
- performance lane이 필요한 support claim은 실제 tagged scenario/evidence 없이는 승격되지 않는다.
- real `/graphql` E2E에서 모든 mandatory policy가 최소 한 번 차단/허용 경로를 가진다.
- GraphQL DTO/context/framework type이 application/domain에 유출되지 않는다.
- controller/resolver가 repository, persistence entity, transaction을 직접 소유하지 않는다.
- MVC/WebFlux runtime dependency가 서로의 server stack을 끌어오지 않는다.
- production jar에 testkit/fixed secret/in-memory development facade가 없다.
- public API와 capability support status가 snapshot/manifest로 검증된다.
- docs의 property name/test count/support status는 generated metadata와 JUnit evidence에서 파생된다.
## 10. 이번 리뷰에서 실행한 검증
### 성공
```bash
cd src
./gradlew verifyCleanArchitectureDependencies --console=plain
```
- current HEAD fresh run은 `BUILD SUCCESSFUL in 769ms`, 1 actionable task executed였다.
- 이 결과는 registry에 선언된 project dependency edge가 맞다는 증거다.
- 누락된 내부 package boundary, runtime wiring, correctness를 승인하는 증거는 아니다.
### 실패
```bash
cd src
./gradlew :adapter:inbound:graphql:compileJava --console=plain
./gradlew :adapter:inbound:graphql:test --console=plain
```
- direct `compileJava`의 current HEAD fresh 재현은 `BUILD FAILED in 1s`, 7 errors였다
(직전 첫 재현도 같은 7 errors, 7s).
- focused `test`도 같은 `compileJava` 단계에서 실패했다.
- 누락 package: `dev.caskeleton.adapter.inbound.graphql.build`.
- 참조 파일: `GraphQlPlatformAutoConfiguration`, `GraphQlAdvancedDependencyRules`.
- test 75개는 실행 단계에 진입하지 못했다.
### 정적 재현
```bash
git check-ignore -v --no-index \
src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/build/GraphQlBuildModel.java
```
- `src/.gitignore:2:build/`이 반환되어 누락 source package와 ignore rule의 충돌을 확인했다.
### 미실행
- `graphqlStableTest`, `graphqlContractTest`, `graphqlAdvancedTest`: 동일 compile blocker 때문에 실행 불가.
- `graphqlPerformanceTest`: compile blocker에 더해 실제 tagged load/fault scenario가 없는 상태.
- repository 전체 `test`/`check`: review-only 범위이며 focused compile blocker가 먼저 존재한다.
- production adopter, actual feature schema, JPA/Mongo query-count, real WebSocket/SSE/RSocket, load/soak/fault.
## 11. 남은 위험과 판정 범위
- GraphQL leaf는 현재 두 composition root runtime에 포함되지 않으므로 발견 사항을 현 서비스의 즉시 runtime
장애로 확대하지 않는다.
- 반대로 빈 runtime membership은 adopter 안전성의 증거도 아니다. opt-in 직후 compile/auto-config/runtime
wiring 문제가 드러난다.
- build blocker가 해결되면 지금까지 실행되지 못한 524 test annotation에서 추가 failure가 나올 수 있다.
- Advanced 130개 production class의 모든 concurrent/protocol path를 실환경에서 검증하지 않았다.
- 공식 Spring GraphQL extension point 선택은 타당하지만 정확한 Boot 4.0.0/Spring GraphQL 2.0.0 API
signature와 auto-configuration ordering은 구현 시 lock 기준으로 확인해야 한다.
- 이 리뷰의 `FACT`는 명시한 source/command에 한정되고, target module split은 그 사실에서 도출한
`INFERENCE/권고`다. registry 변경 전에 별도 설계 문서와 실행 계획을 남겨야 한다.
최종 판정은 **CHANGES REQUIRED**다. 구현 순서는 `GQL-001 → GQL-002/003 → GQL-005~018 →
GQL-024~026 → Advanced promotion`을 권장한다.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -25,8 +25,12 @@ status: stub
### Step 1 — 확인
1. ERROR log에서 `OUTBOX_PUBLISH_FAILED` 라인 확인: `event_type`, `event_id`, `correlation_id`, `attempt_count` 추출
2. broker(기본 Kafka adapter) 상태 확인: `APP_MESSAGING_KAFKA_ENABLED`과 broker endpoint 가용성
- Kafka disabled(default) 상태에서 outbox 이벤트가 append 되고 있으면 publish 경로가 `AdapterDisabledException`으로 전부 실패하는 구성 오류 — 이 경우 producer use case 쪽 활성화/구성을 먼저 의심
2. broker 상태 확인: `APP_MESSAGING_BROKER` 값(공백이면 messaging 비활성)과 broker endpoint 가용성
- `APP_MESSAGING_BROKER`가 공백인 채로 relay가 켜져 있으면 **애플리케이션이 기동하지 않는다**
(`OutboxRelayBrokerRequirementValidator`, MSG-024). 이 조합에서는 publish가 전부
`AdapterDisabledException`으로 실패하며 PENDING row가 DEAD까지 소진되기 때문이다.
기동 실패를 보고 있다면 broker를 설정하거나 `ca-skeleton.outbox.relay-enabled=false`로 둔다.
- 기동은 했는데 실패가 쌓인다면 broker는 설정돼 있고 도달이 안 되는 것이다 — endpoint부터 본다.
3. `outbox.pending.size` status 분포 확인 (FAILED 누적 vs PENDING 누적)
### Step 2 — 임시 격리
+31 -10
View File
@@ -81,10 +81,19 @@ fi
# --- actual-topology -------------------------------------------------------------------------
echo ""
echo "=== [actual-topology] provider environments"
# The URI travels in the environment, never as a JVM argument. `-Dmongodb.sharded.uri=mongodb://
# user:pass@host` is visible in `ps` to every user on the machine, in the Gradle failure output and
# in any CI log that echoes the command.
#
# The selector names the contract's class. `--tests '*Shard*'` was satisfied by the hermetic
# ShardKeyAnalyzerTest, so "sharded topology" was certified by a unit test that never opened a
# connection. Which classes count is `src/config/mongodb/release-contracts.json`, and
# MongoReleaseEvidenceVerifier checks the JUnit XML rather than the exit code.
if [[ -n "${MONGODB_SHARDED_URI:-}" ]]; then
if (cd "${GRADLE_DIR}" && "${GRADLE[@]}" "${MODULE}:test" --tests '*Shard*' \
-Dmongodb.sharded.uri="${MONGODB_SHARDED_URI}"); then
echo "actual-topology(sharded): supplied"
SHARDED_CLASS="$(python3 -c "import json,sys; print(next(c['className'] for c in json.load(open('${REPO_ROOT}/src/config/mongodb/release-contracts.json'))['contracts'] if c['topology']=='sharded'))")"
if (cd "${GRADLE_DIR}" && MONGODB_SHARDED_URI="${MONGODB_SHARDED_URI}" \
"${GRADLE[@]}" "${MODULE}:mongoShardedTest" --tests "${SHARDED_CLASS}"); then
echo "actual-topology(sharded): ${SHARDED_CLASS} executed"
else
FAILED+=("actual-topology:sharded")
fi
@@ -93,26 +102,38 @@ else
MISSING_EVIDENCE+=("actual-topology: sharded cluster")
fi
# Present is not exercised. An environment variable proves somebody exported a string; the
# contract is satisfied by a lane that ran against the deployment it names, which is why this
# records the variable as *not yet* evidence until MONGO-REL-011's class has run.
if [[ -n "${MONGODB_ATLAS_URI:-}" ]]; then
echo "actual-topology(search/vector): MONGODB_ATLAS_URI present"
echo "actual-topology(search/vector): MONGODB_ATLAS_URI present (lane not yet implemented)"
MISSING_EVIDENCE+=("actual-topology: MONGO-REL-011 has no lane; an exported URI is not a run")
else
echo "actual-topology(search/vector): no MONGODB_ATLAS_URI"
MISSING_EVIDENCE+=("actual-topology: search/vector on the actual target deployment")
fi
if [[ -n "${MONGODB_KMS:-}" ]]; then
echo "actual-topology(encryption): MONGODB_KMS present"
echo "actual-topology(encryption): MONGODB_KMS present (lane not yet implemented)"
MISSING_EVIDENCE+=("actual-topology: MONGO-REL-012 has no lane; an exported KMS is not a run")
else
echo "actual-topology(encryption): no MONGODB_KMS"
MISSING_EVIDENCE+=("actual-topology: real KMS and key vault")
fi
# --- security + migration ---------------------------------------------------------------------
# These are review artefacts, not test runs: a role review and a documented migration path per
# capability. The gate records that they are outstanding rather than pretending a green test covers
# them.
MISSING_EVIDENCE+=("security: per-capability privilege review sign-off")
MISSING_EVIDENCE+=("migration: per-capability migration path sign-off")
# Review artefacts, not test runs: a role review and a documented migration path per capability.
# These used to be appended unconditionally, so the gate could never reach PROMOTABLE no matter what
# anybody did — a gate with no passing state is a gate nobody can act on. They are now satisfied by
# a committed sign-off file, which is the artefact the review actually produces.
for signoff in security migration; do
path="${REPO_ROOT}/docs/mongodb/advanced/signoff/${signoff}.md"
if [[ -f "${path}" ]]; then
echo "${signoff}: sign-off recorded at docs/mongodb/advanced/signoff/${signoff}.md"
else
MISSING_EVIDENCE+=("${signoff}: per-capability sign-off (docs/mongodb/advanced/signoff/${signoff}.md)")
fi
done
# --- Report ------------------------------------------------------------------------------------
echo ""
+19
View File
@@ -142,6 +142,25 @@ echo "skipped: ${#SKIPPED[@]}"
for entry in "${SKIPPED[@]:-}"; do [[ -n "${entry}" ]] && echo " ~ ${entry}"; done
echo "failed: ${#FAILED[@]}"
for entry in "${FAILED[@]:-}"; do [[ -n "${entry}" ]] && echo " - ${entry}"; done
# --- promotion manifest -------------------------------------------------------------------------
# What was actually certified, tied to what produced it. A gate output that says "PASSED" and
# nothing else cannot be checked later against the artefact it supposedly certified: the commit, the
# server image and the driver version are exactly what somebody reads during an incident.
MANIFEST_DIR="${REPO_ROOT}/src/adapter/outbound/persistence-mongo/build/reports/mongo-release"
mkdir -p "${MANIFEST_DIR}"
{
echo "{"
echo " \"commit\": \"$(git -C "${REPO_ROOT}" rev-parse HEAD)\","
echo " \"commitDirty\": $( [[ -n "$(git -C "${REPO_ROOT}" status --porcelain)" ]] && echo true || echo false ),"
echo " \"serverImage\": \"${MONGODB_IMAGE:-mongo:8.0.16}\","
echo " \"generatedAt\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\","
echo " \"contractManifest\": \"src/config/mongodb/release-contracts.json\","
echo " \"contractManifestSha256\": \"$(sha256sum "${REPO_ROOT}/src/config/mongodb/release-contracts.json" | cut -d' ' -f1)\""
echo "}"
} > "${MANIFEST_DIR}/promotion.json"
echo "promotion manifest: ${MANIFEST_DIR}/promotion.json"
echo "---------------------------------------------------------------"
if (( ${#FAILED[@]} > 0 )); then
+85 -20
View File
@@ -33,11 +33,21 @@ leaf). 즉 이 레포에는 두 패턴이 공존한다:
모듈 레코드가 그대로 leaf 명세로 승격될 수 있게 설계해 두었다. 그때까지 모듈 경계는 문서가
아니라 기계가 지킨다:
- `build/GraphQlStableModule` · `build/GraphQlAdvancedModule` 이 모듈 정체성과 허용 의존 edge 를
값으로 선언하고, `build/GraphQlBuildModel` 이 실제 소스 트리를 스캔한다.
- `build/GraphQlModuleBoundaryTest` 가 (a) Stable 패키지의 `...graphql.advanced` import 금지,
(b) `graphql-core-api` 계열의 Spring/GraphQL Java/Reactor/persistence import 금지,
(c) Stable 의존 edge 가 Advanced 모듈을 가리키지 않을 것을 강제한다.
- main 의 `moduleboundary/GraphQlStableModule` · `moduleboundary/GraphQlAdvancedModule` 이 모듈
정체성·purity 등급·허용 의존 edge 를 값으로 선언하고, `moduleboundary/GraphQlModuleBoundary`
"이 패키지의 주인은 누구인가 / 이 edge 는 선언됐는가"를 답한다.
- test 의 `moduleboundary/GraphQlBuildModel` 이 실제 소스 트리를 스캔하고,
`moduleboundary/GraphQlModuleBoundaryTest` 가 (a) Stable 패키지의 `...graphql.advanced` import
금지, (b) `CORE` 등급 모듈의 Spring/GraphQL Java/Reactor/Micrometer/Jakarta import 금지,
(c) 선언되지 않은 cross-module edge 금지, (d) 미등록 패키지 금지, (e) 선언만 있고 소스가 없는
모듈 금지를 강제한다. 각 규칙은 **거부되는 합성 트리(negative fixture)** 를 함께 가진다.
**패키지 이름은 `build` 가 아니라 `moduleboundary` 다.** `src/.gitignore:2` 의 anchor 없는
`build/` 규칙은 Gradle 산출물과 Java 패키지를 구분하지 못해서, 예전에 이 경계 모델 전체를
커밋에서 삼켰다(프로덕션 코드는 계속 import 하고, 작성자 작업본만 컴파일되고, fresh checkout
은 7개 오류로 깨졌다). 레포 전역 `verifyNoIgnoredSourcePackages` 가 이 부류를 막고,
`graphqlStableTest` 의 required-class 검사가 "경계 테스트만 조용히 사라지고 레인은 green" 인
나머지 절반을 막는다.
**새 플랫폼 sub-package 를 추가할 때는 반드시 해당 모듈 레코드에 정체성과 허용 edge 를 먼저
등록한다.** 등록 없이 추가된 패키지는 경계 테스트가 실패시킨다.
@@ -59,13 +69,25 @@ leaf). 즉 이 레포에는 두 패턴이 공존한다:
## Allowed
- `:application-core`, `:domain-core`, `:shared-contract`.
- `spring-boot-starter-graphql`, `spring-boot-starter-web`, `jackson-datatype-jsr310`
(전부 Spring Boot BOM 관리 — 버전 명시 없음).
- test scope 에 한해 실제 HTTP 인증/CORS qualification 용 `spring-boot-starter-security`.
- `compileOnly` 로만 `spring-webflux` — REACTIVE_WEBFLUX 전송 프로파일(`http/webflux/`)을
컴파일하기 위한 것이고, 의도적으로 `runtimeClasspath` 에서 제외한다. MVC 배치에 WebFlux 를
끌어들이지 않기 위함이며 `gradle.lockfile` 이 이 스코프 제한을 고정한다
(`spring-webflux:...=compileClasspath,testCompileClasspath,testRuntimeClasspath`).
- `spring-boot-starter-graphql` (Spring Boot BOM 관리 — 버전 명시 없음).
- test scope 에 한해 `spring-boot-starter-web`(random-port 전송 테스트용),
`spring-boot-starter-security`(HTTP 인증/CORS qualification 용),
`io.micrometer:micrometer-core`(실제 `MeterRegistry` 로 metric label cardinality 를 **측정**).
- `java-test-fixtures` — 계약 스위트·통합 fixture·in-memory 스텁은 `src/testFixtures/java`
소유하고 production jar 에 들어가지 않는다. 모듈 경계 스캐너
(`moduleboundary/GraphQlBuildModel`)는 main 과 testFixtures 를 **함께** 스캔한다: 아티팩트가
갈렸다고 패키지 경계 규칙까지 갈리면, 규칙이 조용히 절반만 남는다.
**서버는 이 leaf 가 고르지 않는다.** production 파일 중 `org.springframework.web`·
`jakarta.servlet`·`org.springframework.http` 을 import 하는 것은 **하나도 없다**. 예전에는
`spring-boot-starter-web` 을 production `implementation` 으로 두어 모든 adopter 의
runtimeClasspath 에 Tomcat 을 올리면서, 동시에 같은 artifact 가 `REACTIVE_WEBFLUX` 실행
프로파일을 표방했다 — leaf 와 함께 servlet 컨테이너가 따라오므로 결코 성립할 수 없는 조합이었다.
이제 서버 선택은 composition root 의 결정이고, `gradle.lockfile` 이 이를 고정한다
(`spring-boot-starter-web`·`spring-webmvc`·`spring-webflux`·`tomcat-embed-*` 전부
`testCompileClasspath,testRuntimeClasspath` 만). `GraphQlRuntimeTransport` 가 실제 실행 중인
서버를 감지해 `backend.graphql.execution-profile` 과 어긋나면 **부팅을 거부**한다.
- `annotationProcessor``spring-boot-configuration-processor``GraphQlPlatformProperties`
`@ConfigurationProperties` 이므로 레포 전역 `verifyConfigurationPropertiesProcessor` 패리티
게이트가 이 선언을 요구한다.
@@ -98,12 +120,30 @@ feature 는 `ApiErrorCarrier` 를 구현한 예외(자신의 `ApiErrorCode` 를
현재 sample 에 feature GraphQL schema/controller/resolver 가 있다고 가정하지 않는다. 이 leaf 는
health 스키마만 소유한다.
## 구현된 플랫폼 범위
## 구현된 플랫폼 범위 — 등급으로 말한다
query depth/cost 제한(`cost/`), persisted operation(`advanced/persisted/`),
DataLoader/batching(`dataloader/`), subscription(`advanced/subscription/`, `advanced/websocket/`,
`advanced/sse/`)은 **더 이상 미구현이 아니다.** 다만 이들은 정책·계약·검증 기계이며, 실제
composition root 가 채택할 때 정책 값과 인증/인가 빈을 함께 제공해야 한다.
"구현됐다" 는 네 가지 서로 다른 사실을 한 단어로 덮는다. 그래서 capability 마다 아래 등급을
쓰고, **현재 등급보다 높게 표현하지 않는다.**
| 등급 | 의미 |
| --- | --- |
| `modelled` | 정책·계약 객체가 있고 단위 테스트가 있다. 요청 경로에는 없다. |
| `wired` | Spring 실행 경로에 연결돼 있고, 실제 endpoint 테스트가 그 사실을 증명한다. |
| `integration-verified` | 실제 외부 시스템(datastore/broker) 과의 통합 증거가 있다. |
| `production-verified` | 실부하·장애 시나리오 증거가 있다. |
| Capability | 등급 | 증거 |
| --- | --- | --- |
| 실행 파이프라인 / 인가 / cost 예산 | `wired` | `runtime/GraphQlPlatformExecutionPathTest` (random-port, 거부 시 resolver 호출 0회) |
| depth/complexity 제한 (`cost/`) | `wired` | 같은 테스트의 depth/alias/complexity 케이스 |
| preparsed document cache (`execution/`) | `wired` | `GraphQlPreparsedDocumentAdapter` + 같은 테스트의 캐시 hit 케이스 |
| 커스텀 scalar (`scalar/`) | `wired` | 같은 테스트의 scalar coercion 케이스 |
| 요청 크기/Accept 협상 (`http/`) | `wired` | `GraphQlRequestBoundsTest`, `GraphQlAcceptNegotiationTest` |
| DataLoader/batching (`dataloader/`) | `wired` | `runtime/GraphQlBatchLoaderRegistrar` + `dataloader/GraphQlBatchContractTest` |
| persisted operation (`advanced/persisted/`) | `modelled` | 중립 `OperationalRecordStorePort` 기반 레지스트리 + 방향성 테스트. durable 구현체는 미제공 |
| subscription / WebSocket / SSE / RSocket | `modelled` | 정책·상태기계 단위 테스트만. Spring transport handler 는 없다(그래서 타입 이름도 `*Admission` 이다) |
| federation / incremental / codegen / compat | `modelled` | 단위 테스트만 |
| 실부하·장애 | 미달성 | `graphqlPerformanceTest` 레인이 자리를 예약, 증거 없으면 릴리스 게이트가 거부 |
여전히 미구현인 것:
@@ -114,7 +154,13 @@ composition root 가 채택할 때 정책 값과 인증/인가 빈을 함께 제
생성한다(`graphqlPerformanceTest` 레인이 그 자리를 예약해 둔다).
- 실제 datastore 통합 증거 — `testkit/GraphQlJpaIntegrationFixture` /
`GraphQlMongoIntegrationFixture` 가 계약을 정의하고 `GraphQlStorageIntegrationEvidence`
증거를 요구한다. 실 datastore 기동은 persistence leaf 의 책임 범위다.
증거를 요구한다. 실 datastore 기동은 persistence leaf 의 책임 범위다. 이 testkit 은
**production jar 에 없다**`src/testFixtures/java` 에 살고 `verifyGraphQlProductionJar`
그 사실을 jar 내용으로 확인한다.
- persisted operation 의 durable 저장 구현체 — 이 leaf 는 중립 계약
`dev.caskeleton.shared.opstore.OperationalRecordStorePort` 에만 의존하고 key/value 매핑만
소유한다. Postgres/Redis 구현체는 **그 중립 계약을** 구현하며, 이 leaf 의 타입을 구현하지
않는다(그랬다면 인프라 → 인바운드 전송으로 의존이 뒤집힌다).
- Advanced capability 는 전부 **기본 비활성**이다(`advanced/bootstrap/GraphQlAdvancedFeatureFlags`).
EXPERIMENTAL 등급(RSocket, incremental delivery, HTTP GET draft)은 명시적 승인 없이는
`GraphQlAdvancedModuleGuard` 가 production 활성화를 거부한다.
@@ -133,11 +179,30 @@ cd src
`quarantine`·`graphql-performance` 태그를 제외한다:
```bash
./gradlew :adapter:inbound:graphql:graphqlStableTest --console=plain # 404 tests
./gradlew :adapter:inbound:graphql:graphqlStableTest --console=plain # 551 tests
./gradlew :adapter:inbound:graphql:graphqlContractTest --console=plain # 9 tests
./gradlew :adapter:inbound:graphql:graphqlAdvancedTest --console=plain # 141 tests
./gradlew :adapter:inbound:graphql:graphqlAdvancedTest --console=plain # 152 tests
./gradlew :adapter:inbound:graphql:graphqlPerformanceTest --console=plain # 실부하 인프라 필요
```
`graphqlPerformanceTest``@Tag("graphql-performance")` 가 하나도 없으면 **실패한다** — 이는
버그가 아니라 "성능 증거 없음"을 통과로 위장하지 않기 위한 fail-closed 설계다.
위 숫자는 `build/test-results/<lane>/*.xml` 의 실제 실행 결과다(기본 `test` 703, transport
qualification 8). 문서에 옮겨 적은 숫자는 반드시 마지막 green 실행에서 다시 읽어 갱신한다 —
컴파일이 깨진 채로 남은 과거 숫자는 통과 증거가 아니라 통과했다는 인상일 뿐이다.
## 아티팩트 게이트
```bash
./gradlew :adapter:inbound:graphql:verifyGraphQlProductionJar --console=plain
./gradlew :adapter:inbound:graphql:verifyGraphQlApiSurface --console=plain
```
- `verifyGraphQlProductionJar` — production jar 에 `testkit`/`InMemory`/`Fixture`/`TestContext`
클래스가 하나라도 있으면 실패한다. 계약 스위트와 in-memory 스텁은 `src/testFixtures/java`
소유한다.
- `verifyGraphQlApiSurface``docs/architecture/graphql-api-surface.txt` 스냅샷과 실제 public
타입 목록이 다르면 실패한다. 단일 jar 안에서 `public` 은 모든 adopter 에게 public 이므로,
표면 증가는 리뷰 결정이지 빌드 부산물이 아니다. 승인 후:
`./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange`.
+43 -8
View File
@@ -72,11 +72,40 @@ gRPC 와 달리 spring-graphql / graphql-java 는 Spring Boot BOM 이 관리한
버전 명시도, 모듈 스코프 platform import 도 필요 없다 — `build.gradle` 은 BOM-managed 좌표만
선언하고, per-module `gradle.lockfile` 이 strict locking 으로 정확한 버전을 고정한다.
## 설정 — 프레임워크 `spring.graphql.*`
## 설정 — 프레임워크 `spring.graphql.*` + 플랫폼 `backend.graphql.*`
이 모듈은 자체 `@ConfigurationProperties` 를 두지 않는다. path, graphiql, introspection, schema
location 은 프레임워크 `spring.graphql.*` composition-root `application.yml` 에서 설정한다
(모듈별 `yml` 없음). 정말 필요한 knob 이 생기기 전까지 커스텀 설정 클래스는 두지 않는다.
전송 계층 설정(path, graphiql, introspection, schema location)은 프레임워크 `spring.graphql.*`
가 소유한다. composition-root `application.yml` 에서 설정하며 모듈별 `yml` 은 없다.
플랫폼 정책은 `spring.graphql.*` 로 표현할 수 없다 — 실행 프로파일, cost/page 한계, preparsed
캐시 경계, cursor 키 링, 관측 label 로 허용할 operation 이름은 전부 이 leaf 의 결정이다. 그래서
`GraphQlPlatformProperties`**`backend.graphql`** prefix 로 `@ConfigurationProperties`
바인딩한다(`spring.graphql.platform.*` 이 아니다 — 그 prefix 는 존재한 적이 없다).
```yaml
backend:
graphql:
production: true
environment: PRODUCTION_PUBLIC
execution-profile: BLOCKING_MVC
validation-policy-version: v1 # preparsed 캐시 키의 일부
console:
graphiql-enabled: false
introspection-enabled: false
limits:
maximum-page-size: 100
maximum-complexity: 10000
preparsed-cache-entries: 1000
preparsed-cache-weight: 10000000
preparsed-cache-expire-after-access: 30m
cursor:
key-ids: [cursor-key-1] # 키 자체는 설정에 오지 않는다
observed-operation-names: [] # 비우면 모든 operation 이름이 `other` 로 접힌다
```
`observed-operation-names` 가 비어 있는 것이 기본값이자 안전한 값이다. operation 이름은 문법만
검증될 뿐 개수가 제한되지 않으므로, 원본을 그대로 metric label 로 쓰면 정상 클라이언트 하나가
metrics 백엔드를 무너뜨릴 수 있다(`observation/GraphQlOperationNameCardinality`).
`GraphqlHttpBoundaryQualificationTest` 는 실제 random-port MVC HTTP 서버 위에서 test-only
SecurityFilterChain 과 CORS allowlist 를 조합해 인증, origin, GraphiQL 비활성화, introspection
@@ -109,10 +138,16 @@ composition root 는 이 leaf 를 채택할 때 인증/인가 및 CORS 정책을
분해 비용은 낮게 유지했다.
어느 패턴이든 "패키지는 경계가 아니다"라는 약점은 기계 검증으로 메웠다 —
`build/GraphQlStableModule`·`GraphQlAdvancedModule` 이 모듈 정체성과 허용 edge 를 값으로 선언하고,
`GraphQlModuleBoundaryTest` 가 **실제 소스 트리를 스캔**해 Stable→Advanced import, core-api 의
프레임워크 import, Stable edge 의 Advanced 참조를 실패시킨다. Gradle 이 해주던 일을 테스트가
한다.
`moduleboundary/GraphQlStableModule`·`GraphQlAdvancedModule` 이 모듈 정체성과 허용 edge 를 값으로
선언하고, `GraphQlModuleBoundaryTest` 가 **실제 소스 트리를 스캔**해 Stable→Advanced import,
CORE 모듈의 프레임워크 import, 선언되지 않은 edge, 미등록 패키지를 실패시킨다. Gradle 이
해주던 일을 테스트가 한다.
스캔은 컴파일된 클래스가 아니라 **소스 텍스트**를 읽는다. 경계가 금지하는 import 는 상수
인라이닝이나 미보존 시그니처로 바이트코드에서 지워지는 경우가 많아서, 바이트코드 스캔은
리뷰어가 읽는 소스가 여전히 경계를 넘는데도 clean 이라고 보고한다. 그리고 스캐너는
**파일을 하나도 못 찾으면 통과가 아니라 실패한다** — 0개 스캔으로 green 이 되는 것이 이
모델이 막으려는 실패 그 자체다.
## ArchUnit/JPA 없이 아키텍처 규칙을 강제한 방법
+196 -9
View File
@@ -10,27 +10,53 @@
// coordinates the BOM does not manage).
description = 'Inbound adapter: GraphQL API (Spring for GraphQL, GraphQL execution platform)'
// The contract suites, the integration fixtures and the in-memory registries are for the people
// verifying an adoption, not for the adoption. Shipped in the production jar they were reachable
// from any adopter's runtime code — an in-memory persisted-operation registry is a perfectly
// working bean until the second instance starts, and a `testContext(String)` mints an authenticated
// actor without a credential. A separate test-fixtures artifact keeps them consumable by the tests
// that want them and out of the jar that runs in production; `verifyGraphQlProductionJar` checks
// the second half rather than trusting it.
apply plugin: 'java-test-fixtures'
apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle"
apply from: "${rootProject.projectDir}/gradle/graphql-platform-conventions.gradle"
dependencies {
implementation project(':shared-contract')
// The fixtures exercise the platform through the same contracts an adopter uses.
testFixturesImplementation project(':shared-contract')
testFixturesImplementation 'org.springframework.boot:spring-boot-starter-graphql'
// Transport-neutral on purpose. The platform binds to Spring for GraphQL's execution and
// interceptor contracts, and to nothing that decides which server runs them: no production file
// imports `org.springframework.web`, `jakarta.servlet` or `org.springframework.http`.
//
// Depending on `spring-boot-starter-web` here put an embedded Tomcat on every adopter's
// runtimeClasspath while the same artifact advertised a REACTIVE_WEBFLUX execution profile —
// a profile that could never have run, because the servlet container arrived with the leaf.
// Choosing the server is the composition root's decision; this leaf states which profile it was
// configured for and refuses to start when the running context disagrees.
implementation 'org.springframework.boot:spring-boot-starter-graphql'
implementation 'org.springframework.boot:spring-boot-starter-web'
// GraphQlPlatformProperties is a @ConfigurationProperties binding, so this leaf owes the
// repository-wide processor parity gate (`verifyConfigurationPropertiesProcessor`) a metadata
// declaration — an adopter configuring spring.graphql.platform.* gets IDE completion and
// validation from the generated metadata rather than from prose.
// declaration — an adopter configuring backend.graphql.* gets IDE completion and validation
// from the generated metadata rather than from prose. (The prefix is `backend.graphql`; this
// comment used to say `spring.graphql.platform.*`, which never existed.)
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
// REACTIVE_WEBFLUX execution profile (design §10). WebFlux is compileOnly on purpose: the
// reactive transport adapter and its event-loop guard compile against Spring's reactive
// transport types, but an adopter that runs the BLOCKING_MVC profile must not inherit a WebFlux
// runtime. Reactor Core itself arrives with spring-graphql, so the reactive contracts stay
// usable in both profiles. spring-webflux is already on the test classpath.
compileOnly 'org.springframework:spring-webflux'
// A raw request body can only be capped before something decodes it, and on a servlet stack the
// only place that exists is a filter. `compileOnly` is what keeps that from contradicting the
// paragraph above: it is the servlet API, not a server, and it stays off runtimeClasspath
// entirely — so the filter class simply never loads for an adopter who is not running servlets.
compileOnly 'jakarta.servlet:jakarta.servlet-api'
// The random-port transport tests need a real servlet server; production does not. Keeping the
// server on the test classpath is what lets the qualification prove the platform works over
// HTTP without shipping that choice to adopters.
testImplementation 'org.springframework.boot:spring-boot-starter-web'
// GraphQlTester (spring-graphql-test, BOM-managed) — the health test assembles the schema +
// controller through a real AnnotatedControllerConfigurer and drives it with an
@@ -41,6 +67,11 @@ dependencies {
// a test-only authentication/CORS composition. Security remains a composition-root concern;
// this dependency does not add production security policy to the opt-in GraphQL adapter.
testImplementation 'org.springframework.boot:spring-boot-starter-security'
// A real MeterRegistry, so the cardinality claim is measured rather than asserted. Only
// micrometer-observation is on the production classpath; a registry that actually stores series
// is what turns "this tag is bounded" into a number a test can fail on.
testImplementation 'io.micrometer:micrometer-core'
}
registerGraphQlPlatformTestLanes()
@@ -52,3 +83,159 @@ registerStrictQualificationTest(
'dev.caskeleton.adapter.inbound.graphql.GraphqlHttpBoundaryQualificationTest'
],
description: 'Runs exact no-skip GraphQL conditional transport wire evidence.')
// verifyGraphQlProductionJar — the production artifact must carry nothing a test wrote.
//
// Moving the testkit into test fixtures is a source-tree decision, and source-tree decisions drift.
// One `implementation` where a `testFixturesImplementation` belonged, one file created in the wrong
// directory, and the contract suites are back inside the jar an adopter deploys — where an
// in-memory persisted-operation registry looks like a working bean until a second instance starts,
// and where `testContext(String)` hands out an authenticated actor to anyone who calls it.
//
// So the claim is checked against the jar rather than against the layout that is supposed to
// produce it. Entry names and class names only: this reads the archive index, never the bytecode.
tasks.register('verifyGraphQlProductionJar') {
group = 'verification'
description = 'Fails when the GraphQL production jar contains testkit, fixture or in-memory-only types.'
dependsOn tasks.named('jar')
def jarFile = tasks.named('jar').flatMap { it.archiveFile }
inputs.file(jarFile)
outputs.upToDateWhen { true }
doLast {
Map<String, String> forbidden = [
'/testkit/' : 'contract suites and integration fixtures belong to test fixtures',
'InMemory' : 'an in-memory implementation is a development stand-in, not a shipped default',
'ForTests' : 'a for-tests factory in the production jar is reachable from production code',
'TestContext' : 'a credential-free authenticated context must not ship',
'Fixture' : 'fixtures belong to test fixtures',
]
List<String> violations = []
new java.util.zip.ZipFile(jarFile.get().asFile).withCloseable { archive ->
archive.entries().each { entry ->
if (entry.directory || !entry.name.endsWith('.class')) {
return
}
forbidden.each { marker, reason ->
if (entry.name.contains(marker)) {
violations << "${entry.name}: ${reason}"
}
}
}
}
if (!violations.isEmpty()) {
throw new GradleException(
"The GraphQL production jar contains non-production types:\n " +
violations.sort().join('\n ') +
"\nMove them to src/testFixtures/java, or declare them with " +
"testFixturesImplementation."
)
}
}
}
tasks.named('check') {
dependsOn tasks.named('verifyGraphQlProductionJar')
}
// verifyGraphQlApiSurface — every public type this leaf exposes is a committed decision.
//
// One jar, 40-odd packages, and a public type in any of them is reachable from every adopter's
// code. Package boundaries express the intended structure but enforce nothing across a single
// artifact: `public` inside a jar means public to everybody who has the jar. The consequence is not
// hypothetical — a package that went missing from a commit was still compiled against by seven
// production files, and nothing in the build had an opinion about what the surface was supposed to
// be.
//
// A snapshot does not shrink the surface. It makes each addition visible in review, which is the
// 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
// split into capability artifacts. Until then the number cannot grow by accident.
def graphQlApiSurfaceFile = rootProject.file('../docs/architecture/graphql-api-surface.txt')
Closure<String> renderGraphQlApiSurface = {
def sourceRoot = file('src/main/java')
def typePattern = ~/(?m)^public\s+(?:final\s+|abstract\s+|sealed\s+|non-sealed\s+)*(class|interface|enum|record|@interface)\s+(\w+)/
def packagePattern = ~/(?m)^package\s+([\w.]+)\s*;/
List<String> types = []
sourceRoot.eachFileRecurse { candidate ->
if (!candidate.isFile() || !candidate.name.endsWith('.java')) {
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')
}
tasks.register('verifyGraphQlApiSurface') {
group = 'verification'
description = 'Fails without mutation when the committed GraphQL public API surface drifts.'
doLast {
if (project.hasProperty('approveGraphQlApiSurfaceChange')) {
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')
}
+80 -76
View File
@@ -1,37 +1,37 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath,testFixturesCompileClasspath
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath,testFixturesCompileClasspath
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath,testFixturesCompileClasspath
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath,testFixturesCompileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
com.graphql-java:graphql-java:25.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.graphql-java:java-dataloader:6.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.graphql-java:graphql-java:25.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
com.graphql-java:java-dataloader:6.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
@@ -39,18 +39,20 @@ com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspat
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
io.micrometer:context-propagation:1.2.0=runtimeClasspath,testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
io.micrometer:context-propagation:1.2.0=runtimeClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
io.micrometer:micrometer-core:1.16.0=testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
jakarta.servlet:jakarta.servlet-api:6.1.0=compileClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
@@ -64,16 +66,16 @@ org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
@@ -84,8 +86,9 @@ org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
org.hdrhistogram:HdrHistogram:2.2.2=testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testFixturesAnnotationProcessor,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
@@ -95,79 +98,80 @@ org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.latencyutils:LatencyUtils:2.0.3=testRuntimeClasspath
org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath,testFixturesCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath,testFixturesCompileClasspath
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath,testFixturesCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath,testFixturesCompileClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.reflections:reflections:0.10.2=checkstyle
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
org.springframework.boot:spring-boot-graphql-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-codec:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-graphql-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-security:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-web:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-web:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webtestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.graphql:spring-graphql-test:2.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.graphql:spring-graphql:2.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.graphql:spring-graphql:2.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.security:spring-security-config:7.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-core:7.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-crypto:7.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-web:7.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webflux:7.0.1=compileClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webflux:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
empty=
@@ -1,72 +0,0 @@
package dev.caskeleton.adapter.inbound.graphql;
import dev.caskeleton.shared.error.ApiErrorCarrier;
import dev.caskeleton.shared.error.ApiErrorCode;
import dev.caskeleton.shared.error.Category;
import graphql.GraphQLError;
import graphql.GraphqlErrorBuilder;
import graphql.schema.DataFetchingEnvironment;
import java.util.Map;
import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter;
import org.springframework.graphql.execution.ErrorType;
import org.springframework.stereotype.Component;
/**
* Centralises the GraphQL error contract: a data fetcher just throws, and this resolver translates
* any throwable carrying a stable {@link ApiErrorCode} (via the shared-contract {@link
* ApiErrorCarrier} hook) into a {@link GraphQLError} with an {@link ErrorType} classification plus
* machine-readable {@code code} / {@code category} extensions — the GraphQL sibling of the web
* adapter's {@code GlobalExceptionHandler} and the gRPC adapter's {@code
* GrpcExceptionHandlingInterceptor}.
*
* <p>The {@link ApiErrorCarrier} hook is implemented by the shared-contract {@code
* PersistenceFailureException} / {@code DependencyFailureException} (an error surfacing from an
* outbound adapter) and by feature throwables (which carry a mapped domain {@code ApiErrorCode}),
* so a single {@code instanceof ApiErrorCarrier} branch covers them all. A non-carrier throwable
* returns {@code null}: Spring for GraphQL then merges the other {@link
* org.springframework.graphql.execution.DataFetcherExceptionResolver} beans (e.g. a feature's own
* resolver mapping its domain exceptions) and finally its default handling. Only the stable {@link
* ApiErrorCode#code()} reaches the client — never the raw exception message, which may carry a
* SQLState or upstream detail.
*/
@Component
public class GraphqlExceptionResolver extends DataFetcherExceptionResolverAdapter {
@Override
protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
if (!(ex instanceof ApiErrorCarrier carrier)) {
return null; // fall through to other resolvers / Spring's default handling
}
ApiErrorCode code = carrier.errorCode();
var builder =
GraphqlErrorBuilder.newError()
.errorType(classify(code.category()))
.message(code.code())
.extensions(Map.of("code", code.code(), "category", code.category().name()));
// A real GraphQL execution always supplies the environment; a unit test may pass null. Only
// attach the field path/location when they are present.
if (env != null) {
builder.path(env.getExecutionStepInfo().getPath());
if (env.getField() != null) {
builder.location(env.getField().getSourceLocation());
}
}
return builder.build();
}
/**
* Maps the 10-value operational {@link Category} SSOT to a GraphQL {@link ErrorType} (design
* Error-Mapping table). The switch is exhaustive, so a new {@link Category} fails to compile
* until a mapping decision is made.
*/
private static ErrorType classify(Category category) {
return switch (category) {
case VALIDATION, CONFLICT, RATE_LIMIT -> ErrorType.BAD_REQUEST;
case AUTH -> ErrorType.UNAUTHORIZED;
case AUTHZ -> ErrorType.FORBIDDEN;
case NOT_FOUND -> ErrorType.NOT_FOUND;
case TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL ->
ErrorType.INTERNAL_ERROR;
};
}
}
@@ -0,0 +1,37 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.admin;
import java.util.Objects;
/**
* A verified administrator, as the transport established them.
*
* <p>The admin service used to take the operator as a bare {@code String} and check it against an
* allowlist. A string is not evidence: any caller that could reach the service could name any
* operator on the list, so the allowlist described who <em>may</em> administer the registry while
* proving nothing about who actually did. The audit trail then recorded that name as fact.
*
* <p>Only the transport can construct this, having verified the credential, and it records whether
* the credential came from the request path. That check used to exist as a method nobody called.
*
* @param operator the verified operator reference
* @param applicationCredential whether the credential is one the request path also holds
*/
public record GraphQlAdminPrincipal(String operator, boolean applicationCredential) {
public GraphQlAdminPrincipal {
Objects.requireNonNull(operator, "operator is required");
if (operator.isBlank()) {
throw new IllegalArgumentException("operator is required");
}
}
/** A principal established from a dedicated operations credential. */
public static GraphQlAdminPrincipal operations(String operator) {
return new GraphQlAdminPrincipal(operator, false);
}
/** A principal established from a credential the request path also holds. */
public static GraphQlAdminPrincipal fromApplicationCredential(String operator) {
return new GraphQlAdminPrincipal(operator, true);
}
}
@@ -26,24 +26,20 @@ public final class GraphQlPersistedOperationAdminAuthorization {
}
/**
* Requires the operator to be an administrator.
* Requires a verified administrator holding an operations credential.
*
* @throws GraphQlPersistedOperationAdminDeniedException when they are not
*/
public void requireAdministrator(String operator) {
if (operator == null || !administrators.contains(operator)) {
throw new GraphQlPersistedOperationAdminDeniedException();
}
}
/**
* Refuses an application credential outright.
* <p>Both halves are checked here now. The credential-kind refusal used to be a separate public
* method that no caller invoked, so an application credential naming an allowlisted operator
* passed — which is the compromise this class exists to prevent, arriving through the door it
* documented.
*
* @throws GraphQlPersistedOperationAdminDeniedException when the caller came from the request
* path
* @throws GraphQlPersistedOperationAdminDeniedException when the principal is absent, not an
* administrator, or authenticated with a credential the request path also holds
*/
public void rejectApplicationCredential(boolean applicationCredential) {
if (applicationCredential) {
public void requireAdministrator(GraphQlAdminPrincipal principal) {
if (principal == null
|| principal.applicationCredential()
|| !administrators.contains(principal.operator())) {
throw new GraphQlPersistedOperationAdminDeniedException();
}
}
@@ -0,0 +1,47 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.admin;
import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperation;
import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationId;
import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationTransition;
/**
* Applies a registry change and records it, as one durable unit.
*
* <p>The two used to be separate steps: the registry was mutated, then an entry was appended to an
* {@code ArrayList} field. A crash between them left a change nobody could account for, a failure
* in the append left a change with no record, and the list itself was not thread-safe, so two
* concurrent administrators could lose an entry outright. An audit trail with any of those
* properties is worse than none, because it is trusted.
*
* <p>A port rather than an implementation: transactional guarantees over both the registry and the
* trail need a store, and which store provides them is a deployment decision.
*/
public interface GraphQlPersistedOperationAdminPort {
/**
* Registers an operation and records it atomically.
*
* @return the stored operation
* @throws dev.caskeleton.adapter.inbound.graphql.advanced.persisted
* .GraphQlPersistedOperationConflictException when the id already holds a different document
*/
GraphQlPersistedOperation register(
GraphQlPersistedOperation operation, GraphQlPersistedOperationAudit audit);
/**
* Applies a transition and records it atomically.
*
* @return the operation as it now stands
* @throws dev.caskeleton.adapter.inbound.graphql.advanced.persisted
* .GraphQlPersistedOperationNotFoundException when the operation does not exist
* @throws dev.caskeleton.adapter.inbound.graphql.advanced.persisted
* .GraphQlPersistedOperationConflictException when the transition is not permitted
*/
GraphQlPersistedOperation apply(
GraphQlPersistedOperationId operationId,
GraphQlPersistedOperationTransition transition,
GraphQlPersistedOperationAudit audit);
/** The recorded trail, oldest first. */
java.util.List<GraphQlPersistedOperationAudit> auditTrail();
}
@@ -2,53 +2,59 @@ package dev.caskeleton.adapter.inbound.graphql.advanced.admin;
import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperation;
import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationId;
import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationRegistry;
import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationStatus;
import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationTransition;
import java.time.Clock;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* The G4 operations plane for approved operations (Advanced plan Task 4).
*
* <p>Every change is authorized against the administrator set and recorded in the audit trail,
* because a registry change silently alters what the whole platform will execute. Blocking takes
* effect immediately; removal has to pass the usage gate first.
* <p>Every change is authorized against a verified principal and recorded in the same durable unit
* as the change itself, because a registry change silently alters what the whole platform will
* execute. Blocking takes effect immediately; retiring has to pass the usage gate first.
*
* <p>Every command returns the stored operation. A command that cannot be applied throws, so the
* audit trail records changes that happened rather than changes that were attempted — the previous
* service wrote {@code ABSENT -> BLOCKED} for operations that did not exist, which is precisely the
* entry an operator would trust during an incident.
*/
public final class GraphQlPersistedOperationAdminService {
private final GraphQlPersistedOperationRegistry registry;
private final GraphQlPersistedOperationAdminPort adminPort;
private final GraphQlPersistedOperationAdminAuthorization authorization;
private final GraphQlPersistedOperationRemovalGate removalGate;
private final Clock clock;
private final List<GraphQlPersistedOperationAudit> auditTrail = new ArrayList<>();
/**
* Creates the service.
*
* @param registry the approved operation store
* @param authorization who may administer it
* @param removalGate the usage gate protecting removals
* @param adminPort applies registry changes and their audit entries atomically
* @param authorization who may administer the registry
* @param removalGate the usage gate protecting retirement
* @param clock clock used for audit timestamps and the quiet period
*/
public GraphQlPersistedOperationAdminService(
GraphQlPersistedOperationRegistry registry,
GraphQlPersistedOperationAdminPort adminPort,
GraphQlPersistedOperationAdminAuthorization authorization,
GraphQlPersistedOperationRemovalGate removalGate,
Clock clock) {
this.registry = Objects.requireNonNull(registry);
this.adminPort = Objects.requireNonNull(adminPort);
this.authorization = Objects.requireNonNull(authorization);
this.removalGate = Objects.requireNonNull(removalGate);
this.clock = Objects.requireNonNull(clock);
}
/** Registers a new approved operation. */
public void register(
GraphQlPersistedOperation operation, String operator, String reason, String traceId) {
authorization.requireAdministrator(operator);
registry.register(operation);
audit(operation.id().value(), operator, reason, "ABSENT", operation.status().name(), traceId);
public GraphQlPersistedOperation register(
GraphQlPersistedOperation operation,
GraphQlAdminPrincipal principal,
String reason,
String traceId) {
authorization.requireAdministrator(principal);
return adminPort.register(
operation,
audit(operation.id(), principal, reason, "ABSENT", operation.status().name(), traceId));
}
/**
@@ -56,77 +62,100 @@ public final class GraphQlPersistedOperationAdminService {
*
* <p>Takes effect on the next request; a cached parse does not keep it executable.
*/
public void block(GraphQlPersistedOperationBlockCommand command) {
authorization.requireAdministrator(command.operator());
String before =
registry
.find(command.operationId())
.map(operation -> operation.status().name())
.orElse("ABSENT");
registry.updateStatus(command.operationId(), GraphQlPersistedOperationStatus.BLOCKED);
audit(
command.operationId().value(),
command.operator(),
public GraphQlPersistedOperation block(
GraphQlPersistedOperationBlockCommand command, GraphQlAdminPrincipal principal) {
return transition(
command.operationId(),
GraphQlPersistedOperationTransition.BLOCK,
principal,
command.reason(),
before,
GraphQlPersistedOperationStatus.BLOCKED.name(),
command.traceId());
}
/**
* Reverses a block.
*
* <p>Its own command, so leaving the emergency state is an explicit decision with its own audit
* entry. It used to be reachable by marking a blocked operation deprecated, which reads like a
* documentation change and made it executable again.
*/
public GraphQlPersistedOperation unblock(
GraphQlPersistedOperationId operationId,
GraphQlAdminPrincipal principal,
String reason,
String traceId) {
return transition(
operationId, GraphQlPersistedOperationTransition.UNBLOCK, principal, reason, traceId);
}
/** Marks an operation deprecated, which keeps it executable while clients migrate. */
public void deprecate(
GraphQlPersistedOperationId operationId, String operator, String reason, String traceId) {
authorization.requireAdministrator(operator);
String before =
registry.find(operationId).map(operation -> operation.status().name()).orElse("ABSENT");
registry.updateStatus(operationId, GraphQlPersistedOperationStatus.DEPRECATED);
audit(
operationId.value(),
operator,
reason,
before,
GraphQlPersistedOperationStatus.DEPRECATED.name(),
traceId);
public GraphQlPersistedOperation deprecate(
GraphQlPersistedOperationId operationId,
GraphQlAdminPrincipal principal,
String reason,
String traceId) {
return transition(
operationId, GraphQlPersistedOperationTransition.DEPRECATE, principal, reason, traceId);
}
/**
* Removes an operation once usage evidence permits it.
* Retires an operation once usage evidence permits it.
*
* <p>Named for what it does. It was called {@code remove}, and it blocked rather than deleted —
* an operator reading the method name would have believed the document was gone.
*
* @throws GraphQlPersistedOperationRemovalRejectedException when it was used within the quiet
* period
*/
public void remove(
public GraphQlPersistedOperation retireAndBlock(
GraphQlPersistedOperationId operationId,
GraphQlPersistedOperationUsage usage,
String operator,
GraphQlAdminPrincipal principal,
String reason,
String traceId) {
authorization.requireAdministrator(operator);
authorization.requireAdministrator(principal);
removalGate.verify(usage, clock.instant());
registry.updateStatus(operationId, GraphQlPersistedOperationStatus.BLOCKED);
audit(
operationId.value(),
operator,
reason,
"REMOVAL_APPROVED",
GraphQlPersistedOperationStatus.BLOCKED.name(),
traceId);
return transition(
operationId, GraphQlPersistedOperationTransition.RETIRE, principal, reason, traceId);
}
/** The audit trail, in order. */
public List<GraphQlPersistedOperationAudit> auditTrail() {
return List.copyOf(auditTrail);
return adminPort.auditTrail();
}
private void audit(
String operationId,
String operator,
private GraphQlPersistedOperation transition(
GraphQlPersistedOperationId operationId,
GraphQlPersistedOperationTransition transition,
GraphQlAdminPrincipal principal,
String reason,
String traceId) {
authorization.requireAdministrator(principal);
// The audit entry names the state the operation is actually leaving. The port applies both
// together, so a rejected transition leaves no entry at all.
GraphQlPersistedOperation updated =
adminPort.apply(
operationId,
transition,
audit(
operationId,
principal,
reason,
transition.name(),
transition.target().name(),
traceId));
return updated;
}
private GraphQlPersistedOperationAudit audit(
GraphQlPersistedOperationId operationId,
GraphQlAdminPrincipal principal,
String reason,
String before,
String after,
String traceId) {
auditTrail.add(
new GraphQlPersistedOperationAudit(
operationId, operator, reason, before, after, clock.instant(), traceId));
return new GraphQlPersistedOperationAudit(
operationId.value(), principal.operator(), reason, before, after, clock.instant(), traceId);
}
}
@@ -26,9 +26,6 @@ public enum GraphQlAdvancedCapability {
/** Client and transport DTO code generation. */
CODE_GENERATION(GraphQlAdvancedCapabilityGrade.ADVANCED),
/** Allowlisted Spring Data repository exposure. */
SPRING_DATA_COMPAT(GraphQlAdvancedCapabilityGrade.ADVANCED),
/** GraphQL Java 25 chained DataLoader dispatch. */
DATALOADER_CHAINING(GraphQlAdvancedCapabilityGrade.ADVANCED),
@@ -1,6 +1,8 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap;
import dev.caskeleton.adapter.inbound.graphql.build.GraphQlBuildModel;
import dev.caskeleton.adapter.inbound.graphql.moduleboundary.GraphQlAdvancedModule;
import dev.caskeleton.adapter.inbound.graphql.moduleboundary.GraphQlModuleBoundary;
import dev.caskeleton.adapter.inbound.graphql.moduleboundary.GraphQlStableModule;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
@@ -23,9 +25,9 @@ public final class GraphQlAdvancedDependencyRules {
* @throws IllegalStateException naming the offending edges
*/
public static void verifyStableDoesNotDependOnAdvanced() {
Set<String> advanced = GraphQlBuildModel.advancedModules();
Set<String> advanced = GraphQlAdvancedModule.moduleIds();
List<String> violations = new ArrayList<>();
GraphQlBuildModel.stableDependencyEdges()
GraphQlStableModule.dependencyEdges()
.forEach(
(module, dependencies) ->
dependencies.stream()
@@ -40,7 +42,7 @@ public final class GraphQlAdvancedDependencyRules {
/** Whether a package belongs to an Advanced capability. */
public static boolean advancedPackage(String packageName) {
return packageName != null
&& packageName.startsWith(GraphQlBuildModel.PACKAGE_ROOT + ".advanced");
&& packageName.startsWith(GraphQlModuleBoundary.PACKAGE_ROOT + ".advanced");
}
/** Every capability that must be flagged before it can run. */
@@ -1,13 +1,17 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.codegen;
import dev.caskeleton.adapter.inbound.graphql.compat.GraphQlSchemaComparator;
import java.util.Objects;
/**
* Generates client request and response types, validating operations against the schema first.
* Plans client code generation and validates the operations it would generate from.
*
* <p>Compile-time validation is most of the value: an operation that no longer matches the schema
* becomes a build failure in the client's repository instead of a runtime error in production.
* <p>Named a plan because that is what it produces: which kinds may be generated and into which
* package. No source writer and no Gradle task exist behind it, and calling it a generator invited
* the reasonable assumption that running it emitted files.
*
* <p>Validation is the part that does real work, and it is most of the value: an operation that no
* longer matches the schema becomes a build failure in the client's repository instead of a runtime
* error in production.
*/
public final class GraphQlClientOperationGenerator {
@@ -27,18 +31,26 @@ public final class GraphQlClientOperationGenerator {
*
* @param sdl the schema
* @param operationDocument the operation to validate
* @throws GraphQlCodegenBoundaryException when either is missing
* @throws GraphQlCodegenBoundaryException when the document does not parse or does not match
*/
public void validateOperation(String sdl, String operationDocument) {
if (sdl == null || sdl.isBlank() || operationDocument == null || operationDocument.isBlank()) {
throw new GraphQlCodegenBoundaryException("CLIENT_REQUEST");
}
// Parsing the schema is what makes generation fail on an invalid schema rather than emitting
// sources against one.
GraphQlSchemaComparator.compare(sdl, sdl);
validateOperation(sdl, operationDocument, null);
}
/** The kinds this generator produces. */
/**
* Validates one named operation from a document.
*
* @param sdl the schema
* @param operationDocument the operation document
* @param operationName which operation, when the document declares several
* @throws GraphQlCodegenBoundaryException when the document does not parse, does not match the
* schema, or names no such operation
*/
public void validateOperation(String sdl, String operationDocument, String operationName) {
GraphQlOperationValidator.validate(sdl, operationDocument, operationName);
}
/** The kinds this plan covers. */
public java.util.Set<String> generatedTypes() {
profile.generatedTypes().forEach(GraphQlGeneratedSourceBoundary.standard()::requireAllowed);
return profile.generatedTypes();
@@ -0,0 +1,111 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.codegen;
import graphql.language.Definition;
import graphql.language.Document;
import graphql.language.OperationDefinition;
import graphql.parser.InvalidSyntaxException;
import graphql.parser.Parser;
import graphql.schema.GraphQLSchema;
import graphql.schema.idl.RuntimeWiring;
import graphql.schema.idl.SchemaGenerator;
import graphql.schema.idl.SchemaParser;
import graphql.schema.idl.TypeDefinitionRegistry;
import graphql.schema.idl.errors.SchemaProblem;
import graphql.validation.ValidationError;
import graphql.validation.Validator;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* Validates a client operation against the schema it will be compiled for.
*
* <p>The previous check confirmed both strings were non-blank and then compared the schema with
* itself, which is true of every schema. The operation document was never read, so a document with
* invalid syntax, an unknown field, or an argument that does not exist passed validation and became
* generated client code that fails at runtime — in the client's repository, against a schema that
* had already changed.
*
* <p>The schema is compiled to an executable {@link GraphQLSchema} because that is what the
* validator needs: field and argument existence, type compatibility and variable usage are
* questions about types, and a parsed SDL registry alone cannot answer them.
*/
public final class GraphQlOperationValidator {
private GraphQlOperationValidator() {}
/**
* Validates one operation document against a schema.
*
* @param sdl the schema, as SDL
* @param operationDocument the client operation
* @param operationName the operation to validate when the document declares several, or {@code
* null} when it declares one
* @throws GraphQlCodegenBoundaryException when either input is missing, the schema does not
* compile, the document does not parse, the document does not match the schema, or the
* operation to validate is ambiguous
*/
public static void validate(String sdl, String operationDocument, String operationName) {
if (sdl == null || sdl.isBlank() || operationDocument == null || operationDocument.isBlank()) {
throw new GraphQlCodegenBoundaryException("CLIENT_REQUEST");
}
GraphQLSchema schema = compile(sdl);
Document document = parse(operationDocument);
requireUnambiguousOperation(document, operationName);
List<ValidationError> errors = new Validator().validateDocument(schema, document, Locale.ROOT);
if (!errors.isEmpty()) {
List<String> messages = new ArrayList<>();
errors.forEach(error -> messages.add(error.getMessage()));
throw new GraphQlCodegenBoundaryException("CLIENT_REQUEST: " + String.join("; ", messages));
}
}
private static GraphQLSchema compile(String sdl) {
try {
TypeDefinitionRegistry registry = new SchemaParser().parse(sdl);
return new SchemaGenerator().makeExecutableSchema(registry, RuntimeWiring.MOCKED_WIRING);
} catch (SchemaProblem | InvalidSyntaxException invalid) {
throw new GraphQlCodegenBoundaryException("CLIENT_REQUEST: schema does not compile");
}
}
private static Document parse(String operationDocument) {
try {
return Parser.parse(operationDocument);
} catch (InvalidSyntaxException invalid) {
throw new GraphQlCodegenBoundaryException("CLIENT_REQUEST: operation does not parse");
}
}
/**
* Refuses a document whose operation cannot be identified.
*
* <p>Generating from a multi-operation document without being told which one means picking by
* position, and the generated client then changes meaning when someone reorders the file.
*/
private static void requireUnambiguousOperation(Document document, String operationName) {
List<OperationDefinition> operations = new ArrayList<>();
for (Definition<?> definition : document.getDefinitions()) {
if (definition instanceof OperationDefinition operation) {
operations.add(operation);
}
}
if (operations.isEmpty()) {
throw new GraphQlCodegenBoundaryException("CLIENT_REQUEST: document declares no operation");
}
if (operationName == null || operationName.isBlank()) {
if (operations.size() > 1) {
throw new GraphQlCodegenBoundaryException(
"CLIENT_REQUEST: document declares " + operations.size() + " operations");
}
return;
}
boolean found = operations.stream().anyMatch(op -> operationName.equals(op.getName()));
if (!found) {
throw new GraphQlCodegenBoundaryException(
"CLIENT_REQUEST: no operation named " + operationName);
}
}
}
@@ -1,39 +0,0 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
import java.util.Set;
/**
* The repositories permitted to back GraphQL fields.
*
* <p>Empty by default. Spring Data's automatic exposure is convenient and turns filter, sort and
* pagination semantics into public API the moment it is switched on — the allowlist is what makes
* each of those a decision.
*/
public final class GraphQlRepositoryAllowlist {
private final Set<String> repositoryNames;
private GraphQlRepositoryAllowlist(Set<String> repositoryNames) {
this.repositoryNames = Set.copyOf(repositoryNames);
}
/** Nothing exposed. */
public static GraphQlRepositoryAllowlist empty() {
return new GraphQlRepositoryAllowlist(Set.of());
}
/** The named repositories exposed. */
public static GraphQlRepositoryAllowlist of(String... repositoryNames) {
return new GraphQlRepositoryAllowlist(Set.of(repositoryNames));
}
/** Whether a repository is allowlisted. */
public boolean contains(String repositoryName) {
return repositoryNames.contains(repositoryName);
}
/** The allowlisted repositories. */
public Set<String> repositoryNames() {
return repositoryNames;
}
}
@@ -1,40 +0,0 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
import java.util.Set;
import java.util.TreeSet;
/**
* Which filter and sort arguments an exposed repository accepts.
*
* <p>Enumerated, because automatic exposure turns GraphQL arguments into Querydsl predicates: an
* unlisted argument becomes a query nobody designed, against a column that may have no index and
* may not be meant to be filterable at all.
*
* @param allowedFilterFields fields that may be filtered on
* @param allowedSortFields fields that may be sorted by
*/
public record GraphQlRepositoryArgumentPolicy(
Set<String> allowedFilterFields, Set<String> allowedSortFields) {
public GraphQlRepositoryArgumentPolicy {
allowedFilterFields = Set.copyOf(allowedFilterFields);
allowedSortFields = Set.copyOf(allowedSortFields);
}
/**
* Verifies the arguments a request supplied.
*
* @throws GraphQlRepositoryExposureRejectedException naming the unlisted arguments
*/
public void verify(Set<String> filterFields, Set<String> sortFields) {
var rejected = new TreeSet<String>();
filterFields.stream()
.filter(field -> !allowedFilterFields.contains(field))
.forEach(rejected::add);
sortFields.stream().filter(field -> !allowedSortFields.contains(field)).forEach(rejected::add);
if (!rejected.isEmpty()) {
throw new GraphQlRepositoryExposureRejectedException(
"unlisted filter or sort fields " + rejected);
}
}
}
@@ -1,19 +0,0 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
/**
* One repository exposed at one schema coordinate.
*
* @param repositoryName the repository
* @param schemaCoordinate the field it backs
*/
public record GraphQlRepositoryExposure(String repositoryName, String schemaCoordinate) {
public GraphQlRepositoryExposure {
if (repositoryName == null || repositoryName.isBlank()) {
throw new IllegalArgumentException("repository name is required");
}
if (schemaCoordinate == null || schemaCoordinate.isBlank()) {
throw new IllegalArgumentException("schema coordinate is required");
}
}
}
@@ -1,21 +0,0 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
/**
* Raised when a repository would be exposed without being allowlisted.
*
* <p>Automatic exposure turns a repository into a public API the moment it is annotated, so the
* default has to be refusal rather than registration.
*/
public class GraphQlRepositoryExposureRejectedException extends RuntimeException {
private static final long serialVersionUID = 1L;
/**
* Creates the failure.
*
* @param repositoryName the repository that is not allowlisted
*/
public GraphQlRepositoryExposureRejectedException(String repositoryName) {
super("repository is not allowlisted for GraphQL exposure: " + repositoryName);
}
}
@@ -1,58 +0,0 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
import java.util.Objects;
/**
* Refuses repository exposure that was not deliberately configured (Advanced plan Task 15).
*
* <p>This is a compatibility path, not the mainstream API. The Stable route is a resolver calling
* an Application use case; automatic exposure exists for the cases where that is genuinely not
* worth writing, and it stays behind an allowlist, an argument policy, an explicit pagination
* choice and an approved projection.
*/
public final class GraphQlRepositoryExposureValidator {
private final GraphQlRepositoryAllowlist allowlist;
/**
* Creates the validator.
*
* @param allowlist repositories permitted to be exposed
*/
public GraphQlRepositoryExposureValidator(GraphQlRepositoryAllowlist allowlist) {
this.allowlist = Objects.requireNonNull(allowlist);
}
/**
* Verifies a repository may be exposed.
*
* @throws GraphQlRepositoryExposureRejectedException when it is not allowlisted
*/
public void verify(GraphQlRepositoryExposure exposure) {
if (!allowlist.contains(exposure.repositoryName())) {
throw new GraphQlRepositoryExposureRejectedException(exposure.repositoryName());
}
}
/**
* Verifies the full exposure configuration.
*
* @param exposure the repository and coordinate
* @param pagination the pagination policy
* @param projection the projection policy
* @throws GraphQlRepositoryExposureRejectedException when anything was left to default
*/
public void verifyConfiguration(
GraphQlRepositoryExposure exposure,
GraphQlRepositoryPaginationPolicy pagination,
GraphQlRepositoryProjectionPolicy projection) {
verify(exposure);
if (pagination.implicitSpringDataDefault()) {
throw new GraphQlRepositoryExposureRejectedException(
exposure.repositoryName()
+ " relies on the implicit offset pagination default; choose a pagination policy");
}
Objects.requireNonNull(projection, "an approved projection is required");
}
}
@@ -1,35 +0,0 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
/**
* Pagination for an exposed repository, stated rather than inherited.
*
* <p>Spring Data's automatic exposure paginates by offset, twenty at a time, unless told otherwise.
* Both defaults are decisions: offset pagination skips and repeats rows under concurrent writes,
* and a page size that arrived by default is one nobody chose.
*
* @param keysetPagination whether keyset pagination is used instead of offset
* @param defaultPageSize page size when the client asks for none
* @param maximumPageSize largest page size the client may ask for
*/
public record GraphQlRepositoryPaginationPolicy(
boolean keysetPagination, int defaultPageSize, int maximumPageSize) {
/** The default Spring Data behaviour, which this platform requires to be chosen explicitly. */
public static final int SPRING_DATA_DEFAULT_PAGE_SIZE = 20;
public GraphQlRepositoryPaginationPolicy {
if (defaultPageSize < 1 || maximumPageSize < defaultPageSize) {
throw new IllegalArgumentException("invalid repository pagination policy");
}
}
/** An explicitly chosen keyset policy. */
public static GraphQlRepositoryPaginationPolicy keyset(int defaultPageSize, int maximumPageSize) {
return new GraphQlRepositoryPaginationPolicy(true, defaultPageSize, maximumPageSize);
}
/** Whether this policy merely restates Spring Data's defaults rather than choosing them. */
public boolean implicitSpringDataDefault() {
return !keysetPagination && defaultPageSize == SPRING_DATA_DEFAULT_PAGE_SIZE;
}
}
@@ -1,39 +0,0 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
import java.util.Set;
/**
* Which projection an exposed repository returns.
*
* <p>Never the entity or document itself. Returning one exposes every persistence field as API —
* including the ones added later, by someone who had no idea this repository was reachable from
* GraphQL.
*
* @param projectionType the approved projection type name
* @param exposedFields fields the projection exposes
*/
public record GraphQlRepositoryProjectionPolicy(String projectionType, Set<String> exposedFields) {
public GraphQlRepositoryProjectionPolicy {
if (projectionType == null || projectionType.isBlank()) {
throw new IllegalArgumentException("an approved projection type is required");
}
exposedFields = Set.copyOf(exposedFields);
if (exposedFields.isEmpty()) {
throw new IllegalArgumentException("a projection must expose at least one field");
}
}
/**
* Verifies the projection is not a persistence type.
*
* @param persistenceTypeNames entity and document type names
* @throws GraphQlRepositoryExposureRejectedException when the projection is one of them
*/
public void verifyNotPersistenceType(Set<String> persistenceTypeNames) {
if (persistenceTypeNames.contains(projectionType)) {
throw new GraphQlRepositoryExposureRejectedException(
projectionType + " is a persistence type and must not be returned directly");
}
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.persisted;
/**
* Raised when an admin command names an operation the registry does not hold.
*
* <p>A distinct failure from a conflict: "there is nothing here" and "you cannot do that from here"
* lead an operator to different next steps, and the admin plane used to report neither — an unknown
* id produced a successful-looking audit entry.
*/
public class GraphQlPersistedOperationNotFoundException extends RuntimeException {
private static final long serialVersionUID = 1L;
/** Stable error code. */
public static final String CODE = "GRAPHQL_PERSISTED_OPERATION_NOT_FOUND";
/**
* Creates the failure.
*
* @param operationId the operation that does not exist
*/
public GraphQlPersistedOperationNotFoundException(String operationId) {
super(CODE + ": " + operationId);
}
}
@@ -0,0 +1,123 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.persisted;
import dev.caskeleton.shared.opstore.OperationalRecord;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* Translates a persisted operation to and from a neutral operational record.
*
* <p>The whole point of the split. Durable storage for persisted operations has to live in
* infrastructure, and infrastructure must not implement a type that belongs to an inbound transport
* — a Postgres adapter implementing {@code GraphQlPersistedOperationRegistry} would point the
* dependency from the database back at the GraphQL boundary. So the store speaks {@link
* OperationalRecord} and knows nothing about GraphQL, and this class is the only place that knows
* both vocabularies.
*
* <p>The encoding is length-framed rather than delimited. A canonical document contains newlines,
* an operation name is client-influenced, and a client profile is a free-form string; any delimiter
* chosen from those alphabets is a delimiter a value can contain, and the field after it is then
* read as something else. Framing each field by its length removes the question.
*/
public final class GraphQlPersistedOperationRecordMapping {
/** The key space this capability owns in the operational store. */
public static final String NAMESPACE = "graphql.persisted-operation";
private static final int FIELDS = 9;
private GraphQlPersistedOperationRecordMapping() {}
/**
* The store key for an operation id.
*
* @param id the operation id
*/
public static String keyFor(GraphQlPersistedOperationId id) {
Objects.requireNonNull(id, "persisted operation id is required");
return id.value();
}
/**
* Encodes an operation as a neutral record.
*
* @param operation the operation to store
* @param version the version the caller read, for the store's compare-and-set
*/
public static OperationalRecord toRecord(GraphQlPersistedOperation operation, long version) {
Objects.requireNonNull(operation, "persisted operation is required");
StringBuilder encoded = new StringBuilder();
write(encoded, operation.id().value());
write(encoded, operation.operationName());
write(encoded, operation.documentHash());
write(encoded, operation.canonicalDocument());
write(encoded, operation.schemaContractHash());
write(encoded, String.join(",", operation.allowedClientProfiles()));
write(encoded, Long.toString(operation.maximumComplexity()));
write(encoded, Integer.toString(operation.maximumVariablesBytes()));
write(encoded, operation.status().name());
return new OperationalRecord(NAMESPACE, keyFor(operation.id()), encoded.toString(), version);
}
/**
* Decodes an operation from a neutral record.
*
* @param record the stored record
* @throws IllegalArgumentException when the record is not a persisted operation of this shape
*/
public static GraphQlPersistedOperation fromRecord(OperationalRecord record) {
Objects.requireNonNull(record, "operational record is required");
if (!NAMESPACE.equals(record.namespace())) {
throw new IllegalArgumentException("operational record belongs to another namespace");
}
List<String> fields = readAll(record.value());
if (fields.size() != FIELDS) {
throw new IllegalArgumentException("stored persisted operation has an unexpected shape");
}
Set<String> profiles = new LinkedHashSet<>();
if (!fields.get(5).isEmpty()) {
profiles.addAll(List.of(fields.get(5).split(",", -1)));
}
return new GraphQlPersistedOperation(
new GraphQlPersistedOperationId(fields.get(0)),
fields.get(1),
fields.get(2),
fields.get(3),
fields.get(4),
profiles,
Long.parseLong(fields.get(6)),
Integer.parseInt(fields.get(7)),
GraphQlPersistedOperationStatus.valueOf(fields.get(8)));
}
private static void write(StringBuilder out, String value) {
out.append(value.length()).append(':').append(value);
}
private static List<String> readAll(String encoded) {
List<String> fields = new java.util.ArrayList<>();
int cursor = 0;
while (cursor < encoded.length()) {
int separator = encoded.indexOf(':', cursor);
if (separator < 0) {
throw new IllegalArgumentException("stored persisted operation is not length-framed");
}
int length;
try {
length = Integer.parseInt(encoded.substring(cursor, separator));
} catch (NumberFormatException malformed) {
throw new IllegalArgumentException("stored persisted operation has an invalid frame");
}
int start = separator + 1;
int end = start + length;
if (length < 0 || end > encoded.length()) {
throw new IllegalArgumentException("stored persisted operation frame runs past its value");
}
fields.add(encoded.substring(start, end));
cursor = end;
}
return List.copyOf(fields);
}
}
@@ -23,9 +23,17 @@ public interface GraphQlPersistedOperationRegistry {
Optional<GraphQlPersistedOperation> find(GraphQlPersistedOperationId id);
/**
* Replaces an operation's lifecycle state.
* Applies a lifecycle transition and returns the operation as it now stands.
*
* <p>Used by the admin plane to block an operation during an incident.
* <p>Returning the record, and failing when there is nothing to change, is what makes an audit
* entry trustworthy. The previous {@code updateStatus} was a no-op for an unknown id, so the
* admin plane recorded {@code ABSENT -> BLOCKED} as a successful incident response for an
* operation that had never existed — the one entry an operator would rely on afterwards.
*
* @throws GraphQlPersistedOperationNotFoundException when the operation does not exist
* @throws GraphQlPersistedOperationConflictException when the transition is not permitted from
* the operation's current state
*/
void updateStatus(GraphQlPersistedOperationId id, GraphQlPersistedOperationStatus status);
GraphQlPersistedOperation apply(
GraphQlPersistedOperationId id, GraphQlPersistedOperationTransition transition);
}
@@ -0,0 +1,80 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.persisted;
import java.util.Map;
import java.util.Set;
/**
* Which lifecycle changes the registry will accept.
*
* <p>Without a table, {@code updateStatus} accepted anything, and the sequence that mattered was
* {@code BLOCKED → DEPRECATED}: an operation stopped during an incident could be made executable
* again by a status change that read like a documentation update. Blocking is the emergency
* control, so leaving it is the transition that has to be deliberate.
*
* <p>{@code BLOCKED} is therefore terminal except through {@link #UNBLOCK}, which exists precisely
* so that reversing an incident block is its own audited command rather than a side effect of
* something else.
*/
public enum GraphQlPersistedOperationTransition {
/** First registration of an operation. */
REGISTER,
/** Marks an operation deprecated while clients migrate. It stays executable. */
DEPRECATE,
/** Stops an operation immediately. */
BLOCK,
/** Reverses a block, deliberately and with its own audit entry. */
UNBLOCK,
/** Retires an operation: it is blocked and no longer offered. */
RETIRE;
private static final Map<
GraphQlPersistedOperationTransition, Set<GraphQlPersistedOperationStatus>>
ALLOWED_FROM =
Map.of(
DEPRECATE,
Set.of(
GraphQlPersistedOperationStatus.ACTIVE,
GraphQlPersistedOperationStatus.DEPRECATED),
BLOCK,
Set.of(
GraphQlPersistedOperationStatus.ACTIVE,
GraphQlPersistedOperationStatus.DEPRECATED),
UNBLOCK,
Set.of(GraphQlPersistedOperationStatus.BLOCKED),
RETIRE,
Set.of(
GraphQlPersistedOperationStatus.ACTIVE,
GraphQlPersistedOperationStatus.DEPRECATED,
GraphQlPersistedOperationStatus.BLOCKED));
/** The state this transition leaves the operation in. */
public GraphQlPersistedOperationStatus target() {
return switch (this) {
case REGISTER, UNBLOCK -> GraphQlPersistedOperationStatus.ACTIVE;
case DEPRECATE -> GraphQlPersistedOperationStatus.DEPRECATED;
case BLOCK, RETIRE -> GraphQlPersistedOperationStatus.BLOCKED;
};
}
/** Whether this transition may be applied to an operation currently in the given state. */
public boolean allowedFrom(GraphQlPersistedOperationStatus current) {
return this != REGISTER && ALLOWED_FROM.getOrDefault(this, Set.of()).contains(current);
}
/**
* Verifies the transition.
*
* @throws GraphQlPersistedOperationConflictException when the change is not permitted from here
*/
public void verify(GraphQlPersistedOperationId id, GraphQlPersistedOperationStatus current) {
if (!allowedFrom(current)) {
throw new GraphQlPersistedOperationConflictException(
id.value() + " cannot go " + current + " -> " + name());
}
}
}
@@ -0,0 +1,96 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.persisted;
import dev.caskeleton.shared.opstore.OperationalRecord;
import dev.caskeleton.shared.opstore.OperationalRecordConflictException;
import dev.caskeleton.shared.opstore.OperationalRecordStorePort;
import java.util.Objects;
import java.util.Optional;
/**
* The persisted-operation registry, backed by whatever durable store the deployment provides.
*
* <p>This is the registry an adopter runs. The dependency points from here to a neutral contract in
* {@code shared-contract}, and the durable implementation of that contract points at the same
* contract from the other side — so a Postgres or Redis adapter never names a GraphQL type, and
* this leaf never names a datastore. The composition root connects the two and owns no policy.
*
* <p>Registration and transitions are compare-and-set against the version this registry read. Two
* admin planes blocking and approving the same operation a second apart used to resolve by arrival
* order, and the loser left no trace; now the loser is told.
*/
public final class OperationalStoreGraphQlPersistedOperationRegistry
implements GraphQlPersistedOperationRegistry {
private final OperationalRecordStorePort store;
/**
* Creates the registry.
*
* @param store the deployment's durable operational store
*/
public OperationalStoreGraphQlPersistedOperationRegistry(OperationalRecordStorePort store) {
this.store = Objects.requireNonNull(store, "operational record store is required");
}
@Override
public void register(GraphQlPersistedOperation operation) {
Objects.requireNonNull(operation, "persisted operation is required");
String key = GraphQlPersistedOperationRecordMapping.keyFor(operation.id());
Optional<OperationalRecord> stored =
store.find(GraphQlPersistedOperationRecordMapping.NAMESPACE, key);
if (stored.isPresent()) {
GraphQlPersistedOperation existing =
GraphQlPersistedOperationRecordMapping.fromRecord(stored.get());
if (!existing.canonicalDocument().equals(operation.canonicalDocument())) {
throw new GraphQlPersistedOperationConflictException(operation.id().value());
}
return;
}
try {
store.compareAndSet(
GraphQlPersistedOperationRecordMapping.toRecord(
operation, OperationalRecord.ABSENT_VERSION),
OperationalRecord.ABSENT_VERSION);
} catch (OperationalRecordConflictException lost) {
// Another instance registered the same id between the read and the write. That is a conflict
// in exactly the sense this capability already has a name for.
throw new GraphQlPersistedOperationConflictException(operation.id().value());
}
}
@Override
public Optional<GraphQlPersistedOperation> find(GraphQlPersistedOperationId id) {
Objects.requireNonNull(id, "persisted operation id is required");
return store
.find(
GraphQlPersistedOperationRecordMapping.NAMESPACE,
GraphQlPersistedOperationRecordMapping.keyFor(id))
.map(GraphQlPersistedOperationRecordMapping::fromRecord);
}
@Override
public GraphQlPersistedOperation apply(
GraphQlPersistedOperationId id, GraphQlPersistedOperationTransition transition) {
Objects.requireNonNull(id, "persisted operation id is required");
Objects.requireNonNull(transition, "transition is required");
OperationalRecord stored =
store
.find(
GraphQlPersistedOperationRecordMapping.NAMESPACE,
GraphQlPersistedOperationRecordMapping.keyFor(id))
.orElseThrow(() -> new GraphQlPersistedOperationNotFoundException(id.value()));
GraphQlPersistedOperation current = GraphQlPersistedOperationRecordMapping.fromRecord(stored);
transition.verify(id, current.status());
GraphQlPersistedOperation next = current.withStatus(transition.target());
try {
store.compareAndSet(
GraphQlPersistedOperationRecordMapping.toRecord(next, stored.version() + 1),
stored.version());
} catch (OperationalRecordConflictException lost) {
throw new GraphQlPersistedOperationConflictException(id.value());
}
return next;
}
}
@@ -5,9 +5,14 @@ import dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocket
/**
* Authorizes a replay before any history is delivered.
*
* <p>Replay reads the past, so the check is stricter than for a live subscription: the actor
* resuming must be the actor the cursor was issued to, and must still be authorized now — access
* granted when the events were produced may since have been withdrawn.
* <p>Replay reads the past, so the check is stricter than for a live subscription: the tenant and
* actor resuming must be the ones the cursor was issued to, and must still be authorized now —
* access granted when the events were produced may since have been withdrawn.
*
* <p>Tenant is checked alongside actor rather than assumed to follow from it. Checking only the
* actor is correct exactly while one actor identity never spans two tenants, and nothing here
* enforces that; when it stops holding, the replay succeeds and hands over history the caller was
* never entitled to.
*/
public final class GraphQlReplayAuthorization {
@@ -17,17 +22,26 @@ public final class GraphQlReplayAuthorization {
* Verifies a replay request.
*
* @param cursorActorFingerprint the actor the cursor was issued to
* @param cursorTenantFingerprint the tenant the cursor was issued to
* @param principal the actor presenting it
* @param stillAuthorized whether the Application still authorizes this actor for the subscription
* @throws GraphQlReplayAuthorizationException when the actor differs or is no longer authorized
* @throws GraphQlReplayAuthorizationException when tenant or actor differs, or authorization has
* since been withdrawn
*/
public static void verify(
String cursorActorFingerprint, GraphQlWebSocketPrincipal principal, boolean stillAuthorized) {
String cursorActorFingerprint,
String cursorTenantFingerprint,
GraphQlWebSocketPrincipal principal,
boolean stillAuthorized) {
if (cursorActorFingerprint == null
|| !cursorActorFingerprint.equals(principal.actorFingerprint())) {
throw new GraphQlReplayAuthorizationException();
}
if (cursorTenantFingerprint == null
|| !cursorTenantFingerprint.equals(principal.tenantFingerprint())) {
throw new GraphQlReplayAuthorizationException();
}
if (!stillAuthorized) {
throw new GraphQlReplayAuthorizationException();
}
@@ -1,14 +1,25 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.replay;
import dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketPrincipal;
import dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorCodec;
import dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorFraming;
import dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorPayload;
import dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorScope;
import java.util.Map;
import java.util.Objects;
/**
* A signed resume position for a subscription.
*
* <p>Signed and bound to the actor and subscription profile for the same reason connection cursors
* are: an unsigned resume token is a "start reading from here" parameter, and replay reads history.
* <p>Signed and bound to the tenant, the actor and the subscription profile for the same reason
* connection cursors are: an unsigned resume token is a "start reading from here" parameter, and
* replay reads history.
*
* <p>The tenant is part of the binding, not left to the actor fingerprint to imply. An actor
* identifier that happens to be unique per tenant today stops being a tenant check the moment one
* identity can act in two tenants, and the failure is silent — the cursor verifies, the actor
* matches, and the replay delivers another tenant's history. Taking the principal rather than a
* bare fingerprint makes issuing a cursor without a tenant impossible to express.
*
* <p>GraphQL itself defines no resume mechanism — this is an extension, and the durability behind
* it belongs to the messaging platform.
@@ -25,20 +36,21 @@ public final class GraphQlSubscriptionCursor {
*
* @param codec the signing codec
* @param subscriptionProfile the subscription this cursor belongs to
* @param actorFingerprint the actor it was issued to
* @param principal the tenant and actor it was issued to
* @param position the resume position
*/
public static String issue(
GraphQlCursorCodec codec,
String subscriptionProfile,
String actorFingerprint,
GraphQlWebSocketPrincipal principal,
GraphQlReplayPosition position) {
return codec.encode(
GraphQlCursorPayload.of(
GraphQlCursorPayload.issue(
QUERY_PROFILE,
GraphQlCursorPayload.FORWARD,
Map.of("id", subscriptionProfile, "sequence", Long.toString(position.sequence())),
actorFingerprint));
subscriptionProfile,
scopeOf(principal)));
}
/**
@@ -46,13 +58,38 @@ public final class GraphQlSubscriptionCursor {
*
* @param codec the signing codec
* @param cursor the cursor the client presented
* @param actorFingerprint the actor presenting it
* @param subscriptionProfile the subscription being resumed
* @param principal the tenant and actor presenting it
* @throws dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorException when the
* cursor was issued for another actor or subscription
* cursor was issued for another tenant, actor or subscription
*/
public static GraphQlReplayPosition resume(
GraphQlCursorCodec codec, String cursor, String actorFingerprint) {
GraphQlCursorPayload payload = codec.decode(cursor, QUERY_PROFILE, actorFingerprint);
GraphQlCursorCodec codec,
String cursor,
String subscriptionProfile,
GraphQlWebSocketPrincipal principal) {
GraphQlCursorPayload payload =
codec.decode(
cursor,
new GraphQlCursorScope(
QUERY_PROFILE,
subscriptionProfile,
GraphQlCursorPayload.FORWARD,
scopeOf(principal)));
return new GraphQlReplayPosition(Long.parseLong(payload.keyset().get("sequence")));
}
/**
* The scope fingerprint a cursor is bound to.
*
* <p>Length-framed rather than concatenated, so a tenant ending in the prefix of an actor cannot
* produce the same scope string as a different pair.
*/
private static String scopeOf(GraphQlWebSocketPrincipal principal) {
Objects.requireNonNull(principal, "principal is required");
StringBuilder scope = new StringBuilder();
GraphQlCursorFraming.write(scope, principal.tenantFingerprint());
GraphQlCursorFraming.write(scope, principal.actorFingerprint());
return scope.toString();
}
}
@@ -6,12 +6,16 @@ import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlOperationType;
import java.util.Objects;
/**
* Registers the RSocket transport, once flag, approval, route and consumer all allow it.
* Decides whether an RSocket route may be served, and which interaction model it gets.
*
* <p>Admission, not a handler and not a registration: nothing here touches an {@code RSocket}
* acceptor. It answers "is this route allowed, and is this request/stream or request/response",
* which is what the runtime needs before it binds anything.
*
* <p>Experimental, so in production the guard additionally requires an approval profile. The
* interaction model comes from the operation type rather than the caller's request.
*/
public final class GraphQlRSocketHandlerFactory {
public final class GraphQlRSocketAdmission {
private final GraphQlAdvancedModuleGuard guard;
private final GraphQlRSocketProperties properties;
@@ -23,7 +27,7 @@ public final class GraphQlRSocketHandlerFactory {
* @param guard the Advanced capability guard
* @param properties transport configuration
*/
public GraphQlRSocketHandlerFactory(
public GraphQlRSocketAdmission(
GraphQlAdvancedModuleGuard guard, GraphQlRSocketProperties properties) {
this.guard = Objects.requireNonNull(guard);
this.properties = Objects.requireNonNull(properties);
@@ -7,13 +7,17 @@ import java.time.Clock;
import java.util.Objects;
/**
* Creates SSE streams, once the capability is enabled (Advanced plan Task 9).
* Decides whether an SSE stream may be opened, and issues its heartbeat schedule if so.
*
* <p>Admission, not a handler: what it returns is a {@link GraphQlSseHeartbeat} and a {@link
* GraphQlSseTermination}, both plain schedules. Nothing here writes an event to a response the
* runtime that owns the response does that, using these bounds.
*
* <p>The request shape is a POST with a JSON body and {@code Accept: text/event-stream} the same
* request envelope as every other transport, with a streaming response. Authorization and cost
* policy are the WebSocket ones; only the delivery mechanism differs.
*/
public final class GraphQlSseHandlerFactory {
public final class GraphQlSseAdmission {
/** The {@code Accept} value that selects SSE. */
public static final String EVENT_STREAM_MEDIA_TYPE = "text/event-stream";
@@ -29,7 +33,7 @@ public final class GraphQlSseHandlerFactory {
* @param properties connection bounds
* @param clock clock used for heartbeats and termination
*/
public GraphQlSseHandlerFactory(
public GraphQlSseAdmission(
GraphQlAdvancedModuleGuard guard, GraphQlSseProperties properties, Clock clock) {
this.guard = Objects.requireNonNull(guard);
this.properties = Objects.requireNonNull(properties);
@@ -1,5 +1,6 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.subscription;
import dev.caskeleton.adapter.inbound.graphql.security.GraphQlContextCleanup;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -10,6 +11,11 @@ import java.util.concurrent.atomic.AtomicBoolean;
* <p>An unsubscribed client whose upstream keeps running is the expensive failure here: the Kafka
* consumer, the polling task and the nested publishers all continue for a subscriber that has gone,
* 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
* abandon the queue at the first failure, so a broken consumer-close left the polling task and the
* nested publishers running — the leak the second and third hooks existed to prevent, caused by the
* first one failing.
*/
public final class GraphQlSubscriptionCancellation {
@@ -40,10 +46,13 @@ public final class GraphQlSubscriptionCancellation {
}
private void drain() {
GraphQlContextCleanup cleanup = GraphQlContextCleanup.create();
Runnable hook = upstream.poll();
while (hook != null) {
hook.run();
cleanup.register(hook);
hook = upstream.poll();
}
// The same run-all-then-rethrow-with-suppressed semantics the request path already uses.
cleanup.close();
}
}
@@ -2,8 +2,8 @@ package dev.caskeleton.adapter.inbound.graphql.advanced.subscription;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
/**
* Graceful shutdown for long-lived subscriptions.
@@ -12,13 +12,29 @@ import java.util.concurrent.atomic.AtomicInteger;
* storm against an instance that is already leaving. New subscriptions are refused immediately,
* existing ones get a bounded window to finish, and the window has a deadline so a stuck stream
* cannot delay shutdown forever.
*
* <p>Phase, count and drain start move together as one immutable value behind a single
* compare-and-set, because they are one fact and not three. Held apart they raced in both
* directions: a subscription that passed the "not draining" check and then incremented the count
* was admitted onto a node that had begun draining in between, and a reader that saw the draining
* flag before the separate {@code drainStartedAt} field was written computed the deadline against
* {@code null}. Shutdown is exactly when both happen, and exactly when neither is easy to see.
*/
public final class GraphQlSubscriptionDrainCoordinator {
/**
* The whole coordinator state, replaced atomically.
*
* @param phase where the node is in its lifetime
* @param active subscriptions currently streaming
* @param startedAt when draining began; non-null whenever the phase is not {@code ACCEPTING}
*/
private record State(GraphQlSubscriptionDrainPhase phase, int active, Instant startedAt) {}
private static final State OPEN = new State(GraphQlSubscriptionDrainPhase.ACCEPTING, 0, null);
private final Duration drainTimeout;
private final AtomicBoolean draining = new AtomicBoolean();
private final AtomicInteger active = new AtomicInteger();
private Instant drainStartedAt;
private final AtomicReference<State> state = new AtomicReference<>(OPEN);
/**
* Creates the coordinator.
@@ -33,44 +49,95 @@ public final class GraphQlSubscriptionDrainCoordinator {
}
/**
* Registers a new subscription.
* Registers a new subscription and returns its lease.
*
* @throws GraphQlSubscriptionDrainingException while draining
* <p>The admission decision and the count increment are the same compare-and-set, so a
* subscription is never admitted onto a node that started draining between the two.
*
* @return the lease to close when the subscription ends
* @throws GraphQlSubscriptionDrainingException while draining or once closed
*/
public void register() {
if (draining.get()) {
throw new GraphQlSubscriptionDrainingException();
public GraphQlSubscriptionLease register() {
while (true) {
State current = state.get();
if (current.phase() != GraphQlSubscriptionDrainPhase.ACCEPTING) {
throw new GraphQlSubscriptionDrainingException();
}
State next = new State(current.phase(), current.active() + 1, current.startedAt());
if (state.compareAndSet(current, next)) {
return new GraphQlSubscriptionLease(this);
}
}
active.incrementAndGet();
}
/** Records that a subscription finished. */
public void deregister() {
active.updateAndGet(current -> Math.max(0, current - 1));
}
/** Starts draining; no new subscriptions are accepted from here. */
/**
* Starts draining; no new subscriptions are accepted from here.
*
* <p>Idempotent: a second call keeps the original start instant, so a shutdown hook that fires
* twice cannot extend the window it is supposed to bound.
*
* @param now the instant draining began
*/
public void startDraining(Instant now) {
if (draining.compareAndSet(false, true)) {
drainStartedAt = now;
Objects.requireNonNull(now, "drain start instant is required");
while (true) {
State current = state.get();
if (current.phase() != GraphQlSubscriptionDrainPhase.ACCEPTING) {
return;
}
GraphQlSubscriptionDrainPhase next =
current.active() == 0
? GraphQlSubscriptionDrainPhase.CLOSED
: GraphQlSubscriptionDrainPhase.DRAINING;
if (state.compareAndSet(current, new State(next, current.active(), now))) {
return;
}
}
}
/** Whether every subscription has finished, or the drain window has elapsed. */
/**
* Whether every subscription has finished, or the drain window has elapsed.
*
* @param now the current instant
*/
public boolean drained(Instant now) {
if (!draining.get()) {
return false;
}
return active.get() == 0 || !now.isBefore(drainStartedAt.plus(drainTimeout));
Objects.requireNonNull(now, "current instant is required");
State current = state.get();
return switch (current.phase()) {
case ACCEPTING -> false;
case CLOSED -> true;
case DRAINING ->
current.active() == 0 || !now.isBefore(current.startedAt().plus(drainTimeout));
};
}
/** Subscriptions still streaming. */
public int activeSubscriptions() {
return active.get();
return state.get().active();
}
/** Whether the coordinator is draining. */
/** Whether the coordinator has stopped accepting new subscriptions. */
public boolean draining() {
return draining.get();
return state.get().phase() != GraphQlSubscriptionDrainPhase.ACCEPTING;
}
/** Where the node is in its subscription lifetime. */
public GraphQlSubscriptionDrainPhase phase() {
return state.get().phase();
}
/** Releases one lease; called by {@link GraphQlSubscriptionLease#close()}. */
void release() {
while (true) {
State current = state.get();
int remaining = Math.max(0, current.active() - 1);
GraphQlSubscriptionDrainPhase next =
current.phase() == GraphQlSubscriptionDrainPhase.DRAINING && remaining == 0
? GraphQlSubscriptionDrainPhase.CLOSED
: current.phase();
if (state.compareAndSet(current, new State(next, remaining, current.startedAt()))) {
return;
}
}
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.subscription;
/**
* Where a node is in its subscription lifetime.
*
* <p>Three phases rather than a boolean, because "draining" and "finished draining" are different
* answers to the shutdown question and a boolean can only carry one of them. The transitions are
* one-way: a node that has started draining never accepts again, and a closed one never reopens.
*/
public enum GraphQlSubscriptionDrainPhase {
/** New subscriptions are admitted. */
ACCEPTING,
/** New subscriptions are refused; existing ones have a bounded window to finish. */
DRAINING,
/** Every subscription finished, or the drain window elapsed. */
CLOSED
}
@@ -1,5 +1,6 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.subscription;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestSize;
import java.util.Map;
/**
@@ -24,8 +25,15 @@ public record GraphQlSubscriptionEvent(Map<String, Object> payload, long sequenc
}
}
/** An estimate of this event's serialized size, for the byte budget. */
/**
* This event's serialized size, for the byte budget.
*
* <p>Counted as canonical JSON, not as {@code Map.toString()}. The two diverge by more than a
* constant: {@code toString} writes {@code {a=1}} where JSON writes {@code {"a":1}}, omits the
* quoting that dominates a string-heavy payload, and renders a nested list differently again. A
* queue bounded by that number is bounded by something that is not the thing being queued.
*/
public long approximateBytes() {
return payload.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8).length;
return GraphQlRequestSize.jsonBytes(payload);
}
}
@@ -0,0 +1,37 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.subscription;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* A subscription's claim on the node, released exactly once when the stream ends.
*
* <p>A lease rather than a paired {@code register}/{@code deregister} call, because the paired form
* makes correctness depend on the caller: a stream that ended on an error path without its matching
* deregister left the count permanently above zero, and the node then drained for the full timeout
* on every shutdown while reporting subscriptions that no longer existed. Releasing twice is just
* as damaging in the other direction it decrements someone else's subscription so the release
* is idempotent and the second call does nothing.
*/
public final class GraphQlSubscriptionLease implements AutoCloseable {
private final GraphQlSubscriptionDrainCoordinator coordinator;
private final AtomicBoolean released = new AtomicBoolean();
GraphQlSubscriptionLease(GraphQlSubscriptionDrainCoordinator coordinator) {
this.coordinator = Objects.requireNonNull(coordinator, "coordinator is required");
}
/** Releases the lease; subsequent calls do nothing. */
@Override
public void close() {
if (released.compareAndSet(false, true)) {
coordinator.release();
}
}
/** Whether this lease has already been released. */
public boolean released() {
return released.get();
}
}
@@ -7,12 +7,19 @@ import java.util.List;
import java.util.Objects;
/**
* Creates connection lifecycles, once the capability is enabled.
* Decides whether a WebSocket connection may be opened, and issues its lifecycle if so.
*
* <p>Admission, not a handler. It was called a handler factory, which promised a Spring {@code
* WebSocketHandler} that reads and writes frames; what it returns is a {@link
* GraphQlWebSocketLifecycle} a state machine with no socket and no I/O. An adopter who wired the
* old name where Spring expected a handler found a name that fit and behaviour that did not. The
* transport binding stays with the runtime that owns the socket; this owns the decision to let a
* connection exist.
*
* <p>The guard is checked here rather than per message, so a deployment without the flag never
* accepts a WebSocket connection at all.
*/
public final class GraphQlWebSocketHandlerFactory {
public final class GraphQlWebSocketAdmission {
private final GraphQlAdvancedModuleGuard guard;
private final GraphQlWebSocketProperties properties;
@@ -25,7 +32,7 @@ public final class GraphQlWebSocketHandlerFactory {
* @param properties connection bounds
* @param clock clock used for lifecycle deadlines
*/
public GraphQlWebSocketHandlerFactory(
public GraphQlWebSocketAdmission(
GraphQlAdvancedModuleGuard guard, GraphQlWebSocketProperties properties, Clock clock) {
this.guard = Objects.requireNonNull(guard);
this.properties = Objects.requireNonNull(properties);
@@ -0,0 +1,72 @@
package dev.caskeleton.adapter.inbound.graphql.architecture;
import java.util.List;
import java.util.Set;
/**
* Whether a resolver's return container yields one value or many.
*
* <p>The distinction is what makes the subscription rule correct. The rule used to reject every
* {@code Publisher} outside a subscription, and {@code Mono<T>} is a {@code Publisher} so {@code
* Mono<OrderView> order()} on a query, which Spring for GraphQL supports and documents, was refused
* by the platform's own boundary check. A query may complete asynchronously; what it may not do is
* emit a stream, because the HTTP profile has no way to deliver one.
*
* <p>Recognised by name rather than by class reference: naming {@code reactor.core.publisher.Mono}
* here would drag Reactor into a rule that exists to keep this boundary framework-light, and the
* check has to work whether or not the adopter has Reactor at all.
*/
public enum GraphQlAsyncReturnShape {
/** Not an async container at all. */
SYNCHRONOUS,
/** Completes once with at most one value: legal on every operation type. */
SINGLE_VALUE,
/** Emits zero or more values over time: legal only on a subscription. */
MULTI_VALUE;
private static final Set<String> SINGLE_VALUE_CONTAINERS =
Set.of(
"reactor.core.publisher.Mono",
"java.util.concurrent.CompletionStage",
"java.util.concurrent.CompletableFuture",
"java.util.concurrent.Future",
"java.util.Optional",
"org.springframework.graphql.data.method.annotation.SchemaMapping");
private static final List<String> MULTI_VALUE_INTERFACES =
List.of("org.reactivestreams.Publisher", "java.util.stream.Stream");
/** Classifies a resolver return type. */
public static GraphQlAsyncReturnShape of(Class<?> returnType) {
if (returnType == null) {
return SYNCHRONOUS;
}
if (SINGLE_VALUE_CONTAINERS.contains(returnType.getName())) {
return SINGLE_VALUE;
}
return implementsAny(returnType, MULTI_VALUE_INTERFACES) ? MULTI_VALUE : SYNCHRONOUS;
}
/** Whether this shape may be returned from a field that is not a subscription. */
public boolean allowedOutsideSubscription() {
return this != MULTI_VALUE;
}
private static boolean implementsAny(Class<?> type, List<String> interfaceNames) {
if (type == null || type == Object.class) {
return false;
}
if (interfaceNames.contains(type.getName())) {
return true;
}
for (Class<?> implemented : type.getInterfaces()) {
if (implementsAny(implemented, interfaceNames)) {
return true;
}
}
return implementsAny(type.getSuperclass(), interfaceNames);
}
}
@@ -6,7 +6,6 @@ import java.lang.reflect.Parameter;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import org.reactivestreams.Publisher;
/**
* Inspects one annotated resolver method against the transport boundary (design §11).
@@ -52,19 +51,30 @@ public final class GraphQlControllerInspector {
List<String> violations = new ArrayList<>();
String coordinate = coordinateOf(method);
Class<?> returnType = method.getReturnType();
String returnRejection = GraphQlReturnTypePolicy.rejection(returnType);
if (returnRejection != null) {
violations.add(coordinate + " returns " + returnRejection);
// The generic type, not the erased one. `Mono<OrderEntity>` erases to `Mono`, which passes
// every persistence rule while carrying exactly the type those rules forbid.
for (Class<?> referenced : GraphQlTypeGraph.referencedTypes(method.getGenericReturnType())) {
String returnRejection = GraphQlReturnTypePolicy.rejection(referenced);
if (returnRejection != null) {
violations.add(coordinate + " returns " + returnRejection);
}
}
if (Publisher.class.isAssignableFrom(returnType) && !subscription(method)) {
violations.add(coordinate + " returns a Publisher outside a subscription");
GraphQlAsyncReturnShape shape = GraphQlAsyncReturnShape.of(method.getReturnType());
if (!shape.allowedOutsideSubscription() && !subscription(method)) {
violations.add(
coordinate
+ " returns a multi-value publisher outside a subscription; a query or mutation may "
+ "complete asynchronously but cannot emit a stream");
}
for (Parameter parameter : method.getParameters()) {
String argumentRejection = GraphQlInputTypePolicy.rejection(parameter.getType());
if (argumentRejection != null) {
violations.add(coordinate + " binds " + parameter.getName() + ": " + argumentRejection);
for (Class<?> referenced :
GraphQlTypeGraph.referencedTypes(parameter.getParameterizedType())) {
String argumentRejection = GraphQlInputTypePolicy.rejection(referenced);
if (argumentRejection != null) {
violations.add(coordinate + " binds " + parameter.getName() + ": " + argumentRejection);
}
}
}
@@ -1,11 +1,13 @@
package dev.caskeleton.adapter.inbound.graphql.architecture;
import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.net.JarURLConnection;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -15,6 +17,9 @@ import java.util.Enumeration;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.Supplier;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.stream.Stream;
/**
@@ -35,13 +40,37 @@ public final class GraphQlResolverBoundaryRules {
Set.of("EntityManager", "MongoTemplate", "SessionFactory", "DataSource", "JdbcTemplate");
/**
* Type-name suffixes that indicate a repository dependency.
* Type-name suffixes that suggest a repository dependency.
*
* <p>A supporting signal, not a verdict. A name is the weakest evidence available: {@code
* OrderRepository} may be a Spring Data interface or an application port that happens to be named
* that way, and a rule that decides on the suffix alone both misses the real repository imported
* under another name and refuses a legitimate collaborator. So the suffix only counts when the
* type also sits in a persistence-shaped package or is an interface see {@link
* #repositoryEvidence}.
*
* <p>DTO mappers are deliberately absent: a resolver mapping an Application result onto a GraphQL
* payload is exactly what it is supposed to do.
*/
public static final Set<String> FORBIDDEN_TYPE_SUFFIXES = Set.of("Repository", "Dao");
/** Package name fragments that make a repository-suffixed type a repository in fact. */
public static final Set<String> PERSISTENCE_PACKAGE_FRAGMENTS =
Set.of(".repository", ".persistence", ".dao", ".jpa", ".mongo");
/** Interfaces whose presence proves a type is a repository, matched by name. */
public static final Set<String> REPOSITORY_INTERFACES =
Set.of(
"org.springframework.data.repository.Repository",
"org.springframework.data.repository.CrudRepository",
"org.springframework.data.repository.PagingAndSortingRepository",
"org.springframework.data.repository.ListCrudRepository",
"org.springframework.data.repository.reactive.ReactiveCrudRepository");
/** Annotations whose presence proves a type is a repository, matched by name. */
public static final Set<String> REPOSITORY_ANNOTATIONS =
Set.of("org.springframework.stereotype.Repository");
private GraphQlResolverBoundaryRules() {}
/**
@@ -66,46 +95,40 @@ public final class GraphQlResolverBoundaryRules {
}
}
/** Persistence-access violations across the given classes, in deterministic order. */
/**
* Persistence-access violations across the given classes, in deterministic order.
*
* <p>Every declared type is walked as a generic type graph. Checking the erased type would let
* {@code Optional<OrderRepository>}, {@code List<OrderEntity>} and {@code Mono<OrderRepository>}
* through the wrapper is what a leak looks like once someone has tidied the signature.
*/
public static List<String> persistenceViolations(Collection<Class<?>> classes) {
List<String> violations = new ArrayList<>();
for (Class<?> type : classes) {
for (Field field : type.getDeclaredFields()) {
if (forbidden(field.getType())) {
violations.add(
type.getSimpleName()
+ "."
+ field.getName()
+ " depends on "
+ field.getType().getName());
}
record(
violations,
field.getGenericType(),
() -> type.getSimpleName() + "." + field.getName() + " depends on ");
}
for (Constructor<?> constructor : type.getDeclaredConstructors()) {
for (Parameter parameter : constructor.getParameters()) {
if (forbidden(parameter.getType())) {
violations.add(
type.getSimpleName() + " constructor injects " + parameter.getType().getName());
}
record(
violations,
parameter.getParameterizedType(),
() -> type.getSimpleName() + " constructor injects ");
}
}
for (Method method : type.getDeclaredMethods()) {
if (forbidden(method.getReturnType())) {
violations.add(
type.getSimpleName()
+ "#"
+ method.getName()
+ " returns "
+ method.getReturnType().getName());
}
record(
violations,
method.getGenericReturnType(),
() -> type.getSimpleName() + "#" + method.getName() + " returns ");
for (Parameter parameter : method.getParameters()) {
if (forbidden(parameter.getType())) {
violations.add(
type.getSimpleName()
+ "#"
+ method.getName()
+ " accepts "
+ parameter.getType().getName());
}
record(
violations,
parameter.getParameterizedType(),
() -> type.getSimpleName() + "#" + method.getName() + " accepts ");
}
}
}
@@ -113,25 +136,100 @@ public final class GraphQlResolverBoundaryRules {
return List.copyOf(violations);
}
private static void record(
List<String> violations, java.lang.reflect.Type declared, Supplier<String> prefix) {
for (Class<?> referenced : GraphQlTypeGraph.referencedTypes(declared)) {
String evidence = repositoryEvidence(referenced);
if (evidence != null) {
violations.add(prefix.get() + referenced.getName() + " (" + evidence + ")");
}
}
}
/** Whether a type represents direct persistence or repository access. */
public static boolean forbidden(Class<?> type) {
if (type == null || type.isPrimitive()) {
return false;
}
Class<?> subject = type.isArray() ? type.getComponentType() : type;
if (GraphQlReturnTypePolicy.forbiddenPrefix(subject) != null
|| GraphQlReturnTypePolicy.isPersistenceMapped(subject)) {
return true;
}
String simpleName = subject.getSimpleName();
if (FORBIDDEN_TYPE_NAMES.contains(simpleName)) {
return true;
}
return FORBIDDEN_TYPE_SUFFIXES.stream().anyMatch(simpleName::endsWith);
return repositoryEvidence(type) != null;
}
/**
* Loads every class declared directly in a package from the current classpath.
* Why a type counts as persistence access, or {@code null} when it does not.
*
* <p>Ordered strongest first, so a diagnostic names the reason that would survive a rename. The
* name-only signal is last and qualified, because it is the one that is wrong in both directions.
*/
public static String repositoryEvidence(Class<?> type) {
if (type == null || type.isPrimitive()) {
return null;
}
Class<?> subject = type;
while (subject.isArray()) {
subject = subject.getComponentType();
}
if (GraphQlReturnTypePolicy.forbiddenPrefix(subject) != null) {
return "declared in a persistence or driver package";
}
if (GraphQlReturnTypePolicy.isPersistenceMapped(subject)) {
return "carries a persistence mapping annotation";
}
if (implementsRepositoryInterface(subject)) {
return "implements a Spring Data repository interface";
}
for (java.lang.annotation.Annotation annotation : subject.getAnnotations()) {
if (REPOSITORY_ANNOTATIONS.contains(annotation.annotationType().getName())) {
return "annotated as a repository";
}
}
String simpleName = subject.getSimpleName();
if (FORBIDDEN_TYPE_NAMES.contains(simpleName)) {
return "is a persistence infrastructure type";
}
boolean repositoryName = FORBIDDEN_TYPE_SUFFIXES.stream().anyMatch(simpleName::endsWith);
if (repositoryName && type.isInterface()) {
return "is a repository-named interface";
}
if (repositoryName && persistenceShaped(subject)) {
return "is named as a repository and declared in a persistence package";
}
return null;
}
/**
* Whether the type sits in a package that makes a repository name mean what it says.
*
* <p>The pair of name-based rules above is what demoting the suffix heuristic looks like in
* practice. A repository-named <em>interface</em> is a port by every convention this repository
* follows, so it still counts on its own. A repository-named concrete class outside a persistence
* package no longer does that shape is far more often a value object or a view than a
* data-access type, and refusing it made the rule something to work around.
*/
private static boolean persistenceShaped(Class<?> type) {
String packageName = "." + type.getPackageName() + ".";
return PERSISTENCE_PACKAGE_FRAGMENTS.stream()
.anyMatch(fragment -> packageName.contains(fragment + "."));
}
private static boolean implementsRepositoryInterface(Class<?> type) {
if (type == null || type == Object.class) {
return false;
}
if (REPOSITORY_INTERFACES.contains(type.getName())) {
return true;
}
for (Class<?> implemented : type.getInterfaces()) {
if (implementsRepositoryInterface(implemented)) {
return true;
}
}
return implementsRepositoryInterface(type.getSuperclass());
}
/**
* Loads every class in a package and its sub-packages from the current classpath.
*
* <p>Recursive and JAR-aware. The previous scan listed direct children of a {@code file:}
* directory only, so an adopter whose controllers sit one package deeper, or whose classes ship
* inside a jar which is to say, every adopter running a packaged application was checked
* against nothing while the rule reported success.
*
* @throws GraphQlControllerContractException when the package cannot be located, so a rule can
* never pass by scanning nothing
@@ -147,17 +245,10 @@ public final class GraphQlResolverBoundaryRules {
Enumeration<URL> roots = classLoader.getResources(resourcePath);
while (roots.hasMoreElements()) {
URL root = roots.nextElement();
if (!"file".equals(root.getProtocol())) {
continue;
}
Path directory = Path.of(root.toURI());
try (Stream<Path> files = Files.list(directory)) {
files
.filter(Files::isRegularFile)
.map(path -> path.getFileName().toString())
.filter(name -> name.endsWith(".class"))
.map(name -> name.substring(0, name.length() - ".class".length()))
.forEach(name -> classNames.add(packageName + "." + name));
if ("file".equals(root.getProtocol())) {
collectFromDirectory(Path.of(root.toURI()), packageName, classNames);
} else if ("jar".equals(root.getProtocol())) {
collectFromJar(root, resourcePath, classNames);
}
}
} catch (IOException ex) {
@@ -175,10 +266,48 @@ public final class GraphQlResolverBoundaryRules {
for (String className : classNames) {
try {
classes.add(Class.forName(className, false, classLoader));
} catch (ClassNotFoundException ex) {
} catch (ClassNotFoundException | NoClassDefFoundError ex) {
throw new IllegalStateException("cannot load " + className, ex);
}
}
return List.copyOf(classes);
}
private static void collectFromDirectory(Path directory, String packageName, Set<String> into)
throws IOException {
try (Stream<Path> entries = Files.walk(directory)) {
entries
.filter(Files::isRegularFile)
.filter(path -> path.getFileName().toString().endsWith(".class"))
.forEach(
path -> {
String relative =
directory.relativize(path).toString().replace(File.separatorChar, '.');
String className =
packageName
+ "."
+ relative.substring(0, relative.length() - ".class".length());
if (!className.contains("$")) {
into.add(className);
}
});
}
}
private static void collectFromJar(URL root, String resourcePath, Set<String> into)
throws IOException {
JarURLConnection connection = (JarURLConnection) root.openConnection();
// The connection owns the jar file when caching is on, so it must not be closed here: doing so
// would shut a JarFile that the rest of the JVM is still reading from.
connection.setUseCaches(true);
JarFile jar = connection.getJarFile();
Enumeration<JarEntry> entries = jar.entries();
while (entries.hasMoreElements()) {
String name = entries.nextElement().getName();
if (!name.startsWith(resourcePath + "/") || !name.endsWith(".class") || name.contains("$")) {
continue;
}
into.add(name.substring(0, name.length() - ".class".length()).replace('/', '.'));
}
}
}
@@ -0,0 +1,112 @@
package dev.caskeleton.adapter.inbound.graphql.architecture;
import java.lang.reflect.GenericArrayType;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.lang.reflect.TypeVariable;
import java.lang.reflect.WildcardType;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* Every class a declared type actually reaches, generics included.
*
* <p>The boundary rules used to inspect {@code Method#getReturnType} and {@code Parameter#getType},
* which erase to the container: {@code List<OrderEntity>} reports {@code List}, {@code
* Mono<OrderEntity>} reports {@code Mono}, and {@code Optional<OrderRepository>} reports {@code
* Optional}. Every one of those passed a rule whose whole purpose was to notice the type inside
* the wrapper is exactly what a resolver leaking an entity looks like in practice.
*
* <p>Traversal is bounded and cycle-guarded. A type variable can refer to its own bound ({@code <T
* extends Comparable<T>>}), and a rule that recursed into that would hang on a perfectly legal
* signature.
*/
public final class GraphQlTypeGraph {
/** Ceiling on how many distinct types one signature may reach before it is refused. */
public static final int MAXIMUM_VISITED_TYPES = 512;
private GraphQlTypeGraph() {}
/**
* Every concrete class reachable from a declared type.
*
* <p>Arrays contribute their component type, parameterized types contribute their raw type and
* every argument, wildcards and type variables contribute their bounds.
*
* @param type a declared return, parameter or field type
* @return the reachable classes, in first-seen order
*/
public static Set<Class<?>> referencedTypes(Type type) {
Set<Class<?>> found = new LinkedHashSet<>();
if (type == null) {
return found;
}
Set<Type> visited = new LinkedHashSet<>();
Deque<Type> pending = new ArrayDeque<>();
pending.push(type);
while (!pending.isEmpty() && visited.size() < MAXIMUM_VISITED_TYPES) {
Type current = pending.pop();
if (current == null || !visited.add(current)) {
continue;
}
if (current instanceof Class<?> raw) {
Class<?> component = raw;
while (component.isArray()) {
component = component.getComponentType();
}
if (!component.isPrimitive()) {
found.add(component);
}
} else if (current instanceof ParameterizedType parameterized) {
pending.push(parameterized.getRawType());
for (Type argument : parameterized.getActualTypeArguments()) {
pending.push(argument);
}
} else if (current instanceof GenericArrayType array) {
pending.push(array.getGenericComponentType());
} else if (current instanceof WildcardType wildcard) {
for (Type bound : wildcard.getUpperBounds()) {
pending.push(bound);
}
for (Type bound : wildcard.getLowerBounds()) {
pending.push(bound);
}
} else if (current instanceof TypeVariable<?> variable) {
for (Type bound : variable.getBounds()) {
pending.push(bound);
}
}
}
return found;
}
/**
* The raw class a declared type erases to, or {@code null}.
*
* <p>Used where the container itself is the subject deciding whether a return type is a
* publisher, for instance as opposed to what it contains.
*/
public static Class<?> rawType(Type type) {
if (type instanceof Class<?> raw) {
return raw;
}
if (type instanceof ParameterizedType parameterized) {
return rawType(parameterized.getRawType());
}
if (type instanceof GenericArrayType array) {
Class<?> component = rawType(array.getGenericComponentType());
return component == null ? null : component.arrayType();
}
if (type instanceof WildcardType wildcard) {
return wildcard.getUpperBounds().length == 0 ? null : rawType(wildcard.getUpperBounds()[0]);
}
if (type instanceof TypeVariable<?> variable) {
return variable.getBounds().length == 0 ? null : rawType(variable.getBounds()[0]);
}
return null;
}
}
@@ -59,7 +59,7 @@ public final class GraphQlPlatformActuatorEndpoint {
properties.environment().name(),
GraphQlHttpProfile.V1.name(),
supportedCapabilities,
properties.cursorKeyIds(),
properties.cursor().keyIds(),
registeredOperations,
registeredFetchProfiles);
}
@@ -1,20 +1,71 @@
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
import dev.caskeleton.adapter.inbound.graphql.build.GraphQlBuildModel;
import dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile;
import dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlControllerInspector;
import dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlResolverBoundaryRules;
import dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlTransportTypeRules;
import dev.caskeleton.adapter.inbound.graphql.context.TenantContext;
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityCalculator;
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlCostCatalog;
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentShapeAnalyzer;
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimitPolicy;
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimits;
import dev.caskeleton.adapter.inbound.graphql.error.GraphQlExceptionResolver;
import dev.caskeleton.adapter.inbound.graphql.execution.BoundedPreparsedDocumentProvider;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipeline;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipelineValidator;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlPreparsedCacheMetrics;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlPreparsedCachePolicy;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlJsonStructurePolicy;
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlDataLoaderObservationConvention;
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlMetricCardinalityPolicy;
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlOperationNameCardinality;
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlRequestObservationConvention;
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlResolverObservationConvention;
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlSensitiveAttributeFilter;
import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlCostBudgetHandler;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlDataFetcherExceptionResolver;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlDocumentAuthorizationHandler;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlExecutionChain;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlOperationSelectionHandler;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformInstrumentation;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformWebInterceptor;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPreparsedDocumentAdapter;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPrincipalResolver;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlWireErrorMapper;
import dev.caskeleton.adapter.inbound.graphql.runtime.servlet.GraphQlRequestBodyLimitFilter;
import dev.caskeleton.adapter.inbound.graphql.scalar.GraphQlScalarWiringConfigurer;
import dev.caskeleton.adapter.inbound.graphql.schema.GraphQlMappingInspectionGate;
import java.util.Set;
import dev.caskeleton.adapter.inbound.graphql.schema.GraphQlScalarDefinition;
import dev.caskeleton.adapter.inbound.graphql.schema.GraphQlScalarManifest;
import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationContextFactory;
import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationInterceptor;
import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationPolicy;
import graphql.execution.instrumentation.Instrumentation;
import graphql.execution.preparsed.PreparsedDocumentEntry;
import graphql.schema.idl.SchemaPrinter;
import java.time.Clock;
import java.util.List;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.graphql.autoconfigure.GraphQlAutoConfiguration;
import org.springframework.boot.graphql.autoconfigure.GraphQlProperties;
import org.springframework.boot.graphql.autoconfigure.GraphQlSourceBuilderCustomizer;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.graphql.execution.DataFetcherExceptionResolver;
import org.springframework.graphql.execution.GraphQlSource;
import org.springframework.graphql.server.WebGraphQlInterceptor;
import org.springframework.stereotype.Controller;
import org.springframework.util.ClassUtils;
/**
* Assembles the Stable platform and validates it at startup (Stable plan Task 46).
@@ -22,11 +73,30 @@ import org.springframework.context.annotation.Configuration;
* <p>Composes Stable capabilities only. Advanced capabilities are opt-in and must never arrive
* through this configuration an Advanced capability that activates because the Stable starter is
* on the classpath is exactly the accident the module boundary exists to prevent.
*
* <p>A real {@code @AutoConfiguration}, registered in {@code AutoConfiguration.imports}, and
* ordered after Spring Boot's own GraphQL auto-configuration. Both halves matter: without the
* registration the class was named auto-configuration while behaving as an ordinary
* {@code @Configuration}, so every {@code @ConditionalOnMissingBean} on it was evaluated before an
* adopter's beans existed and silently failed to back off. Without the ordering, this would race
* the framework's own {@code GraphQlSource} and schema beans.
*/
@Configuration(proxyBeanMethods = false)
@AutoConfiguration(after = GraphQlAutoConfiguration.class)
@EnableConfigurationProperties(GraphQlPlatformProperties.class)
public class GraphQlPlatformAutoConfiguration {
/** Client profile applied to a caller with no verified credential. */
public static final String DEFAULT_ANONYMOUS_PROFILE = "anonymous";
/**
* Tenant applied to a caller with no verified credential.
*
* <p>Declared as {@link TenantContext#system} rather than as a credential-derived tenant, because
* that is what it is: no credential was verified, so no tenant was proven. An adopter serving
* more than one tenant supplies a principal resolver, and the anonymous path then never runs.
*/
public static final String DEFAULT_ANONYMOUS_TENANT = "public";
/** The startup validator. */
@Bean
@ConditionalOnMissingBean
@@ -39,22 +109,377 @@ public class GraphQlPlatformAutoConfiguration {
*
* <p>An {@code InitializingBean} rather than a listener, so an unsafe configuration fails the
* refresh instead of being logged after the application has already begun serving.
*
* <p>This validates the adopter's configuration, which is the only part that varies at runtime.
* The Stable/Advanced module direction is a property of the source tree, not of a deployment, so
* it is enforced where it can actually fail {@code GraphQlModuleBoundaryTest} scans the real
* imports at build time. Re-checking a compile-time constant during refresh proved nothing and
* cost every adopter a startup-time source scan.
*/
@Bean
public InitializingBean graphQlPlatformConfigurationCheck(
GraphQlPlatformProperties properties, GraphQlPlatformStartupValidator validator) {
GraphQlPlatformProperties properties,
GraphQlPlatformStartupValidator validator,
GraphQlExecutionPipeline pipeline,
GraphQlScalarWiringConfigurer scalarWiring,
GraphQlClientPolicy clientPolicy,
ObjectProvider<GraphQlRuntimeTransport> transport,
ObjectProvider<GraphQlProperties> frameworkProperties) {
return () -> {
validator.validate(properties);
GraphQlExecutionPipelineValidator.validate(GraphQlExecutionPipeline.stable());
verifyNoAdvancedCapabilityOnTheStableStarter();
GraphQlProperties framework = frameworkProperties.getIfAvailable();
validator.validateRuntime(
new GraphQlPlatformRuntime(
properties,
pipeline,
scalarWiring,
clientPolicy,
transport.getIfAvailable(() -> GraphQlRuntimeTransport.NONE),
framework == null ? null : framework.getSchema().getIntrospection().isEnabled(),
framework == null ? null : framework.getGraphiql().isEnabled()));
};
}
/** The Stable execution pipeline. */
/**
* Checks the resolvers this application actually registered.
*
* <p>The boundary rules could only be pointed at a package name, which meant the fixture packages
* in this leaf's own tests were the only thing ever checked. An adopter's controllers the ones
* that can actually return an entity or inject a repository were never inspected by anything.
* Reading the context is what closes that: it sees the beans that will serve requests, including
* the ones contributed by a library the adopter did not write.
*/
@Bean
public InitializingBean graphQlControllerBoundaryCheck(ApplicationContext context) {
return () -> {
List<Class<?>> controllers = graphQlControllerClasses(context);
if (controllers.isEmpty()) {
return;
}
GraphQlTransportTypeRules.assertTransportTypesOnly(controllers);
GraphQlResolverBoundaryRules.assertNoPersistenceAccess(controllers);
GraphQlTransportTypeRules.assertRawDataFetcherIsInfrastructureOnly(controllers);
};
}
/**
* The user classes of every {@code @Controller} bean that declares a GraphQL mapping.
*
* <p>Proxies are unwrapped: a transactional or secured controller is registered as a CGLIB
* subclass whose declared methods carry no annotations, and inspecting that would find nothing.
*/
private static List<Class<?>> graphQlControllerClasses(ApplicationContext context) {
List<Class<?>> controllers = new java.util.ArrayList<>();
for (String beanName : context.getBeanNamesForAnnotation(Controller.class)) {
Class<?> beanType = context.getType(beanName);
if (beanType == null) {
continue;
}
Class<?> userClass = ClassUtils.getUserClass(beanType);
for (java.lang.reflect.Method method : userClass.getDeclaredMethods()) {
if (!method.isSynthetic() && GraphQlControllerInspector.isResolver(method)) {
controllers.add(userClass);
break;
}
}
}
return List.copyOf(controllers);
}
/**
* Detects a servlet runtime.
*
* <p>Nested conditional configurations rather than a classpath probe in the validator: this is
* exactly the question {@code @ConditionalOnWebApplication} answers, and it answers it the same
* way Spring Boot decides which transport to wire.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
static class ServletRuntimeDetection {
@Bean
GraphQlRuntimeTransport graphQlRuntimeTransport() {
return GraphQlRuntimeTransport.SERVLET;
}
}
/** Detects a reactive runtime. */
@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
static class ReactiveRuntimeDetection {
@Bean
GraphQlRuntimeTransport graphQlRuntimeTransport() {
return GraphQlRuntimeTransport.REACTIVE;
}
}
/**
* Caps the raw request body upstream of the JSON decoder, on a servlet stack.
*
* <p>Loaded only where servlets exist. The leaf takes the servlet API as {@code compileOnly}, so
* on a reactive or non-web application this class is simply not on the classpath and the
* condition never matches which is why the guard is {@code @ConditionalOnClass} as well as
* {@code @ConditionalOnWebApplication}.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(jakarta.servlet.Filter.class)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
static class ServletRequestBodyLimit {
/**
* Registers the cap with the highest precedence.
*
* <p>The whole point is to run before anything reads the body, so it has to precede the
* decoder, Spring Security's filters and any application filter that might buffer the request.
*/
@Bean
@ConditionalOnMissingBean
FilterRegistrationBean<GraphQlRequestBodyLimitFilter> graphQlRequestBodyLimitFilter(
GraphQlClientPolicy clientPolicy, ObjectProvider<GraphQlProperties> frameworkProperties) {
// The body carries the document, the variables and the extensions plus JSON framing, so the
// cap is their sum with room for the envelope rather than any one of them.
long maxBodyBytes =
(long) clientPolicy.maxDocumentBytes()
+ clientPolicy.maxVariablesBytes()
+ clientPolicy.maxVariablesBytes()
+ ENVELOPE_FRAMING_ALLOWANCE_BYTES;
// The endpoint path follows the framework when the framework is there. It is optional
// because the platform can be assembled without Boot's GraphQL auto-configuration a slice
// test, or a composition root that wires the endpoint itself and a cap that refuses to
// exist in those contexts would make the leaf harder to test than to secure.
String path = frameworkProperties.getIfAvailable(GraphQlProperties::new).getHttp().getPath();
FilterRegistrationBean<GraphQlRequestBodyLimitFilter> registration =
new FilterRegistrationBean<>(new GraphQlRequestBodyLimitFilter(path, maxBodyBytes));
registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
registration.addUrlPatterns(path);
return registration;
}
}
/** Slack for the JSON envelope around the three sized fields. */
static final int ENVELOPE_FRAMING_ALLOWANCE_BYTES = 1024;
/**
* The execution pipeline, derived from the handlers that actually run.
*
* <p>Derived rather than declared: a hand-written stage list can describe a pipeline the code
* does not have, and this one cannot. The startup check above validates this value, so a chain
* assembled in the wrong order fails the refresh instead of serving requests.
*/
@Bean
@ConditionalOnMissingBean
public GraphQlExecutionPipeline graphQlExecutionPipeline() {
return GraphQlExecutionPipeline.stable();
public GraphQlExecutionPipeline graphQlExecutionPipeline(GraphQlExecutionChain chain) {
return chain.pipeline();
}
/** The clock every deadline and expiry check reads. */
@Bean
@ConditionalOnMissingBean(name = "graphQlPlatformClock")
public Clock graphQlPlatformClock() {
return Clock.systemUTC();
}
/**
* The limits applied to a request when the adopter has not registered a client policy.
*
* <p>Page size, complexity and introspection follow {@code backend.graphql.*}; the rest are the
* calibration starting points from the design. An adopter replaces this bean rather than editing
* a table of constants.
*/
@Bean
@ConditionalOnMissingBean
public GraphQlClientPolicy graphQlClientPolicy(GraphQlPlatformProperties properties) {
return GraphQlClientPolicy.defaults(
properties.limits().maximumPageSize(),
properties.limits().maximumComplexity(),
properties.console().introspectionEnabled());
}
/**
* How a request is authenticated.
*
* <p>Anonymous by default, because authentication belongs to the composition root. This is not a
* permissive default in itself: what an anonymous caller may do is decided by the authorization
* policy and the client profile, both of which are checked on every request.
*/
@Bean
@ConditionalOnMissingBean
public GraphQlPrincipalResolver graphQlPrincipalResolver() {
return GraphQlPrincipalResolver.anonymous();
}
/** The only factory allowed to build a request context. */
@Bean
@ConditionalOnMissingBean
public GraphQlAuthenticationContextFactory graphQlAuthenticationContextFactory(
Clock graphQlPlatformClock) {
return new GraphQlAuthenticationContextFactory(graphQlPlatformClock);
}
/**
* Coordinate authorization rules.
*
* <p>There is no safe default here, so production does not get one. Coordinate rules are
* application knowledge: a deny-by-default skeleton policy would answer nothing and adopters
* would replace it with an allow-all one, while an allow-by-default policy shipped into
* production would be an unauthorized endpoint. So development gets the permissive default that
* lets the schema be explored, and {@code backend.graphql.production=true} refuses to start
* without an explicit policy.
*/
@Bean
@ConditionalOnMissingBean
public GraphQlAuthorizationPolicy graphQlAuthorizationPolicy(
GraphQlPlatformProperties properties) {
if (properties.production()) {
throw new GraphQlPlatformConfigurationException(
List.of(
"backend.graphql.production=true requires an explicit GraphQlAuthorizationPolicy "
+ "bean; the platform has no application coordinates to authorize on its own"));
}
return GraphQlAuthorizationPolicy.builder().denyByDefault(false).build();
}
/** Registered field costs; unregistered coordinates fall back to a conservative weight. */
@Bean
@ConditionalOnMissingBean
public GraphQlCostCatalog graphQlCostCatalog() {
return GraphQlCostCatalog.of();
}
/** Document structure measurement. */
@Bean
@ConditionalOnMissingBean
public GraphQlDocumentShapeAnalyzer graphQlDocumentShapeAnalyzer() {
return new GraphQlDocumentShapeAnalyzer();
}
/** Per-field pricing, driven by the page policy the client policy declares. */
@Bean
@ConditionalOnMissingBean
public GraphQlComplexityCalculator graphQlComplexityCalculator(
GraphQlCostCatalog catalog, GraphQlClientPolicy clientPolicy) {
return new GraphQlComplexityCalculator(
catalog, clientPolicy.defaultPageSize(), clientPolicy.maxPageSize());
}
/** Structural ceilings derived from the client policy. */
@Bean
@ConditionalOnMissingBean
public GraphQlStructuralLimitPolicy graphQlStructuralLimitPolicy(
GraphQlClientPolicy clientPolicy) {
return new GraphQlStructuralLimitPolicy(GraphQlStructuralLimits.from(clientPolicy));
}
/** The executable chain: select the operation, authorize it, judge its cost. */
@Bean
@ConditionalOnMissingBean
public GraphQlExecutionChain graphQlExecutionChain(
GraphQlClientPolicy clientPolicy,
GraphQlAuthorizationPolicy authorizationPolicy,
GraphQlDocumentShapeAnalyzer analyzer,
GraphQlStructuralLimitPolicy structuralLimits,
GraphQlComplexityCalculator calculator,
Clock graphQlPlatformClock) {
return GraphQlExecutionChain.stable(
new GraphQlOperationSelectionHandler(clientPolicy),
new GraphQlDocumentAuthorizationHandler(
new GraphQlAuthorizationInterceptor(authorizationPolicy), analyzer, clientPolicy),
new GraphQlCostBudgetHandler(
analyzer, structuralLimits, calculator, clientPolicy, graphQlPlatformClock));
}
/**
* Puts the chain on the real execution path.
*
* <p>Spring for GraphQL picks up every {@code Instrumentation} bean, so this is what turns the
* policy objects from a catalogue into something a request has to pass.
*/
@Bean
@ConditionalOnMissingBean(GraphQlPlatformInstrumentation.class)
public Instrumentation graphQlPlatformInstrumentation(GraphQlExecutionChain chain) {
return new GraphQlPlatformInstrumentation(chain);
}
/**
* The application's deliberate failure mappings.
*
* <p>Empty by default, which means every unrecognised failure is masked. An adopter replaces this
* bean to give its own modelled failures a stable code rather than an opaque internal error.
*/
@Bean
@ConditionalOnMissingBean
public GraphQlExceptionResolver graphQlApplicationExceptionMappings() {
return GraphQlExceptionResolver.defaults();
}
/** The single vocabulary every client-visible error is produced from. */
@Bean
@ConditionalOnMissingBean
public GraphQlWireErrorMapper graphQlWireErrorMapper(GraphQlExceptionResolver mappings) {
return new GraphQlWireErrorMapper(mappings);
}
/** Adapts the mapper onto Spring's data-fetcher exception contract. */
@Bean
@ConditionalOnMissingBean(GraphQlDataFetcherExceptionResolver.class)
public DataFetcherExceptionResolver graphQlDataFetcherExceptionResolver(
GraphQlWireErrorMapper mapper) {
return new GraphQlDataFetcherExceptionResolver(mapper);
}
/** Shape ceilings for decoded {@code variables} and {@code extensions}. */
@Bean
@ConditionalOnMissingBean
public GraphQlJsonStructurePolicy graphQlJsonStructurePolicy(GraphQlClientPolicy clientPolicy) {
return GraphQlJsonStructurePolicy.from(clientPolicy);
}
/** Establishes the request context on the real {@code /graphql} endpoint. */
@Bean
@ConditionalOnMissingBean(GraphQlPlatformWebInterceptor.class)
public WebGraphQlInterceptor graphQlPlatformWebInterceptor(
GraphQlPrincipalResolver principalResolver,
GraphQlAuthenticationContextFactory contextFactory,
GraphQlClientPolicy clientPolicy,
GraphQlJsonStructurePolicy structurePolicy,
GraphQlPlatformProperties properties,
Clock graphQlPlatformClock) {
return new GraphQlPlatformWebInterceptor(
principalResolver,
contextFactory,
clientPolicy,
structurePolicy,
new GraphQlClientProfile(DEFAULT_ANONYMOUS_PROFILE),
TenantContext.system(DEFAULT_ANONYMOUS_TENANT),
properties.production(),
graphQlPlatformClock);
}
/** The approved scalar set; the wiring configurer refuses to wire anything absent from it. */
@Bean
@ConditionalOnMissingBean
public GraphQlScalarManifest graphQlScalarManifest() {
return GraphQlScalarManifest.of(
GraphQlScalarWiringConfigurer.stableScalars().keySet().stream()
.map(GraphQlScalarDefinition::named)
.toArray(GraphQlScalarDefinition[]::new));
}
/**
* Registers the approved custom scalars through Spring's supported wiring entry point.
*
* <p>Declared as the concrete type rather than as {@code RuntimeWiringConfigurer} so the startup
* check can ask it which scalars it will wire. Spring still picks it up as a configurer.
*/
@Bean
@ConditionalOnMissingBean
public GraphQlScalarWiringConfigurer graphQlScalarWiringConfigurer(
GraphQlScalarManifest manifest) {
return new GraphQlScalarWiringConfigurer(manifest);
}
/** The Stable schema mapping gate. */
@@ -83,8 +508,22 @@ public class GraphQlPlatformAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public GraphQlRequestObservationConvention graphQlRequestObservationConvention(
GraphQlSensitiveAttributeFilter filter) {
return new GraphQlRequestObservationConvention(filter);
GraphQlSensitiveAttributeFilter filter, GraphQlOperationNameCardinality operationNames) {
return new GraphQlRequestObservationConvention(filter, operationNames);
}
/**
* Which operation names may become metric labels.
*
* <p>Defaults to the deployment's declared list, which is empty unless configured. Empty means
* every named operation collapses to one label: correct by default, and legible for any adopter
* who names the operations they care about.
*/
@Bean
@ConditionalOnMissingBean
public GraphQlOperationNameCardinality graphQlOperationNameCardinality(
GraphQlPlatformProperties properties) {
return new GraphQlOperationNameCardinality(properties.observedOperationNames());
}
/** The resolver observation convention. */
@@ -103,16 +542,58 @@ public class GraphQlPlatformAutoConfiguration {
return new GraphQlDataLoaderObservationConvention(filter);
}
private static void verifyNoAdvancedCapabilityOnTheStableStarter() {
Set<String> advanced = GraphQlBuildModel.advancedModules();
GraphQlBuildModel.stableDependencyEdges()
.forEach(
(module, dependencies) -> {
if (dependencies.stream().anyMatch(advanced::contains)) {
throw new GraphQlPlatformConfigurationException(
java.util.List.of(
"stable module " + module + " depends on an advanced capability"));
}
});
/**
* The preparsed document cache bounds.
*
* <p>Idle expiry and both size bounds come from configuration rather than from a constant, so an
* adopter whose documents are large can trade entries for weight without forking the platform.
*/
@Bean
@ConditionalOnMissingBean
public GraphQlPreparsedCachePolicy graphQlPreparsedCachePolicy(
GraphQlPlatformProperties properties) {
return new GraphQlPreparsedCachePolicy(
properties.limits().preparsedCacheEntries(),
properties.limits().preparsedCacheWeight(),
properties.limits().preparsedCacheExpireAfterAccess());
}
/** The bounded parse/validate cache. */
@Bean
@ConditionalOnMissingBean
public BoundedPreparsedDocumentProvider<PreparsedDocumentEntry> graphQlPreparsedDocumentCache(
GraphQlPreparsedCachePolicy policy, Clock clock) {
return new BoundedPreparsedDocumentProvider<>(
policy, new GraphQlPreparsedCacheMetrics(), clock);
}
/**
* Hands the bounded cache to graphql-java, which is the only thing that consults one.
*
* <p>The schema hash is resolved through an {@code ObjectProvider} on first use: this customizer
* runs while the {@code GraphQlSource} is still being built, so asking for the schema here would
* be asking for the bean currently under construction.
*/
@Bean
@ConditionalOnMissingBean(name = "graphQlPreparsedDocumentCustomizer")
public GraphQlSourceBuilderCustomizer graphQlPreparsedDocumentCustomizer(
BoundedPreparsedDocumentProvider<PreparsedDocumentEntry> cache,
ObjectProvider<GraphQlSource> graphQlSource,
GraphQlPlatformProperties properties) {
GraphQlPreparsedDocumentAdapter adapter =
new GraphQlPreparsedDocumentAdapter(
cache, () -> schemaContractHash(graphQlSource), properties.validationPolicyVersion());
return builder ->
builder.configureGraphQl(graphQl -> graphQl.preparsedDocumentProvider(adapter));
}
private static String schemaContractHash(ObjectProvider<GraphQlSource> graphQlSource) {
GraphQlSource source = graphQlSource.getIfAvailable();
if (source == null) {
// A cache keyed on an unknown schema would survive a schema change, which is the one thing
// the schema part of the key exists to prevent. Partition it instead of guessing.
return "schema-unavailable";
}
return GraphQlPreparsedDocumentAdapter.sha256(new SchemaPrinter().print(source.schema()));
}
}
@@ -1,12 +1,20 @@
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfile;
import java.time.Duration;
import java.util.Set;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
/**
* The platform's configuration surface (design §23).
*
* <p>Every default is declared with {@link DefaultValue}, which is the only kind of default the
* binder actually applies. A primitive with no annotation binds to zero, and this record's zeros
* a page size of nothing, a complexity budget of nothing are exactly the values the startup
* validator refuses, so the platform used to refuse to start until an operator supplied two numbers
* that have perfectly good defaults.
*
* <p>The unsupported capabilities appear here as explicit flags rather than being absent. A
* deployment that tries to enable multipart upload, HTTP array batching, a request-wide transaction
* or automatic repository exposure should fail at startup with a clear reason silently ignoring
@@ -15,84 +23,202 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
* @param production whether production rules apply
* @param environment environment governing introspection and GraphiQL
* @param executionProfile runtime execution profile
* @param graphiqlEnabled whether GraphiQL is served
* @param introspectionEnabled whether introspection is answered
* @param maximumPageSize largest connection page size
* @param maximumComplexity largest accepted complexity score
* @param cursorKeyIds signing key identities for cursors
* @param multipartUploadEnabled unsupported; must stay false
* @param httpArrayBatchEnabled unsupported; must stay false
* @param requestWideTransactionEnabled unsupported; must stay false
* @param repositoryAutoExposureEnabled unsupported outside the Advanced compatibility capability
* @param responseCacheEnabled unsupported; must stay false
* @param advancedCapabilitiesOnStableStarter whether Advanced modules leaked into the Stable
* starter
* @param console query console and introspection exposure
* @param limits page and cost ceilings
* @param cursor cursor signing key ring
* @param unsupported capabilities this platform deliberately does not implement
* @param unbridgedBlockingResolvers resolvers that block without an approved bridge
*/
@ConfigurationProperties("backend.graphql")
public record GraphQlPlatformProperties(
boolean production,
GraphQlPlatformEnvironment environment,
GraphQlExecutionProfile executionProfile,
boolean graphiqlEnabled,
boolean introspectionEnabled,
int maximumPageSize,
long maximumComplexity,
Set<String> cursorKeyIds,
boolean multipartUploadEnabled,
boolean httpArrayBatchEnabled,
boolean requestWideTransactionEnabled,
boolean repositoryAutoExposureEnabled,
boolean responseCacheEnabled,
boolean advancedCapabilitiesOnStableStarter,
Set<String> unbridgedBlockingResolvers) {
@DefaultValue("false") boolean production,
@DefaultValue("PRODUCTION_PUBLIC") GraphQlPlatformEnvironment environment,
@DefaultValue("BLOCKING_MVC") GraphQlExecutionProfile executionProfile,
@DefaultValue Console console,
@DefaultValue Limits limits,
@DefaultValue Cursor cursor,
@DefaultValue Unsupported unsupported,
@DefaultValue("v1") String validationPolicyVersion,
@DefaultValue Set<String> observedOperationNames,
@DefaultValue Set<String> unbridgedBlockingResolvers) {
public GraphQlPlatformProperties {
// Belt and braces for programmatic construction: the binder honours @DefaultValue, but this
// record is also built directly in tests and by adopters composing a policy in Java.
environment = environment == null ? GraphQlPlatformEnvironment.PRODUCTION_PUBLIC : environment;
executionProfile =
executionProfile == null ? GraphQlExecutionProfile.BLOCKING_MVC : executionProfile;
cursorKeyIds = cursorKeyIds == null ? Set.of() : Set.copyOf(cursorKeyIds);
console = console == null ? Console.disabled() : console;
limits = limits == null ? Limits.defaults() : limits;
cursor = cursor == null ? Cursor.none() : cursor;
unsupported = unsupported == null ? Unsupported.none() : unsupported;
// Part of the preparsed cache key: bumping it invalidates every cached validation, which is
// what an adopter needs when their own validation rules change without the schema changing.
validationPolicyVersion =
validationPolicyVersion == null || validationPolicyVersion.isBlank()
? "v1"
: validationPolicyVersion;
// The operation names allowed to become metric labels. Empty collapses them all, which is the
// safe default: a cardinality bound that has to be switched on is one nobody has switched on.
observedOperationNames =
observedOperationNames == null ? Set.of() : Set.copyOf(observedOperationNames);
unbridgedBlockingResolvers =
unbridgedBlockingResolvers == null ? Set.of() : Set.copyOf(unbridgedBlockingResolvers);
}
/**
* Query console and schema disclosure.
*
* @param graphiqlEnabled whether GraphiQL is served
* @param introspectionEnabled whether introspection is answered
*/
public record Console(
@DefaultValue("false") boolean graphiqlEnabled,
@DefaultValue("false") boolean introspectionEnabled) {
/**
* Neither the console nor introspection, which is the only safe default for an unknown host.
*/
public static Console disabled() {
return new Console(false, false);
}
}
/**
* Page, cost and cache ceilings.
*
* @param maximumPageSize largest connection page size
* @param maximumComplexity largest accepted pre-execution complexity score
* @param preparsedCacheEntries largest number of cached parsed documents
* @param preparsedCacheWeight largest total cached document weight, in characters
* @param preparsedCacheExpireAfterAccess how long an unused cached document is kept
*/
public record Limits(
@DefaultValue("100") int maximumPageSize,
@DefaultValue("10000") long maximumComplexity,
@DefaultValue("1000") long preparsedCacheEntries,
@DefaultValue("10000000") long preparsedCacheWeight,
@DefaultValue("30m") Duration preparsedCacheExpireAfterAccess) {
/** The declared defaults, for programmatic construction. */
public static Limits defaults() {
return new Limits(100, 10_000, 1_000, 10_000_000, Duration.ofMinutes(30));
}
}
/**
* The cursor signing key ring.
*
* @param keyIds signing key identities; the keys themselves never appear in configuration
*/
public record Cursor(@DefaultValue Set<String> keyIds) {
public Cursor {
keyIds = keyIds == null ? Set.of() : Set.copyOf(keyIds);
}
/** An empty key ring, which production refuses to start with. */
public static Cursor none() {
return new Cursor(Set.of());
}
/** A key ring with the given identities. */
public static Cursor of(Set<String> keyIds) {
return new Cursor(keyIds);
}
}
/**
* Capabilities the platform does not implement.
*
* <p>Present as flags so enabling one fails the boot instead of being ignored.
*
* @param multipartUpload GraphQL multipart upload
* @param httpArrayBatch HTTP array batching
* @param requestWideTransaction one database transaction spanning a whole request
* @param repositoryAutoExposure automatic repository exposure as GraphQL fields
* @param responseCache cross-request response caching
* @param advancedCapabilitiesOnStableStarter Advanced modules reachable from the Stable starter
*/
public record Unsupported(
@DefaultValue("false") boolean multipartUpload,
@DefaultValue("false") boolean httpArrayBatch,
@DefaultValue("false") boolean requestWideTransaction,
@DefaultValue("false") boolean repositoryAutoExposure,
@DefaultValue("false") boolean responseCache,
@DefaultValue("false") boolean advancedCapabilitiesOnStableStarter) {
/** Nothing unsupported enabled. */
public static Unsupported none() {
return new Unsupported(false, false, false, false, false, false);
}
/** Returns a copy with one capability toggled, for startup-validation tests. */
public Unsupported with(String capability, boolean enabled) {
return new Unsupported(
"multipart".equals(capability) ? enabled : multipartUpload,
"arrayBatch".equals(capability) ? enabled : httpArrayBatch,
"requestWideTransaction".equals(capability) ? enabled : requestWideTransaction,
"repositoryAutoExposure".equals(capability) ? enabled : repositoryAutoExposure,
"responseCache".equals(capability) ? enabled : responseCache,
"advancedOnStableStarter".equals(capability)
? enabled
: advancedCapabilitiesOnStableStarter);
}
}
/** Safe production defaults. */
public static GraphQlPlatformProperties productionDefaults() {
return new GraphQlPlatformProperties(
true,
GraphQlPlatformEnvironment.PRODUCTION_INTERNAL,
GraphQlExecutionProfile.BLOCKING_MVC,
false,
false,
100,
10_000,
Set.of("cursor-key-1"),
false,
false,
false,
false,
false,
false,
Console.disabled(),
Limits.defaults(),
Cursor.of(Set.of("cursor-key-1")),
Unsupported.none(),
"v1",
Set.of(),
Set.of());
}
/** Returns a copy with GraphiQL enabled or disabled. */
public GraphQlPlatformProperties withGraphiqlEnabled(boolean enabled) {
return withConsole(new Console(enabled, console.introspectionEnabled()));
}
/** Returns a copy with introspection enabled or disabled. */
public GraphQlPlatformProperties withIntrospectionEnabled(boolean enabled) {
return withConsole(new Console(console.graphiqlEnabled(), enabled));
}
/** Returns a copy with a different console policy. */
public GraphQlPlatformProperties withConsole(Console replacement) {
return new GraphQlPlatformProperties(
production,
environment,
executionProfile,
enabled,
introspectionEnabled,
maximumPageSize,
maximumComplexity,
cursorKeyIds,
multipartUploadEnabled,
httpArrayBatchEnabled,
requestWideTransactionEnabled,
repositoryAutoExposureEnabled,
responseCacheEnabled,
advancedCapabilitiesOnStableStarter,
replacement,
limits,
cursor,
unsupported,
validationPolicyVersion,
observedOperationNames,
unbridgedBlockingResolvers);
}
/** Returns a copy with different limits. */
public GraphQlPlatformProperties withLimits(Limits replacement) {
return new GraphQlPlatformProperties(
production,
environment,
executionProfile,
console,
replacement,
cursor,
unsupported,
validationPolicyVersion,
observedOperationNames,
unbridgedBlockingResolvers);
}
@@ -102,17 +228,12 @@ public record GraphQlPlatformProperties(
production,
environment,
executionProfile,
graphiqlEnabled,
introspectionEnabled,
maximumPageSize,
maximumComplexity,
Set.copyOf(keyIds),
multipartUploadEnabled,
httpArrayBatchEnabled,
requestWideTransactionEnabled,
repositoryAutoExposureEnabled,
responseCacheEnabled,
advancedCapabilitiesOnStableStarter,
console,
limits,
Cursor.of(keyIds),
unsupported,
validationPolicyVersion,
observedOperationNames,
unbridgedBlockingResolvers);
}
@@ -122,19 +243,12 @@ public record GraphQlPlatformProperties(
production,
environment,
executionProfile,
graphiqlEnabled,
introspectionEnabled,
maximumPageSize,
maximumComplexity,
cursorKeyIds,
"multipart".equals(capability) ? enabled : multipartUploadEnabled,
"arrayBatch".equals(capability) ? enabled : httpArrayBatchEnabled,
"requestWideTransaction".equals(capability) ? enabled : requestWideTransactionEnabled,
"repositoryAutoExposure".equals(capability) ? enabled : repositoryAutoExposureEnabled,
"responseCache".equals(capability) ? enabled : responseCacheEnabled,
"advancedOnStableStarter".equals(capability)
? enabled
: advancedCapabilitiesOnStableStarter,
console,
limits,
cursor,
unsupported.with(capability, enabled),
validationPolicyVersion,
observedOperationNames,
unbridgedBlockingResolvers);
}
@@ -144,17 +258,12 @@ public record GraphQlPlatformProperties(
production,
environment,
executionProfile,
graphiqlEnabled,
introspectionEnabled,
maximumPageSize,
maximumComplexity,
cursorKeyIds,
multipartUploadEnabled,
httpArrayBatchEnabled,
requestWideTransactionEnabled,
repositoryAutoExposureEnabled,
responseCacheEnabled,
advancedCapabilitiesOnStableStarter,
console,
limits,
cursor,
unsupported,
validationPolicyVersion,
observedOperationNames,
Set.copyOf(coordinates));
}
@@ -164,17 +273,12 @@ public record GraphQlPlatformProperties(
production,
environment,
profile,
graphiqlEnabled,
introspectionEnabled,
maximumPageSize,
maximumComplexity,
cursorKeyIds,
multipartUploadEnabled,
httpArrayBatchEnabled,
requestWideTransactionEnabled,
repositoryAutoExposureEnabled,
responseCacheEnabled,
advancedCapabilitiesOnStableStarter,
console,
limits,
cursor,
unsupported,
validationPolicyVersion,
observedOperationNames,
unbridgedBlockingResolvers);
}
}
@@ -0,0 +1,43 @@
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipeline;
import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy;
import dev.caskeleton.adapter.inbound.graphql.scalar.GraphQlScalarWiringConfigurer;
import java.util.Objects;
/**
* What the context actually assembled, gathered so it can be validated as one thing.
*
* <p>The platform's beans are all replaceable, which is the point of
* {@code @ConditionalOnMissingBean} and also the risk. An adopter can supply a pipeline with
* authorization after execution, a scalar manifest naming a scalar with no coercion, or a client
* policy whose page ceiling is higher than the one the operator configured. None of those fail at
* wiring time; they fail at request time, in production, quietly.
*
* @param properties the bound configuration
* @param pipeline the pipeline that will run, derived from the registered handler chain
* @param scalarWiring the scalar wiring that will be applied to the schema
* @param clientPolicy the limits a request will be measured against
* @param transport the server this context is actually running on
* @param frameworkIntrospectionEnabled {@code spring.graphql.schema.introspection.enabled}, or
* {@code null} when the framework properties are not on the classpath
* @param frameworkGraphiqlEnabled {@code spring.graphql.graphiql.enabled}, or {@code null} when the
* framework properties are not on the classpath
*/
public record GraphQlPlatformRuntime(
GraphQlPlatformProperties properties,
GraphQlExecutionPipeline pipeline,
GraphQlScalarWiringConfigurer scalarWiring,
GraphQlClientPolicy clientPolicy,
GraphQlRuntimeTransport transport,
Boolean frameworkIntrospectionEnabled,
Boolean frameworkGraphiqlEnabled) {
public GraphQlPlatformRuntime {
Objects.requireNonNull(properties, "properties are required");
Objects.requireNonNull(pipeline, "pipeline is required");
Objects.requireNonNull(scalarWiring, "scalar wiring is required");
Objects.requireNonNull(clientPolicy, "client policy is required");
transport = transport == null ? GraphQlRuntimeTransport.NONE : transport;
}
}
@@ -32,42 +32,43 @@ public final class GraphQlPlatformStartupValidator {
// Reported once even when both the production flag and the environment forbid it, so a single
// misconfiguration does not appear as two problems.
if (properties.graphiqlEnabled()
if (properties.console().graphiqlEnabled()
&& (properties.production() || !properties.environment().graphiqlAllowed())) {
problems.add(
"GraphiQL must not be enabled in " + properties.environment() + " or in production");
}
if (properties.production() && properties.cursorKeyIds().isEmpty()) {
if (properties.production() && properties.cursor().keyIds().isEmpty()) {
problems.add("a cursor signing key is required; unsigned cursors are client-editable");
}
if (properties.introspectionEnabled() && !properties.environment().introspectionAllowed()) {
if (properties.console().introspectionEnabled()
&& !properties.environment().introspectionAllowed()) {
problems.add("introspection is not permitted in " + properties.environment());
}
if (properties.maximumPageSize() < 1) {
if (properties.limits().maximumPageSize() < 1) {
problems.add("maximum page size must be positive");
}
if (properties.maximumComplexity() < 1) {
if (properties.limits().maximumComplexity() < 1) {
problems.add("maximum complexity must be positive");
}
if (properties.multipartUploadEnabled()) {
if (properties.unsupported().multipartUpload()) {
problems.add(
"GraphQL multipart upload is unsupported; use the Fileserver upload reservation");
}
if (properties.httpArrayBatchEnabled()) {
if (properties.unsupported().httpArrayBatch()) {
problems.add("HTTP array batching is unsupported");
}
if (properties.requestWideTransactionEnabled()) {
if (properties.unsupported().requestWideTransaction()) {
problems.add("request-wide database transactions are unsupported; use one mutation use case");
}
if (properties.repositoryAutoExposureEnabled()) {
if (properties.unsupported().repositoryAutoExposure()) {
problems.add(
"automatic repository exposure is unsupported outside the Advanced compatibility capability");
}
if (properties.responseCacheEnabled()) {
if (properties.unsupported().responseCache()) {
problems.add("response caching is unsupported until an actor/tenant cache key model exists");
}
if (properties.advancedCapabilitiesOnStableStarter()) {
if (properties.unsupported().advancedCapabilitiesOnStableStarter()) {
problems.add("the Stable starter must not activate Advanced capabilities");
}
if (properties.executionProfile() == GraphQlExecutionProfile.REACTIVE_WEBFLUX
@@ -78,4 +79,105 @@ public final class GraphQlPlatformStartupValidator {
}
return List.copyOf(problems);
}
/**
* Validates the beans the context actually assembled, not the ones the platform ships.
*
* @throws GraphQlPlatformConfigurationException listing every problem found
*/
public void validateRuntime(GraphQlPlatformRuntime runtime) {
List<String> problems = runtimeProblems(runtime);
if (!problems.isEmpty()) {
throw new GraphQlPlatformConfigurationException(problems);
}
}
/**
* Problems with the assembled runtime, in a deterministic order.
*
* <p>Every check here is on an injected bean rather than on a platform constant. Validating
* {@code GraphQlExecutionPipeline.stable()} would prove the platform's own default is well formed
* and say nothing about the pipeline an adopter actually replaced it with which is the only one
* that will serve requests.
*/
public List<String> runtimeProblems(GraphQlPlatformRuntime runtime) {
List<String> problems = new ArrayList<>();
problems.addAll(
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipelineValidator.problems(
runtime.pipeline()));
if (!runtime.transport().supports(runtime.properties().executionProfile())) {
problems.add(
"backend.graphql.execution-profile is "
+ runtime.properties().executionProfile()
+ " but this context runs on "
+ runtime.transport()
+ "; the composition root chooses the server and the two must agree");
}
// Independent of the declared profile: on an event loop, a resolver that blocks without a
// bridge stalls every other request sharing the thread. MIXED_CONTROLLED is accepted on a
// reactive transport precisely because the crossings are declared, so an undeclared one is the
// condition that makes the profile a lie.
if (runtime.transport() == GraphQlRuntimeTransport.REACTIVE
&& !runtime.properties().unbridgedBlockingResolvers().isEmpty()) {
problems.add(
"resolvers block without an executor bridge on a reactive transport: "
+ new java.util.TreeSet<>(runtime.properties().unbridgedBlockingResolvers()));
}
try {
runtime.scalarWiring().wiredScalars();
} catch (RuntimeException unwirable) {
problems.add("scalar manifest cannot be wired: " + unwirable.getMessage());
}
if (runtime.clientPolicy().maxPageSize() > runtime.properties().limits().maximumPageSize()) {
problems.add(
"client policy maxPageSize ("
+ runtime.clientPolicy().maxPageSize()
+ ") exceeds backend.graphql.limits.maximum-page-size ("
+ runtime.properties().limits().maximumPageSize()
+ ")");
}
if (runtime.clientPolicy().maxComplexity()
> runtime.properties().limits().maximumComplexity()) {
problems.add(
"client policy maxComplexity ("
+ runtime.clientPolicy().maxComplexity()
+ ") exceeds backend.graphql.limits.maximum-complexity ("
+ runtime.properties().limits().maximumComplexity()
+ ")");
}
if (runtime.clientPolicy().introspectionAllowed()
&& !runtime.properties().console().introspectionEnabled()) {
problems.add(
"client policy allows introspection while backend.graphql.console.introspection-enabled "
+ "is false; one of the two is not what the operator configured");
}
// The framework flags are what a request actually meets. A platform that says introspection is
// off while `spring.graphql.schema.introspection.enabled` says it is on has two answers to one
// question, and the client gets the framework's.
if (runtime.frameworkIntrospectionEnabled() != null
&& runtime.frameworkIntrospectionEnabled()
!= runtime.properties().console().introspectionEnabled()) {
problems.add(
"backend.graphql.console.introspection-enabled ("
+ runtime.properties().console().introspectionEnabled()
+ ") contradicts spring.graphql.schema.introspection.enabled ("
+ runtime.frameworkIntrospectionEnabled()
+ ")");
}
if (runtime.frameworkGraphiqlEnabled() != null
&& runtime.frameworkGraphiqlEnabled() != runtime.properties().console().graphiqlEnabled()) {
problems.add(
"backend.graphql.console.graphiql-enabled ("
+ runtime.properties().console().graphiqlEnabled()
+ ") contradicts spring.graphql.graphiql.enabled ("
+ runtime.frameworkGraphiqlEnabled()
+ ")");
}
return List.copyOf(problems);
}
}
@@ -0,0 +1,44 @@
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfile;
/**
* The server this context is actually running on, as opposed to the one it was configured for.
*
* <p>The two used to be unable to disagree, because the leaf shipped an embedded servlet container
* itself which made {@code REACTIVE_WEBFLUX} a profile nobody could ever run. Now the composition
* root chooses the server, so the two can disagree, and something has to notice.
*/
public enum GraphQlRuntimeTransport {
/** A servlet application: Spring MVC owns the {@code /graphql} route. */
SERVLET,
/** A reactive application: WebFlux owns the {@code /graphql} route. */
REACTIVE,
/** No web server — a plain application context, a test slice, or a batch process. */
NONE;
/**
* Whether an execution profile can run on this transport.
*
* <p>{@link #NONE} accepts every profile: a context with no server has no route to contradict,
* and failing there would break every non-web test that assembles the platform.
*
* <p>{@code MIXED_CONTROLLED} runs on both by definition it is the profile for a deployment
* that crosses between blocking and reactive work through declared bridges. Accepting it here is
* not a loophole: a reactive context still refuses to start with resolvers that block without a
* bridge, whichever profile is declared.
*/
public boolean supports(GraphQlExecutionProfile profile) {
if (this == NONE || profile == GraphQlExecutionProfile.MIXED_CONTROLLED) {
return true;
}
return switch (this) {
case SERVLET -> profile == GraphQlExecutionProfile.BLOCKING_MVC;
case REACTIVE -> profile == GraphQlExecutionProfile.REACTIVE_WEBFLUX;
case NONE -> true;
};
}
}
@@ -177,11 +177,65 @@ public enum GraphQlChangeKind {
GraphQlCompatibilityImpact.BREAKING,
"removing a scalar breaks existing operations"),
/** A scalar's declared serialization contract changed. */
SCALAR_COERCION_CHANGED(
/**
* A scalar's SDL declaration changed.
*
* <p>Not a coercion change. Whether {@code DateTime} still parses the same strings is a property
* of its {@code Coercing} implementation, which the SDL does not contain swapping the codec
* while leaving the SDL alone was invisible here, and editing the description raised a false
* breaking change. Coercion compatibility belongs to the scalar manifest's codec version, and
* this kind now says only what it can see.
*/
SCALAR_DECLARATION_CHANGED(
GraphQlCompatibilityImpact.REVIEW_REQUIRED,
GraphQlCompatibilityImpact.REVIEW_REQUIRED,
"a changed scalar declaration needs its manifest codec version checked for a coercion change"),
/**
* A type kept its name but changed kind, such as {@code type Foo} becoming {@code input Foo}.
*
* <p>Compared before anything else. Field-by-field comparison of two different kinds produces
* plausible per-field findings and misses the only one that matters: every operation naming the
* type breaks, whatever its fields now are.
*/
TYPE_KIND_CHANGED(
GraphQlCompatibilityImpact.BREAKING,
GraphQlCompatibilityImpact.BREAKING,
"changing a scalar coercion requires a new scalar or a new scalar manifest version"),
"a type that changed kind breaks every operation naming it"),
/**
* A default value was removed from an argument or input field.
*
* <p>Breaking for a non-null input: the default was the reason omitting it was legal.
*/
INPUT_DEFAULT_REMOVED(
GraphQlCompatibilityImpact.BREAKING,
GraphQlCompatibilityImpact.BREAKING,
"removing a default makes a previously omissible input required"),
/** A default value changed, so an omitted input now means something different. */
INPUT_DEFAULT_CHANGED(
GraphQlCompatibilityImpact.REVIEW_REQUIRED,
GraphQlCompatibilityImpact.REVIEW_REQUIRED,
"a changed default silently changes what an omitted input means"),
/** A default value was added, which makes a previously required input omissible. */
INPUT_DEFAULT_ADDED(
GraphQlCompatibilityImpact.COMPATIBLE,
GraphQlCompatibilityImpact.REVIEW_REQUIRED,
"a new default is accepting but changes generated models"),
/**
* The directives applied to a schema element changed.
*
* <p>Distinct from a directive definition change. {@code @deprecated} appearing on a field, or
* {@code @oneOf} disappearing from an input, changes what clients are told and what the engine
* enforces, and comparing only definitions could not see either.
*/
APPLIED_DIRECTIVE_CHANGED(
GraphQlCompatibilityImpact.REVIEW_REQUIRED,
GraphQlCompatibilityImpact.REVIEW_REQUIRED,
"a change to applied directives changes advertised or enforced behaviour"),
/** A directive definition appeared. */
DIRECTIVE_ADDED(
@@ -58,12 +58,14 @@ public final class GraphQlSchemaComparator {
List<GraphQlSchemaChange> changes = new ArrayList<>();
compareTypePresence(previous, candidate, changes);
compareTypeKinds(previous, candidate, changes);
compareOutputTypes(previous, candidate, changes);
compareInputTypes(previous, candidate, changes);
compareEnums(previous, candidate, changes);
compareUnions(previous, candidate, changes);
compareScalars(previous, candidate, changes);
compareDirectives(previous, candidate, changes);
compareAppliedDirectives(previous, candidate, changes);
return new GraphQlCompatibilityReport(changes.stream().sorted(DETERMINISTIC_ORDER).toList());
}
@@ -84,6 +86,121 @@ public final class GraphQlSchemaComparator {
.forEach(name -> changes.add(GraphQlSchemaChange.of(name, GraphQlChangeKind.TYPE_ADDED)));
}
/**
* Reports types that kept their name and changed kind.
*
* <p>Runs before the per-kind comparisons, which only ever look at types of their own kind and
* would therefore report {@code type Foo -> input Foo} as a removal from one map and an addition
* to another, or as nothing at all.
*/
private static void compareTypeKinds(
TypeDefinitionRegistry previous,
TypeDefinitionRegistry candidate,
List<GraphQlSchemaChange> changes) {
Map<String, TypeDefinition> previousTypes = previous.types();
Map<String, TypeDefinition> candidateTypes = candidate.types();
for (String name : new TreeSet<>(previousTypes.keySet())) {
TypeDefinition after = candidateTypes.get(name);
if (after == null) {
continue;
}
if (!previousTypes.get(name).getClass().equals(after.getClass())) {
changes.add(GraphQlSchemaChange.of(name, GraphQlChangeKind.TYPE_KIND_CHANGED));
}
}
}
/**
* Reports changes to the directives applied to types and their fields.
*
* <p>Applied directives, not definitions: {@code @deprecated} appearing on a field and
* {@code @oneOf} disappearing from an input are both behaviour changes that leave every directive
* definition untouched.
*/
private static void compareAppliedDirectives(
TypeDefinitionRegistry previous,
TypeDefinitionRegistry candidate,
List<GraphQlSchemaChange> changes) {
Map<String, TypeDefinition> previousTypes = previous.types();
Map<String, TypeDefinition> candidateTypes = candidate.types();
for (String name : new TreeSet<>(previousTypes.keySet())) {
TypeDefinition before = previousTypes.get(name);
TypeDefinition after = candidateTypes.get(name);
if (after == null || !before.getClass().equals(after.getClass())) {
continue;
}
if (!appliedDirectives(before).equals(appliedDirectives(after))) {
changes.add(GraphQlSchemaChange.of(name, GraphQlChangeKind.APPLIED_DIRECTIVE_CHANGED));
}
if (before instanceof ImplementingTypeDefinition<?> beforeType
&& after instanceof ImplementingTypeDefinition<?> afterType) {
compareFieldDirectives(name, beforeType, afterType, changes);
}
}
}
private static void compareFieldDirectives(
String typeName,
ImplementingTypeDefinition<?> before,
ImplementingTypeDefinition<?> after,
List<GraphQlSchemaChange> changes) {
Map<String, FieldDefinition> candidateFields =
byName(after.getFieldDefinitions(), FieldDefinition::getName);
for (FieldDefinition field : before.getFieldDefinitions()) {
FieldDefinition candidateField = candidateFields.get(field.getName());
if (candidateField == null) {
continue;
}
if (!appliedDirectives(field).equals(appliedDirectives(candidateField))) {
changes.add(
GraphQlSchemaChange.of(
typeName + "." + field.getName(), GraphQlChangeKind.APPLIED_DIRECTIVE_CHANGED));
}
}
}
/** Applied directives, printed and sorted so declaration order is not a change. */
private static Set<String> appliedDirectives(graphql.language.Node<?> node) {
List<graphql.language.Directive> directives =
node instanceof graphql.language.DirectivesContainer<?> container
? container.getDirectives()
: List.of();
return directives.stream()
.map(GraphQlSchemaComparator::print)
.collect(Collectors.toCollection(TreeSet::new));
}
/**
* Reports a change to an input's default value.
*
* <p>A default is part of the input contract: removing one from a non-null input makes every
* request that omitted the field invalid, and changing one silently changes what omitting it
* means. Neither shows up as a type change, which is all the comparator used to look at.
*/
private static void compareInputDefault(
String coordinate,
InputValueDefinition before,
InputValueDefinition after,
List<GraphQlSchemaChange> changes) {
String beforeDefault =
before.getDefaultValue() == null ? null : print(before.getDefaultValue());
String afterDefault = after.getDefaultValue() == null ? null : print(after.getDefaultValue());
if (java.util.Objects.equals(beforeDefault, afterDefault)) {
return;
}
if (beforeDefault == null) {
changes.add(GraphQlSchemaChange.of(coordinate, GraphQlChangeKind.INPUT_DEFAULT_ADDED));
} else if (afterDefault == null) {
changes.add(GraphQlSchemaChange.of(coordinate, GraphQlChangeKind.INPUT_DEFAULT_REMOVED));
} else {
changes.add(GraphQlSchemaChange.of(coordinate, GraphQlChangeKind.INPUT_DEFAULT_CHANGED));
}
}
private static void compareOutputTypes(
TypeDefinitionRegistry previous,
TypeDefinitionRegistry candidate,
@@ -268,6 +385,7 @@ public final class GraphQlSchemaComparator {
GraphQlChangeKind.INPUT_FIELD_RELAXED,
GraphQlChangeKind.INPUT_FIELD_TYPE_CHANGED,
changes);
compareInputDefault(coordinate, previousFields.get(fieldName), candidateField, changes);
}
candidateFields.keySet().stream()
@@ -396,7 +514,7 @@ public final class GraphQlSchemaComparator {
}
if (!print(previousScalars.get(name)).equals(print(after))) {
changes.add(
GraphQlSchemaChange.of("scalar " + name, GraphQlChangeKind.SCALAR_COERCION_CHANGED));
GraphQlSchemaChange.of("scalar " + name, GraphQlChangeKind.SCALAR_DECLARATION_CHANGED));
}
}
candidateScalars.keySet().stream()
@@ -0,0 +1,60 @@
package dev.caskeleton.adapter.inbound.graphql.context;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
/**
* The request context reduced to values an application command can carry.
*
* <p>An anti-corruption boundary, and the direction is the whole point. {@link
* GraphQlRequestContext} is an inbound transport type: it knows about client profiles, operation
* ids and GraphQL locales. Handing it to a use case would make {@code application-core} and then
* every persistence and HTTP-client adapter the use case reaches compile against the GraphQL
* boundary, so a change to a transport concern would ripple to the database layer and a non-GraphQL
* caller could not construct a command at all.
*
* <p>What crosses instead is this: four values with no transport vocabulary, which a REST, gRPC or
* scheduled caller can produce just as easily.
*
* @param actorId the acting identity, or {@code null} for an unauthenticated caller
* @param tenantId the tenant the work belongs to
* @param deadline when the caller stops waiting
* @param traceId correlation identity for logs and downstream calls
*/
public record GraphQlCommandAttribution(
String actorId, String tenantId, Instant deadline, String traceId) {
public GraphQlCommandAttribution {
Objects.requireNonNull(tenantId, "tenant is required");
Objects.requireNonNull(traceId, "trace id is required");
// Required, because the request context it comes from cannot exist without one. A command that
// travelled without a deadline would run until something else timed out, which is the point at
// which the caller has already given up and the work is being done for nobody.
Objects.requireNonNull(deadline, "deadline is required");
}
/**
* Maps a request context onto the values a command carries.
*
* @param context the inbound request context
*/
public static GraphQlCommandAttribution from(GraphQlRequestContext context) {
Objects.requireNonNull(context, "request context is required");
return new GraphQlCommandAttribution(
context.actor().authenticated() ? context.actor().value() : null,
context.tenant().value(),
context.deadline().value(),
context.traceId());
}
/** The acting identity, absent for an unauthenticated caller. */
public Optional<String> actor() {
return Optional.ofNullable(actorId);
}
/** When the caller stops waiting. */
public Instant deadlineAt() {
return deadline;
}
}
@@ -0,0 +1,295 @@
package dev.caskeleton.adapter.inbound.graphql.cost;
import graphql.language.Argument;
import graphql.language.Definition;
import graphql.language.Document;
import graphql.language.Field;
import graphql.language.FragmentDefinition;
import graphql.language.FragmentSpread;
import graphql.language.InlineFragment;
import graphql.language.IntValue;
import graphql.language.OperationDefinition;
import graphql.language.Selection;
import graphql.language.SelectionSet;
import graphql.language.Value;
import graphql.language.VariableReference;
import graphql.schema.GraphQLFieldDefinition;
import graphql.schema.GraphQLFieldsContainer;
import graphql.schema.GraphQLList;
import graphql.schema.GraphQLNonNull;
import graphql.schema.GraphQLObjectType;
import graphql.schema.GraphQLSchema;
import graphql.schema.GraphQLType;
import java.math.BigInteger;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/**
* Scores a whole document against the cost catalogue, before any resolver runs.
*
* <p>{@link GraphQlComplexityCalculator} prices one field; this walks the selection tree so a
* request has a single number to judge. The walk is schema-aware on purpose: a coordinate is {@code
* TypeName.fieldName}, and the type half only exists once each selection set has been resolved
* against the schema. Guessing it from the operation root would price {@code order { customer {
* orders { } } }} as three root fields and miss the multiplication entirely.
*
* <p>Cardinality comes from the request, not from the schema: a connection's children are
* multiplied by the effective page size, and a page size supplied through a variable is resolved
* from the request variables rather than assumed to be the default. That is the difference between
* a budget and a suggestion {@code first: $n} would otherwise cost the same at 1 and at 1000.
*
* <p>Traversal is bounded, and fragment cycles are cut by tracking the expansion path. This runs on
* documents that have passed validation, but the bound stays because the scorer is also used from
* the pre-execution path where a hostile document is exactly what it is meant to price.
*/
public final class GraphQlDocumentComplexityScorer {
/** Prefix that marks an introspection field, which is gated rather than priced. */
public static final String INTROSPECTION_FIELD_PREFIX = "__";
private final GraphQlComplexityCalculator calculator;
private final int maximumVisitedNodes;
/** Creates a scorer with the default traversal budget. */
public GraphQlDocumentComplexityScorer(GraphQlComplexityCalculator calculator) {
this(calculator, 200_000);
}
/**
* Creates a scorer.
*
* @param calculator per-field pricing
* @param maximumVisitedNodes traversal budget; exceeding it rejects the document
*/
public GraphQlDocumentComplexityScorer(
GraphQlComplexityCalculator calculator, int maximumVisitedNodes) {
this.calculator = Objects.requireNonNull(calculator, "complexity calculator is required");
if (maximumVisitedNodes < 1) {
throw new IllegalArgumentException("traversal budget must be positive");
}
this.maximumVisitedNodes = maximumVisitedNodes;
}
/**
* Scores one operation of a document.
*
* @param schema schema the document was validated against
* @param document the parsed document
* @param operation the selected operation
* @param variables the request variables, used to resolve page sizes
* @throws GraphQlComplexityRejectedException when a requested page exceeds the maximum
* @throws GraphQlStructuralLimitViolation when traversal exceeds the node budget
*/
public GraphQlComplexityResult score(
GraphQLSchema schema,
Document document,
OperationDefinition operation,
Map<String, Object> variables) {
Objects.requireNonNull(schema, "schema is required");
Objects.requireNonNull(document, "document is required");
Objects.requireNonNull(operation, "operation is required");
Map<String, FragmentDefinition> fragments = new LinkedHashMap<>();
for (Definition<?> definition : document.getDefinitions()) {
if (definition instanceof FragmentDefinition fragment) {
fragments.put(fragment.getName(), fragment);
}
}
GraphQLObjectType root = rootType(schema, operation);
if (root == null) {
// The schema does not define this operation type; validation rejects the document, and
// pricing a tree with no root would be inventing a number.
return new GraphQlComplexityResult(0);
}
long total =
selectionSetCost(
schema,
root,
operation.getSelectionSet(),
fragments,
variables == null ? Map.of() : variables,
new Counter(),
new ArrayDeque<>());
return new GraphQlComplexityResult(total);
}
private long selectionSetCost(
GraphQLSchema schema,
GraphQLFieldsContainer parent,
SelectionSet selectionSet,
Map<String, FragmentDefinition> fragments,
Map<String, Object> variables,
Counter counter,
Deque<String> expansionPath) {
if (selectionSet == null || parent == null) {
return 0;
}
long total = 0;
for (Selection<?> selection : selectionSet.getSelections()) {
counter.visit(maximumVisitedNodes);
if (selection instanceof Field field) {
total =
Math.addExact(
total,
fieldCost(schema, parent, field, fragments, variables, counter, expansionPath));
} else if (selection instanceof InlineFragment inlineFragment) {
GraphQLFieldsContainer target =
inlineFragment.getTypeCondition() == null
? parent
: fieldsContainer(schema, inlineFragment.getTypeCondition().getName());
total =
Math.addExact(
total,
selectionSetCost(
schema,
target,
inlineFragment.getSelectionSet(),
fragments,
variables,
counter,
expansionPath));
} else if (selection instanceof FragmentSpread spread) {
FragmentDefinition fragment = fragments.get(spread.getName());
// A fragment already on this path is a cycle. Validation rejects it, but the scorer must
// terminate on its own or the defence becomes the denial of service.
if (fragment == null || expansionPath.contains(spread.getName())) {
continue;
}
GraphQLFieldsContainer target =
fragment.getTypeCondition() == null
? parent
: fieldsContainer(schema, fragment.getTypeCondition().getName());
expansionPath.push(spread.getName());
total =
Math.addExact(
total,
selectionSetCost(
schema,
target,
fragment.getSelectionSet(),
fragments,
variables,
counter,
expansionPath));
expansionPath.pop();
}
}
return total;
}
private long fieldCost(
GraphQLSchema schema,
GraphQLFieldsContainer parent,
Field field,
Map<String, FragmentDefinition> fragments,
Map<String, Object> variables,
Counter counter,
Deque<String> expansionPath) {
if (field.getName().startsWith(INTROSPECTION_FIELD_PREFIX)) {
// Introspection is an allow/deny decision made by the authorization stage. Pricing it here
// would let an allowed introspection query consume the data budget it was never spending.
return 0;
}
GraphQLFieldDefinition definition = parent.getFieldDefinition(field.getName());
String coordinate = parent.getName() + "." + field.getName();
GraphQLFieldsContainer childContainer =
definition == null ? null : fieldsContainer(unwrap(definition.getType()));
long childCost =
selectionSetCost(
schema,
childContainer,
field.getSelectionSet(),
fragments,
variables,
counter,
expansionPath);
Integer first = pageArgument(field, "first", variables);
Integer last = pageArgument(field, "last", variables);
if (first != null || last != null) {
return calculator.connectionCost(coordinate, first, last, childCost).total();
}
return calculator.fieldCost(coordinate, childCost).total();
}
private static Integer pageArgument(Field field, String name, Map<String, Object> variables) {
for (Argument argument : field.getArguments()) {
if (!argument.getName().equals(name)) {
continue;
}
return intValue(argument.getValue(), variables);
}
return null;
}
private static Integer intValue(Value<?> value, Map<String, Object> variables) {
if (value instanceof IntValue intValue) {
BigInteger raw = intValue.getValue();
// A literal outside int range cannot be a page size; treating it as the maximum lets the
// calculator reject it rather than silently overflowing to something affordable.
return raw.bitLength() >= Integer.SIZE ? Integer.MAX_VALUE : raw.intValue();
}
if (value instanceof VariableReference reference) {
Object supplied = variables.get(reference.getName());
if (supplied instanceof Number number) {
long asLong = number.longValue();
return asLong > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) asLong;
}
}
return null;
}
private static GraphQLObjectType rootType(GraphQLSchema schema, OperationDefinition operation) {
OperationDefinition.Operation kind =
operation.getOperation() == null
? OperationDefinition.Operation.QUERY
: operation.getOperation();
return switch (kind) {
case QUERY -> schema.getQueryType();
case MUTATION -> schema.getMutationType();
case SUBSCRIPTION -> schema.getSubscriptionType();
};
}
private static GraphQLFieldsContainer fieldsContainer(GraphQLSchema schema, String typeName) {
GraphQLType type = schema.getType(typeName);
return fieldsContainer(type);
}
private static GraphQLFieldsContainer fieldsContainer(GraphQLType type) {
GraphQLType unwrapped = unwrap(type);
return unwrapped instanceof GraphQLFieldsContainer container ? container : null;
}
private static GraphQLType unwrap(GraphQLType type) {
GraphQLType current = type;
while (current instanceof GraphQLNonNull nonNull) {
current = nonNull.getWrappedType();
}
while (current instanceof GraphQLList list) {
current = unwrap(list.getWrappedType());
}
return current;
}
private static final class Counter {
private int visited;
void visit(int budget) {
if (++visited > budget) {
throw GraphQlStructuralLimitViolation.of("COMPLEXITY_TRAVERSAL", visited, budget);
}
}
}
}
@@ -16,10 +16,13 @@ import graphql.language.SelectionSet;
import graphql.language.Value;
import graphql.parser.Parser;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Measures a document's structure before execution (design §18).
@@ -61,11 +64,27 @@ public final class GraphQlDocumentShapeAnalyzer {
}
/**
* Measures a parsed document.
* Measures every operation in a document.
*
* <p>Kept for callers that judge a document before one operation has been chosen. Once an
* operation is selected, {@link #analyze(Document, OperationDefinition)} is the honest
* measurement: summing operations the request will not run charges a client for a document it
* only sent one part of, and worse in the other direction averages away the one that matters.
*
* @throws GraphQlStructuralLimitViolation when traversal exceeds the node budget
*/
public GraphQlDocumentShape analyze(Document document) {
return analyze(document, null);
}
/**
* Measures one selected operation and the fragments it can actually reach.
*
* @param document the parsed document
* @param operation the selected operation, or {@code null} to measure every operation
* @throws GraphQlStructuralLimitViolation when traversal exceeds the node budget
*/
public GraphQlDocumentShape analyze(Document document, OperationDefinition operation) {
Map<String, FragmentDefinition> fragments = new LinkedHashMap<>();
int operationCount = 0;
for (Definition<?> definition : document.getDefinitions()) {
@@ -77,29 +96,63 @@ public final class GraphQlDocumentShapeAnalyzer {
}
Counters counters = new Counters();
for (Definition<?> definition : document.getDefinitions()) {
if (definition instanceof OperationDefinition operation) {
walk(operation.getSelectionSet(), fragments, counters, 1, new ArrayDeque<>());
}
List<OperationDefinition> measured =
operation != null ? List.of(operation) : operations(document);
Set<String> reachableFragments = new LinkedHashSet<>();
for (OperationDefinition candidate : measured) {
walk(
candidate.getSelectionSet(),
fragments,
counters,
1,
new ArrayDeque<>(),
reachableFragments);
}
return new GraphQlDocumentShape(
counters.depth,
counters.fields,
counters.aliases,
fragments.size(),
// Fragments the walk could actually reach. Counting every definition would charge a client
// for fragments the selected operation never spreads, and let an unreachable one raise the
// count until an honest request is refused.
operation != null ? reachableFragments.size() : fragments.size(),
counters.fragmentSpreads,
operationCount,
operation != null ? 1 : operationCount,
counters.inputNestingDepth);
}
/** Whether a document selects any introspection field. */
/** Whether any operation in a document selects an introspection field. */
public boolean selectsIntrospection(Document document) {
return document.getDefinitions().stream()
.anyMatch(
definition ->
definition instanceof OperationDefinition operation
&& selectsIntrospection(operation.getSelectionSet()));
return selectsIntrospection(document, null);
}
/**
* Whether the selected operation reaches an introspection field.
*
* <p>Named fragments are expanded. The gate used to walk only fields and inline fragments, so
* {@code query Q { ...I } fragment I on Query { __schema { types { name } } }} passed a check
* whose entire purpose was to stop that query the same class already expanded fragments for
* shape counting, which made the omission invisible.
*
* @param document the parsed document
* @param operation the selected operation, or {@code null} to check every operation
*/
public boolean selectsIntrospection(Document document, OperationDefinition operation) {
Map<String, FragmentDefinition> fragments = new LinkedHashMap<>();
for (Definition<?> definition : document.getDefinitions()) {
if (definition instanceof FragmentDefinition fragment) {
fragments.put(fragment.getName(), fragment);
}
}
List<OperationDefinition> candidates =
operation != null ? List.of(operation) : operations(document);
for (OperationDefinition candidate : candidates) {
if (selectsIntrospection(candidate.getSelectionSet(), fragments, new ArrayDeque<>())) {
return true;
}
}
return false;
}
/**
@@ -108,24 +161,60 @@ public final class GraphQlDocumentShapeAnalyzer {
* @throws GraphQlStructuralLimitViolation when introspection is selected but not permitted
*/
public void verifyIntrospection(Document document, boolean introspectionAllowed) {
if (!introspectionAllowed && selectsIntrospection(document)) {
verifyIntrospection(document, null, introspectionAllowed);
}
/**
* Rejects introspection reached by the selected operation.
*
* @throws GraphQlStructuralLimitViolation when introspection is selected but not permitted
*/
public void verifyIntrospection(
Document document, OperationDefinition operation, boolean introspectionAllowed) {
if (!introspectionAllowed && selectsIntrospection(document, operation)) {
throw GraphQlStructuralLimitViolation.of("INTROSPECTION", 1, 0);
}
}
private boolean selectsIntrospection(SelectionSet selectionSet) {
private static List<OperationDefinition> operations(Document document) {
List<OperationDefinition> operations = new ArrayList<>();
for (Definition<?> definition : document.getDefinitions()) {
if (definition instanceof OperationDefinition operation) {
operations.add(operation);
}
}
return operations;
}
private boolean selectsIntrospection(
SelectionSet selectionSet,
Map<String, FragmentDefinition> fragments,
Deque<String> expansionPath) {
if (selectionSet == null) {
return false;
}
for (Selection<?> selection : selectionSet.getSelections()) {
if (selection instanceof Field field) {
if (field.getName().startsWith(INTROSPECTION_FIELD_PREFIX)
|| selectsIntrospection(field.getSelectionSet())) {
|| selectsIntrospection(field.getSelectionSet(), fragments, expansionPath)) {
return true;
}
} else if (selection instanceof InlineFragment inlineFragment) {
if (selectsIntrospection(inlineFragment.getSelectionSet(), fragments, expansionPath)) {
return true;
}
} else if (selection instanceof FragmentSpread spread) {
FragmentDefinition fragment = fragments.get(spread.getName());
if (fragment == null || expansionPath.contains(spread.getName())) {
continue;
}
expansionPath.push(spread.getName());
boolean found = selectsIntrospection(fragment.getSelectionSet(), fragments, expansionPath);
expansionPath.pop();
if (found) {
return true;
}
} else if (selection instanceof InlineFragment inlineFragment
&& selectsIntrospection(inlineFragment.getSelectionSet())) {
return true;
}
}
return false;
@@ -136,7 +225,8 @@ public final class GraphQlDocumentShapeAnalyzer {
Map<String, FragmentDefinition> fragments,
Counters counters,
int depth,
Deque<String> expansionPath) {
Deque<String> expansionPath,
Set<String> reachableFragments) {
if (selectionSet == null) {
return;
@@ -153,17 +243,36 @@ public final class GraphQlDocumentShapeAnalyzer {
}
counters.inputNestingDepth =
Math.max(counters.inputNestingDepth, argumentNestingDepth(field.getArguments()));
walk(field.getSelectionSet(), fragments, counters, depth + 1, expansionPath);
walk(
field.getSelectionSet(),
fragments,
counters,
depth + 1,
expansionPath,
reachableFragments);
} else if (selection instanceof InlineFragment inlineFragment) {
walk(inlineFragment.getSelectionSet(), fragments, counters, depth + 1, expansionPath);
walk(
inlineFragment.getSelectionSet(),
fragments,
counters,
depth + 1,
expansionPath,
reachableFragments);
} else if (selection instanceof FragmentSpread spread) {
counters.fragmentSpreads++;
FragmentDefinition fragment = fragments.get(spread.getName());
// A fragment already on this expansion path is a cycle; expanding it again would not
// terminate, and the document is rejected by validation anyway.
if (fragment != null && !expansionPath.contains(spread.getName())) {
reachableFragments.add(spread.getName());
expansionPath.push(spread.getName());
walk(fragment.getSelectionSet(), fragments, counters, depth, expansionPath);
walk(
fragment.getSelectionSet(),
fragments,
counters,
depth,
expansionPath,
reachableFragments);
expansionPath.pop();
}
}
@@ -13,9 +13,13 @@ import java.util.function.BiFunction;
* Runs a batch load in ordered chunks under one context and budget (design §13).
*
* <p>Every chunk receives the same actor, tenant and deadline: a chunk that ran with a different
* scope would produce a result set mixing two tenants inside one logical batch. The budget is
* checked between chunks so a batch that has already exhausted the request deadline stops instead
* of issuing more work.
* scope would produce a result set mixing two tenants inside one logical batch.
*
* <p>The budget is checked before and after every chunk. Checking only before it meant the last
* chunk could run unbounded a batch that started with a millisecond left was allowed to issue one
* 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;
* this check is what stops the batch continuing past a budget that has already gone.
*/
public final class GraphQlBatchExecutor {
@@ -56,14 +60,21 @@ public final class GraphQlBatchExecutor {
Map<K, V> loaded = new LinkedHashMap<>();
for (List<K> chunk : chunker.chunk(keys)) {
if (Duration.between(started, clock.instant()).compareTo(budget) > 0) {
throw new GraphQlBatchTimeoutException(policy.loaderName().value());
}
requireBudget(started, budget);
loaded.putAll(loadChunk.apply(chunk, context));
// After, too: a chunk that overran the budget must not have its result used and must not be
// followed by another one.
requireBudget(started, budget);
}
return Map.copyOf(loaded);
}
private void requireBudget(java.time.Instant started, Duration budget) {
if (Duration.between(started, clock.instant()).compareTo(budget) > 0) {
throw new GraphQlBatchTimeoutException(policy.loaderName().value());
}
}
/**
* The budget for this batch: the loader's own timeout, never more than the request has left.
*
@@ -11,6 +11,12 @@ import java.util.Set;
* <p>Keys the loader did not return become {@link GraphQlBatchValue.Missing}, and keys it failed on
* become {@link GraphQlBatchValue.Failed}. Flattening both to null is the defect this mapper exists
* to prevent it makes a dependency outage indistinguishable from empty data.
*
* <p>A null value means <em>missing</em>, in both loader shapes. The two used to disagree: a mapped
* loader returning {@code {k: null}} produced {@code Present(null)} while an ordered loader
* returning {@code [null]} produced {@code Missing}, so the same "no value for this key" answer
* meant two different things depending on which loader shape a field happened to use and only one
* of them triggered the missing-key policy.
*/
public final class GraphQlBatchResultMapper {
@@ -39,19 +45,42 @@ public final class GraphQlBatchResultMapper {
public <K, V> GraphQlBatchResult<K, V> map(
List<K> keys, Map<K, V> loaded, Set<K> failedKeys, String errorCode) {
requireOnlyRequestedKeys(keys, loaded);
var result = new LinkedHashMap<K, GraphQlBatchValue<V>>();
for (K key : keys) {
V value = loaded.get(key);
if (failedKeys.contains(key)) {
result.put(key, GraphQlBatchValue.failed(errorCode));
} else if (loaded.containsKey(key)) {
result.put(key, GraphQlBatchValue.present(loaded.get(key)));
} else {
} else if (value == null) {
result.put(key, GraphQlBatchValue.missing());
} else {
result.put(key, GraphQlBatchValue.present(value));
}
}
return new GraphQlBatchResult<>(result);
}
/**
* Refuses a result that answers keys nobody asked for.
*
* <p>Matching cardinality is not the same as matching keys. A loader that returned the right
* number of entries under different keys used to pass: every requested key resolved to {@code
* Missing}, which reads as "the rows do not exist" rather than "the loader answered the wrong
* question", and the field quietly rendered null.
*/
private static <K, V> void requireOnlyRequestedKeys(List<K> keys, Map<K, V> loaded) {
Set<K> requested = new java.util.LinkedHashSet<>(keys);
Set<K> unrequested =
loaded.keySet().stream()
.filter(key -> !requested.contains(key))
.collect(java.util.stream.Collectors.toCollection(java.util.LinkedHashSet::new));
if (!unrequested.isEmpty()) {
throw new IllegalArgumentException(
"loader returned " + unrequested.size() + " key(s) that were not requested");
}
}
/**
* Maps ordered-loader output.
*
@@ -73,6 +102,7 @@ public final class GraphQlBatchResultMapper {
var result = new LinkedHashMap<K, GraphQlBatchValue<V>>();
for (int index = 0; index < keys.size(); index++) {
V value = orderedValues.get(index);
// Same rule as the mapped shape: null is the absence of a value, not a present null.
result.put(
keys.get(index),
value == null ? GraphQlBatchValue.missing() : GraphQlBatchValue.present(value));
@@ -1,8 +1,13 @@
package dev.caskeleton.adapter.inbound.graphql.execution;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
/**
@@ -14,7 +19,15 @@ import java.util.function.Function;
* data leak.
*
* <p>Eviction is least-recently-used and bounded by both entry count and total document weight,
* because the key space is client-controlled.
* because the key space is client-controlled. Entries also expire after a period without access, so
* a burst of one-off documents does not hold memory until enough later traffic pushes it out.
*
* <p>A miss parses outside the lock. The whole method used to be {@code synchronized}, which made
* one slow parse block every other request including the ones that would have hit the cache the
* cache's own miss path became the contention point it existed to remove. Concurrent misses on the
* <em>same</em> key still parse once: they are the one case where waiting is cheaper than parsing,
* and letting a cold popular document be parsed by every arriving request is how a cache turns a
* deploy into a CPU spike.
*
* @param <D> the cached parsed-document type
*/
@@ -22,7 +35,11 @@ public final class BoundedPreparsedDocumentProvider<D> {
private final GraphQlPreparsedCachePolicy policy;
private final GraphQlPreparsedCacheMetrics metrics;
private final Clock clock;
private final Map<GraphQlPreparsedCacheKey, Entry<D>> cache;
private final ConcurrentHashMap<GraphQlPreparsedCacheKey, CompletableFuture<D>> inFlight =
new ConcurrentHashMap<>();
private final Object lock = new Object();
private long weight;
/**
@@ -30,11 +47,13 @@ public final class BoundedPreparsedDocumentProvider<D> {
*
* @param policy cache bounds
* @param metrics counters
* @param clock the clock idle expiry is measured against
*/
public BoundedPreparsedDocumentProvider(
GraphQlPreparsedCachePolicy policy, GraphQlPreparsedCacheMetrics metrics) {
this.policy = Objects.requireNonNull(policy);
this.metrics = Objects.requireNonNull(metrics);
GraphQlPreparsedCachePolicy policy, GraphQlPreparsedCacheMetrics metrics, Clock clock) {
this.policy = Objects.requireNonNull(policy, "cache policy is required");
this.metrics = Objects.requireNonNull(metrics, "cache metrics are required");
this.clock = Objects.requireNonNull(clock, "clock is required");
this.cache = new LinkedHashMap<>(16, 0.75f, true);
}
@@ -45,33 +64,60 @@ public final class BoundedPreparsedDocumentProvider<D> {
* @param documentWeight the document's size, used for the weight bound
* @param parseAndValidate invoked on a miss
*/
public synchronized D getDocument(
public D getDocument(
GraphQlPreparsedCacheKey key,
long documentWeight,
Function<GraphQlPreparsedCacheKey, D> parseAndValidate) {
Entry<D> cached = cache.get(key);
Objects.requireNonNull(key, "cache key is required");
Objects.requireNonNull(parseAndValidate, "parse function is required");
Instant now = clock.instant();
D cached = lookup(key, now);
if (cached != null) {
metrics.recordHit();
return cached.document();
return cached;
}
CompletableFuture<D> mine = new CompletableFuture<>();
CompletableFuture<D> leader = inFlight.putIfAbsent(key, mine);
if (leader != null) {
metrics.recordCoalesced();
return await(leader);
}
metrics.recordMiss();
D document = parseAndValidate.apply(key);
cache.put(key, new Entry<>(document, Math.max(1, documentWeight)));
weight += Math.max(1, documentWeight);
evictIfNeeded();
return document;
try {
D document = parseAndValidate.apply(key);
store(key, document, documentWeight, clock.instant());
mine.complete(document);
return document;
} catch (RuntimeException failure) {
// Failures are not cached: an invalid document is the client's to fix, and remembering the
// rejection would make a later schema deploy unable to accept a document it now supports.
mine.completeExceptionally(failure);
throw failure;
} finally {
inFlight.remove(key, mine);
}
}
/** Entries currently cached. */
public synchronized int size() {
return cache.size();
/** Entries currently cached, after expiring anything idle. */
public int size() {
Instant now = clock.instant();
synchronized (lock) {
expireIdle(now);
return cache.size();
}
}
/** Total weight currently cached. */
public synchronized long weight() {
return weight;
/** Total weight currently cached, after expiring anything idle. */
public long weight() {
Instant now = clock.instant();
synchronized (lock) {
expireIdle(now);
return weight;
}
}
/** The counters. */
@@ -79,6 +125,45 @@ public final class BoundedPreparsedDocumentProvider<D> {
return metrics;
}
private D lookup(GraphQlPreparsedCacheKey key, Instant now) {
synchronized (lock) {
expireIdle(now);
Entry<D> cached = cache.get(key);
if (cached == null) {
return null;
}
// Access refreshes the idle deadline, which is what expire-after-access means: a document
// still being used stays, and only the ones nobody asks for any more leave.
cache.put(key, new Entry<>(cached.document(), cached.weight(), now));
return cached.document();
}
}
private void store(GraphQlPreparsedCacheKey key, D document, long documentWeight, Instant now) {
long entryWeight = Math.max(1, documentWeight);
synchronized (lock) {
Entry<D> previous = cache.put(key, new Entry<>(document, entryWeight, now));
if (previous != null) {
weight -= previous.weight();
}
weight += entryWeight;
expireIdle(now);
evictIfNeeded();
}
}
private void expireIdle(Instant now) {
var entries = cache.entrySet().iterator();
while (entries.hasNext()) {
Map.Entry<GraphQlPreparsedCacheKey, Entry<D>> entry = entries.next();
if (!now.isBefore(entry.getValue().lastAccessAt().plus(policy.expireAfterAccess()))) {
weight -= entry.getValue().weight();
entries.remove();
metrics.recordExpiry();
}
}
}
private void evictIfNeeded() {
while (cache.size() > policy.maximumEntries() || weight > policy.maximumWeight()) {
var oldest = cache.entrySet().iterator();
@@ -92,5 +177,18 @@ public final class BoundedPreparsedDocumentProvider<D> {
}
}
private record Entry<D>(D document, long weight) {}
private D await(CompletableFuture<D> leader) {
try {
return leader.join();
} catch (CompletionException wrapped) {
// The leader's failure is this caller's failure too, but it belongs to them unwrapped: a
// CompletionException in a resolver stack says nothing about the document that was rejected.
if (wrapped.getCause() instanceof RuntimeException cause) {
throw cause;
}
throw wrapped;
}
}
private record Entry<D>(D document, long weight, Instant lastAccessAt) {}
}
@@ -18,13 +18,21 @@ public record GraphQlExecutionPipeline(List<GraphQlExecutionStage> stages) {
stages = List.copyOf(stages);
}
/** The Stable pipeline: context, authorization, parse/validate, cost, execute. */
/**
* The Stable pipeline: context, parse/validate, authorization, cost, execute.
*
* <p>Parsing precedes authorization because authorization has nothing to decide before it. A
* coordinate rule is keyed by {@code Type.field} and an operation rule by the selected operation,
* and neither exists until the document has been parsed and one operation has been chosen.
* Authorizing first would either authorize a request whose shape is still unknown, or force the
* authorization stage to parse the document itself a second parser on the hostile-input path.
*/
public static GraphQlExecutionPipeline stable() {
return new GraphQlExecutionPipeline(
List.of(
GraphQlExecutionStage.CONTEXT,
GraphQlExecutionStage.AUTHORIZATION,
GraphQlExecutionStage.PARSE_VALIDATE,
GraphQlExecutionStage.AUTHORIZATION,
GraphQlExecutionStage.COST,
GraphQlExecutionStage.EXECUTE));
}
@@ -15,12 +15,20 @@ public final class GraphQlExecutionPipelineValidator {
/** Ordering constraints every pipeline must satisfy, as (earlier, later) pairs. */
private static final List<GraphQlExecutionStage[]> ORDERING_CONSTRAINTS =
List.of(
new GraphQlExecutionStage[] {
GraphQlExecutionStage.CONTEXT, GraphQlExecutionStage.PARSE_VALIDATE
},
new GraphQlExecutionStage[] {
GraphQlExecutionStage.CONTEXT, GraphQlExecutionStage.AUTHORIZATION
},
new GraphQlExecutionStage[] {
GraphQlExecutionStage.PERSISTED_LOOKUP, GraphQlExecutionStage.PARSE_VALIDATE
},
// Authorization is keyed by coordinates and by the selected operation, so the document
// has to be parsed and one operation chosen before it can decide anything.
new GraphQlExecutionStage[] {
GraphQlExecutionStage.PARSE_VALIDATE, GraphQlExecutionStage.AUTHORIZATION
},
new GraphQlExecutionStage[] {
GraphQlExecutionStage.PARSE_VALIDATE, GraphQlExecutionStage.COST
},
@@ -3,10 +3,14 @@ package dev.caskeleton.adapter.inbound.graphql.execution;
/**
* The ordered stages of GraphQL request execution (design §9, §18).
*
* <p>The order is a security property, not a preference. Context must exist before authorization
* can decide anything; a persisted lookup has to happen before parsing or the registry cannot
* supply the document; and cost has to be judged before resolvers run, because a budget checked
* afterwards has already been spent.
* <p>The order is a security property, not a preference. Context must exist before anything can
* decide who is calling; a persisted lookup has to happen before parsing or the registry cannot
* supply the document; authorization needs the parsed document, because a coordinate rule has no
* coordinate to check until one operation has been selected; and cost has to be judged before
* resolvers run, because a budget checked afterwards has already been spent.
*
* <p>Constants are declared in execution order, which is also the order {@code
* GraphQlExecutionPipeline.stable()} composes them in.
*/
public enum GraphQlExecutionStage {
@@ -16,12 +20,12 @@ public enum GraphQlExecutionStage {
/** Resolve an operation ID to its approved document (Advanced persisted-operation capability). */
PERSISTED_LOOKUP(false),
/** Operation-level authorization, before the document is executed. */
AUTHORIZATION(true),
/** Parse and validate the document against the schema. */
/** Parse and validate the document against the schema, and select one operation. */
PARSE_VALIDATE(true),
/** Operation and coordinate authorization, before the document is executed. */
AUTHORIZATION(true),
/** Structural and complexity budgets, before any resolver runs. */
COST(true),
@@ -13,6 +13,8 @@ public final class GraphQlPreparsedCacheMetrics {
private final AtomicLong hits = new AtomicLong();
private final AtomicLong misses = new AtomicLong();
private final AtomicLong evictions = new AtomicLong();
private final AtomicLong expiries = new AtomicLong();
private final AtomicLong coalesced = new AtomicLong();
/** Records a cache hit. */
public void recordHit() {
@@ -29,6 +31,21 @@ public final class GraphQlPreparsedCacheMetrics {
evictions.incrementAndGet();
}
/** Records an entry dropped for being idle past its expiry. */
public void recordExpiry() {
expiries.incrementAndGet();
}
/**
* Records a miss that waited for another caller's parse instead of parsing again.
*
* <p>Counted apart from misses so the two questions stay separable: how often the cache did not
* have the document, and how often concurrent demand for one cold document was coalesced.
*/
public void recordCoalesced() {
coalesced.incrementAndGet();
}
/** Cache hits so far. */
public long hits() {
return hits.get();
@@ -44,6 +61,16 @@ public final class GraphQlPreparsedCacheMetrics {
return evictions.get();
}
/** Entries dropped for being idle past their expiry. */
public long expiries() {
return expiries.get();
}
/** Misses that waited for another caller's parse. */
public long coalesced() {
return coalesced.get();
}
/** Hit ratio, or {@code 0} when nothing has been looked up yet. */
public double hitRatio() {
long total = hits.get() + misses.get();
@@ -0,0 +1,151 @@
package dev.caskeleton.adapter.inbound.graphql.http;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
/**
* A parsed {@code Accept} header, ordered the way the client asked for.
*
* <p>Two properties of the header are easy to drop and expensive to get wrong. {@code q=0} is not a
* weak preference, it is a refusal {@code application/graphql-response+json;q=0} means "never
* send me that" — and quality ranks the client's alternatives against each other. Iterating the
* server's own preference list and returning the first type that appears anywhere in the header
* ignores both, which is how a refused media type gets sent as if it had been requested.
*
* <p>Parsing is done here rather than with the framework's {@code MediaType} because this module is
* framework free, and the grammar involved is a comma-separated list with one parameter that
* matters. A malformed entry is dropped rather than failing the request: a client that sends
* nonsense alongside a usable type gets the usable type, and one that sends only nonsense gets the
* same answer as one that sent nothing acceptable.
*
* @param type the type half, lowercased, for example {@code application}
* @param subtype the subtype half, lowercased, for example {@code graphql-response+json}
* @param quality the {@code q} parameter, defaulting to {@code 1.0}
* @param specificity how concrete the entry is: 2 for a full type, 1 for {@code type/*}, 0 for
* {@code * / *}
* @param order the entry's position in the header, which breaks ties in the client's stated order
*/
public record GraphQlAcceptHeader(
String type, String subtype, double quality, int specificity, int order) {
private static final double DEFAULT_QUALITY = 1.0;
private static final String WILDCARD = "*";
// Precompiled with an explicit limit: String.split drops trailing empty results, which would
// silently change how a header ending in a comma is read.
private static final Pattern ENTRY_SEPARATOR = Pattern.compile(",");
private static final Pattern PARAMETER_SEPARATOR = Pattern.compile(";");
/**
* Parses an {@code Accept} header into entries ranked most acceptable first.
*
* <p>Ranked by quality, then by specificity, then by the order the client wrote them. Entries
* with {@code q=0} are dropped, because they are refusals and must never be selectable.
*
* @param accept the raw header, possibly {@code null}
*/
public static List<GraphQlAcceptHeader> parse(String accept) {
if (accept == null || accept.isBlank()) {
return List.of();
}
List<GraphQlAcceptHeader> entries = new ArrayList<>();
String[] parts = ENTRY_SEPARATOR.split(accept, -1);
for (int index = 0; index < parts.length; index++) {
GraphQlAcceptHeader entry = parseEntry(parts[index], index);
if (entry != null && entry.quality() > 0) {
entries.add(entry);
}
}
entries.sort(
Comparator.<GraphQlAcceptHeader>comparingDouble(GraphQlAcceptHeader::quality)
.reversed()
.thenComparing(
Comparator.<GraphQlAcceptHeader>comparingInt(GraphQlAcceptHeader::specificity)
.reversed())
.thenComparingInt(GraphQlAcceptHeader::order));
return List.copyOf(entries);
}
/**
* Whether this entry explicitly refuses a media type.
*
* <p>Only a refusal that names the type or its subtype family counts. A {@code * / *;q=0} entry
* is dropped at parse time and never reaches here.
*/
public static boolean refuses(String accept, String mediaType) {
if (accept == null || accept.isBlank()) {
return false;
}
String[] parts = ENTRY_SEPARATOR.split(accept, -1);
for (int index = 0; index < parts.length; index++) {
GraphQlAcceptHeader entry = parseEntry(parts[index], index);
if (entry != null && entry.quality() == 0 && entry.matches(mediaType)) {
return true;
}
}
return false;
}
/** Whether a concrete media type is covered by this entry. */
public boolean matches(String mediaType) {
if (mediaType == null) {
return false;
}
int separator = mediaType.indexOf('/');
if (separator < 0) {
return false;
}
String candidateType = mediaType.substring(0, separator).strip().toLowerCase(Locale.ROOT);
String candidateSubtype = mediaType.substring(separator + 1).strip().toLowerCase(Locale.ROOT);
return (WILDCARD.equals(type) || type.equals(candidateType))
&& (WILDCARD.equals(subtype) || subtype.equals(candidateSubtype));
}
private static GraphQlAcceptHeader parseEntry(String raw, int order) {
String entry = raw.strip();
if (entry.isEmpty()) {
return null;
}
int parameterStart = entry.indexOf(';');
String base = (parameterStart < 0 ? entry : entry.substring(0, parameterStart)).strip();
int separator = base.indexOf('/');
if (separator < 0) {
return null;
}
String type = base.substring(0, separator).strip().toLowerCase(Locale.ROOT);
String subtype = base.substring(separator + 1).strip().toLowerCase(Locale.ROOT);
if (type.isEmpty() || subtype.isEmpty()) {
return null;
}
// `*/subtype` is not a shape the grammar allows, and treating it as a wildcard would let a
// malformed header match more than a well-formed one.
if (WILDCARD.equals(type) && !WILDCARD.equals(subtype)) {
return null;
}
double quality =
parameterStart < 0 ? DEFAULT_QUALITY : qualityOf(entry.substring(parameterStart + 1));
int specificity = WILDCARD.equals(type) ? 0 : WILDCARD.equals(subtype) ? 1 : 2;
return new GraphQlAcceptHeader(type, subtype, quality, specificity, order);
}
private static double qualityOf(String parameters) {
for (String parameter : PARAMETER_SEPARATOR.split(parameters, -1)) {
String candidate = parameter.strip();
if (!candidate.regionMatches(true, 0, "q=", 0, 2)) {
continue;
}
try {
double value = Double.parseDouble(candidate.substring(2).strip());
// Out-of-range values are not meaningful quality; treating them as the default keeps a
// sloppy client working without letting `q=5` outrank an honest `q=1`.
return value < 0 || value > 1 ? DEFAULT_QUALITY : value;
} catch (NumberFormatException malformed) {
return DEFAULT_QUALITY;
}
}
return DEFAULT_QUALITY;
}
}
@@ -11,8 +11,8 @@ import java.util.Map;
*
* @param query the GraphQL document
* @param operationName selected operation name, or {@code null}
* @param variables variable values, never {@code null}
* @param extensions protocol extensions, never {@code null}
* @param variables variable values, never {@code null}, null entries preserved
* @param extensions protocol extensions, never {@code null}, null entries preserved
*/
public record GraphQlHttpRequestEnvelope(
String query,
@@ -21,8 +21,12 @@ public record GraphQlHttpRequestEnvelope(
Map<String, Object> extensions) {
public GraphQlHttpRequestEnvelope {
variables = variables == null ? Map.of() : Map.copyOf(variables);
extensions = extensions == null ? Map.of() : Map.copyOf(extensions);
// Deep and null-preserving. `Map.copyOf` threw on `{"id": null}` a legal variables object
// whose explicit null is a different instruction from omitting the key and left nested maps
// and lists shared with the decoder, so the value a validator checked was not necessarily the
// value an executor later read.
variables = GraphQlJsonValues.immutableObject(variables);
extensions = GraphQlJsonValues.immutableObject(extensions);
}
/** An envelope carrying only a document. */
@@ -0,0 +1,100 @@
package dev.caskeleton.adapter.inbound.graphql.http;
import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Bounds the shape of decoded {@code variables} and {@code extensions}.
*
* <p>A byte limit bounds how much a client can send; it does not bound what that costs to process.
* Sixty kilobytes of {@code [[[[[]]]]]} is small on the wire and expensive to walk, coerce and
* validate, and the same bytes as one enormous list turn into one enormous coercion loop. So depth,
* element count and key count get their own budgets.
*
* <p>Diagnostics report the dimension and the two counts. A variable value never appears: these are
* exactly the inputs that carry identifiers, tokens and personal data.
*/
public final class GraphQlJsonStructurePolicy {
/** Stable request-error code for every structural rejection of a JSON input. */
public static final String CODE = "GRAPHQL_INPUT_SHAPE_REJECTED";
private final int maxDepth;
private final int maxListElements;
private final int maxObjectKeys;
/**
* Creates the policy.
*
* @param maxDepth deepest accepted nesting of objects and arrays
* @param maxListElements most accepted elements in one array
* @param maxObjectKeys most accepted keys in one object
*/
public GraphQlJsonStructurePolicy(int maxDepth, int maxListElements, int maxObjectKeys) {
if (maxDepth < 1 || maxListElements < 1 || maxObjectKeys < 1) {
throw new IllegalArgumentException("JSON structure limits must be positive");
}
this.maxDepth = maxDepth;
this.maxListElements = maxListElements;
this.maxObjectKeys = maxObjectKeys;
}
/**
* Derives the policy from a client policy.
*
* <p>Input nesting reuses the document depth budget, because a variable tree and a selection tree
* are walked by the same kind of recursion and there is no reason for a client to need one deeper
* than the other. Key count reuses the list-element budget for the same reason.
*/
public static GraphQlJsonStructurePolicy from(GraphQlClientPolicy policy) {
Objects.requireNonNull(policy, "client policy is required");
return new GraphQlJsonStructurePolicy(
policy.maxDepth(), policy.maxInputListElements(), policy.maxInputListElements());
}
/** The deepest accepted nesting. */
public int maxDepth() {
return maxDepth;
}
/**
* Verifies a decoded JSON object.
*
* @param field field name used in the diagnostic, for example {@code variables}
* @param value the decoded object
* @throws GraphQlRequestFormatException on the first exceeded dimension
*/
public void verify(String field, Map<String, Object> value) {
if (value == null || value.isEmpty()) {
return;
}
walk(field, value, 1);
}
private void walk(String field, Object value, int depth) {
if (depth > maxDepth) {
throw rejection(field, "DEPTH", depth, maxDepth);
}
if (value instanceof Map<?, ?> object) {
if (object.size() > maxObjectKeys) {
throw rejection(field, "OBJECT_KEYS", object.size(), maxObjectKeys);
}
object.values().forEach(entry -> walk(field, entry, depth + 1));
return;
}
if (value instanceof List<?> list) {
if (list.size() > maxListElements) {
throw rejection(field, "LIST_ELEMENTS", list.size(), maxListElements);
}
list.forEach(element -> walk(field, element, depth + 1));
}
}
private static GraphQlRequestFormatException rejection(
String field, String dimension, int observed, int allowed) {
return new GraphQlRequestFormatException(
CODE + " " + field + " " + dimension + ": " + observed + " > " + allowed);
}
}
@@ -0,0 +1,56 @@
package dev.caskeleton.adapter.inbound.graphql.http;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Copies decoded JSON so it is immutable without losing what the client actually sent.
*
* <p>{@code Map.copyOf} cannot be used here, and the reason is a correctness bug rather than a
* style preference: it throws on a null value, and a null variable is legal, meaningful GraphQL
* input. The three cases {@code {"a": 1}}, {@code {"a": null}} and {@code {}} coerce differently
* a value, an explicit null, and an absent argument that falls back to its default so collapsing
* the middle one into an exception makes valid requests fail.
*
* <p>The copy is deep. A shallow copy leaves the nested maps and lists shared with whatever decoded
* them, so the envelope a validator inspected and the envelope an executor later reads are not
* guaranteed to be the same value.
*/
public final class GraphQlJsonValues {
private GraphQlJsonValues() {}
/**
* A deep, null-preserving, unmodifiable copy of a decoded JSON object.
*
* @param value the decoded object, or {@code null}
* @return an unmodifiable copy; an empty map when {@code value} is {@code null}
*/
public static Map<String, Object> immutableObject(Map<String, Object> value) {
if (value == null || value.isEmpty()) {
return Map.of();
}
Map<String, Object> copy = new LinkedHashMap<>(value.size());
value.forEach((key, entry) -> copy.put(key, immutableValue(entry)));
return Collections.unmodifiableMap(copy);
}
/** A deep, null-preserving, unmodifiable copy of any decoded JSON value. */
@SuppressWarnings("unchecked")
public static Object immutableValue(Object value) {
if (value instanceof Map<?, ?> map) {
return immutableObject((Map<String, Object>) map);
}
if (value instanceof List<?> list) {
List<Object> copy = new ArrayList<>(list.size());
list.forEach(element -> copy.add(immutableValue(element)));
return Collections.unmodifiableList(copy);
}
// Everything else a JSON decoder produces is already immutable: String, Boolean, the boxed
// numbers, BigDecimal, BigInteger and null, which must survive as null.
return value;
}
}
@@ -1,7 +1,9 @@
package dev.caskeleton.adapter.inbound.graphql.http;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/**
* Media types of the Stable HTTP profile (design §9.1).
@@ -39,6 +41,21 @@ public final class GraphQlMediaTypes {
/**
* Chooses the response media type for an {@code Accept} header.
*
* <p>The client's ranking decides, not the server's. Walking the server's preference list first
* and returning the first type named anywhere in the header ignored both quality and refusal, so
* {@code application/graphql-response+json;q=0, application/json} a client saying "anything but
* that one" — was answered with exactly the refused type.
*
* <p>Absent or blank {@code Accept} means no constraint, so the profile's preferred type is
* returned. A wildcard is matched like any other entry, at its own quality and specificity, which
* is what lets {@code * / *;q=0.1, application/json;q=0.9} pick JSON rather than the wildcard.
*
* <p>Where the client ranked two producible types equally same quality, same specificity, as in
* a plain {@code application/json, application/graphql-response+json} it has expressed no
* preference between them, and the server's own preference breaks the tie. That is the one place
* server preference still applies, and it applies only after the client's ranking has been
* exhausted.
*
* @param accept raw {@code Accept} header, possibly {@code null}
* @return the negotiated media type, or {@code null} when nothing acceptable was offered
*/
@@ -46,21 +63,33 @@ public final class GraphQlMediaTypes {
if (accept == null || accept.isBlank()) {
return GRAPHQL_RESPONSE_JSON;
}
List<String> offered = List.of(accept.split(","));
for (String candidate : PRODUCIBLE) {
for (String entry : offered) {
if (baseType(entry).equals(candidate)) {
return candidate;
}
Set<String> bestTier = new LinkedHashSet<>();
double tierQuality = 0;
int tierSpecificity = -1;
for (GraphQlAcceptHeader entry : GraphQlAcceptHeader.parse(accept)) {
List<String> matches =
PRODUCIBLE.stream()
// A concrete refusal outranks a wildcard acceptance: `*/*, application/json;q=0`
// accepts everything and then names one exception, and the exception is the specific
// instruction.
.filter(candidate -> entry.matches(candidate))
.filter(candidate -> !GraphQlAcceptHeader.refuses(accept, candidate))
.toList();
if (matches.isEmpty()) {
continue;
}
}
for (String entry : offered) {
String base = baseType(entry);
if ("*/*".equals(base) || "application/*".equals(base)) {
return GRAPHQL_RESPONSE_JSON;
if (bestTier.isEmpty()) {
tierQuality = entry.quality();
tierSpecificity = entry.specificity();
} else if (entry.quality() != tierQuality || entry.specificity() != tierSpecificity) {
break;
}
bestTier.addAll(matches);
}
return null;
return PRODUCIBLE.stream().filter(bestTier::contains).findFirst().orElse(null);
}
private static String baseType(String mediaType) {
@@ -1,6 +1,8 @@
package dev.caskeleton.adapter.inbound.graphql.http;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
/**
* Measured sizes of one request envelope, in bytes.
@@ -36,6 +38,50 @@ public record GraphQlRequestSize(int documentBytes, int variablesBytes, int exte
return documentBytes + variablesBytes + extensionsBytes;
}
/**
* The UTF-8 size of a decoded JSON value, as a canonical encoding without whitespace.
*
* <p>Computed by walking the decoded value rather than by re-serialising it, because this module
* is framework free and must not acquire a JSON library to measure one. The number is what the
* content costs in memory, which is the quantity the limit is protecting; it is deliberately not
* a claim about the exact bytes the client sent, since escaping and whitespace are the encoder's
* business and neither is attacker-controlled in a way the count would miss.
*
* @param value a decoded JSON value, or {@code null}
*/
public static int jsonBytes(Object value) {
if (value == null) {
return 4; // "null"
}
if (value instanceof String text) {
return utf8Length(text) + 2; // surrounding quotes
}
if (value instanceof Map<?, ?> object) {
int bytes = 2; // braces
boolean first = true;
for (Map.Entry<?, ?> entry : object.entrySet()) {
if (!first) {
bytes++; // comma
}
first = false;
bytes += utf8Length(String.valueOf(entry.getKey())) + 3; // quotes and colon
bytes += jsonBytes(entry.getValue());
}
return bytes;
}
if (value instanceof List<?> list) {
int bytes = 2; // brackets
for (int index = 0; index < list.size(); index++) {
if (index > 0) {
bytes++; // comma
}
bytes += jsonBytes(list.get(index));
}
return bytes;
}
return utf8Length(String.valueOf(value));
}
private static int utf8Length(String value) {
return value == null ? 0 : value.getBytes(StandardCharsets.UTF_8).length;
}
@@ -1,80 +0,0 @@
package dev.caskeleton.adapter.inbound.graphql.http.mvc;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpExecutor;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpProfile;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestEnvelopeValidator;
import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy;
import java.time.Clock;
import java.util.concurrent.ExecutorService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Wires the blocking MVC transport when the application runs the servlet stack.
*
* <p>Conditional on a servlet web application and on {@code BLOCKING_MVC} being the selected
* execution profile, so a reactive deployment never gets a blocking transport by accident. Every
* bean backs off if the application defines its own.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@ConditionalOnProperty(
prefix = "backend.graphql",
name = "execution-profile",
havingValue = "BLOCKING_MVC",
matchIfMissing = true)
public class GraphQlMvcAutoConfiguration {
/** Default bounded pool size when virtual threads are not in use. */
public static final int DEFAULT_BOUNDED_POOL_SIZE = 64;
/**
* The thread policy for resolver work.
*
* <p>Virtual threads by default: the blocking profile exists for JPA and blocking SDK work, and a
* thread-per-request model with virtual threads is what makes that affordable on Java 21.
*/
@Bean
@ConditionalOnMissingBean
public GraphQlMvcExecutorPolicy graphQlMvcExecutorPolicy() {
return GraphQlMvcExecutorPolicy.VIRTUAL_THREAD;
}
/** The executor resolver work runs on. */
@Bean(destroyMethod = "shutdown")
@ConditionalOnMissingBean(name = "graphQlMvcExecutorService")
public ExecutorService graphQlMvcExecutorService(GraphQlMvcExecutorPolicy policy) {
return policy.createExecutor(DEFAULT_BOUNDED_POOL_SIZE);
}
/** Pre-parse envelope limits derived from the client policy. */
@Bean
@ConditionalOnMissingBean
@ConditionalOnBean(GraphQlClientPolicy.class)
public GraphQlRequestEnvelopeValidator graphQlRequestEnvelopeValidator(
GraphQlClientPolicy clientPolicy) {
return GraphQlRequestEnvelopeValidator.forPolicy(clientPolicy);
}
/** The MVC transport adapter. */
@Bean
@ConditionalOnMissingBean
@ConditionalOnBean({GraphQlHttpExecutor.class, GraphQlRequestEnvelopeValidator.class})
public GraphQlMvcTransportAdapter graphQlMvcTransportAdapter(
GraphQlRequestEnvelopeValidator validator,
GraphQlHttpExecutor executor,
ExecutorService graphQlMvcExecutorService,
GraphQlMvcExecutorPolicy policy) {
return new GraphQlMvcTransportAdapter(
GraphQlHttpProfile.V1,
validator,
executor,
graphQlMvcExecutorService,
policy,
Clock.systemUTC());
}
}

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