diff --git a/.github/scripts/verify-gradle-wrapper.sh b/.github/scripts/verify-gradle-wrapper.sh index 6581c124..8c558cec 100755 --- a/.github/scripts/verify-gradle-wrapper.sh +++ b/.github/scripts/verify-gradle-wrapper.sh @@ -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' ) diff --git a/.github/workflows/jpa-next-hibernate8.yml b/.github/workflows/jpa-next-hibernate8.yml index eb81e6c7..7d102b86 100644 --- a/.github/workflows/jpa-next-hibernate8.yml +++ b/.github/workflows/jpa-next-hibernate8.yml @@ -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 diff --git a/.github/workflows/jpa-next-jpa4.yml b/.github/workflows/jpa-next-jpa4.yml index 79cd2eef..9ed0eb43 100644 --- a/.github/workflows/jpa-next-jpa4.yml +++ b/.github/workflows/jpa-next-jpa4.yml @@ -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 diff --git a/.github/workflows/jpa-next-postgresql19.yml b/.github/workflows/jpa-next-postgresql19.yml index 34fc6364..236d82ab 100644 --- a/.github/workflows/jpa-next-postgresql19.yml +++ b/.github/workflows/jpa-next-postgresql19.yml @@ -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 diff --git a/.github/workflows/jpa-nightly.yml b/.github/workflows/jpa-nightly.yml index a609bd6a..cc4da84b 100644 --- a/.github/workflows/jpa-nightly.yml +++ b/.github/workflows/jpa-nightly.yml @@ -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 diff --git a/.github/workflows/jpa-release.yml b/.github/workflows/jpa-release.yml index 9e78dedd..6b41868e 100644 --- a/.github/workflows/jpa-release.yml +++ b/.github/workflows/jpa-release.yml @@ -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 diff --git a/.github/workflows/notification-platform.yml b/.github/workflows/notification-platform.yml index ca40fc3f..5bd03e15 100644 --- a/.github/workflows/notification-platform.yml +++ b/.github/workflows/notification-platform.yml @@ -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 diff --git a/.gitignore b/.gitignore index 4e9c6006..6ee181ab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .vscode/ src/**/bin/ +.claude/ diff --git a/AGENTS.md b/AGENTS.md index 6c723834..e99f6a1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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별 명령 목록을 복제하지 않는다. ## 설정과 런타임 diff --git a/CLAUDE.md b/CLAUDE.md index 377f21c4..7c686f0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | | --- | --- | --- | diff --git a/docs/architecture/graphql-api-surface.txt b/docs/architecture/graphql-api-surface.txt new file mode 100644 index 00000000..d433ed8e --- /dev/null +++ b/docs/architecture/graphql-api-surface.txt @@ -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 diff --git a/docs/architecture/mongo-api-surface.txt b/docs/architecture/mongo-api-surface.txt new file mode 100644 index 00000000..81f61ec7 --- /dev/null +++ b/docs/architecture/mongo-api-surface.txt @@ -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 diff --git a/docs/jpa/repository-adaptation.md b/docs/jpa/repository-adaptation.md index 3aa7d544..62a13758 100644 --- a/docs/jpa/repository-adaptation.md +++ b/docs/jpa/repository-adaptation.md @@ -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 diff --git a/docs/jpa/support-matrix.md b/docs/jpa/support-matrix.md index 8daf6383..c163710c 100644 --- a/docs/jpa/support-matrix.md +++ b/docs/jpa/support-matrix.md @@ -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 diff --git a/docs/messaging/configuration-reference.md b/docs/messaging/configuration-reference.md index 06f345e9..0e864ee5 100644 --- a/docs/messaging/configuration-reference.md +++ b/docs/messaging/configuration-reference.md @@ -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 이하여야 한다 diff --git a/docs/messaging/cutover.md b/docs/messaging/cutover.md new file mode 100644 index 00000000..a65e341f --- /dev/null +++ b/docs/messaging/cutover.md @@ -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. diff --git a/docs/messaging/outbox-inbox.md b/docs/messaging/outbox-inbox.md index a3813cab..e28bd94b 100644 --- a/docs/messaging/outbox-inbox.md +++ b/docs/messaging/outbox-inbox.md @@ -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이 지울 때까지 쌓이기 때문이다. diff --git a/docs/messaging/support-matrix.md b/docs/messaging/support-matrix.md index dc9356c3..000cfcae 100644 --- a/docs/messaging/support-matrix.md +++ b/docs/messaging/support-matrix.md @@ -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 없음(플랫폼이 대행), 기본 비활성 | diff --git a/docs/mongodb/advanced/signoff/README.md b/docs/mongodb/advanced/signoff/README.md new file mode 100644 index 00000000..23ecf70e --- /dev/null +++ b/docs/mongodb/advanced/signoff/README.md @@ -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. diff --git a/docs/mongodb/repository-adaptation.md b/docs/mongodb/repository-adaptation.md index 2d8b1609..1c527959 100644 --- a/docs/mongodb/repository-adaptation.md +++ b/docs/mongodb/repository-adaptation.md @@ -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 diff --git a/docs/notification/adr/NOTIF-ADR-005-canonical-namespace.md b/docs/notification/adr/NOTIF-ADR-005-canonical-namespace.md new file mode 100644 index 00000000..a7b83d93 --- /dev/null +++ b/docs/notification/adr/NOTIF-ADR-005-canonical-namespace.md @@ -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은 제거 계획이 생길 때 붙인다. diff --git a/docs/notification/api-surface-snapshot.txt b/docs/notification/api-surface-snapshot.txt new file mode 100644 index 00000000..e51453a9 --- /dev/null +++ b/docs/notification/api-surface-snapshot.txt @@ -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 diff --git a/docs/notification/configuration-reference.md b/docs/notification/configuration-reference.md index bee67c07..e4763ec2 100644 --- a/docs/notification/configuration-reference.md +++ b/docs/notification/configuration-reference.md @@ -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__`. + +| 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). diff --git a/docs/notification/evidence-manifest.json b/docs/notification/evidence-manifest.json new file mode 100644 index 00000000..edbb148a --- /dev/null +++ b/docs/notification/evidence-manifest.json @@ -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" +} diff --git a/docs/notification/migration-guide.md b/docs/notification/migration-guide.md index 61dd23e3..4269cbb4 100644 --- a/docs/notification/migration-guide.md +++ b/docs/notification/migration-guide.md @@ -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` 한 곳에만 둔다. diff --git a/docs/notification/module-mapping.md b/docs/notification/module-mapping.md index 0eb882c1..3b1c3fa5 100644 --- a/docs/notification/module-mapping.md +++ b/docs/notification/module-mapping.md @@ -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. diff --git a/docs/notification/support-matrix.md b/docs/notification/support-matrix.md index 0e97a725..d70d5de2 100644 --- a/docs/notification/support-matrix.md +++ b/docs/notification/support-matrix.md @@ -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` → diff --git a/docs/registries/env-keys.yaml b/docs/registries/env-keys.yaml index 11fe5ec2..815edf7d 100644 --- a/docs/registries/env-keys.yaml +++ b/docs/registries/env-keys.yaml @@ -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 diff --git a/docs/reviews/2026-08-14-graphql-module-code-review.md b/docs/reviews/2026-08-14-graphql-module-code-review.md new file mode 100644 index 00000000..e98ef5c4 --- /dev/null +++ b/docs/reviews/2026-08-14-graphql-module-code-review.md @@ -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`, `Mono`, + `Optional`의 generic 내부 타입을 놓친다. +- package scan은 `file:` protocol, 직접 자식 `.class`만 지원해 JAR/subpackage를 건너뛴다. +- tests는 고정 fixture package만 직접 호출하고 production startup caller가 없다. +- `Publisher`를 subscription 외에서 모두 거부하지만 Spring GraphQL controller는 Query/Mutation에서 + `Mono`와 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-`가 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`을 권장한다. diff --git a/docs/reviews/2026-08-14-jpa-module-code-review.md b/docs/reviews/2026-08-14-jpa-module-code-review.md new file mode 100644 index 00000000..b1cb584b --- /dev/null +++ b/docs/reviews/2026-08-14-jpa-module-code-review.md @@ -0,0 +1,1605 @@ +# JPA persistence 모듈 상세 코드·아키텍처 리뷰 + +- 기준 일자: 2026-08-14 +- 기준 Git HEAD: `539e3eb58bed5db63e3a17f47eec213db2d2df79` +- 대상 Gradle leaf: `:adapter:outbound:persistence-jpa` +- 주 대상 경로: `src/adapter/outbound/persistence-jpa` +- 교차 확인 경로: `src/application-core`, `src/app-bootstrap`, `src/config`, `.github/workflows`, `docs/jpa` +- 판정: **CHANGES REQUIRED** +- 검토 방식: 전체 트리 정적 탐색, 핵심 실행 경로 정독, 3개 병렬 리뷰, source/test/CI 교차검증 +- 변경 범위: 이 리뷰 문서만 추가했다. production/test 코드는 수정하지 않았다. + +## 1. 결론 + +이 leaf는 더 이상 단순한 JPA repository adapter가 아니다. transaction과 retry, completion evidence, +Spring Data 확장, Hibernate provider 기능, PostgreSQL 전용 SQL, migration, idempotency/outbox/inbox, +fileserver/notification persistence, observability/security, experimental multi-tenancy와 release evidence까지 +한 Gradle leaf에 담은 관계형 persistence platform이다. framework-free API, typed policy, SQLSTATE 기반 +분류, real PostgreSQL 계약 테스트, testkit source set처럼 보존할 설계도 많다. + +그러나 현재 상태를 그대로 Stable 또는 production-ready라고 판단하면 안 된다. 가장 먼저 고쳐야 할 +계약은 다음과 같다. + +1. release job이 PostgreSQL 16·17·18 전체 계약을 실행한다고 주장하지만 대부분의 테스트는 첫 버전인 + 16만 실행한다. +2. notification migration은 기본 Flyway와 readiness 어디에도 연결되지 않았고, entity에는 없는 컬럼과 + 잘못된 JSONB mapping이 있으며 일부 Spring Data repository는 발견조차 되지 않는다. +3. notification lease 갱신은 소유자·상태·fence를 검사하지 않아 만료된 worker가 새 owner의 lease를 + 다시 탈취할 수 있다. +4. 새 JPA platform 구성 클래스는 이름과 달리 Spring configuration이 아니어서 transaction retry, + completion evidence, observability, endpoint가 실제 runtime에 조립되지 않는다. +5. application이 이미 사용하는 `PolicyTransactionPort`와 새 JPA executor/AOP 계약이 두 벌로 존재하고, + 새 retry path에는 raw PostgreSQL/optimistic failure를 stable exception으로 바꾸는 실행 연결이 없다. +6. transaction evidence stack은 manager와 executor가 같은 frame을 각각 pop할 수 있어 nested + `REQUIRES_NEW` 뒤 outer reconciliation key를 잃을 수 있다. +7. keyset sort와 predicate가 동일한 ordering specification을 공유하지 않아 mixed type과 mixed + ASC/DESC를 올바르게 표현할 수 없다. + +따라서 즉시 운영 원칙은 다음처럼 잡는 것이 안전하다. + +- 이 문서의 JPA-001~010을 해결하기 전에는 JPA platform/notification을 Stable로 승격하지 않는다. +- `PolicyTransactionPort`를 application-facing transaction SSOT로 유지하고 병렬 transaction API를 더 + 확산시키지 않는다. +- notification V1~V3가 어느 환경에도 적용되지 않았다는 증거가 없으면 기존 SQL을 수정하지 않고 V4 + forward migration으로 수습한다. +- package 대이동은 correctness 수정 뒤에 한다. 현재 19-leaf registry를 임의로 늘리지 않는다. +- unit test 통과를 runtime bean 조립, migration upgrade, PostgreSQL version compatibility의 증거로 + 해석하지 않는다. + +## 2. 범위와 증거 경계 + +### 2.1 현재 규모 + +| 항목 | 현재 값 | +|---|---:| +| production Java 파일 | 324 | +| production Java LOC | 23,086 | +| 일반 unit-test Java 파일 | 84 | +| PostgreSQL integration-test Java 파일 | 49 | +| testkit Java 파일 | 38 | +| performance-test Java 파일 | 3 | +| production 최상위 package | 22 | +| public top-level type 선언 파일 | 318 / 324 | + +최상위 package는 `api`, `audit`, `auditing`, `cache`, `config`, `envers`, `experimental`, `failure`, +`fileserver`, `h2`, `hibernate`, `idempotency`, `lock`, `migration`, `notification`, `observation`, +`outbox`, `postgresql`, `querydsl`, `security`, `springdata`, `transaction`이다. + +### 2.2 검토 깊이 + +| 영역 | 상태 | 대표 근거 | +|---|---|---| +| registry/build/source set/runtime membership | READ_FULL | `modules.json`, JPA `build.gradle`, root JPA tasks | +| application transaction port와 JPA transaction/retry/evidence | READ_FULL | port/result algebra, 두 executor 계열, manager/classifier/interceptor와 tests | +| Spring runtime composition | READ_FULL | main scan, JPA factory/config/settings/endpoint, auto-configuration imports와 tests | +| notification entity/repository/migration/config | READ_FULL | V1~V3, entity/repository/store/config, notification workflow | +| Spring Data keyset/sort/cursor | READ_FULL | registry/mapper/predicate/codec/page request와 tests | +| package/ArchUnit 경계 | READ_FULL | `JpaModuleBoundaryTest`, reusable rules, root architecture tests | +| PostgreSQL matrix/migration/release lane | READ_FULL | support/extension/scenarios/manifest/gates/workflows | +| idempotency/outbox/inbox/fileserver | READ_PARTIAL | composition 및 큰 실행 seam 중심 정독, 전수 method 승인은 아님 | +| Hibernate/querydsl/envers/cache/security/experimental | READ_PARTIAL | public entry, activation, dependency와 release 주장 중심 | + +`READ_PARTIAL` 영역의 모든 method를 승인했다는 뜻은 아니다. Docker-backed PostgreSQL lane과 실제 +장애 주입을 이번 통합 리뷰에서 다시 실행하지 않았으므로 운영 결과는 `UNVERIFIED`다. 리뷰 도중 다른 +사용자 작업이 root build/settings와 `src/config/spotbugs/exclude.xml`을 변경하고 conflict 상태로 만든 +것을 확인했으며, 해당 변경은 이 리뷰 범위에 포함하거나 수정하지 않았다. 위 JPA/app-bootstrap/docs/CI +대상 파일은 기준 HEAD와 동일함을 `git diff --quiet HEAD -- `로 확인했다. + +## 3. 유지할 설계 + +다음은 리팩터링 중에도 보존할 가치가 있다. + +- `domain-core`와 `application-core`가 JPA/Hibernate/Spring Data type에 의존하지 않는다. +- `GenericRepository`나 platform base entity/repository를 도입하지 않고 도메인별 port를 둔다. +- `TransactionProfile`, `RetryProfile`, `QueryName`, `SafeSortRegistry`처럼 policy를 문자열 분기보다 + 명시적 값으로 모델링한다. +- completion-unknown을 retryable로 표현하지 못하게 한 failure-context 불변식은 유지해야 한다. +- SQL message text가 아니라 SQLSTATE와 등록된 constraint를 사용해 분류하려는 방향이 맞다. +- cursor MAC의 constant-time 비교, 최소 32-byte key, sort allowlist, unique tie-breaker 요구는 적절하다. +- Testcontainers/testkit을 main output과 분리한 source-set 구조는 production classpath 오염을 막는다. +- Docker가 없을 때 skip하지 않고 실패하고, platform task에 `failOnNoDiscoveredTests=true`를 둔 정책은 + release evidence의 기본 전제다. +- H2를 PostgreSQL 호환성 증거로 사용하지 않고 local convenience로 제한한 문서화가 명확하다. +- Querydsl/Envers를 `compileOnly`로 두어 Stable runtime classpath에서 제외한 선택은 유지한다. +- fileserver의 독립 migration stream, activation record, readiness card는 notification capability를 + 정리할 때 재사용할 좋은 선례다. +- owner-safe idempotency store가 아직 candidate라는 이유로 stereotype을 제거한 방식은 다른 candidate + adapter에도 적용할 수 있다. + +## 4. 우선순위 요약 + +| ID | 우선순위 | 심각도 | 주제 | 완료 조건 | +|---|---|---|---|---| +| JPA-001 | P0 | High | PG17/18 full-suite release 증거가 거짓 양성 | Stable major별 전체 lane이 별도 job에서 실행됨 | +| JPA-002 | P0 | High | notification schema stream이 runtime/readiness에 미연결 | enabled 시 ACTIVE schema만 boot, disabled 시 entity/repo 0개 | +| JPA-003 | P0 | Critical | notification entity/schema/JSONB/repository 불일치 | forward migration + Hibernate validate + 모든 repository CRUD 통과 | +| JPA-004 | P0 | High | stale notification worker가 lease를 탈취 | owner/state/fence CAS와 두-worker PG test 통과 | +| JPA-005 | P1 | High | persistence에 roll-up 정책과 tenant 없는 API 유출 | application이 상태를 결정하고 모든 query/update가 tenant scoped | +| JPA-006 | P1 | High | Stable JPA runtime composition이 실제로 없음 | auto-config bean/advisor/endpoint context test 통과 | +| JPA-007 | P1 | High | transaction authority와 application contract가 두 벌 | `PolicyTransactionPort` 단일 facade로 통합 | +| JPA-008 | P1 | High | raw DB failure가 새 retry classifier에 도달하지 않음 | 실제 optimistic/40001/40P01 번역·retry test 통과 | +| JPA-009 | P1 | High | evidence frame double-pop과 empty ThreadLocal 재생성 | identity scope ownership과 nested commit-unknown test 통과 | +| JPA-010 | P1 | High | migration release lane이 실제 migration을 검증하지 않음 | empty/N-1/oldest snapshot으로 migrate+validate+invariant 실행 | +| JPA-011 | P1 | High | keyset sort/predicate 계약 불일치 | mixed type/direction 한 SSOT와 real criteria test 통과 | +| JPA-012 | P1 | High | broad scan이 optional/candidate bean을 활성화 | capability marker scan과 candidate bean inventory 통과 | +| JPA-013 | P1 | Medium | signed cursor input 크기가 무제한 | pre-decode token/payload bound와 boundary test 통과 | +| JPA-014 | P1 | Medium | integration support의 Hikari/container lifecycle 누수 | pool→container close 및 shared-resource isolation 통과 | +| JPA-015 | P1 | Medium | package DAG rule이 9개 package와 cycle을 놓침 | exact package catalog/edge/cycle negative fixture 통과 | +| JPA-016 | P1 | Medium | reusable ArchUnit rule이 production graph에 미적용 | 실제 runtime leaves를 import해 네 rule 모두 실행 | +| JPA-017 | P2 | Medium | release manifest/support 문서가 실행 SSOT가 아님 | typed manifest에서 docs/task/matrix를 생성·검증 | +| JPA-018 | P2 | Medium | experimental gate/workflow가 대상 runtime을 실행하지 않음 | 별도 variant에서 실제 dependency/PG19 lane 실행 | +| JPA-019 | P2 | Medium | performance flag와 R2/release evidence 연결이 무효 | 명칭을 contract로 낮추거나 측정 artifact를 실제 gate에 연결 | +| JPA-020 | P2 | Medium | owner-safe idempotency store가 미조립인 890-line facade | provider-selected facade와 package-private gateway로 분해 | +| JPA-021 | P2 | Medium | notification FK/tenant/index 불변식 부족 | V4 composite FK/fence index 또는 명시적 retention invariant | +| JPA-022 | P2 | Low-Medium | `audit`와 `auditing` 계약이 병존 | canonical 한 모델과 schema migration/activation 선택 | +| JPA-023 | P3 | Medium | public surface와 package 상태가 통제되지 않음 | api/spi/config export allowlist와 internal 축소 | +| JPA-024 | P3 | Low | 문서·Hibernate baseline·H2 표현 drift | machine manifest 및 문서 검증으로 실제 실행과 일치 | +| JPA-025 | P0 | Critical | notification idempotency loser가 aborted tx에서 재조회 | `ON CONFLICT DO NOTHING RETURNING` 동시성 수렴 | +| JPA-026 | P1 | High | provider callback dedupe/matching/status가 durable하지 않음 | hash 기반 CAS bind와 sweep/outcome PG test 통과 | +| JPA-027 | P0 | High | batch clear가 unflushed entity를 유실 | clear-before-flush 불가와 300-row real PG test 통과 | +| JPA-028 | P1 | High | fileserver cleanup claim/writer fencing 부재 | expiring fenced claim과 terminal upload state 경쟁 test 통과 | +| JPA-029 | P1 | Medium | inbox tuple cutoff와 durable signal 계약 불일치 | `(createdAt,id)` update와 transactional outbox replay | +| JPA-030 | P2 | Medium | query/stateless/stream safety policy가 선언만 됨 | construction-time guard와 실행-time row/fetch bound 강제 | + +## 5. 상세 발견 사항과 구현 명세 + +### JPA-001 — PostgreSQL 17·18 전체 계약을 실행하지 않고 full release로 판정한다 + +**근거** + +- `.github/workflows/jpa-release.yml:38-45`는 한 job에 + `-Pjpa.matrix.versions=16,17,18`을 전달한다. +- JPA `build.gradle:244-260`은 그 문자열을 각 tagged lane의 system property로 그대로 전달한다. +- `JpaPlatformContractSupport.java:38-48`의 parameterless `start()`는 + `selectedVersions().get(0)`만 시작한다. +- 현재 28개 integration test class가 parameterless `start()`를 사용한다. +- 전 버전을 순회하는 `StablePostgreSqlMatrixContractTest.java:28-76`은 server version, unique + SQLSTATE, `SKIP LOCKED` 세 종류의 얕은 검증만 한다. +- `docs/jpa/support-matrix.md:9-15`는 PG16·17·18을 모두 “full contract suite, release lane”으로 + 기록한다. + +**실패 모드** + +PG17/18에서만 달라진 JSONB/range mapping, Hibernate SQL, Flyway upgrade, runtime role, query plan, +deadlock/commit ambiguity가 있어도 tag release는 PG16의 전체 결과와 PG17/18의 얕은 smoke 결과만으로 +통과할 수 있다. PR/nightly 일부가 단일 major job을 사용해도 tag release의 같은 SHA가 문서에 적힌 +전체 gate를 재현하지 못한다. + +**구현 결정: CI matrix + single-version fail-closed contract** + +1. `.github/workflows/jpa-release.yml`을 `strategy.matrix.postgresql: [16, 17, 18]`로 나눈다. +2. 각 job은 정확히 한 major만 `-Pjpa.matrix.versions=${{ matrix.postgresql }}`로 전달하고 contract, + migration, failure, queryplan, security를 모두 실행한다. +3. 현재 구조에서는 `JpaPlatformContractSupport.start()`가 선택 버전 수 `!= 1`이면 즉시 실패하게 한다. + comma selection을 유지하려면 모든 test를 `@TestTemplate`/extension으로 버전별 반복시키는 별도 + 리팩터링이 필요하다. +4. 세 job의 JUnit XML/evidence manifest에 `git SHA`, major, image digest, 실행 task를 기록하고 aggregate + promotion job은 세 artifact가 모두 같은 SHA인지 확인한다. +5. container 비용은 JPA-014의 root-store sharing을 먼저 적용해 줄인다. + +**필수 테스트** + +- `JpaPlatformContractSupportTest.rejectsMultipleVersionsForSingleVersionStart` +- PG16/17/18 각각에서 다섯 non-performance lane을 `--rerun-tasks`로 실행한다. +- mutation: `selectedVersions().get(0)` 또는 PG17 job 제거 시 release manifest 검증이 실패해야 한다. + +### JPA-002 — notification migration은 opt-in인데 runtime과 readiness가 그 stream을 소유하지 않는다 + +**근거** + +- `V1__notification_platform_core.sql:3-5`는 notification이 opt-in tree이며 기본 Flyway location에서 + 실행되지 않는다고 명시한다. +- `PostgreSqlPersistenceConfig.java:55-58`은 기본 location을 + `classpath:db/migration/postgresql` 하나로 고정한다. +- `PersistenceJpaConfig.java:12-14`는 반대로 persistence 전체 package의 entity/repository를 스캔한다. +- `NotificationPlatformPersistenceConfig.java:61-66`은 adapter bean만 feature property로 막는다. +- root readiness exact set(`src/build.gradle:1161-1189`)의 16 card/8 owned stream에는 notification이 + 없다. +- notification workflow는 persistence의 일반 unit `test`만 실행한다 + (`.github/workflows/notification-platform.yml:66-68`). + +**실패 모드** + +- `ddl-auto=validate`: feature가 꺼져도 notification entity가 persistence-unit에 포함되어 table이 없는 + DB에서 boot가 실패할 수 있다. +- `ddl-auto=none`: feature를 켜도 migration activation guard가 없어 첫 repository 호출에서 + `relation does not exist`가 발생한다. +- local `ddl-auto=update`는 Hibernate가 migration 밖에서 table을 만들어 schema ownership 결함을 + 숨길 수 있다. + +**구현 결정: Capability Module + activation record** + +1. notification을 진짜 optional capability로 유지할지, 항상 설치되는 base schema로 바꿀지 먼저 + 결정한다. 현재 문서와 fileserver 선례에 맞는 권장은 optional 유지다. +2. `jpa-notification-platform-v4` readiness card를 추가하고 location, dedicated history table, core + prerequisite, checksum/content hash, ACTIVE promotion을 등록한다. +3. `NotificationSchemaActivation`을 fileserver와 같은 lifecycle로 구현한다. `enabled=true`인데 ACTIVE가 + 아니면 repository/worker 생성 전에 boot를 실패시킨다. +4. `NotificationJpaPersistenceConfiguration`에서 notification marker entity/repository만 scan하고 master + switch와 activation condition을 함께 건다. disabled일 때 entity metadata와 repository bean이 모두 + 없어야 한다. +5. broad `classpath:db/migration`으로 합치지 않는다. 여러 독립 V1 stream과 history ownership이 섞인다. + +**필수 테스트** + +- disabled + fresh DB: notification entity/repository/bean 0개, boot 성공. +- enabled + stream 미적용: startup fail-closed. +- first enable, V1→V4, V2→V4, V3→V4, disable/re-enable, interrupted migration recovery. +- migration 후 Hibernate `ddl-auto=validate`로 실제 application context boot. + +### JPA-003 — notification entity와 V1~V3 schema가 첫 실제 CRUD에서 충돌한다 + +**근거** + +- `NotificationRequestEntity.java:46-47`은 `template_locale`을 mapping하지만 + V1 request table(`V1...sql:11-29`)에는 해당 column이 없다. +- migration의 다음 column은 `jsonb`지만 entity는 일반 `String`/VARCHAR mapping이다. + - `metadata_json`: `NotificationRequestEntity.java:70-71` + - `routing_plan_json`: `RecipientDeliveryEntity.java:39-40` + - `normalized_payload_json`: `ProviderEventEntity.java:68-69` + - `content_json`: `TemplateVersionEntity.java:48-49`, `InboxItemEntity.java:42-43` + - `preferred_order`, `muted_channels`: `PreferenceEntity.java:28-32` + - `attributes`: `AdminAuditEntity.java:37-38` +- 같은 entity들의 UUID에는 이미 `@JdbcTypeCode(SqlTypes.UUID)`를 쓰지만 JSON column에는 + `@JdbcTypeCode(SqlTypes.JSON)` 또는 converter가 없다. +- `NotificationPolicyJpaRepositories.java:8-58`은 repository 5개를 nested interface로 묶었다. + `@EnableJpaRepositories`의 nested repository discovery 기본값은 false인데 현재 config는 + `considerNestedRepositories`를 켜지 않는다. +- bootstrap config는 이 다섯 repository bean을 모두 constructor parameter로 요구한다 + (`NotificationPlatformPersistenceConfig.java:159-200`). + +**실패 모드** + +- Hibernate schema validate가 없는 `template_locale` 또는 VARCHAR↔JSONB mismatch로 boot에서 실패한다. +- validate를 끄면 첫 insert/update에서 PostgreSQL이 `character varying`을 `jsonb` column에 바인딩하는 + 것을 거부할 수 있다. +- schema를 고친 뒤에도 preference/consent/dedup/admin/template repository bean이 발견되지 않아 enabled + context가 조립되지 않는다. + +**구현 결정: forward migration + explicit JSON type + top-level repository** + +1. 기존 migration 적용 이력을 먼저 확인한다. 미적용을 증명하지 못하면 V1~V3를 편집하지 않는다. +2. V4에서 `notification_request.template_locale varchar(35)`을 추가한다. null 허용 여부와 backfill 후 + NOT NULL 전환 여부는 application contract에 맞춘다. +3. JSON을 계속 canonical String으로 보관한다면 각 field에 `@JdbcTypeCode(SqlTypes.JSON)`를 붙이고 + mapper boundary에서 parsing/size validation을 한다. 구조적 query가 필요하면 immutable value/object + 또는 `JsonNode`로 통일하되 application DTO를 entity에 넣지 않는다. +4. nested repository 5개를 각각 top-level file로 분리한다. 전역 + `considerNestedRepositories=true`는 의도하지 않은 nested test/repository까지 발견할 수 있어 권장하지 + 않는다. +5. migration/entity column manifest test를 추가해 name, nullable, type, length의 drift를 비교한다. + +**필수 테스트** + +- real PG에서 migration 후 `ddl-auto=validate` boot. +- 모든 JSONB field의 insert/read/update round-trip과 malformed/oversized payload rejection. +- 5개 repository bean exact inventory와 실제 CRUD. +- V3 seed row가 V4 후 보존되고 `template_locale` backfill 정책을 만족하는지 검증. + +### JPA-004 — notification lease renew가 새 owner의 lease를 다시 빼앗을 수 있다 + +**근거** + +- `RecipientDeliveryJpaRepository.java:37-49`의 `markLeased` update는 `WHERE id IN (:ids)`만 검사한다. +- `JpaRecipientLeaseStore.java:50-57`의 `renew()`가 같은 update를 재사용한다. +- release query는 그나마 `lease_owner`를 조건으로 검사한다 + (`RecipientDeliveryJpaRepository.java:51-59`). +- claim은 select-for-update와 update가 adapter 내부 transaction으로 묶였다는 보장이 없고, native update는 + entity `@Version`을 자동 증가시키지 않는다. +- scheduler는 `leases.claim`을 직접 호출하고, runtime config는 모든 instance에 동일한 + `notification-worker-1` owner 문자열을 제공한다. 동일 owner면 stale process의 release도 새 process의 + lease와 구분되지 않는다. + +**실패 모드** + +worker A의 lease가 만료된 뒤 B가 claim했는데 A의 늦은 renew가 도착하면, id만 일치하므로 owner와 +`lease_until`을 다시 A 값으로 덮을 수 있다. 두 worker가 동일 수신자에게 provider request를 보내 +duplicate delivery가 발생할 수 있다. + +**구현 결정: fenced lease state machine + compare-and-set** + +1. claim, renew, release를 서로 다른 SQL로 분리한다. +2. lease row에 monotonically increasing `lease_fence`를 추가한다. claim 결과는 `(id, owner, fence, + leaseUntil)` token을 반환한다. +3. renew 조건은 최소한 `id`, `lease_owner`, `lease_fence`, `delivery_state='DISPATCHING'`, 아직 유효한 + lease를 모두 확인한다. update count가 1이 아니면 `LeaseLost`를 반환한다. +4. provider side effect를 시작하기 직전에도 현재 fence 소유를 확인하고, completion projection update도 + 같은 fence를 조건으로 한다. +5. claim을 PostgreSQL single-statement CTE(`FOR UPDATE SKIP LOCKED` + `UPDATE ... RETURNING`)로 만들거나 + application-owned transaction port로 select/update를 원자화한다. +6. owner는 `${instanceId}:${startupNonce}`처럼 process incarnation을 포함한다. 설정된 instance id만으로 + 소유권 token을 만들지 않는다. + +**필수 테스트** + +- A claim → expiry → B claim → A late renew가 0 row이고 B owner/fence가 유지되는 real PG concurrency test. +- double claim, partial batch claim, transaction rollback, process crash 후 expiry reclaim. +- stale fence로 provider completion을 기록할 수 없는지 검증. + +### JPA-005 — notification 상태 정책과 tenant 경계가 persistence adapter로 유출됐다 + +**근거** + +- `JpaNotificationRequestStore.java:10`은 application service인 `NotificationSubmissionService`를 직접 + import한다. +- 같은 파일 `:78-90`은 recipient state를 읽고 `NotificationSubmissionService.rollUp(states)`로 최종 + `RequestStatus`를 결정한다. +- 비즈니스 결정표는 application의 `NotificationSubmissionService.java:249-278`에 있다. +- `NotificationRequestStorePort.java:21-25`의 `recipientsOf(NotificationId)`와 + `refreshStatus(NotificationId)`는 tenant를 받지 않는다. +- `NotificationRequestJpaRepository`는 주석과 달리 `JpaRepository`를 상속해 tenant 없는 `findById`, + `findAll`, `deleteById`를 모두 노출한다. +- `JpaRecipientDeliveryStore.java:26-37,59-64`와 `JpaNotificationRequestStore.java:85-90`에 실제 + 무tenant 조회가 있다. + +**실패 모드** + +현재 caller가 tenant를 먼저 확인하면 우연히 안전할 수 있지만 port/repository 자체는 다른 tenant의 UUID를 +구조적으로 거부하지 않는다. 새 consumer가 선행 확인을 빠뜨리면 cross-tenant read/update가 가능하다. +또한 request 상태 결정 규칙을 바꾸려면 application과 persistence를 동시에 수정해야 한다. + +**구현 결정: application Policy + narrow Repository port** + +1. application service가 recipient states를 받아 `rollUp`하고 결정된 `RequestStatus`만 port에 전달한다. +2. port를 `recipientsOf(TenantId, NotificationId)`와 + `updateStatus(TenantId, NotificationId, RequestStatus)`로 바꾼다. +3. adapter에서 `NotificationSubmissionService` import와 상태 결정 로직을 제거한다. +4. tenant-owned repository는 `JpaRepository` 대신 Spring Data marker `Repository`를 + 상속하고 필요한 `save`와 tenant-scoped finder/update만 선언한다. +5. persistence package가 `..application..*Service`/`*UseCase`에 의존하지 못하게 ArchUnit rule을 둔다. + +**필수 테스트** + +- application unit: recipient state 조합별 roll-up 결정표. +- persistence PG: 틀린 tenant의 read/update/delete가 empty 또는 0 row. +- reflection/architecture: tenant repository public API에 `findAll`, raw `findById`, `deleteById`가 없음. + +### JPA-006 — 이름만 auto-configuration이고 Stable platform bean은 runtime에 없다 + +**근거** + +- `CaSkeletonApplication.java:42-44,66-68`은 `bootstrap.autoconfigure.*`를 component scan에서 + 제외한다. 이 package는 auto-configuration entry로만 들어와야 한다. +- `AutoConfiguration.imports:1-2`에는 fileserver와 HTTP client만 있고 JPA entry가 없다. +- `JpaPlatformAutoConfiguration.java:19-28`, `JpaTransactionAutoConfiguration.java:28-32`, + `JpaObservabilityAutoConfiguration.java:13-26`은 `@AutoConfiguration`, `@Configuration`, `@Bean`이 + 없는 plain factory다. +- `JpaPlatformEndpoint.java:20-21`은 `@Endpoint`일 뿐 production `@Bean` 등록이 없다. +- `RetryableJpaTransactionInterceptor`를 실제 method pointcut과 연결하는 Advisor도 없다. +- `JpaPlatformAutoConfigurationTest.java:21-25,108-119`는 factory와 endpoint를 직접 생성하므로 Spring + bean graph를 검증하지 않는다. +- 기존 `RuntimeSafetyConfig`가 OSIV/schema validator를 별도로 조립하므로 모든 JPA safety가 사라진 것은 + 아니다. 문제는 새 platform의 retry/evidence/provider/report/endpoint가 inert하다는 점이다. + +**실패 모드** + +capability report는 transaction retry, completion evidence, observability를 Stable로 나열하지만 default +application context에는 executor/coordinator/evidence-aware manager/advisor/endpoint가 없다. 개발자는 +annotation을 붙여도 실제 retry되지 않는 method를 운영에 배포할 수 있다. + +**구현 결정: Composition Root + explicit auto-configuration** + +1. application contract를 JPA-007대로 먼저 결정한다. outbound annotation을 제거한다면 불필요한 AOP를 + 새로 wiring하지 않는다. +2. 남길 Stable component는 실제 `@AutoConfiguration(proxyBeanMethods=false)` class로 만들고 imports에 + 등록한다. +3. `@ConditionalOnBean(DataSource/EntityManagerFactory/PlatformTransactionManager)`, master property, + `@ConditionalOnMissingBean`을 각 bean 의미에 맞게 적용한다. +4. evidence-aware manager를 기본 manager로 채택할 경우 manager replacement/back-off 규칙을 명시하고 + JTA/custom manager를 침범하지 않는다. +5. endpoint와 report supplier를 bean으로 등록하고 management exposure는 기존 actuator policy를 따른다. +6. `JpaSafetySettings.enabled`는 primitive binding absent 시 false인데 `defaults()`는 true라고 말하는 + 모순을 없앤다. property default를 명시하거나 constructor defaulting을 일관되게 한다. + +**필수 테스트** + +- `ApplicationContextRunner`: disabled, missing DataSource, custom TM, default JPA, invalid property 조합. +- 실제 `CaSkeletonApplication` context에서 Stable bean/advisor/endpoint exact inventory. +- annotation을 유지할 때만 proxy fixture가 raw serialization failure를 실제 새 transaction으로 retry. + +### JPA-007 — application transaction SSOT와 JPA 전용 transaction API가 병렬로 존재한다 + +**근거** + +- application에는 `TransactionPort`, `PolicyTransactionPort`, `TransactionRequest`, + `TransactionResult`가 있고 다수 use case가 이를 사용한다. +- live adapter인 `SpringTransactionPort.java:31-32`는 `@Component`이며 `PolicyTransactionPort`를 + 구현한다. +- 새 `JpaTransactionExecutor`, `SpringJpaTransactionExecutor`, `FullTransactionRetryCoordinator`는 별도 + outcome/retry 모델이다. +- coordinator는 constructor의 고정 `JpaRetryPolicy`와 호출별 임의 `TransactionProfile`을 함께 사용해 + profile A eligibility/backoff와 profile B attempt budget을 섞을 수 있다. +- `DefaultJpaRetryPolicy`는 failure context의 `retryable=false`를 먼저 거부하지 않아 translator가 terminal로 + 표시한 category를 profile allowlist가 다시 활성화할 여지가 있다. +- outbound leaf의 `RetryableJpaTransaction.java:9-29`는 “public application-service method”가 사용한다고 + 문서화한다. application이 이를 import하면 의존 방향을 역행한다. +- `IrreversibleSideEffectContext`, `TransactionCompletionResolver`, 일부 constraint/reconciliation type도 + application/domain이 호출해야 한다고 설명하지만 outbound adapter에 있다. + +**실패 모드** + +Clean Architecture를 지키면 새 JPA annotation과 ambient API를 application에서 사용할 수 없어 dead +surface가 된다. 반대로 사용하면 application-core → outbound adapter 역의존으로 HARD-STOP이다. 두 +transaction engine은 isolation/timeout/retry/translation/completion-unknown 결과를 다르게 발전시킨다. + +**구현 결정: application Port + adapter Facade 하나** + +1. 템플릿의 canonical boundary를 `PolicyTransactionPort.inTransaction(TransactionRequest, Supplier)`로 + 고정한다. +2. `TransactionPolicyId`가 propagation/isolation/readOnly/timeout/replay-safe를 표현하게 하고 JPA 전용 + annotation은 제거한다. 선언형 API가 꼭 필요하면 annotation은 inbound/composition concern으로 두되 + application-core가 outbound type을 import하지 않게 한다. +3. reconciliation key/result/resolver는 application-core의 outbound port/value로 이동한다. +4. JPA executor, retry coordinator, evidence manager, translator는 `SpringTransactionPort` 내부 facade 구현 + 세부로 축소한다. +5. legacy와 new engine의 policy/result parity contract를 만든 뒤 호출을 한쪽으로 옮기고 중복 코드를 + 삭제한다. +6. 통합 전 임시로 새 engine을 유지한다면 policy는 호출 profile에서 매번 resolve하고 + `failure.context().retryable()==false`를 category allowlist보다 우선한다. + +**필수 테스트** + +- application unit에서 fake `PolicyTransactionPort`로 committed, determinate rollback, indeterminate, + post-commit failure 분기. +- 모든 `TransactionPolicyId` → Spring definition mapping contract. +- application/sample production code의 outbound persistence import 0개 ArchUnit. + +### JPA-008 — 새 retry coordinator는 raw PostgreSQL/optimistic failure를 retry하지 못한다 + +**근거** + +- `FullTransactionRetryCoordinator.java:79-99`는 `JpaPersistenceException`만 catch한다. +- `SpringJpaTransactionExecutor.java:58-70`은 `TransactionTemplate`을 실행하지만 raw failure translator를 + 호출하지 않는다. +- `PostgreSqlExceptionTranslator`와 `OptimisticConflictTranslator`는 production retry path의 caller가 + 검색되지 않는다. +- `RetryableJpaTransactionInterceptorTest.java:63-65`는 이미 번역된 + `SerializationFailureException`을 직접 던져 real provider translation을 우회한다. +- completion-unknown commit path만 evidence-aware manager가 별도로 번역한다. + +**실패 모드** + +실제 Hibernate/Spring이 던지는 `OptimisticLockException`, `OptimisticLockingFailureException`, raw +serialization/deadlock `DataAccessException`은 coordinator의 catch에 걸리지 않아 retry 없이 밖으로 +나간다. unit fixture가 녹색이어도 운영 contention retry는 동작하지 않는다. + +**구현 결정: Chain of Responsibility failure translation** + +1. executor attempt 경계에서 단 하나의 `PersistenceFailureTranslatorChain`을 호출한다. +2. 순서는 completion-unknown 보존 → optimistic conflict → vendor SQLSTATE → known constraint → unknown + passthrough로 고정한다. +3. translator는 operation, attempt, elapsed, trace/reconciliation metadata를 한 context factory에서 + 받는다. 각 translator가 서로 다른 attempt/context를 만들지 않게 한다. +4. domain/application exception과 programming error는 persistence failure로 오분류하지 않고 그대로 + 보낸다. +5. JPA-007 통합 후 legacy `PersistenceExceptionTranslator`와 새 translator의 SQLSTATE catalog도 하나로 + 합친다. + +**필수 테스트** + +- real PG 40001/40P01가 번역되고 각 attempt가 새 transaction/EntityManager를 사용하는지 검증. +- optimistic version conflict가 whole-use-case replay되며 domain rule이 다시 실행되는지 검증. +- 23505는 retry되지 않고 registered constraint만 노출. +- commit 08xxx/40003은 body replay 0회이며 reconciliation key를 보존. +- non-persistence application exception은 identity 그대로 전파. + +### JPA-009 — transaction evidence frame의 pop 소유자가 두 곳이라 outer frame을 지운다 + +**근거** + +- `TransactionEvidenceContext.java:24-25`는 `ThreadLocal.withInitial(ArrayDeque::new)` stack을 사용한다. +- `EvidenceAwareJpaTransactionManager.java:50-60,64-70`은 commit/rollback finally에서 frame을 pop한다. +- `SpringJpaTransactionExecutor.java:63-69`도 finally에서 현재 frame의 operation과 attempt가 같으면 pop한다. +- default execute는 attempt 1이다. nested `REQUIRES_NEW`의 outer/inner가 같은 operation/attempt를 가질 수 + 있다. +- 현재 nested test(`EvidenceAwareJpaTransactionManagerTest.java:74-87`)는 context를 수동으로 한 번만 + clear하며 manager+executor 조합을 검증하지 않는다. +- 마지막 manager clear 뒤 executor가 `current()`를 호출하면 `withInitial`이 빈 deque를 다시 등록한다. + 이는 `TransactionEvidenceContext.java:67-71`의 제거 의도와 다르다. + +**실패 모드** + +inner manager가 inner frame을 지운 다음 executor finally가 top의 outer frame을 같은 +operation/attempt로 오인해 한 번 더 지운다. 이후 outer commit이 08xxx로 실패하면 +`CommitFailureClassifier.java:83-100`은 `UnknownOperation`, attempt 1, reconciliation key 없음으로 +보고한다. + +**구현 결정: identity-bound Scope token** + +1. `begin()`은 opaque identity를 가진 `TransactionEvidenceScope implements AutoCloseable`을 반환한다. +2. scope close는 자기 token이 stack top일 때만 정확히 한 frame을 pop한다. out-of-order close는 + fail-closed diagnostic을 남기고 다른 frame을 지우지 않는다. +3. lifecycle ownership을 executor 하나로 통일한다. manager는 phase만 mark하고 pop하지 않는다. +4. read-only 조회는 `FRAMES.get()`으로 값을 생성하지 않는 nullable ThreadLocal 접근을 사용한다. +5. test 전용 `hasRawThreadLocalValue()` 또는 injectable context storage로 종료 후 잔존 여부를 검증한다. + +**필수 테스트** + +- same operation/attempt outer + `REQUIRES_NEW` inner success 후 outer frame/key 유지. +- inner completion 후 outer 08xxx commit failure가 outer operation/key/attempt를 보존. +- begin/begin/close outer 순서 오류가 inner를 삭제하지 않음. +- 정상/rollback/begin failure/mandatory admission failure 뒤 raw ThreadLocal value 없음. + +### JPA-010 — migration release lane은 실제 repository migration tree를 upgrade하지 않는다 + +**근거** + +- `FlywayUpgradeContractTest.java:48-108`은 temp directory에 합성 V1/V2 SQL을 만든다. +- `PostgreSqlMigrationUpgradeContractTest.java:47-53`은 세 scenario 이름만 확인한다. +- 같은 test `:98-108`의 clean validation은 존재하지 않는 location과 missing migration 허용을 사용한다. +- `MigrationScenario.java:39-57`의 previous/oldest setup SQL과 invariant는 비어 있다. +- 실제 restore→migrate→validate→invariant를 수행하는 `MigrationContractRunner.java:44-58`은 production/test + caller가 없다. +- 이 task는 release gate에 포함된다(JPA `build.gradle:267-270,301-310`). + +**실패 모드** + +`db/migration/postgresql` 또는 optional stream에 SQL 문법 오류, checksum drift, N-1 upgrade 파손, +data loss, entity/schema mismatch가 생겨도 synthetic policy test는 녹색일 수 있다. + +**구현 결정: Snapshot Migration Contract** + +1. 실제 release artifact에서 N-1/oldest schema snapshot과 seed data를 만든다. 빈 문자열 setup은 + 허용하지 않는다. +2. `MigrationContractRunner`를 각 real location에 연결해 restore → migrate → Flyway validate → seed + invariant → Hibernate validate를 실행한다. +3. base/fileserver/notification 등 독립 stream마다 history table, prerequisite, snapshot owner를 둔다. +4. migration file을 수정한 mutation, missing location, ignored migration, seed deletion이 test를 실패하게 + 한다. +5. synthetic Flyway-policy test는 unit lane에 남기되 release upgrade evidence로 이름 붙이지 않는다. + +**필수 테스트** + +- empty install, N-1, oldest-supported, interrupted recovery, rolling window, checksum mutation. +- 모든 stream에서 preexisting row count/semantic invariant와 `ddl-auto=validate` context boot. + +### JPA-011 — keyset ordering과 predicate가 하나의 계약을 공유하지 않는다 + +**근거** + +- `KeysetPredicateBuilder.java:38-39`는 전체 `List>`에 하나의 generic `T`와 하나의 + `SortDirection`을 강제한다. +- typical cursor `(Instant createdAt, UUID id)`는 서로 다른 `T`라 한 list로 compile할 수 없다. +- `KeysetPredicateBuilder.java:55-70`은 모든 strict comparison에 동일 direction을 적용한다. +- `SafeSortMapper.java:43-49,63-65`는 요청 term별 direction을 허용하고 tie-breaker가 없으면 무조건 + DESC로 붙인다. +- 따라서 `createdAt ASC, id DESC`는 mapper가 만들 수 있지만 predicate builder는 표현할 수 없다. +- 현재 production/test에서 `KeysetPredicateBuilder` caller가 0개라 즉시 장애보다는 사용 전 차단해야 할 + 깨진 public seam이다. + +**실패 모드** + +caller가 mixed order를 ASC 하나로 builder에 넘기면 올바른 조건 +`created_at > t OR (created_at = t AND id < x)` 대신 id에도 `>`가 적용된다. page 경계에서 row가 +skip/duplicate된다. null ordering과 cursor sort context까지 다르면 같은 token을 다른 query에 재사용할 +위험도 생긴다. + +**구현 결정: Query Object + Specification 한 개** + +1. `KeysetOrder`를 sort의 SSOT로 만든다. 각 term은 path/expression resolver, typed cursor extractor, + direction, null policy, unique 여부를 갖는다. +2. heterogeneous term은 `List>`와 private generic-capture helper로 안전하게 + 비교한다. raw `Comparable` cast를 public API에 노출하지 않는다. +3. 같은 `KeysetOrder`가 Spring `Sort`, Criteria `Order`, lexicographic `Predicate`, cursor payload/order + fingerprint를 모두 생성한다. +4. tie-breaker direction을 임의 DESC로 붙이지 않는다. registry가 endpoint별 total order 전체를 + 선언하고 request는 허용된 변형만 선택한다. +5. null 허용 column은 explicit `NULLS FIRST/LAST` 의미를 predicate와 order 양쪽에 동일하게 구현한다. + +**필수 테스트** + +- `(Instant ASC, UUID DESC)`와 `(String DESC, Long ASC, UUID DESC)` criteria integration. +- page 사이 insert/delete에도 허용된 consistency semantics 안에서 duplicate/skip 0건. +- sort fingerprint가 다른 endpoint/order에 cursor 재사용 시 거부. +- unique tie-breaker 누락, null boundary, backward scan, first/last row. + +### JPA-012 — 전체 component/entity/repository scan이 optional과 candidate 상태를 무시한다 + +**근거** + +- `CaSkeletonApplication.java:29-35`는 `dev.caskeleton.adapter` 전체를 component-scan한다. +- `PersistenceJpaConfig.java:12-14`는 persistence root 전체의 entity/repository를 scan한다. +- README가 implemented-candidate로 분류한 다음 구현은 unconditional `@Repository`다. + - `PostgreSqlSameStoreInboxAdapter.java:39-40` + - `PostgreSqlImmutableOutboxAppendAdapter.java:34-35` + - `PostgreSqlPollingDeliveryAdapter.java:31-32` +- 반대로 `PostgreSqlOwnerSafeIdempotencyStore.java:45-55`는 provider selection 전 자동 bean이 되지 않게 + stereotype을 제거했다. +- fileserver/notification bean property gate는 entity metadata와 Spring Data repository scan을 막지 + 않는다. + +**실패 모드** + +H2/local이나 capability-off runtime에도 PostgreSQL candidate port bean과 optional entity/repository가 +생긴다. 호출하면 PG 전용 SQL 또는 적용되지 않은 capability table에서 실패할 수 있고, `ddl-auto=validate` +에서는 호출 전 boot부터 실패할 수 있다. 같은 port의 legacy/candidate 구현이 동시에 bean이 되면 +selection ambiguity도 생긴다. + +**구현 결정: explicit Capability Configuration + bean inventory** + +1. candidate 세 adapter에서 stereotype을 제거한다. candidate integration test는 직접 생성한다. +2. `PersistenceJpaConfig`를 최소 세 marker configuration으로 나눈다. + - core/base persistence + - fileserver capability + - notification capability +3. vendor-specific candidate는 `PostgreSql...Configuration`이 provider + capability + schema ACTIVE를 확인한 + 뒤에만 등록한다. +4. 장기적으로 adapter root를 broad component scan에서 제외하고 app-bootstrap이 명시적 configuration + facade만 import한다. +5. runtime profile별 bean inventory snapshot을 둔다: H2, PG base, fileserver on, notification on, + candidate qualification. + +**필수 테스트** + +- H2/base에서 PG candidate port bean 0개. +- capability off에서 해당 entity/repository/store 0개. +- legacy/candidate provider별 동일 port 구현 정확히 1개. +- schema inactive인데 worker/store만 생성되는 mutation이 context startup에서 실패. + +### JPA-013 — signed cursor가 크기 제한 없이 decode/MAC/JSON parsing을 수행한다 + +**근거** + +- `SignedJsonCursorCodec.java:53-82`는 encoded token/payload 길이 상한을 검사하지 않는다. +- `substring`, Base64 decode, MAC 입력 byte 배열, JSON string을 attacker가 보낸 크기만큼 할당한다. +- HMAC-SHA256 presented MAC가 32 bytes인지 별도로 확인하지 않는다. +- `SignedJsonCursorCodecTest.java:12`는 cursor를 “bounded”라고 설명하지만 `:24-77`은 tamper, key, + version과 page size만 검사한다. + +**실패 모드** + +공개 paging endpoint에 매우 큰 token을 반복 전송하면 signature 검증 전에 큰 String/byte array를 만들고 +payload decoder까지 호출해 CPU/heap pressure를 일으킨다. page size bound는 cursor token bound를 +대체하지 않는다. + +**구현 결정: input Boundary Object** + +1. `MAX_ENCODED_LENGTH`, `MAX_PAYLOAD_BYTES`를 endpoint/cursor contract에서 정하고 decode 첫 줄에서 + encoded char length를 검사한다. +2. Base64 expansion 계산으로 payload segment가 byte bound를 넘는지 decode 전에 거부한다. +3. presented MAC decoded length가 정확히 32 bytes가 아니면 constant-time comparison 전에 거부한다. +4. encode 결과도 같은 bound를 넘으면 application programming/configuration error로 실패시킨다. +5. payload에는 schema version뿐 아니라 query/sort fingerprint를 포함해 다른 ordering에 재사용되지 않게 + 한다. + +**필수 테스트** + +- max-1/max/max+1 encoded와 payload boundary. +- oversized token에서 payload decoder invocation 0회. +- huge malformed Base64, extra separator, wrong MAC length, Unicode byte/char 차이. + +### JPA-014 — PostgreSQL contract support가 Hikari pool과 container를 소유하지 못한다 + +**근거** + +- `JpaPlatformContractSupport.java:14-19`의 주석은 JVM에서 PostgreSQL을 공유한다고 설명한다. +- 실제 `start(version)`은 호출마다 새 container를 생성/시작한다(`:44-48`). +- `dataSource()`도 호출마다 새 `HikariDataSource`를 만든다(`:63-70`). +- `connection()`은 새 pool에서 connection만 반환해 pool owner reference를 잃는다(`:90-93`). +- `close()`는 container만 stop한다(`:137-140`). +- parameterless start caller는 28 class, support `dataSource()/connection()` call은 76곳이다. +- `PostgreSqlReadinessSupport.java:22-28,61-70,119-125,155-159`에는 pool을 보관하고 닫는 더 나은 + 소유 모델이 이미 있다. + +**실패 모드** + +connection close는 connection을 unreachable pool로 반환할 뿐 pool housekeeping thread와 physical +connection을 닫지 않는다. suite가 진행될수록 `max_connections`, thread, startup time을 소모하고 90분 +CI timeout과 flaky container failure 가능성을 키운다. + +**구현 결정: JUnit CloseableResource + schema isolation** + +1. support가 기본 Hikari pool 하나와 role-specific pools registry를 소유한다. +2. `close()`는 모든 pool을 먼저 닫고 container를 마지막에 stop한다. +3. 미사용 `PostgreSqlContractExtension.java:24-52`을 JUnit root-store `CloseableResource`로 연결해 major별 + container를 job/JVM 안에서 공유한다. +4. 공유할 때 test class/scenario마다 unique database 또는 schema를 생성하고 migration/history/search_path를 + 격리한다. +5. pool name에 bounded test id를 넣고 종료 후 active pool/thread 0개를 검증한다. + +**필수 테스트** + +- dataSource/connection 반복 호출이 동일 owned pool을 사용. +- exception/cancel/failed migration 뒤에도 pool→container close order와 active connection 0. +- parallel test에서 schema/history 오염이 없음. + +### JPA-015 — package를 module처럼 쓴다고 하지만 DAG와 cycle을 강제하지 않는다 + +**근거** + +- `JpaModuleBoundaryTest.java:31-46`의 downstream catalog는 13개 package만 나열한다. +- 실제 top-level 중 `audit`, `config`, `failure`, `fileserver`, `h2`, `idempotency`, `lock`, + `notification`, `outbox` 9개가 빠져 있다. +- rule은 api 역의존, testkit, stable→experimental, 일부 opt-in sibling만 검사한다(`:84-149`). +- 현재 `transaction → postgresql` (`SpringTransactionPort.java:6,207-210`)과 + `postgresql → transaction` (`PostgreSqlPersistenceConfig.java:8,44-47`) cycle도 통과한다. +- `springdata → hibernate`, `observation → transaction` 같은 문서 map 밖 edge도 있다. +- `docs/jpa/repository-adaptation.md:38-39`는 이 test가 package mapping을 재현한다고 주장해 실행보다 + 강한 보장을 한다. + +**실패 모드** + +새 package/edge가 catalog에 등록되지 않아도 test가 녹색이다. vendor-neutral transaction이 PostgreSQL +구현을 직접 만들고 PostgreSQL config가 transaction SPI를 다시 제공하므로 provider 교체나 향후 physical +module 추출 시 양쪽을 동시에 수정해야 한다. + +**구현 결정: closed Package Catalog + Dependency Rule** + +1. production root의 direct child package를 자동 발견하고 명시적 catalog와 exact equality를 비교한다. +2. catalog에 package 상태(Stable/Advanced/Candidate/Experimental), exported 여부, allowed dependency를 둔다. +3. 모든 observed production edge가 allowed map 안에 있는지 검사하고 top-level cycle rule을 추가한다. +4. `SpringTransactionPort`의 PostgreSQL mapping 직접 생성을 제거하고 composition이 + `Collection`을 주입한다. +5. cross-cutting SPI(`RetryEventListener`, timeout configurer, query-name context)는 dependency 방향상 더 + 안쪽인 api/spi로 이동한다. + +**필수 테스트** + +- unregistered package, forbidden `transaction → postgresql`, A↔B cycle mutation fixture. +- source import가 0개인 빈 package test false-pass 방지. +- `JpaModuleBoundaryTest` focused rerun에서 현재 illegal fixture가 실제 실패하는지 검증. + +### JPA-016 — reusable ArchUnit rule은 fixture만 검사하고 production graph를 검사하지 않는다 + +**근거** + +- `JpaArchitectureRules.java:33-94`는 controller entity exposure, domain Hibernate dependency, + generic repository 등 네 rule을 제공한다. +- `JpaArchitectureRulesTest.java:25-90`은 test fixture class만 import한다. +- `domainDoesNotDependOnHibernate()`와 `noGenericRepository()`는 그 fixture test에서도 호출되지 않는다. +- repository 전체 검색에서 rule pack의 production graph caller가 없다. +- root `CleanArchitectureTest.java:1192-1210`의 기존 rule은 raw return type/과거 package naming 중심이라 + `List` 같은 generic exposure와 현재 entity package를 놓칠 수 있다. + +**실패 모드** + +rule 구현 자체의 unit test는 녹색이지만 실제 controller/application/domain class에 rule이 한 번도 +적용되지 않는다. 테스트 이름만 보고 persistence entity leak이나 domain Hibernate import가 자동 차단된다고 +오해할 수 있다. + +**구현 결정: Consumer-side production architecture suite** + +1. testkit outgoing test configuration을 만들고 app-bootstrap architecture test가 소비한다. +2. `JpaProductionArchitectureTest`가 등록된 runtime production leaves를 import하고 네 rule을 모두 실행한다. +3. imported class count, entity count, controller count가 nonzero인지 먼저 assert해 empty-import false-pass를 + 막는다. +4. generic return/component type을 재귀적으로 검사한다. +5. fixture test는 rule library 자체의 negative test로 유지하고 production suite와 역할을 구분한다. + +**필수 테스트** + +- `List` controller fixture와 application의 Hibernate import fixture가 실패. +- `GenericRepository` 이름뿐 아니라 tenant entity에 broad CRUD를 노출하는 repository policy fixture. +- runtime membership 변경 시 import set이 자동 갱신되고 0 class면 실패. + +### JPA-017 — Markdown regex와 static list가 release/support SSOT를 흉내 낸다 + +**근거** + +- `JpaReleaseManifest.java:24-25,50-71`은 문서 전체의 `PostgreSQL NN`과 `` `name` | gate``를 + 정규식으로 수집한다. support-level column이나 table boundary를 해석하지 않는다. +- 따라서 Experimental PG19와 prose에 우연히 언급된 major도 versions에 들어간다. +- `JpaReleaseManifestTest.java:51-62`는 Stable exact set이 아니라 `.contains(16,17,18)`만 검사한다. +- `JpaReleaseGate.java:35-43`에 gate static list가 또 있고 실제 Gradle task/selector 존재를 연결하지 + 않는다. +- workflow에도 Stable version이 별도로 hard-code돼 있다. +- `support-matrix.md:27-33`은 Hibernate 7.4를 Stable baseline이라 부르지만 실제 Boot BOM은 + 7.1.8.Final이고 fetch-pagination gate도 실제 7.1에서 돈다. + +**실패 모드** + +PG17을 Experimental로 낮추거나 gate task dependency를 삭제해도 이름이 prose 어딘가에 남으면 test가 +통과할 수 있다. Hibernate 7.4에서 한 번도 실행하지 않은 gate가 `hibernate-7.4-*` 증거로 표시된다. + +**구현 결정: typed Release Manifest SSOT** + +1. JSON/YAML registry에 database major, support level, image/digest source, required tasks, JUnit selector, + blocking 여부를 구조화한다. +2. Gradle task와 workflow matrix는 registry에서 생성/검증한다. Markdown support matrix는 같은 registry를 + 렌더링한다. +3. Stable set은 exact `[16,17,18]`, Experimental `[19]`로 검증하고 duplicate/unknown level/task를 + 거부한다. +4. Hibernate는 실제 7.1.x를 Stable tested baseline으로 기록하고 7.4는 target/compatibility lane으로 + 분리하거나, 실제 7.4 dependency로 full lane을 실행한 뒤에만 Stable로 바꾼다. +5. gate는 이름 존재가 아니라 Gradle dependency graph와 fresh JUnit artifact까지 확인한다. + +**필수 테스트** + +- Stable→Experimental mutation, prose-only major, duplicate gate, missing task dependency가 실패. +- generated Markdown/workflow drift check. +- resolved Hibernate version과 evidence manifest version exact equality. + +### JPA-018 — experimental flag와 compatibility workflow가 실제 대상 runtime을 격리·실행하지 않는다 + +**근거** + +- `docs/jpa/repository-adaptation.md:154-156`은 experimental 기능이 flag 뒤에 있고 Stable composition에 + 들어오지 않는다고 선언한다. +- `ExperimentalFeatureGate.java:6-32`도 classpath 존재가 consent가 아니라고 설명한다. +- 그러나 `ConsistencyAwareDataSourceRouter`, `RlsTenantSessionBinder`, + `SchemaTenantMigrationOrchestrator` public entry는 gate를 받거나 호출하지 않는다. +- `experimental/**`은 별도 source set/variant가 아니라 main artifact에 포함된다. +- Hibernate 8/JPA4/PG19 workflows는 각각 current 7.x policy unit, lane-definition, boolean evidence test만 + 실행하고 실제 target dependency/container를 실행하지 않는다. +- target-named tests는 오히려 현재 classpath에 JPA4/Hibernate8이 없음을 assert하고 PG19 container를 + 시작하지 않는다. + +**실패 모드** + +direct construction/import 한 번으로 experimental flag를 우회할 수 있다. workflow green은 “미래 target에 +호환된다”가 아니라 “현재 runtime에 target이 없다”는 사실만 증명한다. tenant/RLS/replica 같은 안전성 +영향 코드가 Stable artifact에 상시 포함된다. + +**구현 결정: Gradle Feature Variant + executable compatibility lane** + +1. `jpaExperimental` source set 또는 feature variant를 만들고 `experimental/**`을 main output에서 + 제외한다. +2. app-bootstrap은 variant dependency와 typed settings가 모두 있을 때만 feature configuration을 import한다. +3. 당장 source 분리가 어렵다면 public constructor를 package-private로 줄이고 모든 factory가 exact + `ExperimentalFeatureGate` token을 요구한다. +4. JPA4/Hibernate8은 별도 configuration으로 실제 dependency를 resolve/compile/test한다. PG19는 실제 + experimental image로 contract/migration/security/failure lane을 실행한다. +5. target이 아직 resolve/execute 불가능하면 workflow artifact를 `NOT_EXECUTABLE`로 남긴다. green + compatibility로 표현하지 않는다. + +**필수 테스트** + +- default main JAR에 experimental class 0개 또는 uncomposed public constructor 0개. +- flag absent/false에서 bean 0개, true+variant에서 exact bean inventory. +- 실제 target dependency version/container version이 artifact에 기록됨. + +### JPA-019 — performance flag와 release/R2 evidence가 실제 측정·promotion을 강제하지 않는다 + +**근거** + +- JPA `build.gradle:284-310`은 `performance.assertions.enabled` 기본값 false인 performance task를 release + gate에 포함한다. +- nightly도 명시적으로 false다(`.github/workflows/jpa-nightly.yml:122-130`). +- `PoolPressureContractTest.java:24-50`은 false일 때 false임을 확인할 뿐 true branch에 추가 threshold + assertion이 없다. +- 별도 Hikari saturation/`REQUIRES_NEW` tests는 real PG behavior contract로서 유효하다. 문제는 + machine-bound certification 표현과 flag다. +- root `jpaReleaseGate`는 platform lanes/architecture를 의존하지만 readiness/candidate/R2 evidence producer와 + 동일 SHA artifact를 요구하지 않는다. +- R2 workflow는 수동이고 primary list도 기존 base cards에 머문다. + +**실패 모드** + +release가 “performance certification”과 R2 candidate evidence를 통과한 것처럼 보이지만 실제 latency/ +throughput threshold는 한 번도 assert되지 않고, retained evidence가 없거나 다른 SHA여도 promotion된다. + +**구현 결정: honesty-first lane split** + +1. 목적이 pool behavior contract라면 flag와 “certifies/reports machine bounds” 표현을 제거하고 + `jpaPlatformPoolContractTest`로 이름을 바꾼다. +2. 성능 gate가 필요하면 dedicated runner에서 warmup/sample count, acquire latency, pending, throughput, + variance, threshold를 정의하고 JSON/JUnit artifact를 생성한다. release job은 assertions=true를 강제한다. +3. release manifest는 required readiness/R2 artifacts의 commit SHA, task, content hash, producer version을 + 검증한다. +4. 비용이 큰 R2는 모든 release에서 재실행하거나, 관련 source/migration hash가 변하지 않았을 때만 동일 + SHA/ancestor evidence reuse를 허용하는 명시적 정책을 둔다. + +**필수 테스트** + +- assertions=true mutation에서 threshold 초과가 실제 실패. +- missing/stale/wrong-SHA R2 artifact가 promotion을 실패. +- behavior-only 선택 시 release 문서에 수치 성능 보장 문구가 남지 않음. + +### JPA-020 — owner-safe idempotency V2는 구현됐지만 선택할 수 없고 한 클래스가 너무 많은 책임을 가진다 + +**근거** + +- `PostgreSqlOwnerSafeIdempotencyStore.java:45-55`는 stereotype이 없고 selector에 이 store를 고르는 값도 + 없어 production composition이 없다고 스스로 설명한다. +- integration test는 직접 생성한다. +- 이 class는 890 lines로 claim/start/renew/complete/fail/release/inspect, capability guard, SQL constants, + row mapping, hashing, validation, transaction precondition을 모두 소유한다. +- 현재 public `IdempotencyStorePortV2` 구현이지만 runtime에서는 사용할 수 없어 implemented-candidate다. +- mutation guard는 현재 thread에 read-write transaction이 있다는 것만 확인하고 이 store의 DataSource + resource가 bind됐는지는 확인하지 않는다. outbox V2의 exact `hasResource(dataSource)` guard보다 약하다. +- row에서 codec/policy/replay metadata를 읽지만 replay 결과가 이를 충분히 검증하지 않고, duplicate + transition digest도 TTL/disposition/response digest 같은 semantic argument를 모두 포함하지 않는다. + +**실패 모드** + +capability/report/docs가 존재를 기능으로 오인할 수 있고, provider selector를 성급히 추가하면 거대한 +class의 transaction/capability/mapping seam을 한 번에 production으로 노출한다. SQL transition 하나를 +수정할 때 unrelated hashing/row mapping과 충돌할 가능성이 높다. + +**구현 결정: provider Facade + package-private Gateways** + +1. application의 canonical V2 port와 executor 계약을 먼저 하나로 고정한다. +2. provider enum/selector에 owner-safe PostgreSQL 값을 추가하되 migration ACTIVE와 vendor=PostgreSQL을 + 동시에 요구한다. +3. public facade는 port orchestration과 exact DataSource transaction precondition만 소유한다. +4. 다음 package-private collaborator로 분해한다. + - `IdempotencyCapabilityGuard` + - `IdempotencyClaimGateway` + - `IdempotencyTransitionGateway` + - `IdempotencyRowMapper` + - `IdempotencyDigestPolicy` +5. collaborator를 모두 Spring bean으로 만들 필요는 없다. facade constructor에서 명시적으로 조립해 public + bean surface를 늘리지 않는다. +6. legacy JDBC/Redis/owner-safe V2에 동일 conformance contract를 적용하고 provider별 지원 기능 차이를 + manifest에 기록한다. +7. replay 결과는 codec id/version/policy revision과 DB authoritative time을 검증하고 operation digest는 + 모든 semantic input을 포함한다. 같은 operation id와 다른 input은 `RESULT_CONFLICT`로 분류한다. + +**필수 테스트** + +- disabled/jdbc/redis/owner-safe 각각 store/executor bean exact count. +- full scanned context에서 claim→start→complete→replay와 owner/fence loss. +- capability inactive, wrong vendor, transaction absent에서 work 실행 전 fail-closed. +- wrong-DataSource transaction, codec mismatch, expired inspect, same operation/different arguments. + +### JPA-021 — notification schema가 tenant 관계와 queue query를 DB 불변식으로 보강하지 않는다 + +**근거** + +- V1의 `notification_recipient_delivery.notification_id`는 request id만 FK로 잡고 tenant 일치를 보장하지 + 않는다(`V1...sql:11-34,40-65`). +- `notification_delivery_attempt.contact_point_id`는 NOT NULL이지만 FK가 없다(`:80-110`). contact point + table은 V2에서 만들어진다. +- expired-dispatch query는 `delivery_state='DISPATCHING' AND lease_until ...`을 사용하지만 V1의 queue + indexes(`:67-78`)에는 이 조건을 지원하는 partial index가 없다. + +**실패 모드** + +DB 자체는 tenant B recipient가 tenant A request를 참조하는 행을 허용한다. 존재하지 않거나 삭제된 contact +point를 attempt가 참조할 수 있다. expired lease sweep은 데이터가 커질수록 불필요한 row/index scan을 할 +수 있다. 마지막 성능 영향은 representative cardinality로 아직 측정하지 않았으므로 확정 장애가 아니라 +검증해야 할 위험이다. + +**구현 결정: V4 relational invariant + measured index** + +1. request에 `(id, tenant_id)` unique key를 두고 recipient `(notification_id, tenant_id)` composite FK를 + 추가한다. +2. contact point retention이 attempt보다 길다면 V4에서 FK를 추가한다. 익명화/삭제 정책 때문에 FK가 + 불가능하면 immutable contact snapshot과 cleanup invariant를 명시한다. +3. `WHERE delivery_state='DISPATCHING'` partial index를 `(lease_until, id)`로 검토한다. +4. index는 대표 row distribution의 `EXPLAIN (ANALYZE, BUFFERS)`와 write amplification을 측정한 뒤 + 채택한다. +5. existing orphan/cross-tenant row를 사전 query로 탐지하고 0건일 때 constraint를 validate한다. + +**필수 테스트** + +- cross-tenant FK와 nonexistent contact insertion 거부. +- online `NOT VALID`→backfill/audit→`VALIDATE CONSTRAINT` upgrade. +- due/expired cardinality별 plan shape와 bounded planner-estimate error. + +### JPA-022 — `audit`와 `auditing`이 다른 column/actor/lifecycle 계약으로 공존한다 + +**근거** + +- canonical 문서와 sample은 manual `audit/AuditableEntity`를 사용한다. +- `AuditableEntity.java:13-47`은 `updated_at/updated_by`, actor length 256, 명시적 + `initializeAudit/applyModification`을 사용한다. +- `auditing/AuditMetadata.java:29-46`은 Spring Data annotation과 + `modified_at/modified_by`, actor length 64를 사용한다. +- 새 `JpaAuditingConfiguration`은 Spring `@Configuration`이 아니고 production consumer도 검색되지 않는다. + +**실패 모드** + +새 entity 작성자가 두 package 중 하나를 임의로 고르면 table마다 column name/length/capture lifecycle이 +갈린다. 둘을 동시에 적용하면 같은 의미를 두 번 stamp하거나 migration이 entity마다 달라진다. + +**구현 결정: one canonical technical audit model** + +1. 지금은 `auditing`을 candidate로 명시하고 Stable capability report에서 제외한다. +2. 승격 시 schema 호환을 우선하면 `AuditMetadata`를 `updated_*`, length 256에 맞추고 existing entity를 + 단계적으로 embeddable로 전환한다. +3. 새 `modified_*` schema를 선택하면 forward migration, sample 전환, + `AuditContextPort → AuditorAware`, `Clock → DateTimeProvider` bridge를 원자적으로 적용한다. +4. bulk/native update는 어느 mechanism도 자동 stamp하지 않으므로 explicit audit update policy를 둔다. +5. entity가 audit mechanism을 exactly one 또는 zero만 쓰게 ArchUnit rule을 추가한다. + +**필수 테스트** + +- fixed Clock/actor insert/update stamp, immutable create columns. +- column snapshot과 old→new migration validate. +- bulk/native update audit behavior와 exactly-one architecture rule. + +### JPA-023 — 318개 public type과 app-bootstrap의 구현 import가 리팩터링 경계를 고정한다 + +**근거** + +- production 324 Java file 중 318개에 public top-level type 선언이 있다. +- 명시적 `api` package 외 entity/repository/mapper/adapter 대부분도 public이다. +- root `package-info.java`는 짧은 anchor뿐이고 package별 Stable/Advanced/Candidate/Experimental/export 상태가 + 없다. +- `NotificationPlatformPersistenceConfig.java:3-24`는 app-bootstrap에서 implementation/repository 22개를 + 직접 import한다. +- 같은 leaf의 가장 큰 class는 owner-safe idempotency 890 lines, same-store inbox 588, polling delivery + 531, immutable outbox append 380, policy transaction executor 287 lines다. + +**실패 모드** + +`.api` 밖 구현도 cross-leaf source contract처럼 굳어 package 이동/visibility 축소가 bootstrap compile을 +깨뜨린다. capability 상태가 package나 Gradle artifact가 아니라 문서 관례라 dead/candidate class가 +Stable API처럼 보인다. + +**구현 결정: Export Allowlist + adapter-owned Configuration Facade** + +1. cross-leaf export를 application ports, 좁은 persistence SPI, adapter-owned configuration facade로 + allowlist한다. +2. app-bootstrap은 `NotificationJpaPersistenceConfiguration` 같은 facade 하나만 import하고 entity/repository/ + mapper는 facade package 안에서 package-private로 조립한다. +3. package별 `package-info.java` 또는 machine catalog에 상태와 allowed consumers를 기록하고 ArchUnit이 + 외부 import를 검사한다. +4. 큰 class는 line count만으로 나누지 말고 transaction/state-machine 경계가 보이는 gateway로 분해한다. +5. 현재 19-leaf registry는 유지한다. physical module 분리는 dependency graph와 runtime membership을 + 원자적으로 바꾸는 별도 architecture 승인 사항이다. + +**권장 목표 package 구조** + +```text +dev.caskeleton.adapter.outbound.persistence +├── configuration +│ ├── core +│ ├── fileserver +│ └── notification +├── platform +│ ├── transaction +│ ├── query +│ ├── provider.hibernate +│ └── vendor +│ ├── postgresql +│ └── h2 +├── capability +│ ├── idempotency +│ │ ├── entity +│ │ ├── repository +│ │ └── adapter +│ ├── outbox +│ ├── fileserver +│ └── notification +└── experimental # 별도 feature variant가 소유 +``` + +이 tree는 최종 방향이며 한 번에 324 file을 이동하지 않는다. 먼저 configuration facade와 exact package +catalog를 도입한 뒤 capability 단위로 이동한다. + +### JPA-024 — 문서가 실제 baseline과 검증 범위를 여러 곳에서 다르게 말한다 + +**근거** + +- support matrix는 H2에 `SKIP LOCKED` guarantee가 없다고 말하지만 module `CLAUDE.md`는 현재 H2 + 2.4.240이 syntax와 실제 skip을 수용한다고 측정해 기록한다. “현재 관찰 동작”과 “production guarantee로 + 인정하지 않음”을 구분해야 한다. +- `repository-adaptation.md:38-39`는 `JpaModuleBoundaryTest`가 full package map을 재현한다고 말하지만 + `:100`은 registry/app-bootstrap test가 같은 역할을 한다고 적고 실제 rule도 불완전하다. +- Hibernate 7.4 declared baseline과 실제 7.1.8 execution이 같은 Stable row/gate 이름에 섞여 있다. +- performance lane는 certification/reporting이라고 설명하지만 artifact/threshold가 없다. + +**필요 조치** + +1. JPA-017의 typed manifest에서 provider/database/lane 상태를 렌더링한다. +2. H2 문구는 “현재 버전에서 관찰됐지만 PostgreSQL contract evidence로 인정하지 않는다”로 통일한다. +3. package boundary 문서는 exact catalog가 실제 구현된 뒤에만 “enforced”라고 쓴다. 그전에는 known gap을 + 명시한다. +4. documentation contract test는 단순 문자열 존재가 아니라 manifest/task/resolved version과 비교한다. + +### JPA-025 — notification idempotency loser가 aborted PostgreSQL transaction에서 winner를 조회한다 + +**근거** + +- `JpaNotificationRequestStore.java:48-54`는 `saveAndFlush`의 named unique violation을 catch/translate한다. +- `NotificationSubmissionService.java:105-124`는 전체 submit을 같은 `transactions.inWrite` lambda에서 + 실행한다. +- 같은 lambda의 catch(`:117`)에서 winner를 즉시 SELECT한다(`:118-121`). +- PostgreSQL은 statement-level unique violation 뒤 현재 transaction을 aborted 상태로 두며 rollback 전 + 후속 SQL을 허용하지 않는다. + +**실패 모드** + +동일 tenant/idempotency key의 두 요청이 pre-read를 동시에 miss하면 loser insert가 unique violation을 +낸다. adapter가 exception을 application conflict로 바꿔도 physical transaction은 aborted다. 같은 payload의 +winner를 읽으려는 SELECT는 SQLSTATE 25P02 또는 최종 `UnexpectedRollbackException`으로 실패해 원래 의도인 +“동일 fingerprint는 같은 receipt로 수렴”을 만족하지 못한다. + +**구현 결정: database-authoritative Insert Outcome** + +1. port에 `NotificationRequestInsertOutcome tryInsert(...)`를 추가한다. +2. PostgreSQL adapter는 + `INSERT ... ON CONFLICT (tenant_id,idempotency_key) DO NOTHING RETURNING id`를 사용한다. +3. returned id가 있으면 winner이며 그때만 recipient jobs를 저장한다. +4. 0 row면 transaction을 poison하는 exception 없이 existing winner를 tenant/key로 읽고 fingerprint를 + 비교한다. +5. 다른 fingerprint면 application conflict, 같은 fingerprint면 existing receipt를 반환한다. +6. `REQUIRES_NEW`로 예외를 격리하는 대안은 connection/transaction 경계를 늘리고 request+recipients + atomicity를 복잡하게 하므로 이 경우 권장하지 않는다. + +**필수 테스트** + +- barrier를 사용한 same key 2-thread real PG test. +- same fingerprint: 동일 notification id/receipt, row 1개, 25P02 없음. +- different fingerprint: 한 success/한 deterministic conflict, orphan recipient 0개. +- winner transaction rollback 시 loser가 phantom receipt를 반환하지 않음. + +### JPA-026 — 먼저 온 provider callback과 duplicate/status update가 durable state machine을 이루지 못한다 + +**근거** + +- `JpaProviderEventLedger.java:71-78`은 pre-read 뒤 insert해 concurrent duplicate race를 unique constraint + exception으로 노출한다. +- provider request raw id는 hash만 저장하지만 entity→record rehydrate 시 raw id를 + `Optional.empty()`로 버린다(`:152-176,190-205`). +- projector resolver는 attempt id 또는 raw request id만 사용한다 + (`ProviderEventProjectionService.java:84-95`). +- `ProviderEventEntity.attemptId`는 `updatable=false`인데 bind mutator가 있다(`:40-42,124-127`). +- unmatched/pending query와 `bindAttempt`의 production scheduler caller가 없다. +- NO_PROJECTOR 경로는 transaction 밖에서 `markFailed`를 호출하고, adapter는 detached entity만 mutate한 + 뒤 explicit save/update를 하지 않는다. + +**실패 모드** + +- 동일 callback 두 개가 pre-read를 모두 통과하면 loser는 duplicate outcome이 아니라 HTTP/transaction + failure가 된다. +- callback이 attempt의 provider id 저장보다 먼저 오면 `attempt_id=null` event가 남는다. later sweep이 + raw id를 보지 못해 영구 PENDING이 된다. +- projector 부재를 FAILED로 기록했다고 생각하지만 detached mutation이 DB에 반영되지 않을 수 있다. + +**구현 결정: append idempotency + hash-based reconciliation state machine** + +1. event append도 `ON CONFLICT DO NOTHING RETURNING id`로 만들고 0 row면 existing event를 읽어 + duplicate outcome을 반환한다. +2. application record에 bounded `ProviderRequestIdHash`를 보존하고 resolver port에 + `byProviderRequestIdHash(profile, hash)`를 추가한다. raw provider id는 계속 저장하지 않는다. +3. `attempt_id`를 update 가능하게 바꾸고 + `UPDATE ... SET attempt_id=? WHERE id=? AND attempt_id IS NULL` CAS를 둔다. +4. bounded `ProviderEventProjectionWorker`가 unmatched/pending을 claim, resolve, bind, project한다. +5. projection status transition은 `@Modifying` CAS update 또는 application-owned write transaction 안의 save로 + 명시하고 affected row를 확인한다. + +**필수 테스트** + +- concurrent duplicate: created 1, duplicate 1, exception 0. +- callback-before-attempt → attempt 저장 → sweep bind/project → 재실행 no-op. +- NO_PROJECTOR 후 DB row가 FAILED이고 retry policy가 명확함. +- stale projector가 terminal status를 되돌리지 못함. + +### JPA-027 — batch clear가 flush되지 않은 entity를 detach해 성공한 것처럼 유실한다 + +**근거** + +- `JpaBatchProfile.java:35-42`는 `clearSize >= flushSize`만 검증한다. +- `HibernateJpaBatchExecutor.java:48-60`은 flush와 clear를 서로 독립적인 modulus로 실행한다. +- clear 직전에 항상 flush한다는 불변식이 없다. + +**실패 모드** + +`flushSize=100`, `clearSize=150`, 300 rows이면 100에서 flush한 뒤 101~150 entity를 persist한다. 150에서 +flush 조건은 false이고 clear 조건만 true라 50개 unflushed managed entity가 detach된다. executor는 +processed=300을 성공으로 반환할 수 있지만 DB에는 250개만 남는다. + +**구현 결정: flush-before-clear invariant** + +1. 모든 clear branch는 무조건 `entityManager.flush()` 후 `clear()`한다. +2. 같은 index에서 regular flush가 이미 실행됐더라도 중복 flush는 correctness를 위해 허용한다. 필요하면 + loop에서 `lastFlushedIndex`로 중복만 줄인다. +3. 대안으로 `clearSize % flushSize == 0`을 constructor에서 강제할 수 있지만 두 knob의 독립 조정 의미를 + 포기한다. 기존 API가 독립 값을 노출하므로 flush-before-clear가 더 안전하다. +4. final remainder flush와 exception rollback도 명시적으로 검증한다. + +**필수 테스트** + +- real PG에서 300 rows, `(100,150)` 후 count 정확히 300. +- `(100,100)`, `(100,250)`, row count < flush size, exact boundary. +- 151번째 action failure 시 전체 transaction rollback 또는 문서화된 partial semantics. + +### JPA-028 — fileserver cleanup claim은 crash 후 복구되지 않고 active writer와 경쟁한다 + +**근거** + +- `FileserverCleanupRepository.java:31-40`의 claim은 status를 `IN_PROGRESS`로 바꾸지만 owner/token/ + lease-until/version을 기록하지 않는다. +- completion/failure update도 id-only unconditional이다(`:42-58`). +- worker crash 후 expired IN_PROGRESS를 reclaim하는 query/caller가 없다. +- staging cleanup은 session lease를 read-check한 뒤 외부 delete한다 + (`DefaultCleanupService.java:130-137`). +- writer acquire SQL은 file lifecycle/cancel fact를 확인하지 않고 upload expiry/lease만 본다 + (`UploadLeaseRepository.java:20-40`). +- append 역시 session expiry는 보지만 terminal file state를 검사하지 않는다. + +**실패 모드** + +- physical delete 뒤 process가 DB settlement 전에 죽으면 row가 영구 IN_PROGRESS이고 quota/file state가 + 정산되지 않는다. +- cleanup이 “lease 없음”을 읽은 직후 writer가 lease를 획득하면 cleanup이 active staging file을 삭제할 + 수 있다. + +**구현 결정: fenced cleanup lease + terminal upload state** + +1. cleanup item에 claim owner/token/lease-until/version을 추가하고 `CleanupClaim`이 token을 반환한다. +2. done/failed/retry transition은 full token/version CAS를 사용하고 expired-claim reaper를 둔다. +3. upload session에 작은 `ACTIVE | TERMINAL` state를 추가한다. cancel/finalize transaction이 terminalize하고 + acquire/renew/append는 ACTIVE만 허용한다. +4. cleanup은 TERMINAL + writer lease expired 조건을 database에서 claim한 뒤 physical delete한다. +5. delete success 후 settlement가 실패해도 동일 token/reconciliation로 idempotently 재정산한다. + +**필수 테스트** + +- claim → simulated crash/time advance → 다른 worker reclaim. +- cancel/cleanup vs writer acquire barrier에서 acquire 또는 delete 정확히 하나만 승리. +- stale cleanup token으로 terminal settlement 불가. +- physical delete success/DB failure 재시도에서 quota double-decrement 없음. + +### JPA-029 — inbox cursor는 tuple인데 bulk cutoff는 timestamp만 쓰고 signal retry는 구현되지 않았다 + +**근거** + +- `InboxCursor.java:7-17`과 list query는 `(createdAt DESC, id DESC)` total order를 사용한다. +- `JpaNotificationInbox.java:143-150`의 mark-all-read는 createdAt만 repository에 넘긴다. +- `InboxItemJpaRepository.java:69-85`도 `created_at <= cutoff`만 사용한다. +- `NotificationInboxSignalPort.java:3-7`은 relay retry를 약속하지만 + `InboxCommitEventPublisher.java:42-47`은 after-commit publish exception을 영구 swallow한다. +- `InboxOutboxRecordFactory`는 production caller가 없다. + +**실패 모드** + +같은 millisecond에 여러 inbox row가 있을 때 middle cursor 기준 bulk mark-read가 cursor 뒤/앞의 같은 timestamp +row까지 과다 update하거나 누락한다. after-commit signal이 한 번 실패하면 durable retry record가 없어 +downstream notification이 영구 누락될 수 있다. + +**구현 결정: tuple cutoff + Transactional Outbox** + +1. bulk API에 cutoff id를 함께 전달하고 정렬 의미에 맞는 tuple predicate를 사용한다. +2. tenant/owner/category/state 조건을 그대로 유지하고 affected count를 결과에 포함한다. +3. signal이 best-effort라면 port 문서와 운영 기대를 그렇게 낮춘다. +4. retry가 계약이면 inbox mutation과 outbox append를 같은 application-owned transaction에서 수행하고 relay만 + 외부 publish한다. after-commit callback을 durability mechanism으로 사용하지 않는다. + +**필수 테스트** + +- 동일 timestamp UUID 3개 중 middle cursor의 정확한 update 경계. +- outbox append와 inbox update atomic rollback. +- signal 첫 publish 실패 후 relay replay, duplicate publish consumer idempotency. + +### JPA-030 — 여러 safety policy가 runtime input을 실제로 제한하지 않는다 + +**근거와 수정** + +1. **Dynamic query construction guard** + - `RegisteredQuery`와 `WorkQueueDefinition`은 완성된 runtime String에서 Java source token `"' +"`를 + 찾는다. concatenation 연산자는 이미 평가되어 사라졌으므로 보안 guard가 성립하지 않는다. + - raw statement constructor를 외부에 노출하지 말고 enum/catalog id + typed parameter binder만 허용한다. + 동적 construction 금지는 ArchUnit/source rule과 code review가 맡는다. +2. **Stateless max rows** + - `HibernateStatelessSessionRunner.java:35-50`은 `maxRows`를 양수 검증하지만 affected rows를 세거나 + 제한하지 않는다. + - work가 `StatelessWorkResult(value, affectedRows)`를 반환하게 하고 cap 초과 시 transaction을 + rollback한다. 가능하면 query/loop 자체에 remaining budget을 전달한다. +3. **Stream fetch policy** + - `JpaStreamExecutor.java:52-71`의 supplier는 `ScrollPolicy`를 받지 않아 fetch size가 query에 적용되지 + 않는다. + - `Function>` 또는 typed-query factory가 fetch size/hint를 설정한 뒤 stream을 + 열게 한다. resource close/transaction scope guard는 유지한다. + +**필수 테스트** + +- unregistered/dynamic query path는 work 실행 전 거부. +- stateless work가 maxRows+1에서 rollback되고 정확한 max까지 허용. +- PG JDBC fetch behavior를 proxy/statement inspector로 확인하고 stream close 시 ResultSet/connection 반환. + +## 6. 디자인 패턴 적용 지침 + +패턴은 이름을 늘리기 위해 적용하지 않는다. 현재 실패 모드에서 책임과 상태 전이를 한 곳으로 모으는 +경우에만 사용한다. + +| 문제 | 권장 패턴 | 적용 위치 | 핵심 제약 | +|---|---|---|---| +| application transaction 계약 두 벌 | Facade + Adapter | `PolicyTransactionPort` 구현 | application port 하나만 public, JPA engine은 internal | +| raw failure translation 분산 | Chain of Responsibility + Strategy | transaction attempt boundary | completion-unknown 우선, terminal flag 재활성화 금지 | +| keyset sort/predicate drift | Query Object + Specification | `SafeKeysetOrder` | Sort/Predicate/Cursor fingerprint 한 SSOT | +| optional schema/bean scan | Capability Module | fileserver/notification configuration | property만 아니라 schema ACTIVE와 marker scan을 함께 요구 | +| ThreadLocal frame double-pop | Scope/Token (`AutoCloseable`) | transaction evidence | identity 일치 frame만 owner가 close | +| worker/cleanup concurrency | Explicit State Machine + fenced CAS | notification/fileserver claims | owner+token+version/fence를 모든 transition에 사용 | +| 890-line idempotency store | Facade + package-private Gateway | PostgreSQL idempotency package | transaction orchestration과 SQL/row/digest 책임 분리 | +| release/docs drift | Typed Manifest + generated view | `docs/jpa`, Gradle, workflow | prose regex가 실행 권위가 되지 않음 | +| app-bootstrap의 구현 import | Configuration Facade | adapter-owned configuration package | bootstrap은 entity/repository를 직접 import하지 않음 | + +### 6.1 적용하지 말아야 할 패턴 + +- **Generic Repository**: JPA convenience를 이유로 `GenericRepository`를 만들지 않는다. tenant, + lock, aggregate-specific query와 transition을 숨긴다. +- **전역 BaseEntity**: audit/soft-delete/version을 모든 table hierarchy에 강제하지 않는다. 필요한 entity가 + opt-in하는 embeddable/mapped superclass만 사용한다. +- **Active Record**: entity가 repository/service를 호출하거나 transaction을 시작하게 하지 않는다. +- **Class-per-state State pattern**: notification/fileserver 상태 전이가 많아도 먼저 enum + transition table + + conditional SQL로 표현한다. 상태별 class 수가 domain behavior를 실제로 단순화할 때만 고려한다. +- **outbound annotation magic**: application service가 outbound adapter annotation을 import하게 하지 않는다. + 명시적 application port 호출이 이 템플릿의 의존 방향과 더 잘 맞는다. +- **statement-only retry**: serialization/optimistic conflict에서 실패한 SQL 한 줄만 재실행하지 않는다. + application use case 전체를 새 transaction/Persistence Context에서 다시 실행한다. +- **blanket `@Transactional`**: repository 결함을 가리기 위해 adapter class 전체에 붙이지 않는다. transaction + owner는 application port이며, claim처럼 single-statement atomic SQL은 그 계약을 코드로 드러낸다. +- **runtime String 보안 검사**: 이미 조립된 SQL에서 source concatenation 흔적을 찾지 않는다. typed catalog와 + construction API를 제한한다. + +## 7. 권장 구현 순서 + +각 phase는 별도 PR/merge 단위로 만들 수 있다. 앞 phase의 characterization test가 뒤 refactoring의 +safety net이다. + +### Phase 0 — 기준선과 false evidence 차단 + +1. current P0 scenario를 재현하는 failing test부터 추가한다. + - batch `(100,150)` 300-row count + - notification V3 migration + Hibernate validate + - same-key concurrent submission + - stale lease renew/release +2. `JpaPlatformContractSupport.start()`가 multi-version selection을 거부하게 해 tag release의 false green을 + 먼저 끊는다. +3. release workflow를 PG16/17/18 single-major matrix로 바꾼다. +4. 실제 migration tree를 쓰지 않는 lane은 이름/claim을 낮추고 JPA-010 구현 전 promotion blocking으로 + 표시한다. + +완료 조건: 기존 false-positive lane이 실패하도록 만든 negative fixture가 있고, 기존 behavior를 우연히 +green으로 유지하는 bypass가 없다. + +### Phase 1 — 즉시 데이터 유실/중복 수정 + +1. `HibernateJpaBatchExecutor`에 flush-before-clear 불변식을 적용한다. +2. notification V4 migration과 JSON mapping을 추가하고 nested repositories를 top-level로 분리한다. +3. notification schema activation/readiness와 conditional marker scan을 구현한다. +4. request insert와 provider event append를 `ON CONFLICT ... DO NOTHING RETURNING` outcome으로 바꾼다. +5. recipient claim/renew/release와 provider event bind/status를 token/fence CAS로 바꾼다. +6. existing V1~V3 checksum을 변경하지 않았는지 검증한다. + +완료 조건: fresh/V1/V2/V3 upgrade, Hibernate validate, JSON round-trip, concurrent submission/callback/lease +tests가 real PG에서 통과한다. + +### Phase 2 — application policy와 capability 경계 복원 + +1. notification roll-up을 application service로 되돌리고 tenant-aware port를 추가한다. +2. broad `JpaRepository` 상속을 narrow Spring Data repository로 바꾼다. +3. candidate PG adapters의 stereotype을 제거하고 provider/capability configuration에서만 조립한다. +4. fileserver cleanup fenced claim과 terminal upload state를 추가한다. +5. inbox tuple cutoff와 durable signal(outbox 또는 honest best-effort)을 결정한다. + +완료 조건: capability off에서 entity/repository/store 0개, wrong-tenant access 0 row, worker crash/stale owner +경쟁 test 통과. + +### Phase 3 — transaction engine 단일화 + +1. `PolicyTransactionPort`를 canonical API로 확정하고 old/new parity test를 만든다. +2. translation chain과 exact context factory를 canonical executor에 연결한다. +3. identity `TransactionEvidenceScope`를 도입하고 pop owner를 하나로 만든다. +4. JPA platform config를 실제 composition root bean graph로 전환한다. +5. 호출을 canonical engine으로 이관한 뒤 dead annotation/coordinator/classifier를 삭제하거나 internal로 + 축소한다. + +완료 조건: application에 outbound JPA import 0개, real 40001/40P01/optimistic retry, 08xxx no-retry+ +reconciliation, nested `REQUIRES_NEW` scope cleanup 통과. + +### Phase 4 — query/API/package 경계 + +1. `SafeKeysetOrder`로 sort/predicate/cursor를 통합한다. +2. cursor token/payload bound를 적용한다. +3. stateless maxRows, stream fetch policy, registered-query construction boundary를 실행 코드에 연결한다. +4. exact package catalog/allowed edge/cycle rule을 만들고 reusable architecture rule을 production graph에 + 적용한다. +5. adapter-owned configuration facade를 만든 뒤 public/internal surface를 capability 단위로 줄인다. + +완료 조건: mixed direction/type paging, oversized cursor, maxRows/fetch policy, package mutation fixture가 모두 +의도대로 실패/통과한다. + +### Phase 5 — release/experimental/docs 정합화 + +1. typed release manifest를 도입하고 docs/workflow/task를 생성 또는 exact 검증한다. +2. migration snapshots와 same-SHA R2 evidence를 release prerequisite로 연결한다. +3. experimental feature variant와 executable Hibernate8/JPA4/PG19 lane을 만든다. +4. performance lane을 behavior contract 또는 실제 measured gate 중 하나로 명확히 정한다. +5. audit model 하나를 canonical로 선택하고 docs/H2/Hibernate/package claims를 실제 실행과 맞춘다. + +완료 조건: support matrix의 각 Stable claim에서 exact task, target version, artifact SHA/digest로 추적할 수 +있다. + +### 7.1 권장 PR 분할 + +| PR | 포함 범위 | 섞지 않을 항목 | +|---|---|---| +| PR-1 | PG version selection fail-closed + release matrix | package 이동 | +| PR-2 | batch flush-before-clear + regression | notification | +| PR-3 | notification V4/JSON/top-level repositories/validate | worker state machine | +| PR-4 | notification schema activation + conditional scan | transaction engine | +| PR-5 | request/event upsert + lease/event fencing | docs/release manifest | +| PR-6 | tenant-aware port + roll-up policy 이동 | keyset | +| PR-7 | canonical transaction facade + translator + evidence scope | experimental variant | +| PR-8 | keyset/cursor/stream/stateless safety | public package 대이동 | +| PR-9 | fileserver cleanup + inbox outbox | release manifest | +| PR-10 | package catalog/export facade/public 축소 | physical Gradle leaf 추가 | +| PR-11 | typed release manifest/migration snapshots/experimental lanes | domain 기능 추가 | + +## 8. 권장 검증 매트릭스 + +### 8.1 매 변경의 기본 검증 + +```bash +cd src +./gradlew :application-core:test \ + :adapter:outbound:persistence-jpa:test \ + :app-bootstrap:test \ + --rerun-tasks --no-daemon --max-workers=2 --console=plain + +./gradlew verifyCleanArchitectureDependencies \ + verifyRuntimeModuleMembership \ + verifyDependencyLocks \ + verifyPublicPathSnapshot \ + verifyEnvKeys \ + --rerun-tasks --no-daemon --max-workers=2 --console=plain +``` + +### 8.2 notification/batch/fileserver 변경 + +```bash +cd src +./gradlew :adapter:outbound:persistence-jpa:jpaPlatformContractTest \ + :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest \ + :adapter:outbound:persistence-jpa:postgresqlFileserverReclamationIntegrationTest \ + -Pjpa.matrix.versions=16 \ + --rerun-tasks --no-daemon --max-workers=2 --console=plain +``` + +같은 migration/contract를 PG17과 PG18에서 각각 별도 process/job으로 실행한다. notification 전용 test는 +tag나 class selector로 분명히 드러나야 하며 test discovery 0은 실패해야 한다. + +### 8.3 transaction 변경 + +```bash +cd src +./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*Transaction*' \ + --rerun-tasks --no-daemon --max-workers=2 --console=plain + +./gradlew :adapter:outbound:persistence-jpa:jpaPlatformFailureTest \ + -Pjpa.matrix.versions=16 \ + --rerun-tasks --no-daemon --max-workers=2 --console=plain +``` + +그 다음 PG17/18 failure lane을 별도 실행한다. commit ambiguity는 body replay count 0과 reconciliation key +보존을 함께 assert한다. + +### 8.4 architecture/package 변경 + +```bash +cd src +./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*JpaModuleBoundaryTest' \ + --tests '*JpaProductionArchitectureTest' \ + --rerun-tasks --no-daemon --max-workers=2 --console=plain + +./gradlew check --rerun-tasks --no-daemon --max-workers=2 --console=plain +``` + +### 8.5 release 후보 + +release job은 major별로 다음 non-performance lane을 모두 실행해야 한다. + +```bash +./gradlew :adapter:outbound:persistence-jpa:jpaPlatformContractTest \ + :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest \ + :adapter:outbound:persistence-jpa:jpaPlatformFailureTest \ + :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest \ + :adapter:outbound:persistence-jpa:jpaPlatformSecurityTest \ + -Pjpa.matrix.versions=16 \ + --rerun-tasks --no-daemon --max-workers=2 --console=plain +``` + +aggregate promotion은 세 major artifact, migration/R2 manifest, resolved provider, image digest, commit SHA가 +모두 일치할 때만 통과한다. + +## 9. 이번 리뷰에서 실행한 검증 + +### 9.1 성공 + +```bash +cd src +./gradlew :adapter:outbound:persistence-jpa:test --console=plain +``` + +- fresh Gradle process: `BUILD SUCCESSFUL in 30s`. +- 10 actionable tasks: 7 executed, 3 up-to-date. +- 이 성공은 hermetic unit lane의 현재 회귀가 없다는 증거다. runtime composition, notification migration, + PostgreSQL concurrency, PG17/18 compatibility를 증명하지 않는다. +- 리뷰 문서 작성 후 current dirty worktree에서 같은 task를 `--rerun-tasks --no-daemon --max-workers=2`로 + 다시 실행했다. `BUILD SUCCESSFUL in 32s`, 10 actionable tasks 전부 executed였다. 다른 사용자 변경과 + unmerged SpotBugs 설정이 존재해도 scoped JPA unit lane은 fresh 성공했다. + +```bash +cd src +./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*JpaModuleBoundaryTest' --rerun-tasks --console=plain +``` + +- 병렬 architecture review에서 `BUILD SUCCESSFUL in 1m 55s`, 10 tasks 실행. +- 현재 cycle/unregistered packages가 있어도 test가 통과한다는 JPA-015의 false-negative를 재확인했다. + +```bash +cd src +./gradlew verifyJpaReadinessRegistry \ + :adapter:outbound:persistence-jpa:verifyJpaEvidenceHarnessContract \ + --rerun-tasks --console=plain +``` + +- 병렬 test/ops review에서 성공. +- 현재 exact 16 cards/8 owned streams와 skip/dirty/content-mutation fail-closed를 확인했다. +- notification stream이 그 exact set에 없다는 JPA-002의 근거이기도 하다. + +- `:adapter:outbound:persistence-jpa:check --dry-run`: custom source-set 정적 분석은 포함되지만 platform + Docker test tasks가 기본 check에 포함되지 않음을 확인했다. +- `jpaReleaseGate --dry-run`: platform 6개 lane은 포함되지만 readiness/candidate/R2 evidence가 release + dependency에 없음을 확인했다. +- 신규 리뷰 문서: `git diff --no-index --check /dev/null ` whitespace 진단 0 bytes, + Markdown fence 18개(even), priority table/상세 heading JPA-001~030 exact set 일치, placeholder/금지 과장 + 표현 grep 결과 0건. + +### 9.2 실패 또는 제약 + +- 최초 sandbox unit 실행은 user Gradle cache의 `.zip.lck` write 권한 때문에 실패했고 승인된 실행으로 + 재시도해 위 unit success를 얻었다. source failure로 분류하지 않았다. +- 한 병렬 agent의 추가 `:test --rerun-tasks`는 다른 Gradle process와 shared output이 경합해 + application-core JAR/compile-result 관련 실패가 났다. 독립 재시도 결과가 아니라서 source defect 증거로 + 사용하지 않았다. +- 리뷰 후반에 다른 사용자 작업이 root build/settings를 대량 변경하고 + `src/config/spotbugs/exclude.xml`을 `UU` conflict 상태로 둔 것을 확인했다. 사용자 변경을 되돌리거나 + conflict를 해결하지 않았다. 최종 fresh root `check` 가능 여부는 아래에서 별도로 기록한다. + +### 9.3 실행하지 않은 검증 + +- Docker-backed `jpaPlatformContract/Migration/Failure/QueryPlan/Security/PerformanceTest` 실제 실행. +- PG17/18 full suite. +- notification V1~V3 migration + Hibernate validate + JSON CRUD. 현재 전용 test가 없다. +- app-bootstrap 전체 application context와 actuator endpoint/advisor runtime probe. +- 실제 PostgreSQL two-worker race, commit acknowledgement loss, fileserver crash recovery. + +실행하지 않은 영역은 이전 기록이나 test 이름을 이번 fresh 운영 증거로 승격하지 않는다. + +## 10. Definition of Done + +다음 조건을 모두 만족하기 전에는 이 리뷰를 “수정 완료”로 닫지 않는다. + +- [ ] JPA-001~004, 025, 027의 P0 failing test가 먼저 추가되고 수정 후 real PG에서 통과한다. +- [ ] notification V1~V3는 checksum을 유지하고 V4 forward migration으로 정렬된다. +- [ ] notification disabled context에 entity/repository/store가 없고 enabled context는 schema ACTIVE를 + 요구한다. +- [ ] notification 7개 entity의 JSONB field 8개 mapping과 `template_locale`가 Hibernate + validate/CRUD를 통과한다. +- [ ] same-key submission, duplicate callback, stale lease/cleanup owner가 deterministic outcome으로 수렴한다. +- [ ] request roll-up과 tenant policy가 application에 있고 persistence는 mapping/conditional SQL만 소유한다. +- [ ] application-facing transaction 계약은 `PolicyTransactionPort` 하나이며 outbound JPA annotation/API를 + import하지 않는다. +- [ ] raw optimistic/40001/40P01가 번역·retry되고 completion-unknown은 재실행되지 않는다. +- [ ] nested `REQUIRES_NEW` 종료 후 outer evidence/key가 유지되고 thread-local이 남지 않는다. +- [ ] mixed type/direction keyset이 모든 row를 정확히 한 번 반환하고 cursor input이 bounded다. +- [ ] package catalog가 22개 top-level package exact set, allowed edge, cycle, status/export를 강제한다. +- [ ] production graph에 reusable JPA architecture rules가 실제 적용된다. +- [ ] PG16/17/18 각각에서 full release lane artifact가 같은 SHA로 생성된다. +- [ ] migration lane이 실제 empty/N-1/oldest snapshot과 Hibernate validate를 실행한다. +- [ ] support docs, resolved Hibernate/database version, Gradle task, workflow, evidence manifest가 한 typed + SSOT와 일치한다. +- [ ] focused test, full `test`, full `check`, architecture validators의 실행 명령과 결과를 PR에 남긴다. +- [ ] Docker/보호 환경 때문에 실행하지 못한 검증은 이유와 남은 위험을 명시한다. + +## 11. LLM Wiki capture + +이 리뷰는 의미 있는 아키텍처/코드 감사이므로 root `AGENTS.md`의 capture 대상이다. 최종 응답 전에 +`/home/donghyeon/workspace/ai-tool/llm-wiki-private/raw/branch-notes/main.md`에 current HEAD, finding, +변경 파일, 검증, 미실행 Docker lane, evidence grade를 추가한다. 이번 작업은 구현 전 read-only review이므로 +별도 canonical `wiki/` 추출이나 interview/blog 파생 문서는 만들지 않는다. + +실제 capture 결과: + +- `raw/branch-notes/main.md`에 `2026-08-14 캡처 — merge 이후 JPA persistence 모듈 상세 리뷰`를 + 추가했다. +- 제품 HEAD/규모/30개 finding 요약, 단계별 결정, 변경 파일, 성공·실패·미실행 검증과 evidence grade를 + 기록했다. +- `raw/errors/`는 새 실패 모드가 없어 별도 생성하지 않았고, shared Gradle output 경합은 기존 + `[[raw/errors/parallel-gradle-shared-build-race-2026-08-10]]`를 가리켰다. +- `raw/interviews/`, `raw/blog-topics/`, canonical `wiki/`는 구현 전 read-only finding이므로 별도 파생 + 없음으로 기록했다. +- `wiki_structure_lint.py --file raw/branch-notes/main.md --links-only`: PASS. +- full single-file lint는 제품 규칙이 요구하는 실제 branch 파일 `main.md`와 vault의 4-prefix naming rule이 + 충돌해 기존 `NAMING_VIOLATION` 1건으로 실패했다. 파일을 임의 rename하거나 어느 정책도 완화하지 않았다. +- untracked branch-note에 대한 `git diff --no-index --check /dev/null ...`은 whitespace 진단 출력 0 bytes였다 + (내용 diff 때문에 command exit 1은 정상). diff --git a/docs/reviews/2026-08-14-messaging-module-code-review.md b/docs/reviews/2026-08-14-messaging-module-code-review.md new file mode 100644 index 00000000..05baf77e --- /dev/null +++ b/docs/reviews/2026-08-14-messaging-module-code-review.md @@ -0,0 +1,1546 @@ +# Messaging 모듈 상세 코드·아키텍처 리뷰 + +- 기준 일자: 2026-08-14 +- 기준 Git HEAD: `c3043e530a604315c4df341b87b5470c7617ea03` +- `src/messaging` source snapshot: `20664539b0609c6c413759e2b2945bf421c10de7` +- 범위: 신규 `src/messaging/*` 24개 Gradle leaf, 기존 `application-core`/`adapter:outbound:messaging`, + `app-bootstrap`, architecture registry, `docs/messaging`, 관련 테스트와 CI +- 규모: 신규 production Java 320개/22,338 LOC, test Java 87개/13,319 LOC +- 판정: **CHANGES REQUIRED — 신규 messaging platform을 runtime-ready Stable로 사용하면 안 됨** +- 변경 범위: 이 리뷰 문서만 추가했으며 production/test 코드는 수정하지 않았다. + +> 이전 리뷰 뒤 merge가 완료됐다는 요청에 따라 과거 작업 결과를 재사용하지 않고 최종 HEAD의 파일, +> registry, runtime membership, 테스트 산출물을 다시 대조했다. 현재 애플리케이션은 기존 messaging +> adapter만 사용하며 신규 24개 leaf는 모두 `runtime_memberships: []`이다. 따라서 아래 결함은 현재 +> bootstrap이 곧바로 장애 난다는 뜻이 아니라, 신규 starter를 소비하거나 cutover하는 순간 드러나는 +> release blocker다. + +## 1. 최종 결론 + +신규 messaging 코드는 API 타입 수나 단위 테스트 수가 부족해서 문제가 아니다. `PublishResult`의 +`CONFIRMED`/`REJECTED`/`AMBIGUOUS` 구분, sealed handler result, immutable envelope, broker profile, +outbox/inbox, retry, security, observability, admin까지 필요한 개념은 넓게 갖췄다. 실제로 이번 fresh +실행에서 신규 600개와 기존 adapter 81개 테스트가 failure/skip 없이 통과했다. + +문제는 이 조각들이 하나의 production 실행 경로로 조립되지 않았고, 가장 중요한 신뢰성 계약 몇 개가 +기본/public 경로에서 깨진다는 점이다. + +1. `OutboxRepository.append(record)`와 `InboxRepository.reserve(...)`는 문서상 caller transaction에 + 참여해야 하지만 실제 JDBC 구현은 raw 새 connection을 연다. 업무 rollback 뒤 ghost event를 + 발행하거나, inbox 예약만 commit되어 재전달의 업무 효과를 영구 유실할 수 있다. +2. outbox lease에는 owner/fencing token이 없다. lease가 만료되어 새 worker가 처리한 뒤 늦은 기존 + worker가 결과를 덮어쓸 수 있다. +3. 신규 starter에는 production `MessagePublisher`, destination router, broker runtime factory, + consumer outcome pipeline이 없다. 반면 Kafka와 Rabbit을 한 starter가 동시에 끌어온다. +4. Kafka consumer에는 poll batch 일부를 건너뛰는 backpressure 처리, executor rejection 누수, + rebalance 뒤 stale settlement, commit 실패 시 로컬 watermark 선반영 문제가 있다. +5. Rabbit publisher에는 `multiple` confirm, 자동 timeout, synchronous send failure 정리, return + correlation을 책임지는 production channel bridge가 없고, consumer는 handler/settlement 예외까지 + deserialization 실패로 간주해 discard한다. +6. 코드의 `backend.messaging`, 기존 runtime의 `app.messaging`, 문서의 bare `messaging` 세 설정 + namespace가 서로 다르며, 문서에 있는 destination/broker/security 설정은 binder에 존재하지 않는다. +7. Stable/실 브로커 장애 커버리지는 실행 evidence가 아니라 hard-coded matrix가 스스로 주장한다. + Kafka 인증 버전도 선언한 4.2/4.3과 실제 컨테이너 4.1.0이 다르다. +8. 기존 runtime과 신규 platform 사이에 semantic bridge와 단일 publication authority 전환 계획이 + 없다. 두 모델의 `FAILED` 의미도 다르므로 단순 enum 매핑은 안전하지 않다. + +따라서 첫 구현 목표는 폴더 이동이나 패턴 추가가 아니다. **데이터 원자성 → lease fencing → broker +settlement 정확성 → 실제 runtime composition → 단일 cutover authority → release evidence** 순서로 +고쳐야 한다. P0와 P1이 닫히기 전 신규 platform은 `Contract-only/Build-only`로 표시하고 production +runtime membership을 추가하지 않는 것이 안전하다. + +## 2. 검토 범위와 증거 경계 + +### 2.1 신규 모듈 구성 + +| family | leaf | 역할 | +|---|---|---| +| API | core-api, schema-api, reliability-api | envelope/publish/consume/schema/outbox/inbox 계약 | +| codec | schema-json, schema-avro, schema-protobuf, cloudevents | wire encoding과 compatibility | +| runtime policy/SPI | policy, transport-spi | profile/retry/admission/runtime lease | +| stable broker | kafka, rabbit | native transport/consumer/admin capability | +| reliability | outbox-jpa, inbox-jpa, claim-check | PostgreSQL JDBC outbox/inbox와 payload offload | +| ops | observability, security, admin-api, admin-runtime | metric/trace/credential/operator 기능 | +| experimental | kafka-share, pulsar, nats, spring-cloud-stream-bridge | opt-in adapter/bridge | +| Spring/test | spring-boot-starter, testkit | auto-configuration/facade/contract evidence | + +`src/config/architecture/modules.json`에는 기존 19개와 신규 24개, 총 43개 leaf가 등록되어 있다. +`src/settings.gradle:37`도 43개를 기대한다. 그러나 root `AGENTS.md:52,105,185`와 +`CLAUDE.md:24,46`는 아직 정확히 19개라고 선언한다. 신규 24개는 모두 runtime membership이 비어 있고 +`src/messaging/CLAUDE.md`도 없다. + +### 2.2 깊게 따라간 실행 흐름 + +```text +publish API + -> destination/profile/schema/security/admission + -> runtime generation lease + -> Kafka/Rabbit transport + -> broker evidence -> PublishResult + -> outbox state transition/retry + +broker delivery + -> wire mapper + -> MessageHandler/HandleResult + -> retry/DLQ policy + -> broker settlement + -> transactional inbox + business effect +``` + +이상적인 흐름과 실제 production reference를 대조했다. 실제로는 위 중앙 publish/consumer pipeline이 +존재하지 않고, broker adapter와 각 policy 객체가 독립 조각으로 남아 있다. + +### 2.3 이 리뷰가 승인하지 않는 범위 + +- 신규 platform이 `app-bootstrap` full context에서 실제 publish/consume한다는 주장 +- Kafka 4.2/4.3 호환성, Rabbit의 5개 failure scenario 전체 커버리지 +- process kill/restart, rebalance 경쟁, connection loss를 포함한 다중 replica 안정성 +- 대량 payload/header, 고 cardinality diagnostics, credential rotation 부하의 운영 한계 +- 기존 runtime에서 신규 platform으로 rolling cutover/rollback할 수 있다는 주장 + +로컬 Docker 환경에서는 현재 존재하는 Kafka/Rabbit/PostgreSQL IT가 모두 실행됐지만, 존재하지 않는 +시나리오나 다른 broker version을 그 결과로 추론하지 않았다. + +## 3. 유지할 설계 + +다음 방향은 리팩터링하면서 보존한다. + +- `PublishResult`가 성공/거절/모호함과 transmission/confirmation/routing evidence를 분리한다. +- 공통 API에서 `EXACTLY_ONCE`와 global ordering을 약속하지 않는다. +- `HandleResult`, `RetryDecision`의 sealed hierarchy는 exhaustive policy 처리를 돕는다. +- `MessageEnvelope.withPayload`가 identity를 보존하고 encoded/reliability record가 mutable bytes를 + 방어 복사한다. +- core API가 framework/broker dependency를 갖지 않고 transport SPI가 broker strategy 경계를 둔다. +- Rabbit confirm과 mandatory return을 서로 다른 증거로 모델링하려는 방향은 맞다. +- Kafka contiguous watermark, partition pause/seek, runtime generation lease라는 핵심 개념은 맞다. +- stable/experimental module을 분리하고 optional codec dependency를 별도 leaf로 둔 선택은 유지할 가치가 + 있다. +- 기존 application-owned port → outbound adapter → bootstrap composition 의존 방향은 canonical + boundary로 계속 사용해야 한다. + +## 4. 우선순위 요약 + +| ID | 우선순위 | 심각도 | 주제 | 완료 조건 요약 | +|---|---|---|---|---| +| MSG-001 | P0 | Critical | outbox/inbox transaction 원자성 위반 | public port 경로에서 업무 row와 함께 commit/rollback | +| MSG-002 | P0 | Critical | outbox lease fencing 부재 | stale worker의 모든 terminal update가 DB 조건으로 거절 | +| MSG-003 | P0 | Critical | production publisher/router/consumer pipeline 미조립 | starter full context가 fake 없이 실제 transport까지 연결 | +| MSG-004 | P0 | Critical | Kafka dispatch/rebalance/commit settlement 경쟁 | batch/reject/revoke/commit-failure에서 skip·조기 commit 없음 | +| MSG-005 | P0 | Critical | Rabbit confirm/return/consumer 오류 처리 결함 | multiple/timeout/return/handler failure가 단일 state machine으로 처리 | +| MSG-006 | P1 | High | retry budget/backoff가 relay에 미연결 | attempt cap과 next-at이 durable하며 outage 중 hot loop 없음 | +| MSG-007 | P1 | High | starter classpath와 auto-config 설계 불완전 | core/Kafka/Rabbit/reliability/admin 선택형 starter | +| MSG-008 | P1 | High | 설정 namespace/binder/validator 단절 | `app.messaging` 하나로 문서 YAML 전체 fail-closed binding | +| MSG-009 | P1 | High | batch timeout/stop 계약 미구현 | batch deadline과 비동기 rejection 이후 미제출 항목이 명시됨 | +| MSG-010 | P1 | High | runtime drain/backpressure permit 결함 | generation별 deadline + once-only permit + lifecycle 연결 | +| MSG-011 | P1 | High | credential rotation 경쟁 | generation lease가 끝난 뒤에만 이전 secret clear | +| MSG-012 | P1 | High | wire identifier/header validation 부족 | CRLF/NUL/Unicode/size/reserved bypass가 broker 전 거절 | +| MSG-013 | P1 | High | observability cardinality/secret 경계 우회 | arbitrary diagnostics가 metric tag가 되지 않음 | +| MSG-014 | P1 | High | Stable certification이 실행 evidence와 분리 | fail-closed broker/version/scenario artifact gate | +| MSG-015 | P1 | High | legacy/new semantic bridge와 cutover 권한 부재 | 단일 writer/relay + outcome/wire compatibility + rollback | +| MSG-016 | P1 | Critical | envelope/header/outbox/CDC canonical 정보 유실·위조 | broker/DB/CDC round-trip이 동일 canonical envelope 보존 | +| MSG-017 | P1 | High | PublishOptions/capability/result 계약 미적용 | option별 deadline/지원 여부와 result truth table 강제 | +| MSG-018 | P1 | Critical | Kafka transaction callback이 transaction 밖에서 실행 | handler/output/offset이 실제 한 Kafka transaction 안에 있음 | +| MSG-019 | P2 | Medium | public/vendor surface와 Gradle exposure 불일치 | external compile fixture와 public API allowlist 통과 | +| MSG-020 | P2 | Medium | codec가 제한 검사 전에 전체 할당 | bounded stream/parser로 max+1에서 중단 | +| MSG-021 | P2 | High | admin idempotency가 process-local/선점-only | durable state/fingerprint/lease 기반 one-shot execution | +| MSG-022 | P2 | Medium | experimental adapter의 unknown failure 오분류 | typed pre-send만 REJECTED, 나머지는 보수적 AMBIGUOUS | +| MSG-023 | P2 | Medium | module 명칭·폴더·정본 정책 drift | JDBC/PostgreSQL 명칭, family policy, registry-derived count | +| MSG-024 | P1 | High | legacy runtime disabled/retry/payload/log 안전성 | disabled broker가 row를 소진하지 않고 wire/log bound 강제 | +| MSG-025 | P3 | Low | runbook/outbox reclaim 문서 drift | 실제 env/class/expired IN_FLIGHT predicate와 동기화 | + +## 5. 상세 발견 사항과 구현 명세 + +### MSG-001 — JDBC outbox/inbox의 기본 port 경로가 caller transaction을 벗어난다 + +**근거** + +- `OutboxRepository.java:12-22`는 `append`가 caller business transaction 안에서 실행돼야 한다고 + 명시한다. +- `JdbcOutboxRepository.java:91-109`에는 caller `Connection`을 받는 안전한 overload가 있지만, + 실제 interface override는 `112-119`에서 `withConnection`을 호출하고 `253-259`에서 raw + `dataSource.getConnection()`을 열고 닫는다. +- `InboxRepository.java:9-25`도 reservation과 side effect가 같은 transaction이어야 한다고 명시한다. +- `IdempotentConsumer.java:55-60`은 transaction runner 안에서 interface 메서드 + `inbox.reserve(...)`를 호출하지만, `JdbcInboxRepository.java:76-83`은 별도 raw connection을 연다. +- `OutboxPostgresIT.java:152-171`과 `InboxPostgresIT.java:109-124`의 rollback 검증은 안전한 + `Connection` overload를 직접 호출한다. production/public port 경로를 검증하지 않는다. + +**실패 시나리오** + +```text +Inbox +T1: reserve()가 별도 connection에서 auto-commit +T2: business side effect 실행 후 rollback +redelivery: 이미 inbox row가 있으므로 duplicate 처리 +결과: 업무 효과 영구 유실 + +Outbox +T1: business row 변경 +T2: append()가 별도 connection에서 auto-commit +T1 rollback +relay: rollback된 업무의 event를 발행 +결과: ghost publication +``` + +Spring transaction 안에서 raw Hikari `DataSource#getConnection()`을 부르는 것만으로 같은 resource에 +자동 참여하지 않는다. 외부에서 `TransactionAwareDataSourceProxy`를 우연히 씌웠을 때만 동작하는 설계는 +port 계약이 아니다. + +**구현 결정: Unit of Work + transaction-aware adapter** + +1. Spring JDBC 구현은 `JdbcTemplate`/`NamedParameterJdbcTemplate`를 사용하거나 + `DataSourceUtils.getConnection/releaseConnection`으로 transaction-bound connection을 얻는다. +2. outbox append/inbox reserve는 active, non-read-only transaction과 같은 `DataSource` resource가 + 없으면 `OUTBOX_TRANSACTION_REQUIRED`/`INBOX_TRANSACTION_REQUIRED`로 fail-fast한다. +3. `Connection`을 reliability API에 노출하지 않는다. public overload는 제거하거나 adapter 내부 + package-private helper로 낮춘다. +4. `IdempotentConsumer.TransactionRunner`는 임의 lambda가 아니라 application transaction port 또는 + Spring adapter의 `TransactionTemplate`로 구성하고, repository와 동일 transaction manager/data source를 + startup에서 검증한다. +5. outbox writer를 application use case transaction의 마지막 단계에 두되, transaction synchronization + `afterCommit`으로 옮기지 않는다. after-commit publish는 원자성을 다시 잃는다. + +권장 port는 JDBC 타입을 넣는 대신 원자성 요구를 표현한다. + +```java +interface TransactionalOutbox { + void append(OutboxRecord record); // active application UnitOfWork required +} + +interface TransactionalInbox { + Reservation reserve(MessageId messageId, ConsumerId consumerId, Instant now); +} +``` + +**필수 테스트** + +- `TransactionTemplate` 안에서 interface `append(record)`만 호출하고 business insert와 함께 rollback; + 둘 다 없어야 한다. +- interface `reserve(...)`와 business insert 후 handler exception; 둘 다 rollback되고 재시도에서 handler가 + 실행돼야 한다. +- 정상 commit, transaction 없음, read-only transaction, 다른 `DataSource` transaction을 각각 검증한다. +- auto-configuration이 repository/runner를 서로 다른 transaction manager로 조립하면 context가 실패해야 + 한다. + +**완료 조건** + +테스트가 안전한 overload를 직접 호출하지 않고 production port와 실제 Spring transaction composition을 +통과해야 한다. + +### MSG-002 — outbox lease에 owner/fencing token이 없어 stale worker가 최신 결과를 덮어쓴다 + +**근거** + +- migration `V1__messaging_outbox.sql:6-25`에는 `lease_expires_at`만 있고 owner/token/version이 없다. +- `JdbcOutboxRepository.java:48-69`는 만료된 `IN_FLIGHT`를 재claim하면서 status와 expiry만 갱신한다. +- `markPublished`, `markAmbiguous`, `markFailed`는 `143-171`에서 모두 `WHERE message_id = ?`만 + 사용한다. `releaseLease`도 owner/token 조건이 없다. + +**실패 시나리오** + +```text +relay A: row claim(token 없음), publish 대기 +lease expiry +relay B: 같은 row reclaim, broker confirm, PUBLISHED 기록 +relay A: 늦은 timeout/exception, AMBIGUOUS 또는 FAILED 기록 +결과: 확정 발행 row가 재시도되거나 terminal 상태가 잘못 회귀 +``` + +lease duration을 publish timeout보다 길게 검증하는 것은 발생 확률을 줄일 뿐 process pause, GC, broker +latency, scheduler stall을 데이터 정합성 제약으로 바꾸지 못한다. + +**구현 결정: Lease + Fencing Token state machine** + +V2 migration으로 다음을 추가한다. + +```sql +ALTER TABLE messaging_outbox + ADD COLUMN lease_owner VARCHAR(160), + ADD COLUMN lease_token BIGINT NOT NULL DEFAULT 0, + ADD COLUMN next_attempt_at TIMESTAMPTZ; +``` + +claim은 token을 증가시키고 lease identity를 반환한다. + +```sql +UPDATE messaging_outbox o +SET status = 'IN_FLIGHT', + lease_owner = :owner, + lease_token = o.lease_token + 1, + lease_expires_at = :expires +FROM claimable c +WHERE o.message_id = c.message_id +RETURNING ..., o.lease_owner, o.lease_token; +``` + +port도 message ID가 아니라 lease를 terminal command에 넘긴다. + +```java +record OutboxLease(OutboxRecord record, String owner, long token, Instant expiresAt) {} + +OutboxTransitionResult markPublished(OutboxLease lease, Instant now); +OutboxTransitionResult markAmbiguous(OutboxLease lease, FailureCode code, Instant now); +``` + +모든 transition은 다음 predicate와 update count 1을 요구한다. + +```sql +WHERE message_id = :id + AND status = 'IN_FLIGHT' + AND lease_owner = :owner + AND lease_token = :token +``` + +0 rows면 성공으로 삼키지 말고 `STALE_LEASE` outcome/metric으로 기록한다. status transition 표를 한 +`OutboxStateMachine`에 두고 임의 SQL이 terminal state를 직접 바꾸지 못하게 한다. + +**필수 테스트** + +- A claim → expiry → B claim/publish → A ambiguous/failed/release가 모두 0-row stale result. +- 같은 worker의 duplicate terminal call도 두 번째는 거절. +- 두 DB connection이 동시에 claim할 때 row 집합과 token이 겹치지 않음. +- process kill 뒤 expiry reclaim은 같은 `messageId`, 증가한 token으로 성공. + +### MSG-003 — 신규 platform에는 production publisher/router와 consumer outcome pipeline이 없다 + +**근거** + +- `messaging-core-api/.../publish/MessagePublisher.java:13-25`에 application-facing contract가 있지만 + 신규 production source에 구현이나 `@Bean MessagePublisher`가 없다. +- Kafka/Rabbit은 `MessagingTransport`만 구현한다. +- `MessagingCoreAutoConfiguration.java:104-107,176-203`은 publisher가 이미 있다고 가정해 + `DeadLetterOrchestrator`, blocking/reactive/batch facade를 만든다. +- broker auto-config 문서도 producer/consumer를 만들지 않는다고 명시한다 + (`KafkaMessagingAutoConfiguration.java:22-24`, `RabbitMessagingAutoConfiguration.java:19-21`). +- `MessagingAdmissionController`, `BackpressureController`, `MessagingRuntimeRegistry`, security validator, + observation은 실제 publish 경로에서 함께 호출되지 않는다. +- `MessageHandler`/`HandleResult`를 broker settlement/retry/DLQ로 변환하는 production orchestrator도 없다. + +**영향** + +starter에 application fake publisher를 넣으면 context 조각 테스트는 통과하지만 destination resolution, +encoding, admission, security, runtime lease, broker selection, timeout, evidence normalization이 실행되지 +않는다. consumer도 handler가 반환한 retry/discard/success를 공통 정책과 정확히 연결하지 못한다. + +**구현 결정: Facade + Pipeline/Decorator + Strategy** + +새 `messaging-runtime-core` leaf에 중앙 orchestration을 둔다. + +```text +DefaultMessagePublisher + -> DestinationRegistry.require(name) + -> CompositeProfileValidator / capability compiler + -> MessageAccessPolicy + MessageSecurityValidator + -> MessageCodecRegistry + bounded encode / ClaimCheck + -> AdmissionPermit + -> MessagingRuntimeLease + -> MessagingTransport.publish # broker Strategy + -> deadline/evidence normalization + -> observation + -> finally permit.close + lease.close +``` + +consumer counterpart는 다음 단일 흐름으로 둔다. + +```text +BrokerDeliveryMapper + -> DefaultDeliveryProcessor + -> MessageHandler.handle + -> HandleResultVisitor + -> RetryDecisionEngine + -> DLQ publish confirmed + -> exactly one source settlement +``` + +- cross-cutting 단계를 자유로운 interceptor map으로 만들지 말고 순서가 고정된 typed decorator/list로 둔다. +- broker별 차이는 `MessagingTransport`와 `BrokerRuntimeFactory` Strategy에만 둔다. +- runtime factory는 profile 하나에서 producer/consumer/admin/security/lifecycle을 완결되게 만드는 Abstract + Factory다. 부분 bean graph는 startup에서 거절한다. +- `HandleResult`와 settlement는 one-terminal-call state machine으로 감싸고, DLQ는 publish confirmation 뒤 + source ACK라는 기존 원칙을 강제한다. + +**필수 테스트** + +- fake publisher 없이 full auto-config import → fake native client → actual `DefaultMessagePublisher` → + transport까지 1건 publish. +- pipeline 각 단계 실패가 pre-wire `REJECTED`와 post-wire `AMBIGUOUS`로 정확히 구분됨. +- admission/runtime lease가 success, async failure, timeout, cancellation 모두에서 exactly once 반환됨. +- handler success/retry/discard/throw, DLQ publish reject/ambiguous, settlement failure의 transition table 테스트. + +### MSG-004 — Kafka consumer가 poll batch와 assignment epoch를 안전하게 관리하지 못한다 + +**근거** + +- `KafkaConsumerRegistrar.pollOnce:154-175`는 poll이 반환한 전체 record를 순회하다 한 partition이 limit에 + 걸리거나 shutdown이 시작되면 현재 record를 seek하고 `break`한다. poll이 이미 반환한 다른 + partition/뒤 record를 처리하거나 seek하지 않는다. +- `dispatch:183-205`의 `handlerPool.execute`가 `RejectedExecutionException`을 던지면 이미 획득한 + partition permit/shutdown work count와 delivered offset을 정리하지 않는다. +- 같은 `dispatch:183-205`는 envelope decode와 `sink.apply(...).join()` 예외를 하나의 catch로 잡아 + `PARKED` command를 넣는다. `applySettlements:210-213`는 실제 quarantine/DLQ write 없이 `PARKED`를 + `COMPLETE`처럼 offset 완료 처리하므로 일시적 handler/DB 장애도 message loss가 된다. +- rebalance listener `114-122`는 handler가 끝나길 기다리지 않고 commit/forget한다. +- `KafkaSettlementCommand`에는 assignment generation/epoch가 없다. revoke 뒤 늦은 이전 handler의 + settlement가 같은 partition의 새 assignment 상태에 적용될 수 있다. +- `QueuedSettlement:323-355`는 one-terminal CAS가 없고, poll thread가 실제 commit하기 전에 + `SettlementResult.settled()`를 즉시 반환한다. +- `commitContiguous:235-254`는 `commitSync` 전에 `committed` map을 갱신하고 tracker를 prune한다. + commit 실패 후 다음 cycle이 해당 offset 재commit을 생략하거나 더 높은 watermark를 commit할 수 있다. +- public `pause/resume:272-285`와 `close:302-307`도 호출 thread에서 `Consumer`를 직접 만져 클래스가 + 선언한 poll-thread-only 불변식을 깬다. +- `ConsumerPolicy.handlerTimeout`은 선언돼 있지만 worker의 blocking `.join()`에 적용되지 않는다. + +**실패 시나리오** + +- partition 0의 첫 record가 limit에 걸린 순간 poll batch에 함께 온 partition 1 record를 잊는다. consumer + position은 이미 poll로 전진했으므로 rebalance/restart 전까지 처리 공백이 생길 수 있다. +- revoke된 epoch A의 handler가 늦게 ACK한 뒤 partition이 epoch B로 재할당되면 B에서 아직 처리하지 않은 + offset이 complete/commit될 수 있다. +- `commitSync` 실패 전에 로컬 prune이 끝나면 이후 높은 offset commit이 실패 구간까지 포함해 message를 + 잃을 수 있다. + +**구현 결정: partition state machine + assignment fencing** + +1. `AssignmentEpoch(topicPartition, generation)`을 assignment마다 만들고 delivery/settlement command에 + 포함한다. current epoch가 아니면 stale settlement로 거절한다. +2. revoke 시 해당 partition을 pause하고 신규 dispatch를 막은 뒤 bounded deadline까지 in-flight를 drain, + 성공한 contiguous prefix만 commit하고 state를 폐기한다. deadline 이후 delivery는 unsettled로 남긴다. +3. poll batch는 partition별로 처리한다. 제출하지 못한 **모든** record의 earliest offset을 partition별로 + seek하고 해당 partition만 pause한다. 한 partition 때문에 다른 partition을 `break`하지 않는다. +4. executor rejection을 잡아 delivered 등록을 되돌리거나 아직 등록하기 전 submit을 시도하고, + coordinator/shutdown permit을 정확히 반환하며 해당 offset을 seek한다. +5. `QueuedSettlement`은 `AtomicReference`로 acknowledge/requeue/discard 중 하나만 허용한다. +6. settlement future는 queue enqueue가 아니라 poll thread의 actual broker operation 결과로 완료한다. +7. `commitSync` 성공 뒤에만 local committed map과 tracker를 prune한다. 실패 시 tracker를 보존하고 retry + policy/health에 노출한다. +8. public pause/resume/close도 poll-thread command queue와 completion future로 직렬화한다. +9. decode failure는 confirmed quarantine/DLQ 뒤에만 source offset을 완료하고, handler exception은 + `RetryDecisionEngine`으로 보낸다. handler deadline도 같은 typed outcome으로 처리한다. + +**필수 테스트** + +- 두 partition을 한 poll에 반환하고 한 partition만 limit에 걸리는 경우 다른 partition은 처리된다. +- executor rejection 후 in-flight=0, offset 미commit, 다음 poll 재전달. +- revoke → reassign → old handler ACK가 새 epoch watermark를 바꾸지 않음. +- ACK 두 번, ACK 후 requeue/discard는 두 번째 terminal call 거절. +- `commitSync` 첫 호출 실패/둘째 성공에서 local watermark가 성공 전 전진하지 않음. +- malformed delivery에서 DLQ unavailable이면 commit하지 않고, confirmed DLQ 뒤에만 commit. +- 다른 thread의 pause/resume/close가 consumer를 직접 호출하지 않고 poll queue에서 실행됨. +- never-completing handler가 configured timeout 뒤 policy대로 unsettled/retry됨. +- close/revoke deadline에서 unfinished handler의 offset이 commit되지 않음. + +### MSG-005 — Rabbit publish/consume state machine이 native broker protocol을 완전히 표현하지 못한다 + +**publisher 근거** + +- `RabbitMessagingTransport.publish:113-122`는 pending 등록 뒤 `channel.publish`를 호출한다. synchronous + 예외를 잡지 않아 pending entry가 남고 메서드가 stage 대신 예외를 던진다. +- timeout은 `onConfirmTimeout:145-153` 외부 호출에 의존하지만 production scheduler/caller가 없다. +- `onConfirm:125-134`에는 Rabbit confirm의 `multiple` flag가 없다. 실제 IT listener도 + `RabbitBrokerIT:110-112`에서 `multiple`을 버린다. +- `RabbitConfirmCoordinator:80-86`은 sequence 하나만 제거한다. multiple ACK/NACK는 `<= tag` 전체를 + 해결해야 한다. +- Rabbit return에는 publish sequence가 없다. IT가 `164-196`에서 `x-seq`를 수동 주입해 correlation하지만 + `RabbitChannelPublisher` contract와 `RabbitPublishMapper`는 이를 보장하지 않고 production 구현도 없다. +- coordinator의 NACK는 `RabbitConfirmCoordinator:143-155`에서 `notTransmitted()`로 기록한다. broker가 + frame을 받고 NACK한 결과이므로 transmission evidence와 모순된다. +- `close()`는 pending stage를 drain/complete하거나 native channel을 닫지 않는다. + +**consumer 근거** + +- `RabbitConsumerRegistrar.onMessage:103-126`의 하나의 catch가 decode, handler stage join, settlement + operation의 모든 `RuntimeException`을 잡아 `RABBIT_UNDECODABLE`로 discard한다. +- handler가 terminal settlement를 전혀 호출하지 않고 정상 complete해도 `true`를 반환한다. +- `close:156-159`는 `active=false`만 설정하지만 `onMessage:89-100`은 active flag를 검사하지 않아 close 뒤 + delivery도 받아들일 수 있다. +- `RabbitSettlementController:74-82`는 native operation 호출 전에 settled flag를 세운다. operation이 + 전송 전에 synchronous failure하면 같은 delivery에 대한 안전한 재시도/상태 판단이 불가능하다. + +**영향** + +multiple ACK를 놓치면 pending publish가 영구 대기하고 memory가 증가한다. handler business failure나 +ACK channel failure를 poison payload로 오인해 discard하면 재시도돼야 할 message를 잃는다. + +**구현 결정: broker-owned publish/settlement state machine** + +- adapter 내부에 실제 native `RabbitChannelBridge`를 구현해 sequence 예약, correlation header, mandatory + publish, confirm/return listener, scheduled timeout, channel-close drain을 한 객체가 소유한다. +- state event를 `Confirm(tag, multiple, ack)`, `Returned(correlation)`, `TimedOut`, `SendFailed`, + `ChannelClosed`로 모델링한다. ordered concurrent map에서 multiple이면 `headMap(tag, true)` 전체를 + 원자적으로 resolve한다. +- synchronous publish failure는 pending을 제거하고 typed classifier로 `REJECTED` 또는 보수적 + `AMBIGUOUS` stage를 완료한다. native 예외를 caller에게 raw throw하지 않는다. +- NACK/return/timeout evidence 생성은 `RabbitPublishResultFactory` 하나로 통일한다. +- consumer는 decode try/catch만 좁게 잡는다. handler failure는 retry engine, settlement failure는 + `UNKNOWN`/unsettled 경로로 보낸다. processor가 exactly-one terminal settlement를 강제한다. +- settlement는 `NEW -> IN_FLIGHT -> TERMINAL` state machine으로 두고, definite pre-send failure에서만 + `NEW`로 복귀한다. close 뒤 `onMessage`는 즉시 false를 반환한다. + +**필수 테스트** + +- multiple ACK/NACK가 tag 이하 pending 전체를 완료하고 pending=0. +- synchronous `basicPublish` throw, scheduled timeout, return-before-confirm, channel close 각각 pending=0. +- missing/malformed return correlation은 metric/audit와 conservative outcome으로 처리. +- handler throw/failed stage/settlement throw가 deserialization DLQ로 잘못 discard되지 않음. +- handler가 settlement 없이 complete하면 success로 ACK하지 않고 contract violation으로 처리. +- settlement synchronous failure와 close-after-delivery race에서 double ACK/discard가 없음. + +### MSG-006 — outbox retry budget와 backoff가 relay 실행 경로에 연결되지 않는다 + +**근거** + +- `OutboxProperties.java:21-29,66-73`은 `maxAttempts=10`을 설정한다. +- `OutboxRetryScheduler.java:53-101`은 pass backoff와 attempt exhaustion 판단을 구현한다. +- 그러나 `OutboxRelay.java:67-103`은 scheduler/maxAttempts를 받지 않고 모든 `AMBIGUOUS`를 즉시 다시 + claim 가능하게 만든다. relay를 실제로 주기 실행하는 production lifecycle도 없다. +- claim SQL `JdbcOutboxRepository.java:48-69`에는 attempt cap이나 `next_attempt_at` 조건이 없다. +- `MessagingReliabilityAutoConfiguration.java:40-55`는 properties/scheduler bean을 만들지만 relay나 + scheduling loop를 구성하지 않는다. +- `InboxCleanupJob`/`OutboxCleanupJob`은 batch size/max batches로 bounded라고 설명하지만 repository purge + port에 limit parameter가 없다. `JdbcInboxRepository.java:103-115`와 + `JdbcOutboxRepository.java:206-218`은 cutoff 전체를 한 DELETE로 지워 큰 table에서 lock/WAL spike를 + 만들 수 있다. + +**영향** + +broker outage 동안 같은 backlog가 poll interval마다 반복 발행되어 broker와 DB를 더 압박한다. 문서와 +settings는 10회 후 park를 약속하지만 실제 row는 `AMBIGUOUS`로 무한 재claim된다. relay 객체를 application이 +직접 만들더라도 scheduler를 별도로 조립하지 않으면 같은 결과다. + +**구현 결정** + +1. retry clock은 process memory가 아니라 row의 `attempts`, `next_attempt_at`, last failure에 둔다. +2. claim predicate는 `next_attempt_at <= now`와 `attempts < maxAttempts`를 적용한다. +3. ambiguous transition에서 policy가 계산한 next-at을 함께 저장한다. attempt budget이 끝나면 별도 + `PARKED`/`EXHAUSTED` terminal status를 사용한다. definite `REJECTED`와 attempt exhaustion을 같은 + `FAILED`로 뭉개지 않는다. +4. pass-level outage backoff는 worker scheduler가 사용하되 row-level eligibility의 대체물이 아니다. +5. `SmartLifecycle` worker가 leader-only인지 all-replica `SKIP LOCKED`인지 명시한다. 후자를 택하면 cleanup + leader election은 별도 문제로 둔다. +6. purge port를 `purgeBefore(cutoff, limit)`로 바꾸고 PostgreSQL CTE에서 bounded ID를 + `FOR UPDATE SKIP LOCKED`로 고른 뒤 delete한다. deleted count가 limit보다 작으면 sweep를 종료한다. + +**필수 테스트** + +- 연속 ambiguous에서 attempt/next-at이 증가하고 deadline 전 claim되지 않음. +- maxAttempts 도달 후 재claim되지 않으며 operator redrive만 가능. +- confirmed publish가 retry 상태를 지우고, definite rejected는 즉시 terminal. +- 두 relay가 outage 중에도 같은 row를 동시에 publish하지 않고 설정된 rate bound를 넘지 않음. +- retention row가 batch보다 많아도 한 SQL call의 delete count가 limit 이하이고 concurrent append/claim을 + 장시간 block하지 않음. + +### MSG-007 — 하나의 starter가 두 broker와 모든 선택 기능을 노출하면서도 자기완결적이지 않다 + +**근거** + +- `messaging-spring-boot-starter/build.gradle:4-19`는 Kafka, Rabbit, 두 JDBC reliability, admin 등 16개 + internal leaf를 전부 `api`로 노출한다. +- AutoConfiguration imports는 core/Kafka/Rabbit/reliability/admin 다섯 구성을 항상 후보로 등록한다. +- starter가 두 broker client를 끌어오므로 양쪽 `@ConditionalOnClass`가 동시에 참이 된다. +- Kafka/Rabbit security configurer는 `CredentialRuntimeRegistry`를 필수 parameter로 받지만 + (`KafkaMessagingAutoConfiguration.java:70-74`, `RabbitMessagingAutoConfiguration.java:56-60`), registry는 + `CredentialProvider`가 있을 때만 생긴다 (`MessagingCoreAutoConfiguration.java:216-220`). +- 현재 `MessagingAutoConfigurationTest.java:41-45,119-131`은 core config만 로드하고 fake publisher를 + 항상 제공해 전체 imports/conditional graph를 검증하지 않는다. + +**구현 결정: 기능별 starter + broker runtime Abstract Factory** + +```text +messaging-spring-boot-autoconfigure-core +messaging-spring-boot-starter-core +messaging-spring-boot-starter-kafka +messaging-spring-boot-starter-rabbit +messaging-spring-boot-starter-reliability-jdbc-postgresql +messaging-spring-boot-starter-admin +``` + +- core starter는 broker SDK, JDBC, admin을 전이 의존하지 않는다. +- Kafka/Rabbit starter가 자기 native client factory, profile binder/validator, runtime factory를 소유한다. +- 일반 internal dependency는 `implementation`으로 낮추고 실제 public signature에 나타나는 타입만 `api`로 + 노출한다. +- credential이 필수인 production profile은 명확한 startup error로 실패한다. local/insecure profile을 + 지원한다면 별도 opt-in이고 production flag와 동시에 허용하지 않는다. +- 기존 통합 artifact 이름을 유지해야 하면 all-in-one runtime이 아니라 BOM/dependency constraints로 + 남긴다. + +**필수 테스트** + +- published imports 전체를 fake publisher/credential 없이 로드했을 때 의도한 error 한 개로 실패. +- core-only, Kafka-only, Rabbit-only classpath fixture compile/context. +- Kafka-only에서 Rabbit config/type가 없고 반대도 동일. +- 두 broker profile이 있을 때 destination별 router가 명시적으로 선택하며 duplicate broker ID는 실패. +- optional admin/reliability가 dependency를 추가하지 않으면 bean도 endpoint도 생기지 않음. + +### MSG-008 — 설정 namespace 세 개와 dead flags 때문에 문서대로 구성할 수 없다 + +**근거** + +| 위치 | namespace | 실제 상태 | +|---|---|---| +| 기존 runtime `MessagingSettings.java:14`, `application.yml:733-740` | `app.messaging` | 현재 배포/환경변수 정본 | +| 신규 `MessagingProperties.java:14` | `backend.messaging` | experimental/bridge/backpressure/shutdown만 binding | +| `docs/messaging/configuration-reference.md:6,80,99,120,138,150` | `messaging` | destination/broker/security 포함하지만 코드 binder 없음 | + +- 신규 properties에는 destination, broker, security profile이 없다 (`MessagingProperties.java:17-20`). +- experimental/bridge getter는 production source에서 읽히지 않고 테스트만 기본값을 확인한다. 관련 + experimental/bridge leaf도 starter dependency가 아니므로 flag를 true로 해도 adapter가 활성화되지 않는다. +- `ValidatedDestinationRegistry`는 bound settings가 아니라 application이 이미 bean으로 제공한 + `DestinationProfile`만 수집한다 (`MessagingCoreAutoConfiguration.java:56-63`). +- Kafka/Rabbit/security validator는 bean으로 존재할 뿐 registry startup validation에 참여하지 않는다. +- `DestinationProfileValidator.validateAll:130-133`은 retry graph와 DLQ graph를 따로 순회해 + `A.retry -> B`, `B.dlq -> A` 같은 mixed-edge cycle을 놓칠 수 있다. + +**구현 결정: 하나의 typed configuration compiler** + +- 이미 배포 계약과 `APP_MESSAGING_*`가 존재하므로 이번 cutover의 canonical prefix는 + **`app.messaging`**로 유지한다. 장기적으로 `ca-skeleton.messaging`로 바꾸고 싶다면 별도 ADR/한정된 + migration release로 다루며 지금 세 번째 namespace를 추가하지 않는다. +- mutable nested bean보다 immutable validated settings를 사용하고 destination/broker/security map을 + 모두 표현한다. unknown field는 거절한다. +- binder 결과를 `MessagingConfigurationCompiler`가 immutable `DestinationProfile`과 broker runtime + descriptor로 컴파일한다. +- generic/Kafka/Rabbit/security validator는 `ProfileValidator` Strategy 목록을 받는 Composite로 묶어 + startup에서 모든 profile에 적용한다. +- retry/DLQ edge label을 가진 하나의 directed graph를 만들고 단일 DFS/SCC cycle validation을 수행한다. + admin topology validator도 같은 compiled graph/report contract를 사용한다. +- `backend.messaging` 또는 bare `messaging` key가 발견되면 actionable migration error로 실패한다. + 한 release alias를 허용해도 두 prefix 동시 사용은 거절한다. +- experimental/bridge flag는 실제 module/bean activation에 연결하거나 공개 설정에서 제거한다. + +**필수 테스트** + +- 문서 YAML 전체를 fixture로 읽어 destination/broker/security/runtime descriptor가 정확히 생성됨. +- typo/unknown field, dangling DLQ, missing broker, unsafe TLS/auth, broker capability mismatch가 boot failure. +- retry와 DLQ edge를 섞은 cycle이 startup에서 정확한 경로와 함께 거절됨. +- old/new prefix 단독·혼합 case와 configuration metadata snapshot. +- experimental flag false/true에서 실제 bean graph가 각각 없고/있으며 dependency 부재 시 명시적 실패. + +### MSG-009 — batch timeout과 stop-on-first-rejection의 공개 계약이 구현되지 않는다 + +**근거** + +- `BatchPublishOptions.java:14-19`는 timeout을 whole-batch deadline으로 정의한다. +- `DefaultBatchMessagePublisher.java:53-90`은 `options.timeout()`을 읽지 않고 `allOf`를 무기한 기다린다. +- `70-81`은 빠른 for-loop에서 각 future가 그 순간 이미 done일 때만 rejection을 본다. 일반적인 비동기 + broker rejection이 도착하기 전에 나머지 요청을 모두 제출한다. +- 현재 async failure test도 이미 완료된 failed future를 사용하고 진짜 delayed rejection 뒤 미제출을 + 검증하지 않는다. + +**구현 결정** + +먼저 option 의미를 두 mode로 명확히 한다. + +1. `BEST_EFFORT_CONCURRENT`: bounded concurrency로 전부 제출하고 whole-batch deadline에서 미확정 항목을 + `AMBIGUOUS/TIMEOUT`으로 완료한다. stop-on-first를 허용하지 않는다. +2. `STOP_AFTER_FIRST_REJECTION`: 순차 또는 작은 bounded window로 제출하고 최초 terminal rejection 뒤 아직 + 시작하지 않은 항목은 `NOT_SUBMITTED_AFTER_REJECTION`으로 결과에 포함한다. 이미 in-flight인 항목은 + 취소했다고 성공/실패를 추측하지 않고 deadline까지 evidence를 기다린다. + +`BatchPublishResult`는 입력 index마다 정확히 한 item result를 가져야 한다. `break`로 결과 개수를 줄이면 +caller가 미제출과 결과 유실을 구분할 수 없다. Java 21 `StructuredTaskScope`를 API에 새로 노출할 필요는 +없으며 bounded executor/semaphore와 deadline scheduler로 충분하다. + +**필수 테스트** + +- never-completing publisher가 batch timeout에 전체 stage를 완료하고 outstanding을 ambiguous로 표시. +- delayed first rejection 후 아직 시작하지 않은 index는 호출되지 않고 explicit not-submitted result. +- 이미 in-flight success/reject/ambiguous는 결과에 보존. +- empty batch, max size 경계, executor rejection, caller cancellation에서 permit/thread leak 없음. + +### MSG-010 — runtime lifecycle과 backpressure가 boolean counter API에 의존해 손상될 수 있다 + +**근거** + +- `BackpressureController.release:73-80`은 destination entry가 없거나 이미 0이어도 global이 양수면 global을 + 감소시킨다. 잘못된 destination/double release로 global limit를 우회할 수 있고 0 counter entry도 + map에 남는다. +- `GracefulShutdownCoordinator.endWork:62-65`도 double release에서 음수가 될 수 있다. +- `DefaultMessagingRuntimeRegistry.closeExpiredDraining:83-110`은 모든 retired generation에 caller가 준 + 하나의 `retiredAt`을 적용한다. `Generation:124-130`에는 자체 retirement time이 없다. +- idle close된 generation도 draining list에서 즉시 제거되지 않는다. `closeExpiredDraining`과 + `beginDrain`의 production caller/`SmartLifecycle` 연결이 없다. + +**구현 결정: RAII-style Permit + generation-owned lifecycle** + +```java +interface AdmissionPermit extends AutoCloseable { + DestinationName destination(); + @Override void close(); // CAS, exactly once +} + +interface WorkPermit extends AutoCloseable {} +``` + +- acquire가 boolean 대신 destination에 묶인 permit을 반환하고 caller는 `try/finally` 또는 async + `whenComplete`에서 close한다. 임의 destination 문자열 release API는 제거한다. +- per-destination counter가 0이면 conditional remove한다. global/per-destination 변경이 항상 한 permit의 + lifecycle로 짝을 이뤄야 한다. +- generation은 `retiredAt`, monotonic generation ID, active leases, closed flag를 보유한다. `Clock`을 + registry에 주입하고 `closeExpiredDraining(now)`가 generation별 deadline을 판단한다. +- registry는 `AutoCloseable`/lifecycle을 구현해 current와 draining runtime 모두 exactly once close한다. +- Spring lifecycle 순서는 consumer registration 중단 → admission close → begin drain → deadline wait → + unresolved work를 unsettled/ambiguous로 종료 → transport close다. + +**필수 테스트** + +- wrong destination/double close/concurrent close가 count를 손상하지 않음. +- 서로 다른 시각에 retired된 두 generation이 자기 deadline에만 close. +- 마지막 lease 반환 시 draining 목록에서 즉시 제거, leaked lease는 deadline에 force-close. +- context close 중 신규 publish/delivery 거절, 기존 work drain, close exactly once. + +### MSG-011 — credential rotation과 broker security configuration이 fail-closed하지 않다 + +**근거** + +- `CredentialRuntimeRegistry.resolve:58-69`는 `get` → 외부 fetch → `put` → 이전 runtime `clear`를 + synchronization 없이 수행한다. +- 두 caller가 동시에 rotation하면 둘 다 같은 old runtime을 보고 replacement를 fetch한 뒤 하나가 map에서 + 유실되고 clear되지 않을 수 있다. +- 조금 늦은 caller는 첫 replacement를 current로 잡고 두 번째 replacement를 넣은 뒤, 첫 caller가 아직 + 쓰는 credential material을 clear할 수 있다. +- `CredentialRuntime.material/clear:81-85,128-132` 자체도 동기화되지 않는다. +- `BrokerTlsPolicy.java:72-96`은 알려진 구버전 denylist 방식이라 `SSL`, `TLSv0.9`, `PLAINTEXT` 같은 + 비정상 protocol 문자열도 통과할 수 있다. +- `KafkaSecurityConfigurer.java:124-133`은 password를 quoted JAAS string에 escaping 없이 삽입한다. + quote/backslash/semicolon/newline이 있는 정상 secret도 parsing을 깨거나 option injection이 된다. +- OAuth와 mTLS branch는 credential을 resolve하거나 mechanism 이름만 설정하고 실제 callback/client + identity 구성까지 완결하지 않는다. 구현되지 않은 mode를 부분 설정으로 허용하면 연결 시점에 실패한다. + +**구현 결정** + +- credential을 raw cached value가 아니라 `CredentialGenerationLease`로 대여한다. +- key별 single-flight refresh(`ConcurrentHashMap.compute` 또는 keyed lock)로 한 replacement만 publish한다. +- 이전 generation은 새 client/runtime가 성공적으로 설치된 뒤 draining으로 보내고 active lease가 0이거나 + deadline이 지났을 때만 clear한다. 이는 messaging runtime generation과 같은 lifecycle에 결합한다. +- provider fetch 실패 시 아직 만료되지 않은 current credential을 정책에 따라 유지하고, 이미 만료된 + credential은 fail-closed한다. 이 정책을 typed outcome으로 기록한다. +- material clone의 소유권과 clear 책임을 문서화하고 char array가 map에서 유실되지 않도록 한다. +- TLS protocol은 `TLSv1.2`/`TLSv1.3` allowlist로 검증하고 hostname verification/production auth를 함께 + compiler invariant로 둔다. +- JAAS 문자열 직접 조립 대신 표준 escaping 또는 callback handler/typed client property를 사용한다. + OAuth/mTLS는 실제 client context까지 구성하기 전 profile validation에서 명시적으로 거절한다. + +**필수 테스트** + +- 100 concurrent resolve에서 provider refresh 1회, winning generation 1개, losing secret 0개. +- old lease 사용 중 rotation해도 material 접근 가능; lease close 뒤 zeroization. +- fetch failure before/after expiry, shutdown clear, double lease close. +- quote/backslash/semicolon/newline secret, protocol case/unknown/SSL/TLSv0.9, incomplete OAuth/mTLS profile. + +### MSG-012 — wire-facing value object/header 경계가 control character와 크기 공격을 허용한다 + +**근거** + +- `MessageId.java:13-19`는 UUIDv7이라고 문서화하지만 constructor는 모든 UUID version/variant를 받는다. +- `MessageType`, `ProducerId`, `CorrelationId`는 blank와 Java character count만 검사한다. control character, + newline, unbounded UTF-8 byte expansion을 허용한다. +- `ContentType.java:26-34`는 slash 포함 여부와 lowercase만 검사해 유효한 media type 문법을 보장하지 않는다. +- `TraceContext.java:17-24`는 Optional non-null 외 W3C grammar/size 제한이 없다. +- `MessageEnvelope.java:42-43,49-65`의 partition/ordering key는 길이 제한이 없다. +- `HeaderName.java:18-35`는 nonblank/128 UTF-8 bytes만 검사해 CRLF, NUL, colon, leading/trailing + whitespace를 허용한다. secret/reserved denylist는 lowercase exact match라 `Authorization ` 같은 변형이 + 우회한다. +- `JdbcOutboxRepository.toJson/fromJson:262-324`는 header JSON을 손으로 처리하며 quote/backslash 외 JSON + control escape를 하지 않는다. 허용된 newline/NUL은 PostgreSQL JSONB insert 실패 또는 lossy parsing을 + 만들 수 있다. +- Kafka/Rabbit header mapper는 검증된 것으로 가정하고 이 이름/값을 broker wire에 전달한다. + +**구현 결정: canonical wire-safe value objects** + +- identifier/header name은 ASCII token grammar와 UTF-8 byte limit를 사용한다. name은 trim을 허용하지 않고 + canonical lowercase를 저장한다. 허용 문자를 정본 regex로 하나만 둔다. +- reserved/secret 검사는 canonical name과 prefix/구조 규칙으로 수행한다. metric-safe라는 문구가 실제 + low-cardinality를 의미하지는 않으므로 producer/message type의 registry allowlist도 별도 둔다. +- header value는 CR/LF/NUL 등 transport-unsafe controls를 거절하고 broker별 encoded byte budget을 + envelope 전체 size budget에 포함한다. +- W3C Trace Context parser로 traceparent/tracestate/baggage grammar와 표준 size/member bound를 적용한다. +- partition/ordering key는 typed `PartitionKey`/`OrderingKey`로 만들고 bytes bound를 둔다. +- UUIDv7만 받으려면 version/variant를 검증한다. 임의 UUID import가 필요하면 문서와 타입명을 일반 + `MessageId`로 정직하게 바꾸고 `newId()`만 v7임을 명시한다. +- outbox JSON은 검증된 JSON serializer/PG JSONB mapping을 사용한다. codec dependency 회피를 위해 + correctness를 포기하지 않는다. + +**필수 테스트** + +- CRLF/NUL/colon/space/Unicode confusable/UTF-8 max+1 header와 identifier. +- `Authorization `, mixed whitespace/control, reserved prefix 변형이 모두 거절됨. +- valid/invalid W3C trace vectors와 total baggage bound. +- Kafka/Rabbit/outbox round-trip에서 canonical header가 byte-for-byte 보존됨. + +### MSG-013 — diagnostics 값이 cardinality guard 뒤에서 metric tag로 추가된다 + +**근거** + +- `MessagingMetrics.recordDiagnostics:115-132`는 base `MessagingTags`만 `admitted(tags)`로 guard한 뒤, + arbitrary diagnostic key/value를 `diagnostic`/`value` tag로 추가한다. +- redaction은 알려진 secret key를 가릴 뿐 고유 message ID, exception message, URL, tenant 값의 무한 + cardinality를 막지 않는다. +- `CardinalityGuard.admit:50-61`의 size-check/add는 원자적이지 않고, `admit(MessagingTags):70-76`은 뒤 + dimension에서 실패해도 앞 dimension을 이미 관측 집합에 추가한다. +- 실제 central pipeline에 `MessagingMetrics`가 조립되지 않아 일부 contract는 사용되지 않는다. + +**영향** + +한 request마다 다른 diagnostic value가 meter series를 영구 생성해 metric backend와 application heap을 +소진할 수 있다. 민감 값이 알려진 key가 아닌 자유 text에 포함되면 redactor도 막지 못한다. + +**구현 결정** + +- metric tag는 destination/broker/operation/outcome/failure-code처럼 닫힌 allowlist와 bounded vocabulary만 + 허용한다. +- arbitrary diagnostics는 sanitized structured log/trace event로 보내고 metric에는 fixed counter와 + normalized failure code만 남긴다. +- guard는 안전망이지 동적 사용자 값을 허용하는 근거가 아니다. 필요하면 dimension별 synchronized + bounded set/atomic compute를 사용하고 tag set 전체를 preflight한 뒤 commit한다. +- `MessagingObservation`을 중앙 publisher/delivery processor decorator에 조립한다. + +**필수 테스트** + +- 10,000개 고유 diagnostic value를 보내도 meter count가 고정. +- secret이 key/value/free-form exception 어느 위치에서도 tag/log에 평문 노출되지 않음. +- concurrent cardinality admission이 configured limit를 넘지 않고 rejected counter가 정확함. + +### MSG-014 — Stable과 live-broker coverage가 실행 결과가 아니라 hard-coded 자기선언이다 + +**근거** + +- `BrokerFailureMatrix.shipped:97-129`는 5개 scenario 모두를 Kafka/Rabbit `LIVE_BROKER`로 하드코딩한다. +- `CrossBrokerContractSuite.java:31-53`은 실제 JUnit/CI artifact가 아니라 그 map을 assert한다. +- `RabbitBrokerIT.java:129-162`의 live test는 routable/unroutable happy contract 네 개뿐이며 + connection-refused, cut-after-write, confirm-timeout, settlement-lost, high-latency 5개를 실행하지 않는다. +- Docker가 없으면 `DockerAvailability` + `@EnabledIf`로 IT 전체가 skip되지만, messaging 전용 fail-closed + workflow/evidence gate가 없다. +- `CompatibilityMatrix.java:62-67`와 `docs/messaging/support-matrix.md:8-13`은 Kafka 4.2/4.3을 인증했다고 + 선언한다. 실제 `KafkaContainerFixture.java:38`, `KafkaBrokerIT.java:74`, + `KafkaAmbiguityChaosIT.java:59`는 `apache/kafka:4.1.0`, lockfile client는 4.1.1이다. + +**영향** + +Docker가 전혀 없는 CI와 선언 버전을 한 번도 실행하지 않은 build도 Stable gate를 통과한다. matrix와 +문서가 서로 일치하는 테스트는 둘이 같은 잘못된 상수를 복제했는지만 증명한다. + +**구현 결정: evidence manifest release gate** + +1. dev `test`는 Docker 부재 시 skip할 수 있지만 별도 `messagingCertificationTest`/workflow는 Docker와 + 각 broker version이 없으면 실패한다. +2. broker/version/scenario/test commit/image digest/result/timestamp를 machine-readable JSON artifact로 + 생성한다. +3. compatibility/failure matrix는 source 상수가 아니라 해당 release run의 signed/immutable evidence를 + 소비한다. 증거가 없으면 `NOT_COVERED`다. +4. Kafka 4.2와 4.3 이미지가 실제 존재하고 프로젝트가 지원할 준비가 됐을 때 각각 matrix job으로 실행한다. + 그 전에는 현재 검증한 4.1.x만 표기하거나 Stable 주장을 내린다. +5. Rabbit도 5개 network fault와 consumer settlement loss를 실제 broker/proxy에서 실행한다. +6. `failOnNoDiscoveredTests`, expected suite count, skip count zero를 release lane에서 강제한다. + +**필수 artifact** + +```json +{ + "adapter": "messaging-rabbit", + "brokerVersion": "4.3.x", + "scenario": "confirm-timeout", + "testId": "...", + "outcome": "AMBIGUOUS", + "imageDigest": "sha256:...", + "gitCommit": "..." +} +``` + +현재 로컬 run에서 Docker IT 10개가 실제 실행되어 skip 0이었던 사실은 긍정적이지만, 위 누락 scenario와 +version을 대신하지 않는다. + +### MSG-015 — 기존 application/runtime과 신규 platform 사이에 semantic bridge와 cutover authority가 없다 + +**근거** + +- 현재 `app-bootstrap` dependency/runtime membership은 기존 `adapter:outbound:messaging`만 포함한다. + 신규 24개 leaf는 모두 `runtime_memberships: []`이고 `dev.caskeleton.messaging.*`를 소비하는 production + bridge가 신규 tree 밖에 없다. +- application canonical port는 `OutboxMessagePublishPort.java:10-19`, 기존 adapter API는 별도 + `core/MessagePublisher`, 신규 API는 `messaging-core-api/.../publish/MessagePublisher.java:13-25`, reliability + API에는 다시 `ReliableMessagePublisher`가 있다. +- 기존 `ValidatedIntegrationEvent`/`OutboxEvent`/v2 모델과 신규 `MessageEnvelope`/`OutboxRecord`가 + identity, metadata, wire bytes를 서로 다르게 표현한다. +- 기존 `OutboxEventStatus.java:25-36`의 `FAILED`는 retryable이고 `DEAD`가 terminal이다. 신규 + `OutboxStatus`의 `AMBIGUOUS`는 retryable이며 `FAILED`는 definite rejection terminal이다. +- application-core가 신규 platform implementation/API를 직접 import하면 현재 registry와 local layer + policy를 위반한다. + +**영향** + +이름이 같은 enum을 기계적으로 매핑하면 retryable/terminal 의미가 뒤집힌다. 두 publisher/outbox writer를 +동시에 켜면 한 business fact가 두 durable store와 두 relay로 발행된다. 신규 adapter의 AMBIGUOUS를 기존 +exception 하나로 축약하면 broker에 저장됐을 수 있는 message의 정합성을 잃는다. + +**구현 결정: Anti-Corruption Layer + single publication authority** + +1. application-owned semantic port/model을 canonical business boundary로 유지한다. +2. application outcome에 최소 `CONFIRMED`, `AMBIGUOUS`, `REJECTED_BEFORE_SEND`, + `REJECTED_AFTER_BROKER`를 표현하고 기존 state transition 표를 먼저 확정한다. +3. outbound `messaging-platform-bridge`가 validated application event를 canonical platform envelope로 + 변환하고 신규 `PublishResult`를 application outcome으로 역변환한다. application은 신규 타입을 모른다. +4. event/message ID, type, schema revision, partition/order/correlation/causation/tenant/trace, exact payload + digest와 wire version을 golden contract로 보존한다. +5. 기존 `OutboxPublicationAuthority`/dispatch fence를 재사용해 writer와 relay authority는 항상 하나만 + ACTIVE가 되게 한다. dual write/publish는 금지한다. +6. 첫 cutover는 기존 outbox storage/writer를 유지하고 **transport만** 신규 platform으로 바꾼다. + storage migration은 별도 release에서 shadow read → authority switch → old backlog drain 순서로 한다. + +**필수 테스트** + +- old event → bridge → new envelope golden bytes/headers/digest. +- 모든 new publish outcome × old outbox state transition table. +- 한 business action당 durable row와 broker publish가 정확히 하나. +- mixed-version rolling deployment에서 authority switch, crash, rollback. +- ArchUnit로 deprecated model에 신규 production import 금지. + +### MSG-016 — broker round-trip과 outbox/CDC가 canonical envelope 정보를 유실하거나 reserved 값을 위조한다 + +**근거** + +- Kafka/Rabbit producer header mapper는 identity/trace 등을 쓰고 reverse mapper도 제공하지만, + `KafkaDeliveryMapper.java:72-92`와 `RabbitDeliveryMapper.java:73-96`는 tenant를 empty, application headers를 + empty, encoded schema reference를 empty로 재구성한다. +- `KafkaRetryMetadataMapper`는 envelope retry header를 읽는데 Kafka delivery mapper가 headers를 비우므로 + retry attempt가 다시 1로 시작할 수 있다. Rabbit의 malformed retry header도 `attemptOf:132-141`에서 + fail-closed하지 않고 1로 되돌린다. +- `OutboxRecord`는 arbitrary `Map`을 받고 `OutboxEnvelopeFactory`는 이를 + `MessageHeaders.platform`으로 만든다. 이 경로는 reserved header를 허용한다. +- Kafka mapper는 canonical reserved headers를 먼저 추가한 뒤 `KafkaHeaderMapper.java:70`에서 envelope + headers를 다시 추가하고 consumer는 `lastHeader:100-104`를 신뢰한다. DB의 forged `msg.id`가 canonical + message ID를 덮을 수 있다. Rabbit도 `RabbitHeaderMapper.java:39-87`에서 application header를 마지막에 + 쓴다. +- 신규 `OutboxRecord`에는 producer/correlation/causation/tenant/trace/schema reference가 없고 + `OutboxEnvelopeFactory`가 일부 값을 주입하거나 empty로 발명한다. +- `DebeziumOutboxEventRouter`는 destination을 event key로 사용하며, polling mapper와 test-only CDC mapper가 + partition key/header 규칙을 다르게 적용한다. + +**영향** + +tenant isolation, inbox identity, retry cap, ordering, tracing, schema resolution이 publish 방식 +(direct/polling/CDC)과 broker에 따라 달라진다. forged reserved ID는 다른 message의 inbox dedup/audit을 +오염시킬 수 있다. + +**구현 결정: canonical EnvelopeHeaderCodec + persistence schema** + +- `ApplicationHeaders`와 `PlatformHeaders`를 타입 수준에서 분리한다. application code/outbox input은 + reserved namespace를 생성할 수 없어야 한다. +- `EnvelopeHeaderCodec` 하나가 canonical reserved field와 application header를 encode/decode한다. + Kafka, Rabbit, outbox polling, Debezium SMT, CloudEvents가 같은 contract를 사용한다. +- duplicate reserved header, malformed retry/tenant/schema/trace 값은 reject/quarantine한다. first/last wins로 + 복구하지 않는다. +- outbox에 canonical metadata column 또는 versioned canonical envelope bytes를 저장한다. CDC event key는 + partition key(없으면 message ID 등 명시된 fallback), destination은 routing metadata로 분리한다. +- `ReservedHeaders`에 tenant/schema reference/retry/redrive fields를 명시하고 typed decode 결과를 사용한다. + +**필수 테스트** + +- full envelope의 Kafka/Rabbit property-based round-trip: 모든 metadata/application headers/payload 동일. +- retry 1 → 2 → 3 → DLQ, malformed retry header quarantine. +- forged/duplicate `msg.id`, type, correlation, tenant header가 broker/outbox 경계에서 거절됨. +- 실제 PostgreSQL + Debezium container에서 polling과 CDC consumer가 key/header/payload byte-for-byte 동일. +- tenant A message가 tenant B context로 재구성되지 않음. + +### MSG-017 — PublishOptions, capability와 PublishResult가 실제 adapter 동작보다 강한 계약을 노출한다 + +**근거** + +- `PublishOptions.java:16-25`는 timeout, confirmation, deduplication, broker hints를 정의하지만 + `KafkaMessagingTransport.publish:106-137`와 `RabbitMessagingTransport.publish:95-122`는 + `request.options()`를 사용하지 않는다. +- `PublishOptions.isM1Compatible()`은 production에서 호출되지 않아 일반 caller가 M3 broker hints를 넣을 수 + 있다. +- Kafka capability는 deduplicated publish를 true로 선언하지만 producer idempotence는 동일 producer + session의 sequence retry를 다루며, message ID 기반 process restart 간 dedup을 보장하지 않는다. +- `PublishResult` public constructor는 completion/evidence/routing의 모순 조합을 만들 수 있다. exception + conversion도 typed transmission evidence가 없는 일부 exception을 `NOT_TRANSMITTED`로 단정한다. + +**구현 결정: normalized options + runtime-derived capabilities + sealed outcome** + +- 중앙 publisher가 destination policy와 call option을 merge하는 `ResolvedPublishOptions`를 만든다. + unsupported confirmation/dedup/hint는 broker에 보내기 전에 명시적으로 거절한다. +- timeout은 admission부터 broker outcome까지 absolute deadline으로 전파하고 timeout 시 transmission + milestone에 따라 AMBIGUOUS를 판단한다. +- capability는 adapter 상수가 아니라 destination + 실제 producer/runtime config에서 계산한다. persistent + message-ID store가 없다면 Kafka `deduplicatedPublish=false`다. +- public arbitrary constructor 대신 private factories/sealed ADT를 사용한다. + +```text +Confirmed +RejectedBeforeTransmission +RejectedAfterBrokerAcceptance +Ambiguous +``` + +- broker hint map은 M3 native API의 typed option으로 이동하거나 일반 M1 API에서 제거한다. + +**필수 테스트** + +- per-call timeout/confirmation/dedup/hint 지원·미지원 matrix. +- producer restart 뒤 같은 message ID가 broker 중복 억제되지 않음을 capability test로 고정. +- 모든 result truth table과 invalid combination compile/construction 불가. +- synchronous native send exception과 callback exception이 evidence-bearing typed outcome으로 변환됨. + +### MSG-018 — Kafka transactional processor가 handler를 transaction 시작 전에 실행한다 + +**근거** + +- `KafkaTransactionalProcessor.java:20-28`은 handler callback이 Kafka transaction 안에서 실행된다고 + 설명한다. +- `SpringKafkaTransactionalProcessor.java:47-56`은 handler를 먼저 호출한 뒤 publisher로 넘긴다. +- 실제 `producer.beginTransaction()`은 `KafkaTransactionalPublisher.java:94`에서 나중에 실행된다. + +**영향** + +handler가 직접 만든 Kafka output이나 외부 side effect는 문서와 달리 transaction 밖이다. handler 성공 뒤 +begin/commit 실패, handler 도중 예외, dynamic output 생성에서 input offset과 output visibility가 한 +transaction이라는 보장이 성립하지 않는다. + +**구현 결정** + +- transaction owner가 `beginTransaction`한 뒤 callback을 실행한다. +- callback은 raw producer를 받지 않고 `ProcessResult(value, outputs)` 또는 transaction-scoped output port를 + 사용한다. +- callback output 전송 + source offsets + commit을 한 try 안에 두고 모든 failure에서 abort한다. +- 외부 DB side effect와 Kafka transaction을 exactly-once로 묶는다고 표현하지 않는다. DB 효과는 inbox/ + outbox 등 별도 idempotency가 필요하다. + +**필수 테스트** + +- handler 안에서 producer transaction state가 active. +- handler throw, output send failure, offset send failure, commit failure마다 abort되고 `read_committed`에서 + output/offset이 보이지 않음. +- handler가 동적으로 만든 여러 output도 같은 transaction에 포함. + +### MSG-019 — public API가 vendor 타입을 노출하지만 Gradle dependency는 숨기거나 지나치게 노출한다 + +**대표 근거** + +- `CloudEventMapper` public API는 `io.cloudevents.CloudEvent`를 노출하지만 module dependency는 + `implementation`이다. +- Kafka public constructor/mapper는 `Producer`, `ProducerRecord`, `RecordMetadata`를 노출하지만 Kafka client는 + `implementation`이다. +- Rabbit public `RabbitChannelPublisher`는 Spring AMQP `Message`를 노출하지만 Spring AMQP는 + `implementation`이다. +- `MessagingMetrics` public constructor는 Micrometer `MeterRegistry`, reactive facade는 Reactor `Mono`를 + 노출하지만 각각 implementation dependency다. +- 반대로 starter는 16개 internal module을 모두 `api`로 노출한다. + +**구현 결정** + +- 외부 public surface를 `api`와 의도한 SPI package로 allowlist한다. native mapper/coordinator/channel seam은 + package-private 또는 `.internal`로 낮춘다. +- vendor extension이 의도된 public API라면 별도 native module에서 dependency를 `api`로 정확히 선언한다. +- 일반 internal project dependency는 `implementation`으로 낮추고 build-time consumer fixture로 검증한다. +- Revapi/japicmp 또는 repository public API snapshot과 `dev.caskeleton.messaging..` 전용 ArchUnit rule을 + 추가한다. + +**필수 테스트** + +- Gradle TestKit 외부 Java consumer가 각 published artifact의 documented API만으로 compile. +- internal package import 실패, transitive Kafka/Rabbit이 core starter classpath에 없음. + +### MSG-020 — codec/schema registry가 size limit 전에 전체 payload를 할당하고 version을 정본 key로 쓰지 않는다 + +**근거** + +- JSON은 `JacksonMessageCodec.java:105-116`에서 `writeValueAsBytes` 후 size를 검사한다. +- Avro는 `AvroMessageCodec.java:96-113`에서 unbounded `ByteArrayOutputStream`/`toByteArray` 후 검사한다. +- Protobuf는 `ProtobufMessageCodec.java:82-87`에서 `toByteArray` 후 검사한다. +- Avro `decodeEvolved:163-179`는 normal decode와 달리 encoded length check가 없고 nested schema map은 outer + map만 copy한다. +- JSON/Protobuf registry는 `(MessageType, SchemaVersion)`이 아니라 message type만 key로 사용해 등록되지 않은 + v999도 기존 class/parser로 decode하고 그 version label을 유지할 수 있다. + +**영향** + +configured max가 allocation bound가 아니므로 대형 object가 heap을 소진한 뒤에야 reject된다. schema +version과 실제 parser가 분리되면 compatibility gate와 audit가 거짓이 된다. + +**구현 결정** + +- max+1에서 즉시 예외를 내는 bounded `OutputStream`/coded stream을 사용하고 가능한 codec의 신뢰 가능한 + serialized-size estimate도 먼저 검사한다. +- decode는 input bytes, depth, nesting, collection/string limits를 parser 전에/내부에서 모두 적용한다. +- registry key를 `MessageContractKey(MessageType, SchemaVersion)`로 바꾸고 descriptor가 Java class, + parser/schema, content type, compatibility policy를 함께 보유한다. +- construction 시 duplicate/mismatch를 fail-fast하고 Avro map을 deep immutable copy한다. + +**필수 테스트** + +- max-1/max/max+1 및 훨씬 큰 streaming object에서 allocation/output이 limit 근처에서 중단. +- unregistered version, parser/class mismatch, duplicate registration 거절. +- Avro evolved oversized input과 caller nested map mutation. +- property/fuzz corpus: deep JSON, malformed varint, recursive/large collection schema. + +### MSG-021 — admin approval은 위조 가능하고 one-shot 실행 기록이 process-local이다 + +**근거** + +- `AdminApproval`, `ApprovedReplayPlan`, `ApprovedRedrivePlan`, destructive admin `Approved`는 public plain + constructor/record로 caller가 직접 승인 객체를 만들 수 있다. +- approval은 operation/source/target/plan digest/max impact에 cryptographically 또는 opaque capability로 + bind되지 않아 다른 plan에 재사용될 수 있다. +- `AdminOperationIdempotencyStore.java:21-57`는 in-memory `ConcurrentHashMap`이고 starter도 이를 기본 등록한다. +- `DefaultMessagingAdminService.java:101-112,136-147`은 work 전에 ticket을 claim한다. 중간 실패하면 ticket은 + 소비됐지만 진행 위치/재개 상태가 없다. +- redrive loop의 synchronous failure는 뒤 item과 final audit을 건너뛸 수 있다. + +**구현 결정: verified capability + durable operation journal** + +- `ApprovalVerifier`만 issuer signature, subject separation, exact operation/source/target/topology version/plan + digest/max impact/expiry를 검증하고 opaque verified token을 만든다. 일반 caller가 verified type constructor를 + 호출할 수 없게 한다. +- shared DB journal에 unique `(approval_id, plan_digest)`와 `STARTED`, item progress, `COMPLETED`, `FAILED`, + lease/fencing을 저장한다. +- retry는 새 실행이 아니라 같은 operation을 checkpoint부터 resume한다. item별 redrive count/max enforcement와 + audit를 transactionally 기록한다. +- destructive API와 non-destructive facade 모두 같은 approval authority/journal을 사용한다. + +**필수 테스트** + +- 직접 constructor/서명 변조/wrong source-target/다른 plan 재사용/expired topology 거절. +- restart와 두 replica 경쟁에서 exactly one operation lease. +- N번째 item 실패 뒤 재개, max redrive count, audit failure/retry. + +### MSG-022 — experimental adapter가 class-name 문자열로 오류를 추측하고 unknown을 definite rejection으로 낮춘다 + +**근거** + +- Pulsar/NATS adapter는 exception class simple name substring으로 일부 오류를 분류한다. +- unknown send failure를 `REJECTED/not transmitted`로 두고 elapsed를 `Duration.ZERO`로 보고하며 per-call + timeout/options를 적용하지 않는다. +- NATS의 substring 판정은 unrelated exception 이름도 no-stream 계열로 오분류할 수 있다. + +**영향** + +experimental이더라도 caller가 `REJECTED`를 보고 새 ID로 재시도하면 실제 broker가 받은 message를 +중복 생성할 수 있다. class name은 SDK version에 따라 바뀌는 비계약 문자열이다. + +**구현 결정** + +- 명시적인 typed SDK pre-send exception만 `REJECTED/NOT_TRANSMITTED`로 분류한다. +- write milestone 이후 또는 알 수 없는 failure의 기본값은 `AMBIGUOUS`다. +- adapter가 실제 client bridge/timeout/cancellation을 구현하기 전에는 `Extension` 또는 + `Experimental contract seam`으로 문서화하고 Stable capability를 주장하지 않는다. +- contract test에 unknown subtype, wrapped exception, timeout, synchronous/asynchronous failure를 추가한다. + +### MSG-023 — module 이름·폴더 구조와 canonical 정책 문서가 실제 43-leaf 구조를 설명하지 못한다 + +**근거** + +- `messaging-outbox-jpa`/`messaging-inbox-jpa`는 JPA가 아니라 Spring JDBC + PostgreSQL 전용 SQL + (`?::jsonb`, `FOR UPDATE SKIP LOCKED`, `ON CONFLICT`, JSONB/BYTEA/TIMESTAMPTZ)을 사용한다. +- root policy는 19개 leaf라 선언하지만 registry/settings는 43개다. +- `src/messaging/CLAUDE.md`가 없어 API/policy/SPI/adapter/autoconfigure/testkit의 framework/public surface와 + Stable promotion 규칙이 local authority에 없다. +- 신규 모든 leaf의 runtime membership이 empty지만 build-only/incubating 의미가 support matrix에 명확하지 + 않다. + +**구현 결정** + +- JDBC module은 `messaging-outbox-jdbc-postgresql`, `messaging-inbox-jdbc-postgresql`로 rename하고 vendor-neutral + port는 reliability API에 둔다. +- hard-coded leaf count를 registry에서 생성하거나 문서를 “registry가 소유하는 leaf 전체”로 표현하고 + consistency task로 drift를 막는다. +- `src/messaging/CLAUDE.md`에 family별 허용 dependency, framework rule, public allowlist, stable evidence, + runtime membership/cutover 원칙을 추가한다. +- 24개 leaf를 무조건 합치지는 않는다. optional broker/codec isolation은 유지하되 아래 목표 tree처럼 + family directory와 starter 경계를 명확히 한다. + +### MSG-024 — 현재 사용 중인 legacy runtime에도 disabled/retry/payload/log 안전성 공백이 있다 + +**근거** + +- `application.yml:466-476`과 `OutboxConfig.java:32-56`은 relay를 기본 활성화하지만 broker가 blank면 + `MessagingConfig.java:40-46`이 disabled publisher를 만든다. +- 기존 relay `PublishPendingOutboxEventsUseCase.java:117-165`는 publish failure를 retry한 뒤 `DEAD`로 + 소진한다. 의도적으로 messaging-off인 환경에서도 pending row를 계속 claim할 수 있다. +- `application-core/.../OutboxEvent.java:36-48`은 payload non-null만 검사하고 + `OutboxEnvelopeJson.java:18-40`은 raw payload를 전체 문자열에 그대로 삽입한다. JSON validity와 UTF-8 + byte cap이 없다. +- `Slf4jOutboxRelayFailureReportAdapter.java:37-50`은 raw Throwable을 structured log cause로 전달한다. + allowlisted field와 별개로 exception message/stack에 payload, endpoint, secret이 포함될 수 있다. +- `OutboundMessagePublisher.java:27-35`는 broker send와 success logging을 한 try로 묶어, broker 성공 뒤 + logger 예외를 publish failure로 오인할 수 있다. +- `KafkaAdapterSettings.java:17-26`은 trim한 값으로 regex 검증하지만 원문을 저장하고 port 0/99999를 + 허용한다. + +**구현 결정** + +- startup invariant `relay-enabled -> broker configured`를 강제하거나 disabled broker에서는 claim 자체를 + 중단해 PENDING을 보존한다. +- append/encode 경계에서 payload JSON을 strict parse/canonicalize하고 UTF-8 byte limit를 적용한다. +- failure report는 exception class + bounded sanitized code만 기록한다. raw Throwable을 운영 JSON log에 + 넣어야 한다면 최종 serialized output 전체에 검증된 redaction을 적용한다. +- publish outcome과 observation을 decorator로 분리해 logging failure가 broker result를 바꾸지 않게 한다. +- broker address를 typed host/port parser로 canonicalize하고 port 1..65535, bracketed IPv6를 검증한다. + +**필수 테스트** + +- broker blank + relay true startup failure 또는 PENDING 보존. +- malformed JSON, max bytes ±1, Unicode/surrogate/large payload. +- 실제 Logstash encoder JSON에 arbitrary secret/payload/endpoint 문자열 없음. +- broker confirmed + logger throw에서도 application publish 성공 유지. +- host whitespace, port 0/65535/65536, IPv6. + +### MSG-025 — 운영 문서와 outbox reclaim 설명이 실제 코드에서 drift했다 + +**근거** + +- `docs/messaging/outbox-inbox.md:41-48`은 claim 대상을 `PENDING`, `AMBIGUOUS`로만 설명하지만 구현과 + partial index는 lease-expired `IN_FLIGHT`도 reclaim한다. +- `docs/runbooks/outbox-publish-failed.md:28`의 `APP_MESSAGING_KAFKA_ENABLED`와 문서의 adapter class 이름은 + 현재 env/class와 맞지 않는다. +- configuration reference의 root prefix와 실제 binder 불일치는 MSG-008에 별도로 다뤘다. + +**구현 결정/테스트** + +- runbook 명령/env/class/predicate를 executable documentation test 또는 source-generated snippet으로 + 연결한다. +- relay crash → lease expiry → same-ID reclaim integration test 이름을 문서 evidence에 링크한다. +- 문서의 Stable 문구는 MSG-014 evidence manifest가 존재할 때만 생성/승격한다. + +## 6. 권장 목표 아키텍처와 폴더 구조 + +### 6.1 책임 흐름 + +```mermaid +flowchart LR + A[application-owned messaging port] --> B[platform anti-corruption bridge] + B --> C[DefaultMessagePublisher] + C --> D[configuration/profile compiler] + C --> E[codec + canonical envelope codec] + C --> F[security + admission permit] + C --> G[runtime generation lease] + G --> H{MessagingTransport Strategy} + H --> K[Kafka adapter] + H --> R[Rabbit adapter] + H --> X[Experimental adapters] + K --> O[typed PublishResult] + R --> O + X --> O + O --> P[application PublicationOutcome] + + K2[Kafka delivery] --> Q[DefaultDeliveryProcessor] + R2[Rabbit delivery] --> Q + Q --> M[MessageHandler] + M --> N[HandleResult + RetryDecision] + N --> L[confirmed DLQ / source settlement] + Q --> I[transactional inbox + business Unit of Work] +``` + +핵심은 application 경계, platform orchestration, broker strategy, persistence adapter를 분리하면서도 +publish/consume 각각의 **한 개짜리 실행 pipeline**을 두는 것이다. 지금처럼 validator, limiter, runtime +registry, transport, observation을 bean으로만 제공하면 호출 순서와 누락을 보장할 수 없다. + +### 6.2 권장 tree + +아래는 최종 방향이다. 한 번에 물리 이동하지 말고 behavior fix와 compatibility test 뒤 별도 change set으로 +진행한다. + +```text +src/ +├── application-core/.../messaging/ +│ ├── event/ # business semantic draft/validated event +│ ├── publication/ # application-owned port/outcome +│ └── reliability/ # application transaction policy +├── adapter/outbound/messaging/ +│ ├── platformbridge/ # temporary anti-corruption layer +│ └── legacy/ # cutover 동안 신규 기능 금지 +├── messaging/ +│ ├── CLAUDE.md +│ ├── api/ +│ │ ├── messaging-core-api/ +│ │ ├── messaging-schema-api/ +│ │ └── messaging-reliability-api/ +│ ├── runtime/ +│ │ ├── messaging-runtime-core/ # publisher/delivery pipelines +│ │ ├── messaging-policy/ +│ │ └── messaging-transport-spi/ +│ ├── codec/ +│ │ ├── json/ +│ │ ├── avro/ +│ │ ├── protobuf/ +│ │ └── cloudevents/ +│ ├── adapter/ +│ │ ├── kafka/ +│ │ ├── rabbit/ +│ │ └── experimental/{kafka-share,pulsar,nats}/ +│ ├── reliability/ +│ │ ├── outbox-jdbc-postgresql/ +│ │ ├── inbox-jdbc-postgresql/ +│ │ └── claim-check/ +│ ├── support/ +│ │ ├── observability-micrometer/ +│ │ └── security/ +│ ├── admin/{api,runtime}/ +│ ├── spring/ +│ │ ├── autoconfigure-core/ +│ │ └── starter/{core,kafka,rabbit,reliability-jdbc-postgresql,admin}/ +│ └── testkit/ +└── app-bootstrap/.../messaging/ + └── PlatformMessagingComposition.java +``` + +### 6.3 visibility/dependency 규칙 + +- application은 신규 broker/runtime type을 직접 보지 않고 자기 port만 소유한다. +- `api`/의도한 `spi` package만 public이다. mapper/coordinator/native bridge/auto-config helper는 internal이다. +- broker/codec optionality를 위해 leaf 분리는 유지한다. 두 broker를 common starter가 의존하지 않는다. +- runtime-core는 broker adapter에 의존하지 않고 `MessagingTransport` strategy만 본다. +- broker adapter는 runtime-core의 concrete class를 import하지 않고 SPI와 자기 native SDK만 본다. +- PostgreSQL reliability adapter는 vendor 특성을 숨기지 않되 JDBC `Connection`은 core port 밖으로 유출하지 + 않는다. +- testkit의 expected contract와 certification artifact를 분리한다. 기대 matrix 자체가 실행 evidence가 + 되어서는 안 된다. + +## 7. 디자인 패턴 적용 판단 + +패턴은 이름을 늘리기 위해서가 아니라 현재 깨진 불변식을 한곳에서 강제하기 위해 사용한다. + +| 문제 | 적용할 패턴 | 구체 적용 | 피할 것 | +|---|---|---|---| +| publish 단계 누락 | Facade + ordered Pipeline/Decorator | `DefaultMessagePublisher`, typed steps, finally permit/lease release | 자유 순서 interceptor/service locator | +| broker 차이 | Strategy + Abstract Factory | `MessagingTransport`, broker별 `BrokerRuntimeFactory` | Kafka/Rabbit/NATS를 상속 Template Method로 평탄화 | +| profile 검증 | Strategy + Composite | generic/broker/security rule을 compiled graph에 적용 | bean만 만들고 호출자에게 검증 책임 전가 | +| outbox/inbox 원자성 | Unit of Work | transaction-aware JDBC adapter + application transaction boundary | core port에 `Connection` 노출, after-commit append | +| lease/offset/confirm | State Machine + Fencing Token | outbox token CAS, assignment epoch, Rabbit confirm events | boolean/status 임의 update | +| invalid result 방지 | Sealed ADT + private factory | publish/settlement/admin verified token | 모든 조합을 받는 public record constructor | +| backpressure/lifecycle | AutoCloseable Permit | destination/work/runtime/credential lease exactly-once close | 문자열 기반 release/end 호출 | +| envelope 정합성 | Canonical Mapper/Codec | Kafka/Rabbit/outbox/CDC 공통 `EnvelopeHeaderCodec` | adapter마다 reserved header 재구현 | +| 기존→신규 migration | Anti-Corruption Layer | application outcome/wire mapping과 single authority | 두 hierarchy/두 relay의 영구 병행 | +| retry/DLQ/admin | durable State Machine/Journal | next-at/park, confirmed-DLQ-before-ACK, resumable admin | memory-only counter/approval claim | + +다음은 도입하지 않는 편이 낫다. + +- broker별 confirmation/settlement 의미가 다른데 거대한 `AbstractBrokerTransport` base class로 합치기 +- PostgreSQL lock/isolation을 숨기는 범용 repository와 모든 DB를 지원한다고 보이게 만들기 +- 외부 연산이 단순한 sealed result에 별도 Visitor class hierarchy를 과도하게 추가하기 +- exception class-name substring classifier, global mutable plugin registry, raw broker hint map +- outbox만으로 exactly-once를 약속하는 facade + +## 8. 구현 순서 + +각 wave는 독립 merge/review 가능한 범위다. 뒤 wave가 앞 wave의 안전성 gate를 우회하지 않게 한다. + +### Wave 0 — 사실성 및 회귀 테스트를 먼저 고정 + +1. 신규 platform을 `Contract-only/Build-only`로 표시하고 Stable promotion/cutover를 보류한다. +2. MSG-001/002/004/005/016/018 재현 테스트를 먼저 실패하도록 추가한다. +3. support matrix의 4.2/4.3 및 Rabbit 5-fault live claim을 실제 evidence 수준으로 낮춘다. +4. legacy/new canonical model과 single publication authority ADR을 작성한다. + +완료 기준: CI와 문서가 현재 executable evidence보다 강한 주장을 하지 않고, 데이터 유실 시나리오가 +red test로 재현된다. + +### Wave 1 — transactional reliability와 durable state + +1. `JdbcOutboxRepository`/`JdbcInboxRepository`를 transaction-aware JDBC로 교체한다. +2. interface/`IdempotentConsumer` 경로의 PostgreSQL commit/rollback IT를 추가한다. +3. outbox V2 migration에 lease owner/token/next-at/parked state를 추가한다. +4. lease-bearing port와 CAS terminal transition을 구현한다. +5. retry scheduler/maxAttempts를 relay와 DB claim에 연결하고 purge를 bounded SQL로 바꾼다. +6. 가능하면 이미 더 강한 application-core v2 delivery owner/CAS pattern을 canonical로 재사용한다. + +완료 기준: business row와 inbox/outbox가 원자적으로 움직이고 stale relay가 어떤 순서에서도 최신 state를 +바꾸지 못한다. + +### Wave 2 — broker state machine + +1. Kafka poll batch partition별 rewind/dispatch와 executor rejection 보상을 구현한다. +2. assignment epoch, revoke drain, poll-thread command queue, post-commit watermark를 구현한다. +3. Kafka decode/handler/DLQ/settlement outcome을 분리하고 handler timeout을 적용한다. +4. Rabbit native channel bridge, multiple confirm, return correlation, deadline, close drain을 구현한다. +5. Rabbit consumer/settlement를 typed state machine과 central delivery processor로 연결한다. +6. Kafka transactional processor callback 순서를 바로잡는다. + +완료 기준: fault/rebalance/timeout/close 모든 테스트에서 미처리 source가 commit/ACK되지 않고 pending future와 +permit이 0으로 돌아온다. + +### Wave 3 — runtime composition과 starter + +1. `messaging-runtime-core`와 `DefaultMessagePublisher`/`DefaultDeliveryProcessor`를 추가한다. +2. canonical `app.messaging` settings compiler와 composite validator를 구현한다. +3. broker runtime factory를 구현해 native producer/consumer/security/lifecycle을 완결한다. +4. core/Kafka/Rabbit/reliability/admin starter를 분리하고 `api` exposure를 축소한다. +5. `SmartLifecycle` drain, generation sweep, admission/work/runtime/credential permit을 pipeline에 연결한다. + +완료 기준: documented profile 하나만으로 full context가 fake facade 없이 actual transport bean까지 조립되고, +불완전 profile은 worker 시작 전에 실패한다. + +### Wave 4 — canonical wire/schema/security + +1. `EnvelopeHeaderCodec`과 application/platform header type을 도입한다. +2. outbox schema에 canonical metadata를 보존하고 polling/CDC mapper를 통합한다. +3. `(MessageType, SchemaVersion)` schema registry와 bounded codec output/decode를 구현한다. +4. PublishOptions normalization, runtime capability, sealed PublishResult를 구현한다. +5. identifier/header/trace/key wire validation, TLS allowlist, JAAS escaping을 적용한다. +6. metrics tag allowlist와 structured diagnostic event를 분리한다. + +완료 기준: direct/Kafka/Rabbit/outbox polling/CDC round-trip이 같은 canonical envelope를 만들며 malicious wire +input이 broker/DB/metric backend 전에 거절된다. + +### Wave 5 — application bridge와 cutover + +1. application-owned publication outcome을 확장하고 platform bridge를 구현한다. +2. golden wire/semantic compatibility와 dual-authority negative test를 추가한다. +3. 기존 outbox writer/storage를 유지한 transport-only cutover를 한다. +4. mixed-version soak 후 별도 release에서 storage/consumer/inbox authority를 이동한다. +5. old backlog, generation, retry/DLQ, retention window가 drain된 뒤 legacy API/config/table을 제거한다. + +완료 기준: rolling deployment와 rollback에서 한 business fact당 writer/relay/publish authority가 항상 하나다. + +### Wave 6 — admin 및 release qualification + +1. verified approval token과 durable resumable admin journal을 구현한다. +2. broker/version/scenario evidence manifest와 fail-closed certification task/workflow를 추가한다. +3. TLS/SASL/ACL, broker failover, multi-node, backlog, soak, p95/p99/allocation 기준을 실행한다. +4. evidence가 있는 조합만 Stable로 승격한다. + +## 9. 권장 테스트 구조와 명령 + +### 9.1 새 테스트 lane + +```text +messaging-runtime-core:test + - full publish/delivery pipeline transition tables + - option/capability/result invariants + +messaging-reliability-postgresql-test + - public port UnitOfWork rollback + - two-relay fencing/retry/purge + +messaging-kafka:test + - partition/epoch/commit/executor deterministic tests + - transaction callback tests + +messaging-rabbit:test + - confirm/return/timeout/close state machine + - consumer/DLQ settlement tests + +messaging-wire-contract-test + - Kafka/Rabbit/outbox/CDC canonical round-trip + - adversarial header/schema/trace/property tests + +messagingCertificationTest + - broker-version matrix + - five fault scenarios + - Docker required, zero skip, evidence manifest + +messagingCutoverTest + - legacy bridge golden bytes + - mixed-version authority switch/rollback +``` + +### 9.2 구현 중 focused 검증 + +```bash +cd src +./gradlew :messaging:messaging-core-api:test --console=plain +./gradlew :messaging:messaging-transport-spi:test --console=plain +./gradlew :messaging:messaging-outbox-jpa:test --console=plain +./gradlew :messaging:messaging-inbox-jpa:test --console=plain +./gradlew :messaging:messaging-kafka:test --console=plain +./gradlew :messaging:messaging-rabbit:test --console=plain +./gradlew :messaging:messaging-spring-boot-starter:test --console=plain +./gradlew :adapter:outbound:messaging:test --console=plain +./gradlew verifyCleanArchitectureDependencies verifyRuntimeModuleMembership --console=plain +``` + +module rename/split 뒤에는 registry의 `gradle_path`에서 새 명령을 파생하고 위 이름을 함께 갱신한다. + +### 9.3 release 전 필수 검증 + +```bash +cd src +./gradlew messagingCertificationTest --console=plain +./gradlew messagingCutoverTest --console=plain +./gradlew test --console=plain +./gradlew check --console=plain +``` + +새 task는 source set과 executable suite를 함께 추가한다. 문서에 존재하지 않는 task 이름만 미리 약속하지 +않는다. certification lane은 Docker/broker/version/test가 없으면 skip이 아니라 failure다. + +## 10. 완료 정의 + +다음 질문에 모두 코드, DB state, broker evidence, CI artifact로 “예”라고 답할 수 있을 때만 신규 messaging +platform을 runtime-ready Stable로 판정한다. + +- public outbox/inbox port가 실제 application transaction과 같은 resource에서 commit/rollback하는가? +- lease-expired worker, duplicate callback, delayed confirm이 최신 terminal state를 덮어쓸 수 없는가? +- retry budget/next-at/parking/retention delete가 restart와 다중 replica에서도 durable한가? +- Kafka poll batch의 모든 미제출 record가 처리 또는 rewind되고 assignment epoch가 stale ACK를 막는가? +- Kafka offset local state는 broker commit 성공 뒤에만 전진하는가? +- Rabbit multiple confirm, return, NACK, timeout, send throw, channel close가 모든 future를 정확히 종료하는가? +- decode/handler/DLQ/settlement failure가 분리되고 confirmed DLQ 전 source ACK/commit이 금지되는가? +- documented configuration만으로 publisher/consumer/runtime이 조립되며 invalid/unknown 설정이 fail-closed인가? +- timeout/confirmation/dedup/hint capability가 실제 runtime behavior와 일치하는가? +- tenant/trace/schema/application header/retry/key가 direct/polling/CDC/Kafka/Rabbit에서 동일하게 보존되는가? +- credential/TLS/auth rotation 중 사용 중인 secret/client가 조기 clear/close되지 않는가? +- metric tag와 log 어디에도 unbounded identifier/secret/payload가 들어가지 않는가? +- application과 platform 사이 writer/relay authority가 한 시점에 정확히 하나인가? +- admin approval을 caller가 위조/다른 plan에 재사용할 수 없고 restart 뒤 안전하게 재개되는가? +- support matrix의 각 Stable broker/version/scenario가 같은 commit의 zero-skip evidence에 연결되는가? +- root/local architecture policy와 registry/module count/runtime membership이 모순되지 않는가? + +하나라도 아니면 해당 기능은 `Experimental`, `Contract-only`, `Unwired` 중 실제 상태로 표시한다. + +## 11. 이번 리뷰에서 실행한 검증 + +검토 중 HEAD가 messaging merge commit `71c0d2122f2c65e9ce7910c6615b57056d9cebb6`에서 +`c3043e530a604315c4df341b87b5470c7617ea03`으로 이동했다. 두 commit 사이 변경은 GraphQL module에 +한정됐고 messaging/application/bootstrap/registry/build 통합 경로에는 변경이 없었다. `src/messaging` +tree hash도 `20664539b0609c6c413759e2b2945bf421c10de7`로 동일했다. 그 뒤 최종 HEAD에서 production code를 +수정하지 않은 채 신규/legacy test와 architecture gate를 `--rerun-tasks`로 다시 실행하고 JUnit XML을 +별도로 합산했다. + +| 명령 | 결과 | 관측 범위 | +|---|---|---| +| 신규 24개 messaging leaf의 모든 `:test` + legacy `:test` + architecture/runtime/one-type gate, `--rerun-tasks --no-daemon --max-workers=2` | BUILD SUCCESSFUL, 3m 53s, 107/107 tasks executed | 최신 HEAD의 신규 600 tests + legacy 81 tests, failure/error/skip 0; 43-leaf gate 통과 | +| `./gradlew :adapter:outbound:messaging:test :messaging:messaging-transport-spi:test :messaging:messaging-outbox-jpa:test :messaging:messaging-inbox-jpa:test :messaging:messaging-spring-boot-starter:test :messaging:messaging-testkit:test --rerun-tasks --console=plain` | BUILD SUCCESSFUL, 1m 27s, 61/61 tasks executed | 핵심 reliability/runtime/starter/testkit fresh 재확인 | +| `./gradlew verifyCleanArchitectureDependencies verifyRuntimeModuleMembership verifyOneTypePerFile --rerun-tasks --console=plain` | 위 최신-HEAD 통합 실행에 포함되어 BUILD SUCCESSFUL | 현재 43-leaf registry dependency/runtime/one-type gate | +| 신규 24 leaf와 legacy adapter의 Checkstyle/SpotBugs, legacy JSON runtime graph, `verifyDependencyLocks verifyCleanArchitectureDependencies --console=plain --no-daemon --max-workers=2` | 최신 HEAD에서 BUILD SUCCESSFUL in 6m, 223 tasks(96 executed/127 up-to-date) | 신규/legacy 정적 분석, 전체 dependency locks, architecture | + +신규 600개 집계에는 다음 Docker-backed IT가 실제 발견·실행됐고 skip은 0이었다. + +- `InboxPostgresIT`, `OutboxPostgresIT` +- `KafkaBrokerIT`, `KafkaAmbiguityChaosIT`, `KafkaConsumerSettlementIT`, `KafkaReadCommittedIT` +- `KafkaTopologyValidationIT`, `KafkaTransactionFencingIT`, `KafkaTransactionIT` +- `RabbitBrokerIT` + +테스트가 모두 통과했는데도 P0/P1이 남는 이유는 명확하다. transaction rollback IT는 production port가 +아닌 safe `Connection` overload를 호출하고, full starter imports는 fake publisher 없이 로드하지 않으며, +failure matrix는 실행 artifact가 아닌 hard-coded map을 검사한다. 현재 test green을 production composition +green으로 해석하면 안 된다. + +### 11.1 실패 및 미실행 검증 + +- 최신 HEAD에서 다시 실행한 `./gradlew :adapter:outbound:messaging:check --console=plain --no-daemon + --max-workers=2`는 `BUILD FAILED in 23s`였다. messaging assertion 실패가 아니라 root 선행 + `verifyNoStaleTraceableJars`가 현재 `c3043e530a60` archive와 함께 과거 hash JAR을 보유한 archive task + 31개를 발견해 중단했다. core/shared/sample, 신규 messaging 17개 leaf, inbound web, outbound + cache/fileserver/httpclient/identifier/legacy-messaging/notification/objectstorage/persistence-jpa/support가 + 대상이다. 사용자 build artifact를 임의 삭제하지 않기 위해 `cleanStaleTraceableJars`는 실행하지 않았다. +- 최초 sandbox Gradle 시도는 user Gradle cache의 lock 파일이 read-only라 실패했고, 같은 명령을 승인된 + Gradle 실행으로 재실행해 위 성공 결과를 얻었다. +- repository 전체 `test`/`check`, messaging JMH, 외부 dependency vulnerability DB/Trivy는 이번 review + 범위에서 실행하지 않았다. +- Kafka 4.2/4.3, Rabbit 5개 network fault 전체, TLS/SASL/ACL, multi-node failover, process restart/cutover, + broker load/soak test lane은 현재 없거나 실행하지 않았다. +- `messaging-reliability-api:test`는 `NO-SOURCE`다. public reliability record/port contract의 직접 테스트는 + adapter 간접 테스트 외에 추가할 필요가 있다. + +위 architecture/static gate의 통과는 현재 registry가 스스로 일관된다는 증거다. root policy의 “19개”와 +실제 43개가 의미적으로 일치하거나 신규 platform이 runtime-ready라는 증거는 아니다. + +## 12. LLM Wiki 캡처 + +필수 작업 기록은 `/home/donghyeon/workspace/ai-tool/llm-wiki-private/raw/branch-notes/main.md`의 +`2026-08-14 캡처 — merge 이후 Messaging 모듈 상세 리뷰` 섹션에 갱신했다. 검토 범위, 25개 finding의 +핵심 판정, 구현 순서, 변경 파일, 실행한 검증, 실패·미실행 범위와 evidence grade를 기록했다. + +이번 작업은 production 구현 전 read-only review이므로 `raw/errors/`, `raw/interviews/`, +`raw/blog-topics/` 및 canonical `wiki/` 파생 문서는 만들지 않았다. 실제 P0/P1 구현과 +transaction/rebalance/restart/cutover evidence가 생긴 뒤 파생 여부를 다시 판단한다. diff --git a/docs/reviews/2026-08-14-mongodb-module-code-review.md b/docs/reviews/2026-08-14-mongodb-module-code-review.md new file mode 100644 index 00000000..3a3429e5 --- /dev/null +++ b/docs/reviews/2026-08-14-mongodb-module-code-review.md @@ -0,0 +1,1227 @@ +# MongoDB persistence 모듈 상세 코드·아키텍처 리뷰 + +- 기준 일자: 2026-08-14 +- 기준 Git HEAD: `92744c57dee5dcd9aa1b12475f1d2d8d16294bc2` +- 대상 Gradle leaf: `:adapter:outbound:persistence-mongo` +- 대상 경로: `src/adapter/outbound/persistence-mongo` +- 판정: **CHANGES REQUIRED** +- 검토 방식: 전체 트리 정적 탐색 + 핵심 실행 경로 정독 + 병렬 교차 리뷰 + fresh unit/contract 검증 +- 변경 범위: 이 리뷰 문서만 추가했으며 production/test 코드는 수정하지 않았다. + +## 1. 결론 + +이 모듈은 단순 Mongo repository adapter가 아니라 mapping, imperative/reactive 실행기, transaction, +query/aggregation, schema/index/migration, change stream, security/observability, auto-configuration, +Advanced capability와 release evidence까지 한 leaf에 담은 persistence platform이다. 정책을 값 객체와 +명시적 타입으로 모델링하고, Mongo를 기본 비활성으로 둔 방향은 좋다. + +그러나 현재 상태를 production-ready Stable platform으로 판정하면 안 된다. 특히 다음 계약은 문서나 +타입 이름과 실제 실행 코드가 다르다. + +1. reactive transaction body가 session-bound `ReactiveMongoOperations`를 실제로 전달받지 못한다. +2. reactive retry의 wall-clock budget이 계산되지 않고, transaction failure context가 서로 모순될 수 있다. +3. keyset cursor가 BSON 값을 문자열로 바꾸어 타입과 정렬 의미를 잃는다. +4. `maxResultBytes`, imperative timeout, 일부 null-order 정책은 선언만 있고 실행 시 강제되지 않는다. +5. README가 약속한 startup validation, client generation, health wiring이 auto-configuration에 없다. +6. package DAG와 repository/controller guard가 문서상 규칙일 뿐, 닫힌 의존 그래프나 실제 ArchUnit + 제약으로 적용되지 않는다. +7. compatibility/failover/performance/Advanced release gate 일부가 실행한 것보다 강한 증거를 만든다. + +따라서 즉시 운영 원칙은 다음과 같이 고정한다. + +- `ca-skeleton.persistence-mongo.enabled`는 계속 기본 `false`로 유지한다. +- 이 문서의 P0/P1 수정 전에는 Stable release evidence를 새로 발행하지 않는다. +- Advanced는 구현 완료가 아니라 contract scaffold/experimental로 표시한다. +- transaction, cursor, mapping 문제를 먼저 고친 뒤 auto-configuration과 구조 리팩터링을 진행한다. +- 폴더 이동이나 디자인 패턴 도입만으로 동작 결함을 가리지 않는다. + +## 2. 검토 범위와 증거 경계 + +### 2.1 현재 규모 + +| 항목 | 현재 값 | +|---|---:| +| production Java 파일 | 313 | +| production Java LOC | 18,059 | +| 일반 test Java 파일 | 64 | +| testkit Java 파일 | 26 | +| performance test Java 파일 | 1 | +| `@Test` 메서드 | 411 | +| public top-level type가 있는 production 파일 | 311 / 313 | + +### 2.2 깊게 확인한 영역 + +| 영역 | 상태 | 대표 근거 | +|---|---|---| +| module registry/build/runtime membership | READ_FULL | `src/config/architecture/modules.json`, Mongo `build.gradle`, bootstrap build | +| module/package architecture tests | READ_FULL | `MongoModuleBoundaryTest`, `MongoRepositoryArchitectureRules*`, root `CleanArchitectureTest` 관련 규칙 | +| imperative/reactive execution | READ_FULL | 두 default executor, operation context/result/outcome, cursor publisher | +| transaction/retry/session | READ_FULL | blocking/reactive executor·session factory·retry coordinator·scope와 관련 tests | +| query/budget/keyset | READ_FULL | policy builder, budget types, cursor codec/page builder와 관련 tests | +| mapping/type metadata | READ_FULL | representation manifest, conversions, type mapper, snapshot/round-trip testkit | +| auto-configuration/settings/health | READ_FULL | opt-in filter, persistence config, platform auto-config/properties/validator/probe/health | +| architecture/README/가이드 주장 | READ_PARTIAL | Mongo README·CLAUDE와 관련 ADR/guide의 해당 계약 구간 | +| migration/change stream/admin/GridFS | READ_PARTIAL | coordinator/runner/gateway/job 및 직접 관련 tests/docs | +| Stable/Advanced release lane | READ_FULL | Mongo build task와 두 verification scripts, lane fixtures/tests | +| Advanced 전체 54파일 | READ_PARTIAL | 전수 import/type/flag 사용 탐색 + 실행 진입점 표본 정독 | + +`READ_PARTIAL` 영역은 모든 메서드의 품질을 승인했다는 뜻이 아니다. 이 보고서의 결론은 확인한 계약과 +실행 seam에 한정한다. Docker-backed lane과 실제 Atlas/KMS/sharded topology는 이번 재검증에서 실행하지 +않았으므로 해당 운영 결과는 `UNVERIFIED`다. + +## 3. 유지할 설계 + +다음은 리팩터링하면서 보존할 가치가 있다. + +- `api` package에 Spring, Mongo driver, BSON, Reactor, Micrometer import가 없고 ArchUnit으로 이를 + 검사한다. +- 현재 production code에서 `domain-core`, `application-core`, 다른 adapter production import가 + 발견되지 않았다. +- Mongo auto-configuration 후보를 기본 비활성화하는 import filter의 Boot 4.0.0 대상 목록은 현재 + dependency JAR의 Mongo auto-configuration 목록과 일치한다. +- failure classification에서 label을 code보다 먼저 판단하려는 정책은 Mongo transaction semantics에 + 맞다. +- transaction body retry와 commit-only retry를 별도 개념으로 둔 것은 반드시 유지해야 한다. +- immutable record, defensive copy, 입력 검증을 일관되게 사용한다. +- cursor HMAC에 constant-time comparison과 32-byte 이상 key를 요구한다. +- query field/operator/sort allowlist와 keyset의 unique tie-breaker 원칙은 적절하다. +- change projection 후 checkpoint를 저장하려는 순서, stable에서 advanced import를 금지한 규칙, + Docker lane을 기본 `test`와 분리한 선택은 유지한다. +- container image tag와 driver version을 중앙에서 관리하려는 방향은 좋다. 다만 release evidence에는 + digest와 실제 실행 artifact가 추가로 필요하다. + +## 4. 우선순위 요약 + +| ID | 우선순위 | 심각도 | 주제 | 완료 조건 | +|---|---|---|---|---| +| MNG-001 | P0 | Critical | reactive transaction body의 session 미바인딩 | bound operations를 callback 인자로 강제하고 실제 rollback test 통과 | +| MNG-002 | P0 | High | retry deadline/backoff/result buffering/phase cleanup | monotonic deadline과 단일 backoff 계산, phase test 통과 | +| MNG-003 | P0 | High | transaction failure context 불변식 붕괴 | category/outcome/retry scope가 한 classifier 결과에서 생성됨 | +| MNG-004 | P0 | High | typed keyset cursor가 BSON type을 잃음 | versioned typed codec round-trip/server pagination 통과 | +| MNG-005 | P0 | High | 선언된 timeout/result budget이 미강제 | 모든 entry path가 실제 deadline/byte/result bound를 적용 | +| MNG-006 | P0 | High | BSON representation manifest와 실제 converter 불일치 | manifest 모든 축의 real converter round-trip/golden test 통과 | +| MNG-007 | P0 | High | startup/health/client generation auto-config 부재 | enabled/disabled/invalid context에서 실제 bean/lifecycle 검증 | +| MNG-008 | P0 | High | release lane가 과도한 증거를 생성 | contract-to-artifact mapping과 실 topology 결과로만 promotion 가능 | +| MNG-009 | P1 | High | package DAG가 닫힌 그래프로 강제되지 않음 | exact allowed-edge test가 현재 illegal edge를 탐지 | +| MNG-010 | P1 | High | repository/controller guard가 실행되지 않음 | root ArchUnit negative fixture가 raw Mongo injection을 거부 | +| MNG-011 | P1 | High | Advanced opt-in 불변식이 사실이 아님 | 모든 실행 entry point가 동일 guard/decorator 경유 | +| MNG-012 | P1 | High | session/resource/ambient scope lifecycle 결함 | acquisition 실패·nested scope·cancellation leak test 통과 | +| MNG-013 | P1 | High | change stream dedupe가 atomic하지 않음 | claim/complete state machine과 동시성 test 통과 | +| MNG-014 | P1 | High | migration lease heartbeat/fencing 부재 | 느린 migration 중 lease 갱신과 경쟁 runner 차단 | +| MNG-015 | P1 | High | GridFS stream/checkpoint 의미 결함 | close와 restart/failed-id semantics test 통과 | +| MNG-016 | P1 | High | admin audit가 실패 결과를 표현하지 못함 | intent/succeeded/failed terminal audit와 approval binding | +| MNG-017 | P1 | Medium | mutable registry의 thread safety/durability 부족 | atomic immutable state 또는 durable store로 교체 | +| MNG-018 | P1 | Medium | production security settings/secret wiring 불완전 | prod TLS/auth validation과 secret resolver/client factory 연결 | +| MNG-019 | P2 | Medium | contract suite 중복 실행 | `test`와 `mongoStableContractTest`가 disjoint | +| MNG-020 | P2 | Medium | 311개 public type/god leaf | public API allowlist와 internal package 경계 확보 | +| MNG-021 | P2 | Medium | runtime membership/README 계약 불일치 | library-only 또는 shipped runtime 중 하나를 명시적으로 선택 | +| MNG-022 | P2 | Medium | regex/health/version capability 판정이 과장됨 | 안전한 subset/structured probe/semantic version으로 교체 | +| MNG-023 | P0 | High | collection-scoped callback이 raw operations로 경계를 우회 | collection 인자를 숨기는 scoped capability API로 교체 | +| MNG-024 | P1 | High | consistency template 재생성이 callback/runtime 설정을 잃음 | 원 template의 Spring runtime contract 보존 test 통과 | +| MNG-025 | P1 | High | failure classifier가 operation type/phase를 모름 | 같은 오류의 read/write/body/commit 분류를 구분 | +| MNG-026 | P1 | High | bulk failure/policy/result semantics 붕괴 | Spring wrapper 추출, atomic policy 공유, item state 완전 표현 | +| MNG-027 | P1 | High | optimistic revision invariant 우회 | revision field를 정확히 한 번 `$inc 1`만 허용 | +| MNG-028 | P1 | High | change-stream ordering/identity/source wiring 불완전 | 순차 처리 또는 monotonic CAS와 실제 resume consumer 검증 | +| MNG-029 | P2 | Medium | mutable Query와 driver observability dead wiring | Query copy와 client customizer context test 통과 | + +## 5. 상세 발견 사항과 구현 명세 + +### MNG-001 — reactive transaction body가 session에 묶이지 않는다 + +**근거** + +- `ReactiveMongoTransactionExecutor.java:15`는 body를 + `Supplier>`로 받는다. +- `SpringReactiveMongoTransactionExecutor.java:84`는 `session.runBody(work, session.operations())`를 + 호출한다. +- `SpringReactiveMongoTransactionSessionFactory.java:115-119`는 `operations` 인자를 사용하지 않고 + `Supplier::get`만 실행한다. + +**실패 모드** + +호출자는 session-bound operations를 전달받을 방법이 없다. 일반 `ReactiveMongoTemplate`을 closure로 +캡처하면 write가 transaction session 밖에서 실행되고 executor는 비어 있는 transaction을 commit할 수 +있다. API 이름과 주석만 보고 atomicity를 신뢰한 use case에서 부분 write가 남을 수 있으므로 Critical이다. + +**구현 결정: Context Object + Unit of Work** + +1. `ReactiveMongoTransactionExecutor.execute`를 다음 의미로 바꾼다. + `Function> work`. +2. `ReactiveMongoTransactionSession.runBody`도 같은 function을 받고 자기 `bound`를 인자로 전달한다. +3. callback이 arbitrary template을 얻지 못하도록 application adapter의 transaction helper는 전달받은 + operations만 사용한다. +4. blocking API도 후속 단계에서 `Function`로 맞추고 `ThreadLocal` 의존을 제거한다. +5. binary compatibility가 필요하면 기존 overload를 바로 유지하지 않는다. 기존 overload는 atomicity를 + 강제할 수 없으므로 한 release 동안 `@Deprecated(forRemoval=true)` + 명시적 `unsafeExecute` 이름으로만 + 두고, 기본 `execute`는 새 계약으로 전환한다. + +**필수 테스트** + +- `SpringReactiveMongoTransactionExecutorReplicaSetTest.rollsBackTwoWritesUsingBoundOperations` +- `...commitsTwoWritesUsingTheSameSession` +- `...bodyRetryOpensANewSessionAndDoesNotReuseBoundOperations` +- callback이 외부 template을 사용한 경우를 API/architecture test에서 금지하거나 unsafe API로 명시한다. + +### MNG-002 — reactive retry budget과 transaction phase가 실제 시간/상태를 반영하지 않는다 + +**근거** + +- `SpringReactiveMongoTransactionExecutor.java:96,113`은 매 retry마다 elapsed로 + `Duration.ZERO.plusNanos(1)`을 넘긴다. +- 같은 파일 `:99-102`, `:120-123`은 jitter가 있는 `delayBefore`를 기록용과 실행용으로 두 번 호출한다. +- blocking coordinator도 decision과 sleep에 `delayBefore`를 별도로 호출하며, transaction profile의 + `maxAttempts`와 coordinator의 별도 retry budget을 하나의 effective budget으로 합치지 않는다. +- transaction profile timeout은 body deadline이 아니라 driver `maxCommitTime`에만 적용된다. +- `:84-89`는 transaction body의 모든 값을 `collectList()`로 heap에 모은 뒤 commit한다. +- `:90-92`의 generic error cleanup은 transaction phase를 표현하지 않고 abort/release한다. + +**실패 모드** + +- `maxElapsed`가 사실상 무시되어 caller deadline을 넘겨 retry한다. +- metric에 기록된 delay와 실제 delay가 다를 수 있다. +- 다건 Publisher는 commit 전에 unbounded memory를 사용할 수 있다. +- commit 결과가 모호한 단계에서도 generic cleanup이 abort를 시도해 원래 오류를 가리거나 상태 해석을 + 더 어렵게 만들 수 있다. + +**구현 결정: Strategy + explicit state machine** + +1. `NanoClock` 또는 `Ticker` interface를 주입하고 최초 subscription에서 deadline을 계산한다. +2. transaction profile과 platform budget의 각 제한에서 최소값을 취한 + `EffectiveTransactionRetryBudget`을 한 번 만든다. +3. `BackoffStrategy.nextDelay(attempt, remaining)`가 delay를 한 번만 계산하고 decision과 `Mono.delay`가 + 같은 값을 사용하게 한다. +4. `TransactionPhase`를 `ACQUIRING`, `BODY`, `COMMITTING`, `COMMIT_UNKNOWN`, `TERMINAL`로 둔다. +5. cleanup policy는 phase별로 결정한다. `BODY` 실패/cancel은 abort, commit-unknown은 abort하지 않고 + reconciliation metadata를 반환한다. +6. transaction 결과 API가 다건을 정말 요구하지 않으면 `Mono`로 좁힌다. 다건이 필요하면 + `maxBufferedResults`/`maxBufferedBytes`를 profile에 추가하고 초과 시 commit 전 실패한다. +7. `MongoRetryBudget.none()`은 첫 실행은 허용하고 추가 retry만 금지하도록 `nextAttempt == 1`을 별도로 + 처리한다. 현재 `maxElapsed=ZERO`와 `< maxElapsed` 조합은 첫 attempt조차 거부할 수 있다. + +**필수 테스트** + +- virtual clock으로 deadline 직전/동일/초과, backoff 포함 deadline 초과를 검증한다. +- fixed random으로 recorder delay와 실제 scheduler delay가 같은지 검증한다. +- commit-unknown 뒤 body 재구독 0회, commit 재구독 N회, abort 0회를 검증한다. +- `MongoRetryBudget.none()`이 body를 정확히 1회 실행하는 test를 추가한다. +- result bound 초과 시 commit/side effect가 없는지 검증한다. + +### MNG-003 — transaction failure context가 exception type과 모순될 수 있다 + +**근거** + +- sync/reactive session factory는 각각 `SpringMongoTransactionSessionFactory.java:147-161`, + `SpringReactiveMongoTransactionSessionFactory.java:144-159`에서 먼저 + `MongoFailureContext.commitUnknown(...)`을 만든다. +- classifier가 `WHOLE_TRANSACTION`을 반환하면 그 context를 + `MongoTransactionTransientException`에 넣는다. +- `MongoTransactionRetryCoordinatorTest.java:182-188`도 transient exception에 commit-unknown context를 + 직접 넣어 이 모순을 고정한다. +- 두 session factory는 driver `MongoException`만 직접 mapping하며 Spring `DataAccessException` cause + chain을 공통 방식으로 추출하지 않는다. + +**실패 모드** + +exception type은 whole transaction retry를 뜻하지만 context category/outcome은 +`TRANSACTION_COMMIT_UNKNOWN`, `retryable=false`, `ambiguous=true`가 될 수 있다. telemetry, retry policy, +caller reconciliation이 서로 다른 결론을 내린다. Spring Data가 감싼 label-bearing driver failure는 +retry 분류를 건너뛸 수 있다. + +**구현 결정: classification-derived factory + Strategy** + +1. `MongoFailureClassification` 하나에서 category, retryScope, outcome, retryable, ambiguous를 파생한다. +2. `MongoFailureContext.from(classification, operation, code, elapsed, attempt)` factory만 public으로 둔다. +3. record constructor에서 다음 불변식을 검증한다. + - `COMMIT_ONLY` ↔ `TRANSACTION_COMMIT_UNKNOWN` + - `WHOLE_TRANSACTION` ↔ transient transaction category + - `outcome.isAmbiguous()` ↔ `ambiguous=true` +4. `MongoTransactionTransientException`과 `MongoTransactionCommitUnknownException` constructor는 예상 + classification이 아니면 즉시 거부한다. +5. `MongoFailureExtractor` Strategy를 만들고 `MongoException`, `DataAccessException`, nested cause, + Reactor timeout을 모든 executor/session에서 동일하게 처리한다. raw message/cause를 외부 계약에 + 노출하지 않고 code/label/type만 bounded metadata로 보존한다. + +**필수 테스트** + +- exception type × category × outcome × retry scope invariant parameterized test. +- Spring `DataAccessException` 안의 labelled `MongoException`이 whole/commit-only retry로 분류되는 test. +- cyclic/deep cause chain, no-cause, non-Mongo cause의 fail-closed test. + +### MNG-004 — keyset cursor가 BSON type과 framing을 보존하지 않는다 + +**근거** + +- `MongoKeysetCursorCodec.java:88-99`는 값에 `toString()`을 사용한다. +- `:102-117` decode는 모든 값을 `String`으로 복원한다. +- payload는 제어문자 separator와 `String.split`에 의존한다. +- `MongoKeysetQueryBuilderTest.java:77-87`은 문자열 값만 검증한다. +- `MongoKeysetPageRequest.java:17`의 `nullOrdering`은 builder에서 읽히지 않는다. +- `MongoKeysetQueryBuilder.java:100`의 `pageSize + 1`은 상한이 없고 overflow 가능하다. + +**실패 모드** + +`Instant`, `Date`, `ObjectId`, UUID, numeric/Decimal128 cursor가 String으로 바뀌면 Mongo 비교 BSON type이 +달라져 다음 page가 비거나 중복/누락될 수 있다. 문자열 자체에 separator가 들어가면 framing이 깨진다. +nullable sort는 선언된 null order가 predicate에 반영되지 않는다. + +**구현 결정: versioned typed value codec** + +1. token header를 `version`, `keyId`, `sortVersion`, `issuedAt`으로 고정한다. +2. payload는 canonical BSON 또는 explicit type-tag + length-prefixed binary로 encode한다. +3. 허용 타입을 String, boolean, signed numeric variants, Decimal128, ObjectId, UUID, Instant/Date로 + 닫고 알 수 없는 type은 encode 시 거부한다. +4. HMAC은 raw canonical bytes에 적용하고 key rotation을 위해 `keyId`를 포함한다. +5. maximum token bytes, field count, duplicate field, malformed length를 decode 전에 검증한다. +6. null을 허용한다면 sort key descriptor가 nullability/order를 소유하고 builder가 null branch를 명시적으로 + 생성한다. 그렇지 않으면 nullable field를 keyset sort에서 construction-time 거부한다. +7. page size를 registered query budget 이하로 제한하고 `Math.addExact` 또는 + `pageSize <= maxPageSize` 선검증을 사용한다. + +**필수 테스트** + +- 위 모든 허용 BSON type round-trip. +- separator/control/Unicode 문자열, duplicate field, unknown version/key, expired/oversized/tampered token. +- 동일 sort value + `_id` tie-breaker, ascending/descending, nullable field의 실제 Mongo server pagination. +- `Integer.MAX_VALUE` page size 거부. + +### MNG-005 — operation timeout과 result budget이 계약대로 강제되지 않는다 + +**근거** + +- `MongoOperationContext.java:8-30`은 모든 operation에 positive timeout을 요구한다. +- `DefaultMongoImperativeExecutor.java:51-86`은 timeout을 읽지 않고 elapsed만 사후 측정한다. +- `DefaultReactiveMongoExecutor.java:76-83,103-109`은 Reactor `.timeout`을 적용하지만 generic + `TimeoutException`을 Mongo failure로 변환하지 않아 observer failure도 기록되지 않을 수 있다. +- `MongoOperationBudget`의 `maxResultBytes`는 비교/교집합에는 쓰이지만 result consumption 경로에서 + 측정되지 않는다. +- `MongoReactiveCursorPublisher.java:47`은 batch/maxTime만 적용하고 total result/bytes limit을 두지 않는다. +- 두 generic executor는 read 성공도 `WRITE_CONFIRMED`로 기록한다 + (`DefaultMongoImperativeExecutor.java:73-77`, `DefaultReactiveMongoExecutor.java:78-80,104-106`). + +**실패 모드** + +blocking callback은 선언한 deadline을 넘길 수 있고, reactive timeout은 raw Reactor exception으로 +유출된다. 대용량 result stream은 byte budget을 넘는다. FIND metric/result가 write confirmed로 기록되어 +운영 지표가 잘못된다. + +**구현 결정: execution policy decorator** + +1. `MongoExecutionPolicy`를 만들어 deadline, result count/bytes, success outcome을 operation type에 따라 + 계산한다. +2. query/aggregation에는 server `maxTimeMS`와 `limit`을 context/budget의 최소값으로 적용한다. +3. blocking arbitrary callback에 hard timeout을 약속하지 못하면 API를 typed operation으로 좁혀 driver + timeout을 설정한다. 별도 thread interrupt로 Mongo I/O를 취소한다고 가정하지 않는다. +4. reactive path에서 `TimeoutException`을 `MongoTimeoutException`으로 변환하고 operation phase에 따라 + `NOT_SENT` 또는 unknown outcome을 선택한다. +5. `MongoResultBudgetTracker`가 encoded BSON byte와 count를 누적하고 초과 시 cursor를 cancel/close한다. +6. read/write completion을 분리한다. 권장안은 `MongoCompletion { READ_CONFIRMED, + WRITE_CONFIRMED, ... }`이며, write ambiguity enum을 억지로 read에 재사용하지 않는다. + +**필수 테스트** + +- fake/virtual time 기반 reactive timeout translation + observer failure exactly once. +- blocking/query maxTime propagation과 effective minimum deadline test. +- multi-batch result byte/count 초과 시 cancel/close. +- FIND/COUNT/AGGREGATE와 write별 completion metric parameterized test. + +### MNG-006 — representation manifest가 실제 mapping policy를 고정하지 않는다 + +**근거** + +- `MongoTypeRepresentationManifest.standard()`은 UUID, decimal, BigInteger, temporal, enum, + type metadata 정책을 선언한다. +- `MongoCustomConversionsFactory.java:38-46`이 등록하는 것은 Decimal128과 DomainId converter뿐이다. +- BigInteger, enum, temporal, UUID 축을 manifest로부터 compile하는 converter/configuration이 없다. +- `PolicyAwareMongoTypeMapper.java:67-78`은 registry에 없는 type을 + `CLASS_METADATA_ALLOWED`로 처리하여 long-lived alias 기본 정책과 어긋난다. +- testkit `MongoRoundTripContract`는 production test에서 사용되지 않고, BSON snapshot은 test codec에서 + UUID representation을 직접 지정한다. + +**실패 모드** + +manifest를 바꾸거나 standard를 사용해도 실제 `MappingMongoConverter`/driver codec이 같은 정책을 쓰는지 +보장되지 않는다. `_class`, UUID, BigInteger, temporal representation이 환경 기본값에 따라 달라질 수 +있고 기존 document를 조용히 오독할 수 있다. + +**구현 결정: compiled mapping policy** + +1. `MongoMappingPolicy`를 manifest에서 한 번 compile하고 conversions, codec settings, + type mapper가 모두 이를 참조한다. +2. manifest의 각 축에 구현이 없으면 startup에서 실패한다. 선언만 있는 option을 허용하지 않는다. +3. long-lived document는 명시적 alias registry 없이는 fail closed한다. ephemeral/internal type만 별도 + allow 정책을 둔다. +4. incompatible stored type을 `basicType`으로 조용히 fallback하지 말고 schema/type metadata exception으로 + 올린다. +5. type metadata registry builder는 alias/type 충돌을 모두 선검사한 뒤 두 map을 원자적으로 갱신한다. +6. `LocalDateTimeMappingGuard`를 단독 bean 이름이 아니라 실제 `MongoMappingContext`와 + `MongoCustomConversions`에 연결한다. standard mode에서는 persistent `LocalDateTime`을 startup에서 + 거부하거나 명시적 UTC converter를 사용한다. +7. testkit contract를 실제 `MappingMongoConverter` + driver round-trip에 사용한다. + +**필수 테스트** + +- 실제 replica set에 UUID, BigInteger, BigDecimal, enum, Instant/Date/OffsetDateTime, DomainId를 저장하고 + raw BSON과 Java round-trip을 동시에 확인한다. +- alias rename/unknown alias/incompatible `_class` fail-closed test. +- golden BSON snapshot은 production configuration에서 생성하고 MongoDB 7/8 lane에서 비교한다. + +### MNG-007 — documented startup/health/client generation wiring이 없다 + +**근거** + +- `README.md:21-23`은 auto-configuration이 startup validator, client generation registry, + health indicator를 등록한다고 말한다. +- `MongoPlatformAutoConfiguration.java:49-123`에는 이 세 bean과 reactive executor가 없다. +- `MongoPlatformProperties.java:16-18`은 binding-time validation을 주장하지만 `validate()`는 수동 method다. +- `MongoStartupValidator`, `MongoTopologyProbe`, `MongoClientGenerationRegistry`, + `MongoPlatformHealthIndicator`는 test에서 직접 생성되며 lifecycle/Actuator SPI에 연결되지 않는다. +- `MongoMappingConfiguration`은 component-scannable `@Configuration`이고 platform condition 밖에서 + 발견될 수 있다. + +**실패 모드** + +설정을 켜도 문서상 startup checks와 health/client generation이 실행되지 않는다. 반대로 leaf를 실제 +bootstrap scan에 넣으면 master flag가 false여도 mapping/configuration 일부가 생성될 가능성이 있다. + +**구현 결정: 단일 composition root + Abstract Factory** + +1. `MongoPlatformAutoConfiguration` 하나만 public auto-config entry point로 둔다. +2. child mapping configuration은 component scan 대상이 아닌 imported nested config로 바꾼다. +3. `@Validated`와 nested Jakarta validation 또는 명시적 validator bean을 사용해 refresh 중 설정을 + 검증한다. +4. `MongoTopologyProbe`는 실제 client의 `hello`/`buildInfo` 등에서 structured capability를 구한다. +5. `MongoClientFactory`가 runtime/admin/capability plane client를 credential reference로 생성하고 + `MongoClientGenerationRegistry`는 metadata가 아니라 실제 handle lifecycle을 관리한다. +6. health는 Spring Boot `HealthContributor` SPI에 연결하고 liveness와 readiness를 분리한다. +7. imperative/reactive auto-config를 class presence 조건의 nested configuration으로 분리한다. + +**필수 테스트** + +- `ApplicationContextRunner`: disabled, enabled-imperative, enabled-reactive, both, missing URI/secret, + insecure production, topology mismatch, user override bean. +- broad `CaSkeletonApplication` scan에서 disabled 시 Mongo platform bean 0개. +- container context에서 startup validator가 실제 topology mismatch를 거부. +- health UP/DEGRADED/DOWN과 credential rotation generation drain. + +### MNG-008 — release lane가 실행한 것보다 강한 증거를 만든다 + +**근거** + +- Advanced script는 `-Dmongodb.sharded.uri`를 넘기지만 Java production/test source가 이를 읽지 않는다. +- `--tests '*Shard*'`는 실제 sharded topology operation이 아닌 unit selector도 만족한다. +- Atlas/KMS는 environment variable 존재만으로 evidence에 포함되고, security/migration evidence는 script가 + missing 목록에 무조건 추가하여 gate가 완결될 수 없다. +- `MongoAdvancedPromotionEvidence`가 요구하는 migration 항목과 `MongoAdvancedPromotionGate`가 검사하는 + required 항목도 서로 다르다. +- complete sharded URI를 JVM system property argument로 전달하여 process inspection/실패 출력에 credential이 + 노출될 수 있다. +- Stable contract tag에는 Advanced GridFS test도 포함되어 “Stable은 Advanced 제외”라는 release script + 분류와 어긋난다. +- performance lane은 한 번의 count를 p50/p95/p99 모두에 넣고 pool wait=0, spill=false를 상수로 기록하며 + timing assertion 기본값은 false다. +- version matrix contract predicate가 실동작 없이 true를 반환할 수 있고, 3-node failover test는 election + 관측보다 강한 unknown-commit/resume 계약을 직접 검증하지 않는다. + +**실패 모드** + +test process의 exit 0 또는 env 존재가 feature certification으로 승격된다. support matrix와 release +evidence가 실제로 실행하지 않은 transaction/change stream/security/performance 동작을 증명한 것처럼 +보일 수 있다. + +**구현 결정: evidence manifest + contract-to-artifact mapping** + +1. 각 release contract에 unique ID, test task/FQCN/method, topology, required artifact를 매핑한다. +2. JUnit XML에서 발견/실행/skip/failure를 검사하고 task 시작 전의 stale XML은 거부한다. +3. sharded/Atlas/KMS lane은 dedicated source set/task에서 실제 command/round-trip을 실행한다. +4. secret URI를 JVM argument/process list에 직접 넣지 않고 file/credential provider reference를 쓴다. +5. performance는 warm-up + 반복 sample + histogram, pool listener, aggregation `explain` spill field, + concurrent pagination invariant를 측정한다. +6. promotion manifest에 image digest, server/driver version, commit SHA, test result hash, topology probe를 + 넣고 누락된 required evidence가 있으면 fail한다. + +**필수 테스트/게이트** + +- selector mutation, zero tests, all skipped, stale XML, wrong topology, missing artifact가 모두 gate를 + 실패시키는 test. +- real election 중 driver operation continuity, unknown commit reconciliation, change stream resume. +- performance assertion을 release gate에서 항상 true로 강제하고 percentile sample 수 하한을 검증한다. + +### MNG-009 — package dependency DAG가 닫힌 그래프로 강제되지 않는다 + +**근거** + +- `build.gradle:6-10`과 `docs/mongodb/repository-adaptation.md:21-25`는 원 설계의 package DAG를 + `MongoModuleBoundaryTest`가 강제한다고 주장한다. +- 현재 test는 선택된 역방향 의존만 금지한다(`MongoModuleBoundaryTest.java:95-168`). +- 현재 존재하지만 원 설계 allowed edge에 없는 import 예: + - reactive → imperative: `DefaultReactiveMongoExecutor.java:13` + - reactive cursor → query budget: `MongoCursorGuard.java:4` + - transaction session → reactive: `ReactiveMongoCausalSessionExecutor.java:6` + - geo → imperative/schema: `SpringMongoGeospatialOperations.java:6-10` + +**구현 결정: closed allowed-edge matrix** + +1. logical slice를 최상위 package + 필요한 하위 slice로 명시한다. +2. `sourceSlice -> allowedTargetSlices`를 단일 map으로 만들고 발견한 모든 production dependency edge가 + map에 있어야 통과하게 한다. +3. 현재 illegal edge를 먼저 test로 red 상태로 만든다. +4. collection profile/budget/context key처럼 여러 실행 경로가 쓰는 contract를 `api` 또는 + `internal.common`의 정확한 owner로 이동한다. +5. 문서 DAG를 바꾸어야 한다면 test와 adaptation doc을 같은 변경에서 갱신한다. + +**필수 테스트** + +- unknown package slice와 unknown edge가 실패하는 negative fixture. +- Stable → Advanced, runtime → testkit, API → framework 금지 유지. +- exact matrix와 문서 표가 동일 source에서 생성/검증되는 drift test. + +### MNG-010 — repository/controller guardrail이 실제 codebase에 적용되지 않는다 + +**근거** + +- `MongoRepositoryArchitectureRules.java:35-56`은 금지 이름/type의 String set만 반환한다. +- `MongoRepositoryArchitectureRulesTest.java:20-41`은 set 내용만 assert한다. +- root controller architecture rule은 JPA/Spring Data repository를 막지만 MongoTemplate, + ReactiveMongoTemplate, MongoClient/Database/Collection injection을 포괄하지 않는다. +- Boot auto-configuration은 raw client/template bean을 제공하므로 composition 후 우회가 가능하다. + +**구현 결정: executable root ArchUnit rule** + +1. production의 inbound/controller/bootstrap/application/domain package가 Mongo driver, Spring Data Mongo + repository/template type에 의존하거나 field/constructor parameter로 받지 못하게 한다. +2. 허용 범위는 Mongo leaf의 구체 implementation package와 명시적 composition config뿐이다. +3. generic `CommonMongoRepository`, `BaseMongoRepository`, raw collection gateway 이름/상속을 실제 class + scan에 적용한다. +4. “domain repositories may extend Spring Data” 문구는 “adapter-local Spring Data repositories”로 + 고친다. domain/application port는 framework-free다. +5. String catalog helper는 testkit으로 옮기거나 actual ArchRule factory로 바꾼다. + +**필수 테스트** + +- controller가 MongoTemplate/MongoRepository/MongoClient를 주입하는 negative fixture 각각 실패. +- outbound adapter implementation과 auto-config의 필요한 reference는 허용. +- root `CleanArchitectureTest`에서 Mongo leaf가 composition되지 않아도 class import로 검사한다. + +### MNG-011 — Advanced opt-in invariant와 always-throw API + +**근거** + +- `CLAUDE.md:69-70`은 모든 Advanced entry point가 flag 없이는 construction을 거부한다고 말한다. +- 54개 Advanced production file 중 flag를 직접 참조하는 것은 일부뿐이다. +- 실행 가능한 `MongoChangeMessagingBridge.java:29-59`, tenancy/search/vector 관련 여러 entry point는 + 동일 guard를 강제하지 않는다. +- `MongoTimeSeriesCapabilityValidator`의 네 public method와 + `MongoQueryableEncryptionProfile`의 일부 query method는 항상 `UnsupportedOperationException`을 던진다. + +**구현 결정: capability guard decorator + Specification** + +1. descriptor/value object와 executable entry point를 명시적으로 분류한다. +2. 모든 executable implementation은 `AdvancedCapabilityGuard` decorator/factory를 통해서만 생성한다. +3. flag를 typed configuration으로 binding하고 disabled/enabled composition test를 둔다. +4. 항상 실패하는 method는 제거한다. capability matrix가 지원/미지원과 이유를 반환하고 descriptor + validation 단계에서 조합을 거부하게 한다. +5. implementation이 없는 search/vector/time-series interface는 문서에서 scaffold로 표시하거나 + experimental artifact로 물리 분리한다. +6. change-to-messaging bridge가 publish 결과를 상수 `published=true, ambiguous=false`로 만들지 않고 broker + adapter의 confirmed/ambiguous/failed 결과를 받아 checkpoint/outbox policy를 실제로 분기하게 한다. + +**필수 테스트** + +- `..advanced..`의 executable concrete type이 guard/factory를 경유하는 ArchUnit rule. +- capability별 disabled construction/operation, enabled supported operation, unsupported combination. +- stable auto-config graph에 Advanced bean/type dependency가 없는지 검증. + +### MNG-012 — session acquisition과 ambient scope lifecycle이 안전하지 않다 + +**근거** + +- sync factory는 session을 연 뒤 `SpringMongoTransactionSessionFactory.java:73`에서 transaction을 + 시작한다. 시작 실패 시 close하는 보호 구문이 없다. +- reactive factory도 `SpringReactiveMongoTransactionSessionFactory.java:76-81` map 안에서 + `startTransaction`이 던지면 session을 release하지 않는다. +- `MongoTransactionScope`와 `SpringMongoCausalSessionExecutor`는 `ThreadLocal.set/remove`로 outer scope를 + 저장하지 않아 nested bind가 outer context를 잃는다. + +**구현 결정** + +1. acquisition은 `try/catch close` 또는 `usingWhen` resource acquisition으로 감싼다. +2. transaction API의 bound operations 인자화로 ambient `ThreadLocal`을 제거한다. +3. 당장 제거하지 못하면 bind 시 existing value를 감지해 nested usage를 명시적으로 거부하거나 stack + token으로 restore한다. +4. abort/release failure가 original failure를 덮지 않도록 suppressed/observation policy를 고정한다. + +**필수 테스트** + +- `startTransaction` sync/reactive throw 시 close 1회. +- cancel/body failure/commit failure/cleanup failure 조합별 abort/release 횟수와 원 exception 보존. +- nested scope rejection 또는 outer restoration. + +### MNG-013 — change-stream dedupe는 check-then-act race다 + +**근거** + +- `MongoChangeDeduplicationStore`는 `alreadyProjected`와 `markProjected`를 분리한다. +- `MongoChangeStreamRunner.java:52-72`는 check → project → mark 순서다. +- 두 subscriber가 동시에 false를 읽으면 둘 다 projection을 실행할 수 있다. +- store/projector가 empty `Mono`를 반환할 때 일부 chain은 terminal action 없이 끝날 수 있다. + +**구현 결정: durable state machine** + +1. store API를 atomic `tryClaim(identity, lease)` → `CLAIMED|ALREADY_COMPLETED|BUSY`로 바꾼다. +2. 성공 후 `complete`, 재시도 가능한 실패/lease expiry는 `releaseOrExpire`한다. +3. projector 자체 idempotency key는 계속 요구하되 dedupe claim이 중복 동시 실행도 줄인다. +4. empty publisher는 `switchIfEmpty`로 protocol violation을 발생시킨다. +5. checkpoint는 projection/dedupe completion 성공 뒤에만 advance한다. + +**필수 테스트** + +- 2개 concurrent runner에서 projector exactly once. +- crash after claim/before project, after project/before complete, after complete/before checkpoint. +- empty store/projector, lease expiration, history lost recovery. + +### MNG-014 — migration lock lease를 긴 batch 중 갱신하지 않는다 + +**근거** + +- schema migration guide와 lock 주석은 between-batch refresh/restart를 약속한다. +- `MongoMigrationRunner.java:107-108`은 `migration.execute(context)` 전체가 끝난 뒤 refresh한다. +- context에는 heartbeat/fencing token이 없어 오래 걸리는 execute 중 lease가 만료될 수 있다. + +**실패 모드** + +두 번째 runner가 만료된 lock을 획득한 뒤 첫 runner가 계속 쓰면 migration이 중첩된다. 단순 refresh의 +`matchedCount` 수정만으로 이 문제를 해결하지 못한다. + +**구현 결정: lease heartbeat + fencing** + +1. lock acquisition이 monotonically increasing fencing token을 반환한다. +2. runner는 lease의 일정 비율마다 heartbeat하고 ownership/fence mismatch 시 작업을 중단한다. +3. migration은 bounded batch/checkpoint API를 사용한다. 임의의 장시간 단일 `execute`는 certification + 대상에서 제외하거나 별도 no-expiry maintenance window 정책을 요구한다. +4. ledger/checkpoint write에도 fence를 조건으로 사용한다. + +**필수 테스트** + +- mutable clock + blocking batch + competing runner. +- heartbeat success/failure, stale fence write rejection, process kill 후 checkpoint restart. +- 실제 replica set migration lane에서 long batch와 lease contention. + +### MNG-015 — GridFS stream과 checkpoint의 의미가 불일치한다 + +**근거** + +- `MongoGridFsCompatibilityReader.java:20-25`는 caller가 stream을 닫아야 한다고 명시한다. +- `MongoGridFsMigrationJob.java:56-66`은 try-with-resources 없이 stream을 넘긴다. +- checkpoint 이름은 `lastMigrated`지만 failure path/test는 failed object ID를 그 자리에 저장한다. +- 문서는 failed IDs 재실행을 말하지만 별도 failed-id collection이 없다. + +**구현 결정** + +1. migration job이 source stream을 try-with-resources로 소유한다. +2. checkpoint를 `lastSuccessfullyProcessed`와 `failedObjects`로 분리한다. +3. target write는 checksum/idempotency key를 사용하고 checkpoint는 성공 후 저장한다. +4. failed object retry queue의 bounded size/retention과 poison object 정책을 명시한다. + +**필수 테스트** + +- close-tracking stream: success/failure/cancel 모두 close 1회. +- N번째 실패 후 restart가 N-1 성공 checkpoint부터 재개하고 성공 object를 중복 생성하지 않음. +- failed ID가 별도 보존되고 retry/poison 정책을 따름. + +### MNG-016 — admin audit가 command 결과와 approval 대상을 증명하지 못한다 + +**근거** + +- `MongoAdminGateway.java:51-61`은 command supplier 실행 전에 applied audit를 기록한다. +- `MongoAdminAuditRecord`에는 outcome/failure terminal state가 없다. +- `MongoAdminAuthorization.approved`의 dry-run 값과 gateway invocation의 dryRun이 cryptographically 또는 + structurally binding되지 않는다. + +**구현 결정: typed command + audit state machine** + +1. command를 type, target digest, plan digest, dry-run, approver, expiry가 있는 immutable request로 만든다. +2. approval token이 같은 digest/dry-run/expiry에 binding되게 한다. +3. audit는 `INTENT_RECORDED` 후 `SUCCEEDED` 또는 sanitized `FAILED` terminal record를 append한다. +4. audit sink 실패 정책을 command 종류별 fail-closed로 고정한다. +5. raw command string/secret/document data는 audit에 저장하지 않는다. + +**필수 테스트** + +- supplier throw 시 FAILED terminal audit. +- approval reuse, target/dry-run mismatch, expiry, concurrent double execution 거부. +- audit sink 실패 시 command가 실행되지 않는지 검증. + +### MNG-017 — client/tenant registry가 singleton 동시성과 restart를 견디지 못한다 + +**근거** + +- `MongoClientGenerationRegistry`는 mutable `LinkedHashMap`을 synchronization 없이 사용한다. +- `MongoTenantMigrationCoordinator`는 tenant checkpoint를 in-memory `LinkedHashMap`에 둔다. +- `MongoTenantClientRegistry`도 mutable access state를 보유한다. + +**구현 결정** + +1. client generation은 profile별 immutable aggregate를 `ConcurrentHashMap.compute`로 원자 교체한다. +2. generation state에 actual client handle, active lease count, retiring timestamp를 함께 둔다. +3. tenant migration checkpoint는 `MongoTenantMigrationCheckpointStore` port에 영속화하고 coordinator는 + stateless orchestration으로 바꾼다. +4. tenant client cache는 max entries, idle expiry, close-on-evict, single-flight create를 강제한다. + +**필수 테스트** + +- rotate/require/release 100-way concurrency에서 lost update/early close 없음. +- tenant client same-key single creation, eviction close, max bound. +- coordinator restart 후 durable checkpoint resume. + +### MNG-018 — production security config와 secret/client wiring이 완성되지 않았다 + +**근거** + +- `MongoProfileProperties`는 `uriSecret`을 가지지만 실제 secret resolver/client settings로 연결되지 않는다. +- production profile에서 TLS/authentication required와 duration 양수 조건이 충분히 startup validation에 + 연결되지 않는다. +- security integration fixture는 auth/RBAC/redaction을 일부 검증하지만 TLS/rotation 전체를 검증하지 + 않으며 test credential literal을 source에 둔다. + +**구현 결정** + +1. `MongoCredentialResolver` port는 secret reference만 받고 value는 client factory의 최소 scope에서만 + 사용한다. +2. production profile은 TLS, authentication, stable API, finite connect/server-selection/socket timeout을 + 필수로 검증한다. +3. credential value/URI는 `toString`, exception, JVM args, audit/metric에 들어가지 않게 한다. +4. integration test credential은 runtime random으로 생성하고 fixture가 전달한다. +5. TLS lane에 trusted CA success, wrong CA, hostname mismatch, expired cert를 포함한다. +6. rotation은 new generation ready → traffic switch → old lease drain → close 순서를 검증한다. + +### MNG-019 — Stable contract 382개가 `check`에서 중복 실행된다 + +**근거** + +- default `test`는 Docker tag만 제외하고 `mongodb-contract`를 제외하지 않는다 + (`build.gradle:97-105`). +- `check`는 별도 `mongoStableContractTest`에 의존한다(`:181-194`). +- fresh 실행에서 `test` 386개, `mongoStableContractTest` 382개가 각각 실행됐다. + +**구현 결정** + +default `test`에서 `mongodb-contract`를 exclude하고 `check`가 `test` + +`mongoStableContractTest`를 각각 한 번 실행하게 한다. contract가 대부분 unit test와 같은 class라면 반대로 +별도 task를 제거할 수도 있으나, release artifact 분리를 위해 전자를 권장한다. + +**검증** + +- 두 task의 XML FQCN/method 집합 교집합이 0인지 build contract test로 검사한다. +- `check` 총 discovered 수가 두 disjoint 집합의 합과 같은지 검사한다. + +### MNG-020 — public API와 Gradle leaf가 과도하게 넓다 + +**근거** + +- 313 production Java 파일 중 311개가 public top-level type을 노출한다. +- 한 leaf에 Stable/Advanced, sync/reactive, admin/migration, starter, architecture policy가 모두 들어 있다. +- sync/reactive starter가 모두 unconditional `implementation` dependency다. + +**판정** + +class 수만으로 god module이라고 단정하지 않는다. 그러나 닫히지 않은 package DAG, 거의 전부 public인 +surface, inseparable Advanced/admin/starter까지 함께 보면 artifact boundary 기준 god leaf다. + +**즉시 구현: 현재 19-leaf 정책을 보존하는 package 리팩터링** + +```text +dev.caskeleton.adapter.outbound.mongo +├── api +│ ├── execution +│ ├── consistency +│ ├── failure +│ ├── mapping +│ ├── query +│ └── transaction +├── autoconfigure +├── internal +│ ├── springdata +│ ├── imperative +│ ├── reactive +│ ├── transaction +│ ├── query +│ ├── schema +│ ├── migration +│ ├── changestream +│ ├── security +│ └── observation +├── advanced +│ ├── api +│ └── internal +└── architecture # production이 아니라 testkit/test로 이동 권장 +``` + +1. external contract만 `api`에 남기고 concrete implementation은 `internal`로 이동한다. +2. 같은 package에서만 쓰는 implementation/constructor는 package-private로 낮춘다. +3. public API allowlist snapshot과 “module 외부에서 internal 접근 금지” ArchUnit rule을 추가한다. +4. `architecture` String rules와 release evidence DTO는 production classpath가 아니라 testkit/build + support로 옮긴다. +5. package 이동은 transaction P0 수정 뒤에 진행해 semantic diff와 mechanical diff를 섞지 않는다. + +**조건부 장기안** + +물리 Gradle module은 `api`, `spring-data-common`, `imperative`, `reactive`, `admin`, `advanced`, +`starter`, `testkit` 정도의 8개가 현실적이다. 다만 현재 repository는 정확히 19 leaf를 canonical로 +강제한다. 따라서 이 분리는 일반 리팩터링으로 바로 실행하면 HARD-STOP 위반이다. 별도 architecture +proposal에서 `AGENTS.md`, `modules.json`, settings 검증, runtime membership, dependency tests를 원자적으로 +바꾸는 승인이 있을 때만 진행한다. + +### MNG-021 — runtime membership과 README activation 계약이 다르다 + +**근거** + +- Mongo registry entry의 `runtime_memberships`는 빈 배열이다. +- shipped `app-bootstrap`과 `sample-portfolio`는 Mongo project dependency가 없다. +- README는 property 설정만으로 활성화되는 것처럼 안내한다. +- registry는 application/shared dependency를 허용하지만 현재 Mongo build는 project dependency가 없다. + +**구현 결정** + +이번 템플릿에서는 **library-only opt-in**을 권장한다. + +1. README에 consumer가 registry/runtime composition을 승인해 추가하기 전 shipped runtime에는 포함되지 + 않는다고 적는다. +2. Mongo leaf의 현재 `allowed_dependencies`는 `[]`로 줄여 fail closed한다. +3. 실제 도메인 Mongo adapter가 필요할 때 별도 approved leaf/구조에서 application/domain port를 구현한다. +4. property-only activation을 지원하기로 결정한다면 modules registry membership, bootstrap dependency, + enabled/disabled full composition test를 같은 변경으로 추가한다. + +### MNG-022 — regex, health, version capability가 실제 보장보다 강하게 표현된다 + +**근거와 조치** + +- `MongoRegexPolicy`의 nested quantifier 검사는 `*`, `+`, `{` 중심의 syntactic 검사다. `?`, alternation, + overlapping group 등 모든 catastrophic pattern을 안전하게 판별하지 못한다. + - 사용자 검색은 기본 literal prefix/escaped contains로 제한한다. + - regex를 열어야 하면 parser 기반 safe subset + maxTime + index/hint policy를 함께 적용한다. +- `MongoPlatformHealthIndicator`의 secondary availability는 topology별 expected secondary 수를 단순화한다. + - 실제 topology probe 결과와 configured threshold를 사용하고 liveness/readiness를 구분한다. +- `MongoTimeSeriesCapabilityValidator`의 version 판정은 문자열 prefix에 의존한다. + - semantic version parser보다 가능하면 실제 server capability/command probe를 권위로 사용한다. + +### MNG-023 — collection-scoped callback이 raw operations로 경계를 우회한다 + +**근거** + +- `MongoCollectionAccess.java:27`과 `ReactiveMongoCollectionAccess.java:26`은 각각 raw + `MongoOperations`/`ReactiveMongoOperations`를 반환한다. +- executor의 `ScopedAccess.collection(requested)`는 다른 collection 이름을 거부하지만 caller는 + `access.operations().find(..., "anotherCollection")`처럼 이 검사를 호출하지 않고 우회할 수 있다. +- consistency binder의 read concern/query setting helper도 caller가 별도 호출해야 하므로 평범한 callback + read에는 자동 적용되지 않는다. + +**실패 모드** + +등록된 collection profile과 tenant boundary를 벗어난 read/write가 가능하다. caller가 +`PRIMARY_MAJORITY`/causal profile을 선택해도 callback이 일반 operation을 호출하면 requested read concern이 +조용히 빠질 수 있다. + +**구현 결정: capability-based scoped adapter** + +1. public callback에서 raw Spring Data operations를 제거한다. +2. `ScopedMongoOperations`와 reactive counterpart가 collection name을 받지 않는 typed + `findOne`, `findMany`, `insert`, `updateOne`, `deleteOne`, `aggregate`만 노출한다. +3. implementation이 physical collection, read/write concern, deadline, result budget, observation을 자동 + 적용한다. +4. native escape가 필요한 capability는 별도 `PolicyAwareMongoNativeGateway`에서 allowlisted command로만 + 제공한다. +5. migration/admin처럼 raw access가 필요한 plane은 runtime callback과 다른 credential/type으로 분리한다. + +**필수 테스트** + +- public callback API에서 collection 문자열/raw operations를 얻을 수 없는 API surface test. +- 모든 scoped operation이 등록된 physical collection을 사용하고 majority/snapshot concern을 적용하는 + argument capture test. +- tenant A callback으로 tenant B collection을 접근할 수 없는 integration test. + +### MNG-024 — consistency별 template 재생성이 Spring runtime contract를 잃는다 + +**근거** + +- `MongoConsistencyBinder.java:41-47`과 reactive counterpart는 factory와 converter로 새 template을 만든다. +- 원 Boot template에 붙은 entity callbacks, auditing, event publisher, write concern resolver, + write-result checking 등 다른 runtime 설정을 명시적으로 이전하지 않는다. + +**실패 모드** + +일반 Spring Data repository/template에서는 실행되던 `BeforeConvertCallback`, auditing, validation/event가 +platform executor 경로에서는 빠질 수 있다. 같은 entity의 저장 결과가 호출 경로에 따라 달라진다. + +**구현 결정** + +1. 먼저 per-operation read/write concern 적용으로 원 template을 재사용할 수 있는지 검토한다. +2. 별도 template이 필수라면 `MongoConsistencyOperationsFactory`가 원 template의 converter, + entityCallbacks, eventPublisher, writeConcernResolver, writeResultChecking 등 지원 계약을 복제한다. +3. reflection 기반 field copy는 금지하고 Spring Data가 제공하는 public extension point만 사용한다. +4. 지원할 수 없는 setting은 startup에서 명시적으로 거부하거나 README에 제한을 적는다. + +**필수 테스트** + +- `BeforeConvertCallback`/auditing이 base path와 consistency-bound path에서 각각 정확히 1회 실행. +- custom write concern resolver와 application event 설정 보존. +- sync/reactive 양쪽 bean graph test. + +### MNG-025 — failure classifier가 operation type과 failure phase를 모른다 + +**근거** + +- `MongoDriverFailureView.from`은 command sent/response 여부를 driver exception subtype과 실제 phase에 + 충분히 연결하지 않는다. +- classifier는 read/write/body/commit context 없이 label/code/view만으로 outcome을 만든다. +- label/code가 없는 driver timeout/server-selection timeout은 unclassified로 떨어질 수 있다. + +**실패 모드** + +동일 socket failure가 FIND에서는 안전한 read retry 후보인데 UPDATE에서는 write result unknown일 수 있다. +현재처럼 operation/phase가 없으면 read를 ambiguous write로 분류하거나 server-selection failure를 +non-retryable unclassified로 보낼 수 있다. + +**구현 결정: ordered classification rule chain** + +```text +classify(operationType, failurePhase, driverFailureView) + 1. authoritative labels + 2. transaction phase-specific rules + 3. exact driver subtype / command-sent state + 4. server code + 5. fail-closed unclassified +``` + +1. `MongoFailurePhase`를 `CLIENT_VALIDATION`, `SERVER_SELECTION`, `COMMAND_SEND`, `RESPONSE_WAIT`, + `TRANSACTION_BODY`, `TRANSACTION_COMMIT`으로 둔다. +2. driver subtype과 bounded labels/codes를 view에 보존한다. +3. `NoWritesPerformed`와 response loss를 별도 rule로 처리한다. +4. retry scope와 execution outcome은 rule result에서 함께 생성한다. + +**필수 테스트** + +- 같은 socket exception을 FIND/UPDATE와 send-before/send-after 조합으로 분류. +- server selection timeout, driver timeout, `NoWritesPerformed`, transient transaction, + unknown commit의 exact category/outcome/retry scope. + +### MNG-026 — bulk path가 Spring failure wrapper와 atomic policy를 우회한다 + +**근거** + +- `MongoBulkExecutor.java:53-61`은 직접 driver bulk exception 중심으로 처리한다. +- Spring Data bulk 실행은 driver `MongoBulkWriteException`을 `BulkOperationException` 또는 + `DataIntegrityViolationException` 계열로 감쌀 수 있다. +- bulk update는 atomic update path가 사용하는 protected-field/operator validator를 공유하지 않는다. +- 현재 result는 upsert, matched-but-unchanged, ordered failure 뒤 not-attempted, write concern unknown을 + item별로 완전히 표현하지 못한다. +- item 수 상한만으로 encoded Mongo command/document byte ceiling을 보장할 수 없다. + +**실패 모드** + +실제 duplicate-key partial failure가 generic translation으로 빠져 성공/실패 index가 사라진다. bulk를 통해 +보호 field/operator 정책을 우회할 수 있고, retry 시 이미 성공한 item을 다시 실행할 위험이 있다. + +**구현 결정** + +1. `SpringDataBulkFailureExtractor`가 Spring wrapper cause chain에서 driver bulk result를 추출한다. +2. atomic/bulk가 같은 `MongoAtomicOperationValidator`를 사용한다. +3. item result를 `APPLIED`, `MATCHED_UNCHANGED`, `FAILED`, `NOT_ATTEMPTED`, `UNKNOWN`으로 모델링한다. +4. ordered/unordered semantics와 write-concern ambiguity를 보존한다. +5. encoded byte budget을 계산해 ordered semantics를 유지하는 chunking Strategy를 적용하거나 초과를 + 실행 전에 거부한다. + +**필수 테스트** + +- Spring wrapper 안 duplicate-key partial result와 실제 server bulk failure. +- protected field/operator, successful upsert, no-op, ordered failure의 후속 item, write concern ambiguity. +- item 수는 적지만 encoded bytes가 ceiling을 넘는 계획. + +### MNG-027 — optimistic revision invariant를 public constructor로 우회할 수 있다 + +**근거** + +- `VersionedUpdateCommand.java:18-30,49-51`은 public record constructor로 임의 filter/update를 받는다. +- 첫 revision increment만 확인한 뒤 같은 field에 추가 `$inc`, `$set`이 있는지 완전히 닫지 않는다. +- filter revision value의 numeric type도 강제되지 않아 accessor에서 cast failure가 날 수 있다. + +**실패 모드** + +revision을 1이 아닌 값으로 증가시키거나 set으로 덮어 optimistic locking의 monotonic invariant를 깨뜨릴 +수 있다. 잘못된 filter value가 operation 전에 안정된 validation error가 아니라 `ClassCastException`으로 +나간다. + +**구현 결정** + +1. public record constructor 대신 검증된 static factory를 가진 final class로 바꾼다. +2. revision field를 건드리는 update가 정확히 하나이고 numeric `$inc 1`인지 확인한다. +3. 같은 field의 conflicting operator/duplicate update를 거부한다. +4. expected revision은 `MongoRevision` value object만 받는다. + +**필수 테스트** + +- `$inc 1` 뒤 `$inc 5`, `$set revision`, duplicate operator, String revision 모두 construction-time 거부. +- matched/no-match/conflict 결과가 outer execution outcome과 일치. + +### MNG-028 — change-stream ordering, identity, source wiring이 완전하지 않다 + +**근거** + +- 현재 runner는 event 단위 `run`을 노출해 caller가 병렬 호출할 수 있다. +- 뒤 event B의 projection/checkpoint가 앞 event A보다 먼저 완료되면 checkpoint가 앞질러 저장되거나 + 나중에 뒤로 회귀할 수 있다. +- `MongoChangeEventIdentity`의 clusterTime/namespace/document/operation tuple은 같은 transaction에서 같은 + document에 같은 operation을 여러 번 한 event를 충돌시킬 수 있다. +- production source에서 driver `changeStream/watch/resumeAfter/startAfter`를 runner/recovery/checkpoint에 + 잇는 lifecycle consumer가 확인되지 않는다. + +**실패 모드** + +process가 B checkpoint 뒤 A 완료 전에 죽으면 A를 영구 건너뛸 수 있다. identity 충돌은 distinct event를 +duplicate로 오인한다. policy/value object는 있어도 실제 resume state machine이 조립되지 않으면 문서의 +at-least-once consumer 계약은 실행되지 않는다. + +**구현 결정** + +1. `ReactiveMongoChangeStreamConsumer.run(Flux)`가 checkpoint load → resume mode → driver stream + → projection → dedupe completion → checkpoint를 하나의 lifecycle로 소유한다. +2. partition당 `concatMap`으로 순차 처리하거나 checkpoint store에 + `saveIfNewer(expectedPrevious, next)` CAS를 둔다. +3. identity에는 stable resume token을 우선 사용하고, 필요 시 lsid/txnNumber/operation index를 보조한다. +4. raw token을 숨기려면 key ID가 있는 HMAC을 사용한다. +5. invalidate/history-lost는 explicit state transition으로 halt/rebuild/startAfter를 선택한다. + +**필수 테스트** + +- A/B completion 순서를 뒤집어도 checkpoint skip/regression 없음. +- same transaction/same document multiple updates의 identity가 다름. +- replica set에서 failover resume, invalidate/startAfter, history-lost halt, crash after projection/before + checkpoint. + +### MNG-029 — mutable Query와 driver observability configuration이 호출 경계에 연결되지 않는다 + +**근거** + +- `MongoReactiveCursorPublisher.java:32-49`는 caller가 준 mutable `Query`에 batch/maxTime을 직접 설정한다. +- 같은 Query를 재사용하거나 concurrent subscription하면 설정이 서로 누출될 수 있다. +- `MongoDriverObservabilityConfiguration`은 listener 적용 method를 제공하지만 auto-config에서 + `MongoClientSettingsBuilderCustomizer`로 연결되지 않는다. + +**구현 결정** + +1. `Query.of(query)` 등 지원되는 copy API로 defensive copy 후 budget을 적용한다. +2. operation request에는 mutable Spring Query 대신 immutable platform descriptor를 우선 사용한다. +3. MeterRegistry가 있을 때 command/pool/SDAM listener를 등록하는 Boot client-settings customizer bean을 + 제공한다. +4. operation observation과 driver observation의 metric/tag ownership을 구분해 double count를 막는다. + +**필수 테스트** + +- 원 Query가 변경되지 않고 서로 다른 두 subscription의 budget이 독립적임. +- `ApplicationContextRunner`에서 customizer/listener 존재와 disabled/no-meter 조건. +- pool checkout/server selection/primary change metric의 bounded tag test. + +## 6. 디자인 패턴 적용 지침 + +패턴은 package 수를 늘리기 위한 장식이 아니라 현재 실패 모드를 없앨 때만 사용한다. + +| 패턴 | 적용 위치 | 해결하는 문제 | 피해야 할 적용 | +|---|---|---|---| +| Context Object / Unit of Work | sync/reactive transaction callback | session-bound operations를 명시적으로 전달 | ThreadLocal을 감춘 facade만 추가 | +| Strategy | failure extractor/classifier, backoff, result budget, success outcome | 분기와 불변식을 한 정책으로 통합 | 모든 작은 validator를 interface로 분해 | +| State Machine | transaction phase, change claim, client rotation, admin audit | 순서·terminal state·재시도 가능성을 명시 | enum만 만들고 transition guard 미구현 | +| Abstract Factory | runtime/admin/capability Mongo client | credential/plane/settings/lifecycle을 한 owner가 구성 | caller에게 raw URI/client settings를 다시 노출 | +| Specification | query allowlist, capability combination, schema/index diff | 조합 가능한 정책과 거부 이유를 표현 | business rule을 persistence Specification으로 이동 | +| Decorator | observation, budget, advanced guard | 모든 executable entry path에 공통 정책 적용 | 일부 constructor만 수동 guard | +| Adapter/Port | secret resolver, durable checkpoint, audit sink | 외부 secret/store/audit backend 교체 | domain/application에 Spring Data type 노출 | + +Generic Repository pattern은 권장하지 않는다. Mongo aggregate마다 query/index/atomic update/consistency 요구가 +다르므로 domain/application에는 좁은 port를 두고 Mongo leaf에서 Spring Data/template 기반 adapter로 +구현한다. `CommonMongoRepository`는 collection/budget/consistency guard를 우회하기 쉽다. + +## 7. 구현 순서 + +### Phase 0 — 변경 전 safety net + +1. 이 문서의 MNG ID를 issue/commit 메시지 추적 키로 사용한다. +2. `MongoModuleBoundaryTest`에 현재 illegal edge를 먼저 재현하되, P0 semantic 수정 branch와 package 이동 + branch는 분리한다. +3. Docker 없이 실행 가능한 unit/contract baseline을 저장한다. +4. traceable JAR hygiene blocker는 사용자 artifact 소유권을 확인한 별도 작업에서 정리한다. + +### Phase 1 — transaction correctness (MNG-001~003, 012) + +1. reactive callback signature와 session binding을 먼저 변경한다. +2. real replica set rollback/commit test를 red → green으로 만든다. +3. monotonic deadline/backoff Strategy와 transaction phase state machine을 도입한다. +4. failure classification-derived context와 common extractor를 적용한다. +5. resource acquisition/nested scope/cancellation test를 보강한다. + +완료 전 다음 phase로 넘어가지 않는다. transaction contract가 잘못된 상태에서 auto-config를 연결하면 +결함의 사용 범위만 넓어진다. + +### Phase 2 — query/mapping/execution contract (MNG-004~006, 023~027, 029) + +1. versioned typed cursor codec과 null policy를 구현한다. +2. deadline/result-count/result-byte decorator를 모든 query/cursor/executor에 적용한다. +3. mapping manifest를 compiled policy로 바꾸고 real converter/driver golden test를 추가한다. +4. raw operations callback을 scoped capability API로 바꾸고 Spring callback/consistency 보존을 검증한다. +5. failure classifier에 operation type/phase를 추가한다. +6. bulk/revision/result semantics를 닫고 mutable Query/driver observability wiring을 보강한다. + +### Phase 3 — composition/security (MNG-007, 018, 021) + +1. library-only runtime 계약을 README/registry에 명확히 한다. +2. auto-config entry를 하나로 통합하고 settings validation을 refresh에 연결한다. +3. client factory/secret resolver/topology probe/health를 실제 bean graph에 연결한다. +4. broad application scan disabled test와 container enabled test를 추가한다. + +### Phase 4 — architecture/API surface (MNG-009~011, 020) + +1. exact allowed-edge matrix를 먼저 적용한다. +2. shared policy owner를 `api`/`internal.common`으로 이동해 illegal edge를 제거한다. +3. root raw-Mongo injection ArchUnit rule을 추가한다. +4. executable Advanced guard를 통일한다. +5. public API allowlist를 만든 뒤 concrete type을 internal/package-private로 축소한다. + +### Phase 5 — operational state machines (MNG-013~017, 028) + +change dedupe, migration lease, GridFS checkpoint, admin audit, client/tenant registry를 각각 독립 change로 +처리한다. 각 change는 concurrent/crash/restart test가 있어야 한다. + +### Phase 6 — release evidence (MNG-008, 019, 022) + +1. test/contract 중복을 제거한다. +2. Stable contract ID → exact test artifact mapping을 추가한다. +3. real topology/chaos/security/performance lane을 강화한다. +4. 마지막에만 support matrix와 release evidence를 갱신한다. + +## 8. 권장 검증 매트릭스 + +### 8.1 매 변경의 기본 검증 + +```bash +cd src +./gradlew :adapter:outbound:persistence-mongo:test --rerun-tasks --no-daemon --console=plain +./gradlew :adapter:outbound:persistence-mongo:mongoStableContractTest --rerun-tasks --no-daemon --console=plain +./gradlew :adapter:outbound:persistence-mongo:check --no-daemon --console=plain +./gradlew verifyCleanArchitectureDependencies verifyDependencyLocks verifyEnvKeys verifyPublicPathSnapshot --no-daemon --console=plain +``` + +### 8.2 transaction/query/mapping 변경 + +```bash +cd src +./gradlew :adapter:outbound:persistence-mongo:mongoReplicaSetTest --no-daemon --console=plain +./gradlew :adapter:outbound:persistence-mongo:mongoFailoverTest --no-daemon --console=plain +./gradlew :adapter:outbound:persistence-mongo:mongoCompatibilityTest --no-daemon --console=plain +``` + +검증할 동작은 transaction commit/rollback, whole-body retry, commit-only retry, response-loss reconciliation, +typed cursor pagination, mapping raw BSON이다. task exit code만으로 완료하지 않고 해당 test ID와 JUnit XML +실행 수를 확인한다. + +### 8.3 migration/security/performance 변경 + +```bash +cd src +./gradlew :adapter:outbound:persistence-mongo:mongoMigrationTest --no-daemon --console=plain +./gradlew :adapter:outbound:persistence-mongo:mongoSecurityIntegrationTest --no-daemon --console=plain +./gradlew :adapter:outbound:persistence-mongo:mongoPerformanceTest -Pperformance.assertions.enabled=true --no-daemon --console=plain +``` + +### 8.4 release 후보 + +```bash +MONGODB_DOCKER=1 bash scripts/verify-mongodb-platform.sh +bash scripts/verify-mongodb-advanced.sh +``` + +Advanced script는 MNG-008을 고치기 전에는 promotion 성공 근거로 사용하지 않는다. 현재 설계상 missing +evidence를 보고하는 exit는 실패가 아니라 아직 promotion할 수 없다는 정직한 상태로 해석한다. + +## 9. 이번 리뷰에서 실행한 검증 + +### 성공 + +```text +./gradlew :adapter:outbound:persistence-mongo:test \ + :adapter:outbound:persistence-mongo:mongoStableContractTest \ + --rerun-tasks --no-daemon --max-workers=2 --console=plain + +BUILD SUCCESSFUL in 1m 5s +6 actionable tasks: 6 executed +``` + +- `test`: 386 tests, 0 failures, 0 errors, 0 skipped +- `mongoStableContractTest`: 382 tests, 0 failures, 0 errors, 0 skipped + +### 별도 repository 검증 + +```text +./gradlew verifyCleanArchitectureDependencies verifyDependencyLocks \ + verifyEnvKeys verifyPublicPathSnapshot \ + --rerun-tasks --no-daemon --max-workers=2 --console=plain + +BUILD SUCCESSFUL in 49s +30 actionable tasks: 30 executed +``` + +- 19개 leaf dependency lock 검증을 포함해 모두 실행·성공했다. +- `verifyEnvKeys`: 155 keys, 67 required placeholders, 173 application references, + 61 typed properties, 280 registry rows — OK. +- 기존 owner가 아직 소비하지 않는 6개 env key warning은 남았지만 Mongo 변경으로 발생한 failure는 아니다. +- `verifyPublicPathSnapshot`: committed public paths unchanged — OK. + +### 차단된 검증 + +Mongo leaf `check`는 Mongo compile/test failure가 아니라 root 선행 task +`verifyNoStaleTraceableJars`에서 차단됐다. 현재 HEAD `92744c5...`와 다른 source SHA +`99a51e5a1614...`로 만들어진 traceable JAR 14개가 남아 있었다. gate가 안내한 +`cleanStaleTraceableJars`는 artifact 삭제 작업이므로 이 read-only review에서 실행하지 않았다. + +### 실행하지 않은 검증 + +- Docker-backed replica set/failover/migration/compatibility/security/performance lane +- 실제 Atlas, KMS, sharded cluster Advanced lane +- 운영 부하와 production topology 검증 + +따라서 unit/contract green은 이 보고서의 runtime correctness finding을 반박하지 않는다. 해당 tests가 +session-bound reactive transaction, typed BSON cursor, real mapping policy, actual auto-config lifecycle, +release evidence fidelity를 아직 검증하지 않기 때문이다. + +## 10. Definition of Done + +Mongo platform을 Stable/production-ready로 다시 판정하려면 최소한 다음이 모두 필요하다. + +- [ ] 모든 P0 및 High finding(MNG-001~018, MNG-023~028) 완료 및 관련 real topology test 통과 +- [ ] reactive transaction callback이 bound operations 외 경로를 기본 API로 사용할 수 없음 +- [ ] retry category/outcome/scope invariant test 전수 통과 +- [ ] cursor 허용 BSON type round-trip + 실제 pagination 전수 통과 +- [ ] 모든 timeout/result budget이 실제 실행 path에서 강제됨 +- [ ] representation manifest 각 축이 real converter/codec에 연결됨 +- [ ] disabled/enabled/invalid auto-configuration context와 health/client lifecycle 통과 +- [ ] package exact DAG와 root raw-Mongo injection rule 통과 +- [ ] scoped execution API 밖에서 raw operations/다른 collection을 접근할 수 없음 +- [ ] consistency-bound path에서 Spring callbacks/auditing/read concern이 보존됨 +- [ ] bulk partial result와 optimistic revision invariant test 통과 +- [ ] change-stream 순서/identity/resume lifecycle의 crash·failover test 통과 +- [ ] Advanced executable entry point 전부 opt-in guard를 경유 +- [ ] compatibility/failover/performance/Advanced evidence가 실제 test artifact/topology와 1:1 연결 +- [ ] hard-coded test credential 제거, TLS/rotation lane 통과 +- [ ] Docker Stable gate fresh 성공 및 JUnit evidence count 확인 +- [ ] root `check`와 architecture/dependency/env/public-path 검증 성공 +- [ ] README, support matrix, ADR의 보장 수준이 실제 구현·검증 수준과 일치 + +이 체크리스트를 충족하기 전의 정확한 표현은 “MongoDB persistence platform contract와 일부 실행 +경로가 구현된 opt-in experimental leaf”다. + +## 11. LLM Wiki capture + +- 갱신: `/home/donghyeon/workspace/ai-tool/llm-wiki-private/raw/branch-notes/main.md` +- 기록 내용: 기준 HEAD, 검토 범위, 핵심 finding, 구현 순서, 변경 파일, fresh Gradle 결과, + `check` blocker, Docker/Advanced 미실행 범위, 증거 등급. +- 파생 raw 문서: 없음. 구현 전 read-only finding이므로 interview/blog/canonical로 승격하지 않았다. +- link-only structure lint: PASS. +- full single-file structure lint: 기존 `main.md` naming conflict로 `NAMING_VIOLATION` 1건. 제품 + `AGENTS.md`가 실제 branch-name path를 요구하고 vault naming rule은 prefix를 요구하므로 임의 rename하지 + 않았다. diff --git a/docs/reviews/2026-08-14-notification-module-code-review.md b/docs/reviews/2026-08-14-notification-module-code-review.md new file mode 100644 index 00000000..ba7f3557 --- /dev/null +++ b/docs/reviews/2026-08-14-notification-module-code-review.md @@ -0,0 +1,1484 @@ +# Notification 모듈 상세 코드·아키텍처 리뷰 + +- 기준 일자: 2026-08-14 +- 기준 Git HEAD: `539e3eb58bed5db63e3a17f47eec213db2d2df79` +- notification 기준 source snapshot: `c1ee1d9dd916719e709bbea0b7cb46118bafc590` +- 대상 Gradle leaf: + - `:application-core` + - `:adapter:outbound:notification` + - `:adapter:outbound:persistence-jpa` + - `:adapter:inbound:web` + - `:app-bootstrap` +- 대상 문서/CI: `docs/notification`, `.github/workflows/notification-platform.yml` +- 판정: **CHANGES REQUIRED — 현재 상태를 production-ready Stable로 승격하면 안 됨** +- 변경 범위: 이 리뷰 문서만 추가했으며 production/test 코드는 수정하지 않았다. + +> 리뷰 도중 HEAD가 `92744c5` → `c1ee1d9` → `539e3eb`로 이동했다. 최종 HEAD의 +> `c1ee1d9..539e3eb` diff를 확인한 결과 notification application/adapter/bootstrap/JPA source는 +> 변경되지 않았고, 마지막 병합은 주로 JPA persistence platform 추가였다. 새로 합쳐진 +> `PostgreSqlWorkClaimExecutor`는 NTF-004의 원자적 claim 구현에 재사용할 수 있으므로 수정안에 +> 반영했다. + +## 1. 최종 결론 + +현재 notification 코드는 단순 알림 adapter가 아니다. 요청 수락, 예약, route, template, contact point, +provider runtime, retry/ambiguity, callback ledger, projection, suppression, admin, inbox까지 포함한 별도 +delivery platform이다. provider-neutral port, 명시적인 evidence 모델, transaction 밖 provider call, +append-only event ledger라는 큰 설계 방향은 좋다. + +그러나 타입과 단위 테스트가 존재하는 것과 실제 application runtime이 완성된 것은 별개다. 현재 기본 +composition root에서는 provider runtime과 route가 모두 빈 상태이고, notification Flyway stream은 운영 +migration에 연결되지 않는다. 예약 row는 claim 대상이 아니며, 멀티 replica lease는 원자성·fencing을 +갖추지 못했다. crash recovery, reconciliation, pending projection, unmatched callback worker도 조립되지 +않는다. 이 상태에서 platform을 켜면 요청은 받아 저장할 수 있어도 안전하게 전송·복구·재생할 수 없다. + +즉시 적용할 운영 원칙은 다음과 같다. + +1. `ca-skeleton.notification.platform.enabled`는 계속 기본 `false`로 유지한다. +2. NTF-001~NTF-012가 해결되기 전 `docs/notification/support-matrix.md`의 `Stable` 표기를 release + 근거로 사용하지 않는다. +3. 설정만으로 platform을 활성화하지 말고, schema activation과 provider assembly가 모두 fail-closed로 + 검증된 뒤 worker를 시작한다. +4. 폴더 이동이나 패턴 도입보다 예약·lease·evidence·ledger의 데이터 정합성을 먼저 고친다. +5. notification provider를 실제 호출하는 검증 없이 “발송 가능”, PostgreSQL 검증 없이 “durable”, + restart 검증 없이 “recovery 지원”이라고 표현하지 않는다. + +## 2. 검토 범위와 증거 경계 + +### 2.1 현재 규모 + +| 영역 | production Java 파일 | LOC | 비고 | +|---|---:|---:|---| +| 기존 + 신규 application notification 전체 | 372 | - | 기존 R1 100개 + 신규 platform 272개 | +| 신규 `application.notification.platform` | 272 | 8,588 | 대부분 public top-level type | +| outbound notification platform | 110 | 7,573 | provider/runtime/template/security | +| JPA notification platform | 36 | 4,266 | request/delivery/attempt/event/contact 등 | +| inbound callback | 8 | 480 | MVC + WebFlux | +| bootstrap notification | 8 | 1,241 | runtime config 한 파일이 506줄 | + +이 규모에서는 “adapter 하나”로 취급해서는 안 된다. 다만 `src/config/architecture/modules.json`의 정확한 +19개 leaf SSOT를 깨면서 31개 Gradle 모듈로 즉시 분해하는 것도 권장하지 않는다. 먼저 package DAG, +public API allowlist, adapter 내부 configuration facade로 경계를 강제한 뒤 실제 독립 배포·빌드 필요가 +생길 때만 leaf 분리를 검토한다. + +### 2.2 깊게 확인한 실행 흐름 + +```text +submit/schedule + -> fingerprint + durable plan writer + -> request/recipient JPA rows + -> scheduler claim/lease + -> render + contact reveal + provider runtime acquire + -> attempt pre-commit + -> provider call outside transaction + -> outcome commit + -> callback verify/normalize/ledger append + -> projector + suppression/evidence roll-up + -> recovery/reconciliation/admin +``` + +다음은 이번 리뷰에서 검증하지 못한 외부 증거다. + +- 실제 APNs/FCM/SES/Twilio/SMTP/WebPush provider sandbox 호출 +- notification migration을 적용한 실제 PostgreSQL CRUD 및 Hibernate schema validation +- 두 application replica가 경쟁하는 lease/fencing 검증 +- provider call 직전·직후 process kill과 restart recovery +- 대량 callback burst와 DNS rebinding/metadata endpoint 공격 검증 + +따라서 provider별 SLA, 성능 한계, 실 provider 호환성을 이 리뷰가 승인하는 것은 아니다. + +## 3. 유지할 설계 + +다음 방향은 리팩터링하면서 보존한다. + +- application이 `NotificationProviderAdapter`, persistence, secret, attachment, callback port를 소유하고 + outbound adapter가 구현하는 의존성 방향은 적절하다. +- `DeliveryStrategy`, `NotificationContent`, `ContactPointValue`, `RetryDecision`, + `ReconciliationResult`, `DispatchGuardOutcome`의 sealed hierarchy와 exhaustive switch는 Java 21을 + 잘 활용한다. +- `ProviderSubmissionResult`가 provider acceptance와 delivery를 구분하고 AMBIGUOUS를 일급 상태로 + 모델링한 점은 타당하다. +- provider call 전에 attempt를 commit하고, 외부 호출은 transaction 밖에서 수행하며, 결과를 다시 + transaction으로 기록하는 큰 순서는 유지해야 한다. +- provider event를 append-only ledger에 저장하고 ordinal status 하나가 아니라 projector로 merge하는 + 방향은 out-of-order callback을 다루기에 적합하다. +- AES-GCM, AAD, lookup HMAC, redacted secret/contact representation을 분리하려는 의도는 좋다. +- provider별 failure classifier와 mapper는 Strategy로 유지한다. 상속 기반 거대한 Template Method로 + 합치지 않는다. +- HTTP redirect `NEVER`, callback raw-byte verification, WebFlux body buffer release, DB unique index, + guaranteed/exactly-once 표현 거부는 좋은 방어선이다. + +## 4. 우선순위 요약 + +| ID | 우선순위 | 심각도 | 주제 | 완료 조건 요약 | +|---|---|---|---|---| +| NTF-001 | P0 | Critical | provider runtime/route/callback graph가 비어 있음 | full context에서 configured profile이 실제 wire call까지 수행 | +| NTF-002 | P0 | Critical | notification schema stream 미활성 + entity/schema 불일치 | 별도 history activation, migrate+validate+CRUD 통과 | +| NTF-003 | P0 | Critical | 예약 row가 영구 미발송 | due 전 0, due 시 정확히 1회 claim | +| NTF-004 | P0 | Critical | lease claim 비원자성·fencing 부재 | atomic CTE+generation, 2-worker disjoint claim | +| NTF-005 | P0 | Critical | recovery/reconciliation/projection worker dead code | kill/restart 후 중복 없이 복구·재생 | +| NTF-006 | P0 | High | 모든 RuntimeException을 AMBIGUOUS로 오분류 | pre-wire failure는 NOT_SUBMITTED, response-loss만 AMBIGUOUS | +| NTF-007 | P0 | High | projection의 engagement/suppression fact 유실 | restart 후 monotonic fact 보존 | +| NTF-008 | P0 | High | callback-before-outcome late binding 불가 | hash bind worker가 정확히 한 번 projection | +| NTF-009 | P0 | High | callback append/ack/dedupe transaction 결함 | concurrent duplicate 모두 204, row 1개 | +| NTF-010 | P0 | High | Web Push subscription persistence가 lossy | protect→DB→reveal→UA decrypt round-trip | +| NTF-011 | P0 | High | callback size/encryption envelope/DB bound 모순 | MVC/WebFlux/DB 동일 경계 계약 | +| NTF-012 | P0 | Critical | dynamic endpoint/SNS/body security 불충분 | SSRF/SNS adversarial suite 통과 | +| NTF-013 | P1 | High | fingerprint와 variables가 비정본·가변 | persisted canonical bytes와 hash 입력 동일 | +| NTF-014 | P1 | High | dedup/collapse/preferred order가 dead contract | submit→DB→wire E2E 동작 | +| NTF-015 | P1 | High | webhook/attachment/VAPID/TTL/payload wire 결함 | provider별 final wire contract test | +| NTF-016 | P1 | High | secret fail-fast/rotation/profile binding 부재 | versioned keyring + startup negative tests | +| NTF-017 | P1 | High | template slot별 escaping/URI 정책 부재 | HTML/TEXT/URI context security test | +| NTF-018 | P1 | High | 기존 R1과 신규 platform 정본 충돌 | ADR + type disposition + 단일 compatibility bridge | +| NTF-019 | P1 | High | mandatory UseCase fitness gate 우회 | 모든 entrypoint marker/capability/permission 보유 | +| NTF-020 | P1 | High | admin/routing business policy가 outbound에 있음 | application use case + 좁은 outbound control port | +| NTF-021 | P1 | High | runtime limiter/state/registry/credential 경쟁 | immutable atomic state와 concurrent tests | +| NTF-022 | P1 | Medium | package DAG/public surface/god config | package edge·cycle·public allowlist ArchUnit 통과 | +| NTF-023 | P1 | Medium | readiness/metrics/audit가 실제 상태를 반영하지 않음 | schema/provider/backlog/lag readiness와 bounded tags | +| NTF-024 | P1 | High | CI/release evidence가 실행 범위보다 강함 | 실 PostgreSQL/restart/callback/provider artifact 매핑 | +| NTF-025 | P2 | Medium | configuration/env surface가 template에 없음 | application.yml/env registry/reference 동기화 | +| NTF-026 | P1 | High | execution evidence certainty가 DB에서 소실 | value+certainty 전체 round-trip | +| NTF-027 | P1 | High | reconciliation event fingerprint 충돌 | SHA-256 canonical event identity | + +## 5. 상세 발견 사항과 구현 명세 + +### NTF-001 — 설정된 provider가 production dispatch graph에 조립되지 않는다 + +**근거** + +- `NotificationPlatformRuntimeConfig.java:274-299`는 빈 `ProviderRuntimeRegistry`, + `CapabilityReconciliationGateway(Map.of(), ...)`, `ConfiguredRoutePlanner(Map.of())`를 만든다. +- `NotificationPlatformSettings.java:17-24,97-145`는 provider profile map을 받지만 adapter/runtime 생성에 + 연결하지 않으며 unknown provider type도 실질적으로 조립 단계에서 거부되지 않는다. +- production source에서 `ProviderRuntimeRegistry.register(...)`와 provider별 runtime assembly 호출은 + 없다. +- SMTP의 `SmtpDispatch`, FCM의 `FcmGateway`, SES SNS의 `SnsCertificateProvider`는 production 구현이 + 없는 seam이다. +- `NotificationPlatformRegistriesConfig.java:35-68`은 이미 존재하는 callback/projector bean 목록만 + map으로 바꾼다. 목록에 넣을 production bean factory가 없다. +- MVC는 `CallbackRequestFactory`와 `ExternalRequestUrlResolver`, WebFlux는 `CallbackRequestFactory`가 + 필요하지만 bootstrap에 해당 bean 조립이 없다. +- N1 `EmailNotifier`, `SmsNotifier` 등의 facade도 production 구현/bean이 없다. + +**실패 모드** + +platform을 enable하고 provider profile을 설정해도 request는 durable acceptance 뒤 eligible route를 찾지 +못한다. 일부 runtime을 수동 구성해도 SMTP/FCM은 실제 transport가 없다. callback enabled context는 +필수 bean 부재로 실패할 수 있다. 현재 `NotificationAutoConfigurationTest`는 codec/validator/JDK HTTP +gateway 조각만 검사하므로 전체 graph 공백을 드러내지 않는다. + +**구현 결정: Abstract Factory + explicit contribution** + +범용 plugin framework나 reflection은 필요 없다. 지원 provider 집합을 닫힌 enum으로 두고 provider별 +assembler가 하나의 완결된 contribution을 반환하게 한다. + +```java +enum ProviderType { APNS, FCM, SES, SMTP, TWILIO, WEB_PUSH, WEBHOOK } + +interface ProviderRuntimeAssembler

{ + ProviderType type(); + AssembledProvider assemble(P profile, ProviderAssemblyDependencies dependencies); +} + +record AssembledProvider( + ProviderRuntime runtime, + Channel channel, + Optional callback, + Optional projector, + Optional reconciliation) {} +``` + +1. string `type`을 `ProviderType`과 provider별 typed settings로 바꾼다. +2. profile 하나를 adapter, mapper, transport, credential generation, limiter, capability, callback, + projector, reconciliation까지 한 번에 조립한다. +3. 조립 결과로 runtime/profile map과 channel route를 불변 map으로 만든다. +4. 중복 profile, 한 channel의 모호한 primary route, 필수 secret/transport 누락, unknown type은 worker + 시작 전에 boot failure로 만든다. +5. provider가 0개인 모드를 허용하려면 `INGEST_ONLY` 같은 별도 mode로 명시하고 readiness를 DOWN 또는 + non-serving으로 표시한다. +6. callback MVC/WebFlux 공통 request factory와 trusted external URL resolver를 composition root에서 + 제공한다. + +**필수 인수 테스트** + +- 실제 `CaSkeletonApplication` context + fake provider profile로 submit → claim → wire request → outcome. +- 각 Stable provider profile이 정확히 1 runtime과 1 adapter를 만든다. +- unknown type, duplicate profile, missing credential/transport, callback adapter 없는 callback-enabled + profile은 context startup이 실패한다. +- servlet/reactive callback enabled context가 각각 하나의 route만 등록한다. + +### NTF-002 — notification Flyway stream이 활성화되지 않고 entity와 schema도 맞지 않는다 + +**근거** + +- `V1__notification_platform_core.sql:3-5`는 notification migration이 opt-in stream이라고 명시한다. +- `PostgreSqlPersistenceConfig.java:55-58`은 기본 Flyway location을 + `classpath:db/migration/postgresql`로 고정한다. +- fileserver에는 `FileserverSchemaActivation.java:49-65`, 전용 history table과 readiness card가 있지만 + notification에는 동등한 activation/registry/readiness 항목이 없다. +- `NotificationRequestEntity.java:46-47`의 `template_locale`은 V1 request table + `V1__notification_platform_core.sql:11-29`에 존재하지 않는다. +- migration의 `metadata_json`, `routing_plan_json`, `normalized_payload_json`, `content_json`은 `jsonb`지만 + entity는 JSON JDBC mapping annotation 없이 `String`으로 선언한 곳이 있다. 실제 Hibernate validate와 + bind 계약을 입증하는 테스트가 없다. +- persistence-jpa notification 테스트는 isolated crypto 위주이며 migration/store PostgreSQL 통합 + 테스트가 없다. + +**실패 모드** + +빈 PostgreSQL에서 platform을 enable하면 table이 없거나, 운영자가 stream을 수동 적용해도 +`template_locale`과 JSON type mismatch로 Hibernate validation/CRUD가 실패할 수 있다. scheduler는 즉시 +시작되고 tick 예외를 log 후 반복하므로 readiness가 거짓 정상일 수 있다. + +**구현 결정: optional schema capability + fail-closed activation** + +1. `db/migration/jpa/notification-platform`에 전용 history table + `flyway_jpa_notification_history`를 부여한다. primary Flyway history에 같은 V1 번호를 섞지 않는다. +2. `jpa-notification-platform-v1` capability registry/readiness card와 operator apply/promote 절차를 + 추가한다. +3. `NotificationSchemaActivation.requireActive()`를 worker/provider bean보다 먼저 실행한다. +4. `template_locale`을 migration에 추가하거나 entity/record에서 제거해 단일 SSOT를 선택한다. +5. JSONB 필드는 `@JdbcTypeCode(SqlTypes.JSON)` 등 현재 Hibernate 7/8 호환 정책에 맞춘 명시적 mapping을 + 사용하거나 DB 타입을 text로 바꾼다. 문자열로 JSONB에 기대어 쓰지 않는다. +6. schema가 ACTIVE가 아니면 platform enabled context가 readiness 이전에 실패한다. + +**필수 인수 테스트** + +- Testcontainers PostgreSQL: empty DB → core migration → notification migration → capability promotion → + Hibernate `validate`. +- request/recipient/attempt/event/contact/template/inbox CRUD와 재기동. +- notification stream 미적용/미승격/잘못된 revision이면 boot failure. +- downgrade/repair가 아니라 forward-only upgrade와 checksum validation. + +### NTF-003 — 예약 notification은 영구적으로 dispatch되지 않는다 + +**근거** + +- `CanonicalNotificationPlanWriter.java:71-76`은 `scheduleAt`이 있으면 recipient를 `PENDING`으로 만든다. +- `RecipientDeliveryJpaRepository.java:24-34`의 claim query는 `READY_TO_DISPATCH`, `RETRY_WAITING`만 + 조회한다. +- production search에서 due `PENDING` → `READY_TO_DISPATCH` activation path는 없다. +- migration index는 PENDING을 포함하지만 query는 포함하지 않는다. +- 테스트는 예약 요청의 초기 PENDING만 확인하고 시간 경과 후 claim을 확인하지 않는다. + +**실패 모드** + +모든 `schedule(...)` 요청은 `next_dispatch_at`이 지나도 claim되지 않아 무기한 정체된다. + +**구현 결정: due-time queue를 단일 상태로 단순화** + +권장안은 scheduled row도 `READY_TO_DISPATCH`로 저장하고 `next_dispatch_at = max(scheduleAt, +notBefore)`만 미래로 두는 것이다. `PENDING`이 별도 UI 의미로 반드시 필요하면 NTF-004의 atomic claim +CTE가 due PENDING을 직접 DISPATCHING으로 전이하도록 한다. 별도 activation daemon을 추가하는 것은 +현재 규모에서는 불필요하다. + +claim 조건에는 다음을 한 문장으로 고정한다. + +```sql +next_dispatch_at <= now +and delivery_state in ('PENDING', 'READY_TO_DISPATCH', 'RETRY_WAITING') +and (expires_at is null or expires_at > now) +and (lease_until is null or lease_until < now) +``` + +**필수 인수 테스트** + +- due 1ns 전 claim 0, due 순간 claim 1, 반복 poll/provider call 1회. +- 과거 schedule은 즉시 claim. +- `notBefore` 전 0회, `expiresAt` 이후 0회와 EXPIRED 전이. +- restart 및 두 worker 경쟁에서도 정확히 한 claim. + +### NTF-004 — lease claim이 원자적이지 않고 fencing이 없다 + +**근거** + +- `JpaRecipientLeaseStore.java:29-46`은 `selectClaimable` 뒤 별도 `markLeased`를 호출하며 enclosing + transaction이 없다. +- `RecipientDeliveryJpaRepository.java:24-49`도 `FOR UPDATE SKIP LOCKED` SELECT와 UPDATE가 별도 + repository call이다. lock이 두 호출 사이에 유지된다는 보장이 없다. +- `markLeased`는 id만 조건으로 사용하고 이전 state/owner/version/token을 확인하지 않는다. +- renew는 `JpaRecipientLeaseStore.java:49-57`에서 동일한 broad update를 재사용하므로 stale worker가 + 새 lease를 덮을 수 있다. +- native update는 `RecipientDeliveryEntity.java:84-86`의 JPA `@Version`을 증가시키지 않아 stale managed + entity write가 lease/state를 덮을 수 있다. +- `NotificationDispatchService.load(lease)`는 owner/token/expiry를 재검증하지 않는다. +- 모든 replica의 worker ID는 `NotificationPlatformRuntimeConfig.java:500-503`의 + `notification-worker-1`이다. +- scheduler는 batch 전체를 먼저 claim한 뒤 semaphore를 기다리므로 concurrency보다 큰 batch는 실행 + 전에 lease가 만료될 수 있다. + +**실패 모드** + +두 replica가 같은 row를 소유했다고 믿거나, 만료된 worker가 새 owner의 claim 이후 provider를 호출하고 +결과를 기록할 수 있다. 이는 duplicate notification과 잘못된 outcome overwrite로 이어진다. + +**구현 결정: atomic work claim + fencing token** + +새 JPA 병합에 들어온 `PostgreSqlWorkClaimExecutor`의 fixed-statement registry와 단일 native query 구조를 +재사용한다. 다만 현재 `WorkClaim`에는 generation이 없으므로 notification queue에는 fencing을 확장해야 +한다. + +```sql +with candidate as ( + select id + from notification_recipient_delivery + where next_dispatch_at <= :now + and delivery_state in ('PENDING','READY_TO_DISPATCH','RETRY_WAITING') + and (lease_until is null or lease_until < :now) + order by next_dispatch_at, id + for update skip locked + limit :batchSize +) +update notification_recipient_delivery d + set lease_owner = :owner, + lease_generation = d.lease_generation + 1, + lease_until = :leaseUntil, + delivery_state = 'DISPATCHING', + version = d.version + 1, + updated_at = :now + from candidate c + where d.id = c.id +returning d.id, d.lease_generation, d.lease_until; +``` + +```java +record RecipientLease( + RecipientDeliveryId id, + String owner, + long generation, + Instant until) {} +``` + +1. worker ID는 instance UUID/pod identity + boot UUID로 만든다. +2. renew/release/begin-attempt/outcome update는 모두 `id + owner + generation + lease_until > now`를 + 조건으로 한다. +3. stale token으로 영향받은 row가 0이면 provider call을 시작하지 않거나 outcome write를 거부한다. +4. claim은 현재 available permit 수만큼만 수행한다. +5. native write와 JPA version을 일치시킨다. +6. transaction은 새 공통 `JpaTransactionExecutor` 또는 명시적 adapter transaction boundary에서 한 + statement 전체를 감싼다. + +**필수 인수 테스트** + +- 실제 PostgreSQL의 두 connection barrier에서 두 worker claim 집합이 disjoint. +- stale owner/generation renew, release, begin-attempt, outcome update가 모두 0 rows. +- lease expiry 직전/직후 race와 process restart. +- batch=10, concurrency=1, 짧은 lease에서도 provider call 최대 1회. +- native claim 뒤 stale JPA entity flush가 state/version을 덮지 못함. + +### NTF-005 — recovery, reconciliation, projection replay가 runtime에 연결되지 않는다 + +**근거** + +- `LeaseRecoveryService`는 class만 있고 bean/scheduler/caller가 없다. +- 구현도 incomplete attempt만 순회하므로 provider attempt row를 쓰기 전에 crash한 DISPATCHING row는 + 복구하지 못한다. +- `ProviderEventLedger.pendingProjection(...)`, `unmatched(...)`는 persistence 구현만 있고 production + consumer가 없다. +- V3 migration은 `notification_reconciliation_job` table을 만들지만 대응 entity/store/worker가 없다. +- `RECONCILIATION_REQUIRED` recipient를 claim하는 worker가 없다. +- `NotificationSchedulerWorker`는 raw virtual thread handle을 보관·join하지 않고 `close()`가 polling + thread를 확실히 중지하지 않는다. +- scheduler catch는 lease를 남겨 recovery를 기대하지만 실제 recovery lifecycle이 없다. + +**실패 모드** + +crash 시 DISPATCHING row, pending/failed projection, unmatched callback, ambiguous attempt가 영구 정체된다. +shutdown 중 executor submission race도 lease를 남긴다. + +**구현 결정: explicit recovery state machine + managed lifecycle** + +다음 세 worker를 `SmartLifecycle`로 관리한다. + +1. `DispatchRecoveryWorker`: 만료 lease를 case별로 복구한다. + - attempt 없음: provider call 전 crash가 증명되므로 safe requeue. + - attempt 존재 + `requestStarted=PROVEN false`: safe retry. + - body committed/unknown: reconciliation queue. + - response가 proven rejected: terminal/fallback policy. +2. `ReconciliationWorker`: due reconciliation job을 claim/fence하고 capability 결과를 적용한다. +3. `ProviderEventWorker`: pending/failed projection replay와 unmatched binding을 수행한다. + +worker는 start/stop phase, interrupt, join timeout, jitter, batch/permit, lag metric을 공통 lifecycle support로 +관리하되 하나의 범용 workflow engine으로 만들 필요는 없다. + +**필수 인수 테스트** + +- attempt insert 전, insert 후/request 전, body commit 후/response 전, outcome commit 전 process kill. +- restart 후 safe case만 retry하고 ambiguous case는 provider resend 없이 reconciliation. +- unsupported reconciliation은 operator-visible 상태로 남고 busy loop하지 않음. +- pending/failed projection replay, callback-before-outcome binding, graceful shutdown 시 lease 반환. + +### NTF-006 — 모든 RuntimeException을 body-committed AMBIGUOUS로 기록한다 + +**근거** + +- `NotificationDispatchService.java:178-190`은 `gateway.submit()`의 모든 `RuntimeException`을 + `ProviderExecutionEvidence.responseLost()`로 변환한다. +- `ProviderRuntime.acquireAttempt()`의 disabled/auth/rate/concurrency failure는 wire call 전 발생한다. +- contact reveal, payload mapping, expiry, size, configuration error도 network call 전에 발생할 수 있다. +- 이미 `ProviderSubmissionResult.notSubmitted(...)`와 `ProviderExecutionEvidence.notStarted()`가 있지만 + 이 경로에서는 사용하지 않는다. +- JDK HTTP gateway는 exception message substring으로 commitment phase를 추정한다. + +**실패 모드** + +provider byte를 하나도 쓰지 않은 limiter/profile/payload 오류가 “이미 전송됐을 수 있음”으로 기록되어 +automatic retry/fallback이 영구 차단되고 불가능한 reconciliation에 들어간다. + +**구현 결정: typed transport milestone result** + +adapter의 실제 transport seam만 commitment evidence를 결정하게 한다. + +```java +sealed interface ProviderCallOutcome { + record Completed(ProviderSubmissionResult result) implements ProviderCallOutcome {} + record FailedBeforeWrite(ProviderFailure failure) implements ProviderCallOutcome {} + record FailedAfterCommit(ProviderFailure failure) implements ProviderCallOutcome {} +} +``` + +1. runtime unavailable, limiter, mapping, validation은 `FailedBeforeWrite`/NOT_SUBMITTED. +2. body write 완료를 transport가 관측한 뒤 response를 잃은 경우만 AMBIGUOUS. +3. SDK hidden retry는 끄고 각 SDK의 milestone/attempt count를 명시적으로 매핑한다. +4. 예상 밖 programming error는 internal failure로 관측하고 body commitment를 추측하지 않는다. +5. generic `Either` library는 추가하지 않고 기존 Result/evidence 타입을 확장한다. + +**필수 인수 테스트** + +- disabled/auth failed/rate exhausted/concurrency exhausted/payload invalid: wire bytes 0, + NOT_SUBMITTED, ambiguous=false. +- connect failure와 request-body write 전 reset: NOT_SUBMITTED. +- body commit 후 socket reset: AMBIGUOUS. +- provider response 완료: accepted/rejected evidence가 정확히 기록됨. + +### NTF-007 — projection snapshot이 engagement와 suppression 사실을 잃는다 + +**근거** + +- `JpaDeliveryAttemptStore.java:130-144`는 DB row를 `DeliveryProjection`으로 복원할 때 persisted + submission/delivery/evidence는 읽지만 `EngagementFacts.NONE`, `SuppressionFacts.NONE`을 항상 넣는다. +- `save(...)`도 delivery outcome/evidence만 attempt/recipient에 roll-up한다. +- migration/entity에는 opened/clicked/complaint/hard-bounce/invalid-target fact를 보존할 컬럼이나 versioned + projection payload가 없다. +- `StandardDeliveryProjector.java:100-111`은 hard bounce가 있으면 later delivered를 무시하지만, reload 후 + hard-bounce fact가 사라져 이 불변식이 깨진다. +- complaint/opened 사실도 다음 event/restart에서 사라진다. + +**실패 모드** + +hard bounce 뒤 late delivered가 적용되거나 complaint/read/open facts가 사라진다. suppression side effect가 +재실행되거나 monotonic projection이 후퇴할 수 있다. + +**구현 결정: durable projection snapshot + replay verifier** + +권장안은 명시적 typed columns 또는 versioned canonical projection JSON을 optimistic version과 함께 +저장하는 것이다. event 수가 작더라도 매번 전체 ledger fold만 수행하면 조회 비용과 side-effect exactly +once 문제가 커지므로 snapshot을 기본으로 하고, ledger replay verifier를 운영/테스트용으로 둔다. + +저장할 최소 내용: + +- submission outcome, delivery outcome, evidence level +- opened/clicked/displayed/read와 최초/최종 시각 +- hard bounce, complaint, invalid target +- projection version/last applied event ID +- suppression side-effect applied marker 또는 독립 idempotency key + +**필수 인수 테스트** + +- hard-bounce → transaction 종료 → restart → delivered: ignored. +- delivered → complaint → restart → late delivered: delivery+complaint 모두 보존. +- read → displayed: read가 후퇴하지 않음. +- 동일 event replay와 projector retry에서 side effect 1회. +- snapshot을 full ledger fold 결과와 비교하는 property test. + +### NTF-008 — callback-before-outcome event를 나중에 attempt에 연결할 수 없다 + +**근거** + +- `JpaProviderEventLedger.java:152-176`은 append 시 raw provider request ID로 즉시 attempt를 찾고, 없으면 + `attempt_id=null`로 저장한다. +- raw provider request ID는 저장하지 않고 hash만 저장한다. +- `toRecord(...):190-214`는 provider request ID를 항상 empty로 복원한다. +- `ProviderEventEntity.java:40-42`의 `attempt_id`는 `updatable=false`다. +- ledger에는 bind API가 없고 `unmatched(...)` consumer도 없다. +- callback은 provider outcome commit보다 먼저 도착할 수 있으므로 이 race는 정상 운영 시나리오다. + +**실패 모드** + +callback-before-outcome event가 영구 unmatched로 남고 delivery/suppression projection에 반영되지 않는다. + +**구현 결정: hash-based late binding** + +1. ledger port에 `bindUnmatched(profileId, providerRequestIdHash, attemptId)` 또는 batch matcher CAS를 + 추가한다. +2. `attempt_id`는 payload identity가 아니라 후발 association이므로 update를 허용한다. +3. provider outcome이 request ID hash를 저장한 직후 matcher를 trigger하고, background worker가 race를 + 보완한다. +4. bind는 `attempt_id is null` 조건의 atomic update이며 성공한 event만 projection queue에 넣는다. +5. 원문 ID가 reconciliation에 필요하면 lookup hash와 별도로 목적별 AEAD ciphertext를 저장한다. + +**필수 인수 테스트** + +- callback append → attempt outcome/hash 저장 → matcher → projection 정확히 1회. +- matcher와 callback/outcome의 모든 순서 permutation. +- 동일 hash의 tenant/profile scope 충돌 방지. +- stale/잘못된 profile은 bind 0건, operator metric 증가. + +### NTF-009 — callback append, acknowledgement, dedupe가 원자적이지 않다 + +**근거** + +- `ProviderCallbackIngestionService.java:84-106`은 normalize/protect/append 뒤 새 event를 동기적으로 + project한다. append 전체를 감싸는 application transaction이 없다. +- `JpaProviderEventLedger.java:66-79`는 event마다 find-before-insert 후 `saveAndFlush`한다. batch 중간 + 실패 시 앞 event만 commit될 수 있다. +- concurrent duplicate는 둘 다 pre-check를 통과한 뒤 unique violation loser가 500이 될 수 있다. +- MVC는 validation exception만 400으로 매핑하고 persistence unique race를 duplicate 204로 바꾸지 않는다. +- `ProviderEventProjectionService.java:56-59`의 no-projector `markFailed`는 transaction 밖이고, + `JpaProviderEventLedger.markFailed`는 명시적 save/update query도 없다. +- “durable append 후 빠른 2xx”라는 주석과 달리 synchronous projector failure가 callback response를 + 실패시킬 수 있다. + +**구현 결정: transactional inbox append + asynchronous projector** + +1. signature 검증·normalization 이후 batch를 하나의 write transaction에서 append한다. +2. PostgreSQL `INSERT ... ON CONFLICT DO NOTHING RETURNING` 또는 정확한 constraint 분류+reread로 + concurrent duplicate를 정상 결과로 만든다. +3. durable append가 commit되면 HTTP 204를 반환한다. projection은 별도 worker가 수행한다. +4. projection status는 `PENDING -> APPLYING -> APPLIED|IGNORED|FAILED` CAS/update query로 전이한다. +5. callback URL의 `{provider}`와 profile에 등록된 provider ID, enabled 상태를 서명 검증 전에 결합한다. + +**필수 인수 테스트** + +- 동일 callback 동시 N개: 모두 204, ledger row 1개, created 1/duplicate N-1. +- batch 3개 중 DB failure: 전부 rollback 또는 명시적 per-event 결과; 부분 성공을 숨기지 않음. +- projector throw: callback은 durable append 후 204, event는 FAILED/PENDING으로 replay 가능. +- wrong provider/profile/disabled profile은 ledger append와 cert fetch 전에 거부. + +### NTF-010 — Web Push subscription이 persistence round-trip에서 손실된다 + +**근거** + +- `WebPushSubscriptionValue`는 endpoint, p256dh, authSecret, vapidKeyId 네 필드를 갖는다. +- `WebPushSubscriptionValue.java:76-80`의 `normalized()`는 endpoint+p256dh만 포함한다. +- `AesGcmContactPointProtector.java:70-80`은 normalized 문자열만 암호화한다. +- reveal의 `parseWebPush(...):191-203`은 authSecret을 zero 16 bytes, vapidKeyId를 `restored`로 만든다. +- 주석은 별도 encrypted columns가 있다고 하지만 `notification_contact_point` schema와 entity에는 해당 + 컬럼이 없다. +- `WebPushRequestMapper.java:103-108`은 subscription의 vapidKeyId도 사용하지 않고 active key를 선택한다. + +**실패 모드** + +저장 후 복원된 subscription으로 RFC 8291 payload를 만들면 browser가 decrypt할 수 없고, VAPID rotation +후 구 subscription 서명 key도 선택하지 못한다. + +**구현 결정: identity와 secret serialization 분리** + +1. lookup fingerprint용 identity codec은 endpoint+p256dh만 사용할 수 있다. +2. encrypted payload codec은 versioned envelope로 네 필드를 모두 직렬화한다. +3. AEAD AAD에 contact type, codec version, tenant/contact ID를 포함한다. +4. `VapidKeyRegistry`가 subscription.vapidKeyId로 historical key pair를 선택한다. +5. 모든 `ContactPointValue` subtype이 같은 codec registry/Strategy를 사용한다. + +**필수 인수 테스트** + +- 모든 contact subtype의 protect → JPA save → load → reveal equality. +- WebPush는 원 UA private key/auth secret으로 provider payload decrypt 성공. +- active VAPID key 변경 후 old subscription은 old key로 서명. +- unknown/retired key ID는 발송 전에 typed failure. + +### NTF-011 — callback body bound, AES-GCM envelope, DB bound가 서로 모순된다 + +**근거** + +- settings는 callback body를 최대 1,048,576 bytes까지 허용한다. +- MVC는 `@RequestBody byte[]`로 이미 전부 할당한 뒤 hard-coded 65,536을 검사한다. +- WebFlux는 configured max를 join 단계에서 적용한다. +- `AesGcmCallbackPayloadProtection.java:55-72`는 plaintext를 max로 자른 뒤 12-byte nonce와 16-byte + GCM tag를 더한다. +- V1 DB check는 ciphertext 전체를 65,536 bytes 이하로 제한한다. +- 정확히 65,536-byte plaintext는 65,564-byte ciphertext가 되어 DB check를 위반한다. +- callback ciphertext에는 key ID/version이 없어 rotation 뒤 historical payload decrypt 계약도 없다. + +**구현 결정: 하나의 end-to-end payload envelope contract** + +예를 들어 stored ciphertext cap을 65,536으로 유지한다면 retained plaintext는 최대 65,508이어야 한다. +request acceptance max와 retained diagnostic max를 분리해도 된다. + +```java +record ProtectedCallbackPayload( + int version, + String keyId, + byte[] nonce, + byte[] ciphertext) {} +``` + +1. shared contract에 `maxRequestBytes`, `maxRetainedPlaintextBytes`, envelope overhead, DB cap을 정의한다. +2. servlet은 endpoint-specific filter/container limit로 deserialization 전에 차단한다. +3. WebFlux와 MVC가 같은 property/boundary semantics를 사용한다. +4. DB에 version/keyId/nonce/ciphertext를 분리하거나 self-describing envelope를 저장한다. +5. raw payload 복호화가 실제 필요 없다면 ciphertext 자체를 제거하고 bounded digest만 보존하는 선택도 + 검토한다. + +**필수 인수 테스트** + +- MVC/WebFlux/PostgreSQL 모두 max-1/max/max+1. +- Content-Length 없음, chunked body, cancellation, oversized stream. +- max request의 encryption/insert 성공. +- active key rotation 뒤 historical envelope decrypt 또는 의도한 삭제 정책. + +### NTF-012 — dynamic endpoint, HTTP response, SNS callback security가 fail-closed가 아니다 + +**근거** + +- `NotificationEndpoints.java:24-35`의 `requireSecureOrLoopback`은 HTTPS라는 이유만으로 private, + link-local, metadata 주소를 허용한다. dynamic webhook/WebPush endpoint에는 SSRF 방어가 되지 않는다. +- `JdkNotificationHttpGateway.java:59-63`은 `BodyHandlers.ofByteArray()`로 response body를 무제한 읽는다. +- 기존 HTTP client platform에는 dynamic target validation, DNS/response size policy가 있지만 notification + bootstrap은 이를 bridge하지 않는다. +- `SnsSignatureVerifier.java:49-75`는 host suffix만 검사한다. `evilamazonaws.com`류 label confusion, + non-default port/path/userinfo/query, private DNS/redirect를 충분히 제한하지 않는다. +- SignatureVersion `2` 이외는 SHA-1로 내려가며 unknown/missing version을 fail-closed로 거부하지 않는다. +- TopicArn/account/region/profile, timestamp/replaySkew가 signature policy에 결합되지 않는다. +- production `SnsCertificateProvider` 구현이 없다. 지금은 unwired지만 연결 순간 보안 결함이 활성화된다. + +**구현 결정: 기존 dynamic HTTP platform 재사용 + provider-specific verifier** + +1. bootstrap에서 notification `NotificationHttpGateway`를 기존 dynamic target gateway에 bridge한다. +2. 외부 endpoint는 DNS resolve/re-resolve, private/loopback/link-local/multicast/metadata deny, redirect deny, + scheme/port allowlist를 적용한다. loopback 허용은 명시적 test/local profile만 가능하게 한다. +3. response는 streaming cap을 적용하고 status+headers+bounded body/digest만 보존한다. +4. SNS cert URL은 AWS partition별 exact hostname grammar, expected path, default HTTPS port, no userinfo/query를 + 검사한다. +5. signature version은 정확히 지원 목록만, TopicArn/account/region과 timestamp skew는 profile에 bind한다. +6. X509 fetch cache는 time/size bounded이며 cert validity와 hostname/profile을 검증한다. + +**필수 인수 테스트** + +- `127.0.0.1`, RFC1918, link-local, IPv6 local, cloud metadata, DNS rebinding, redirect, mixed DNS answer 거부. +- attacker-owned suffix host, port/path/userinfo/query, SignatureVersion 3/missing, wrong TopicArn/profile, + stale timestamp는 cert fetch/ledger append 전에 거부. +- huge/chunked-infinite response가 byte cap에서 중단되고 OOM이 발생하지 않음. +- 정상 SNS v1/v2 fixture만 통과. + +### NTF-013 — idempotency fingerprint가 저장된 요청의 정본 표현이 아니다 + +**근거** + +- `NotificationPlan.java:23,51`은 `Map`를 받고 shallow `Map.copyOf`만 수행한다. +- `RequestFingerprint.java:47-50`은 strategy class simple name과 primary channel만 기록해 ordered fallback + tail/order를 누락한다. +- recipient의 `ChannelPreferenceOverride` preferred/blocked 전체가 fingerprint에 포함되지 않는다. +- dedup key/window는 포함하지만 `DeduplicationAction`은 누락된다. +- delimiter를 escape하지 않은 `key=value,`와 arbitrary value `toString()`을 사용한다. +- 중첩 mutable object가 fingerprint 계산 뒤 persistence encode 전에 바뀔 수 있다. +- persistence는 별도 JSON codec으로 variables를 저장하므로 hash 입력과 저장 byte가 다르다. + +**실패 모드** + +서로 다른 fallback/override/dedup 요청이 같은 idempotency fingerprint로 합쳐지거나, 동일 logical JSON이 +map 순서/구현체에 따라 conflict가 난다. delimiter collision과 TOCTOU도 가능하다. + +**구현 결정: typed immutable values + canonical plan encoder** + +기존 R1의 닫힌 `NotificationTemplateValue` algebra를 승격하거나 새 sealed JSON-neutral value type을 만든다. +application-core에 Jackson `JsonNode`를 넣지는 않는다. + +```java +sealed interface NotificationVariable + permits TextValue, NumberValue, BooleanValue, NullValue, ListValue, ObjectValue {} + +record EncodedNotificationPlan(int version, byte[] bytes, String variablesPayload) {} +``` + +1. public DTO graph에서 arbitrary Object를 제거하고 depth/key/value/total byte bound를 둔다. +2. `CanonicalNotificationPlanEncoderPort`가 versioned length-framed bytes를 한 번 만든다. +3. fingerprint는 그 bytes에 SHA-256을 적용하고 persistence도 같은 encoded payload를 사용한다. +4. full fallback order, override, dedup action, collapse, schedule/expiry, metadata 의미 필드를 모두 포함한다. +5. secret/PII를 `toString()`에 노출하지 않는다. + +**필수 인수 테스트** + +- `[EMAIL,SMS]` vs `[EMAIL,PUSH]`, 순서 변경, override/dedup action/collapse 변경은 다른 hash. +- delimiter/type/nested collection collision property/fuzz test. +- 동일 object map insertion order는 동일 hash. +- input nested object를 계산 중 변경해도 저장 payload와 hash가 어긋나지 않음. +- public DTO graph reflection test가 `Object`/`Map`를 재귀적으로 금지. + +### NTF-014 — deduplication, collapse, recipient preferred order가 공개 계약만 있고 실행되지 않는다 + +**근거** + +- `NotificationPlan`은 deduplication과 collapse를 받는다. +- `DeduplicationService`와 JPA store bean은 있지만 submission path에서 호출되지 않는다. +- `NotificationRequestRecord`/entity/schema는 dedup/collapse frozen semantics를 저장하지 않는다. +- `NotificationDispatchService.java:166-177`은 `ProviderSubmission.collapse`를 항상 empty로 만든다. +- APNs/FCM mapper와 capability는 collapse를 지원하지만 값이 도달하지 않는다. +- `ConfiguredRoutePlanner.java:41-68`은 blocked channel만 보고 `preferredOrder`를 무시한다. + +**실패 모드** + +caller가 dedup/drop/return-existing/collapse/preference를 요청해도 duplicate message가 전송되고 provider +collapse header/route 순서가 반영되지 않는다. + +**구현 결정: acceptance transaction에 정책 고정** + +1. multi-recipient dedup 의미를 확정한다. 정해지기 전에는 dedup plan을 single recipient로 제한하거나 + 명시적 validation failure로 거부한다. +2. candidate notification ID를 먼저 발급하고 request/recipient insert와 dedup claim을 같은 transaction에 + 넣는다. +3. `DROP`과 `RETURN_EXISTING`을 receipt/result에 명시적으로 표현한다. +4. collapse를 durable request/recipient plan에 저장해 retry/redrive에도 동일 값을 사용한다. +5. provider capability가 false면 조용히 무시하지 말고 pre-dispatch typed failure로 거부한다. +6. route는 preferred-order 교집합을 먼저 두고 나머지는 original strategy order를 유지한다. + +**필수 인수 테스트** + +- 동일 tenant/recipient/category/key/window 동시 N건에서 durable notification/provider call 1회. +- DROP과 RETURN_EXISTING 결과 차이, window 경계. +- APNs/FCM final wire collapse key. +- preferred/blocked/fallback 조합의 deterministic route. + +### NTF-015 — provider별 wire contract에 내용 누락과 expiry/limit 오류가 있다 + +**근거** + +- `WebhookNotificationProviderAdapter.java:101-107` body는 attemptId와 contentDigest뿐이며 실제 rendered + content가 없다. +- `WebhookSubscription.signingKeyRef`는 무시되고 global callback-signing key가 사용된다. +- `EmailContent`는 attachment를 계약으로 받지만 SMTP adapter는 MIME factory에 `List.of()`를 넘기고 + SES mapper도 attachment를 처리하지 않는다. `AttachmentResolver`는 production에 연결되지 않는다. +- `WebPushRequestMapper`는 이미 있는 `VapidKeyRegistry`를 사용하지 않고 active key를 사용한다. +- WebPush receipt capability가 선언되지만 production receipt flow에 연결되지 않는다. +- FCM remaining TTL이 이미 음수면 expired가 아니라 provider max TTL로 되살아날 수 있다. +- APNs/FCM capability가 4096-byte limit을 선언하지만 final UTF-8 wire payload size를 provider call 전에 + 강제하지 않는다. +- `ProviderSubmission`은 channel/profile/content compatibility 불변식을 강제하지 않아 webhook test가 + 다른 channel content를 넣어도 성립할 수 있다. + +**구현 결정: provider별 versioned wire DTO + preflight validator** + +1. webhook에 schema version, attempt/idempotency/expiry, channel, 허용된 rendered content/metadata를 담는 + 명시적 envelope를 만들고 정확한 canonical bytes를 key-ref별로 서명한다. +2. attachment는 resolver → digest/size/content-type 검증 → try-with-resources → MIME/SES raw message 순서로 + 처리한다. 지원하지 않으면 provider call 전 typed rejection. +3. WebPush는 subscription-specific VAPID key를 사용하고 receipt end-to-end 연결 전 capability를 false로 + 둔다. +4. FCM expiry는 no-expiry/max, remaining<=0/EXPIRED, future/min의 세 분기로 나눈다. +5. APNs/FCM/WebPush/Webhook은 final serialized bytes에 provider limit을 적용한다. +6. `ProviderSubmission` compact constructor에서 channel/content/profile compatibility를 검사한다. + +**필수 인수 테스트** + +- webhook body에 실제 title/body/data와 schema version이 있고 key ref별 signature가 다름. +- attachment byte/digest/name/content-type, 성공/실패 모두 resource close. +- resolver 미설정/oversize/digest mismatch는 provider call 0회. +- expired FCM은 call 0회, future TTL은 정확한 min. +- APNs/FCM 4096 경계 ±1 byte. +- mismatched channel/content/profile은 construction 또는 preflight에서 실패. + +### NTF-016 — secret startup validation, rotation, profile binding이 완성되지 않았다 + +**근거** + +- `NotificationPlatformSecretsConfig.java:17-23`은 strict startup validation을 설명하지만 + `:41-70`은 blank 값을 건너뛰고 Base64 decode만 한다. +- AES 길이와 purpose별 material distinctness는 첫 protect 시 일부만 검사된다. +- key ID는 `contact-enc`, `payload-enc` 등 상수라 env material 교체 시 동일 ID 아래 ciphertext가 + decrypt 불가능해진다. +- historical key map은 항상 empty다. +- callback payload에는 key ID가 없다. +- SES/Twilio/Webhook/WebPush는 profile의 credential/key ref 대신 global active key를 사용하는 경로가 + 있다. +- contact lookup HMAC을 callback/provider ID hashing에도 재사용해 purpose separation이 약해진다. + +**구현 결정: versioned keyring + capability-aware startup validator** + +```yaml +ca-skeleton: + notification: + platform: + secrets: + contact-encryption: + active-key-id: contact-2026-08 + keys: + contact-2026-08: ${...} + contact-2026-01: ${...} +``` + +1. purpose별 active key ID와 historical key map을 바인딩한다. +2. enabled provider/callback/contact 기능별 required-purpose matrix를 startup에서 검사한다. +3. Base64, AES/HMAC 길이, distinct material, known ref, active/historical 중복을 검증한다. +4. adapter에는 global provider가 아니라 profile-bound credential handle/generation을 전달한다. +5. old generation은 in-flight drain 및 historical decrypt 기간 동안 유지한다. +6. callback fingerprint/provider request lookup에는 별도 SecretPurpose를 둔다. + +**필수 인수 테스트** + +- missing/weak/duplicate/unknown ref는 full context boot failure. +- key rotation 전 row를 rotation 후 historical key로 decrypt. +- 두 provider profile의 wire auth가 서로 다름. +- rotation 중 old in-flight는 old generation, 새 attempt는 new generation을 사용하고 DB 기록과 일치. + +### NTF-017 — template engine이 slot context를 구분하지 않는다 + +**근거** + +- default `PlaceholderTemplateEngine.java:24-45`는 값을 raw string으로 치환한다. +- `NotificationPlatformRuntimeConfig.java:242-271`은 subject, plain text, HTML, SMS, push, URI slot에 같은 + engine을 사용한다. +- HTML_BODY에서 caller value가 active markup이 될 수 있다. +- Thymeleaf를 HTML mode로 모든 slot에 쓰면 plain text/SMS의 `&` 등이 entity로 변할 수 있다. +- rendered deep link는 URI parse만 하고 scheme/host allowlist를 강제하지 않는다. + +**구현 결정: context-aware rendering Strategy** + +```java +enum TemplateSlotMode { SUBJECT, TEXT, HTML_TEXT, URI } + +interface NotificationTemplateEngine { + String render(TemplateSlotMode mode, String source, NotificationVariables variables); +} +``` + +1. SUBJECT/TEXT/SMS는 literal text mode와 CR/LF/length 정책을 적용한다. +2. HTML body는 HTML text/attribute/URL context를 구분하거나 unescaped construct를 금지한다. +3. URI slot은 허용한 `https`/명시적 app scheme만 통과시킨다. +4. template publish 시 slot별 compile/validation을 수행하고 runtime cache는 bounded digest key로 둔다. +5. 범용 expression language는 허용하지 않는다. + +**필수 인수 테스트** + +- HTML element/attribute/URL injection, quote breakout, script/event handler. +- plain text `a & b`가 `&`로 변하지 않음. +- subject CR/LF 거부. +- `javascript:`, `data:`, `file:` 거부, 허용 HTTPS/app scheme 통과. + +### NTF-018 — 기존 R1과 신규 platform의 canonical ownership이 충돌한다 + +**근거** + +- `application-core/CLAUDE.md:75-96`은 기존 `dev.caskeleton.application.notification`을 R1 canonical로 + 설명한다. +- `docs/notification/migration-guide.md:3-18`은 R0 router → 신규 platform만 설명하고 기존 R1 100개 + production type의 처분을 다루지 않는다. +- outbound notification README는 현재 구현 전체를 R0처럼 기술한다. +- 기존/new `Channel`, plan, dispatch, receipt/evidence 모델이 중복되며 production bridge/import가 없다. + +**실패 모드** + +새 consumer가 어느 API를 사용해야 하는지 알 수 없고 두 모델이 별도 진화한다. R0 삭제 후에도 R1 graph가 +고아로 남거나, platform이 R1 정책을 우회하는 이중 canonical이 된다. + +**구현 결정: ADR + temporary Anti-Corruption Layer** + +1. R0 → R1 → platform 버전/역할과 최종 canonical namespace를 ADR로 확정한다. +2. 기존 R1 100개 type을 `replace / bridge / retain / delete`로 전수 분류한다. +3. platform이 canonical이면 R1 public entrypoint에 deprecation/forRemoval과 신규 production consumer 금지 + ArchUnit을 추가한다. +4. 필요한 변환은 `compatibility/r1`의 단 하나 ACL에만 둔다. +5. README, CLAUDE, migration guide, deletion inventory, public path snapshot을 같은 disposition 표와 + 동기화한다. + +**필수 인수 테스트** + +- 허용 ACL 외 두 namespace 간 production dependency 0건. +- 모든 R1 public type disposition 목록. +- R0/R1/platform consumer와 삭제 조건 contract test. +- 중복 simple-name API 제거 또는 명시적 compatibility test. + +### NTF-019 — 신규 orchestration이 mandatory UseCase fitness gate를 우회한다 + +**근거** + +- `NotificationOrchestrator`는 submit/schedule/cancel/get을 한 interface에 섞는다. +- `NotificationSubmissionService`, `ProviderCallbackIngestionService`, `ReconciliationService`, dispatch/admin + orchestration이 `CommandUseCase`/`QueryUseCase`와 `@UseCaseCapability`를 사용하지 않는다. +- MVC controller는 inbound use case port가 아니라 concrete `ProviderCallbackIngestionService`를 주입한다. +- 기존 ArchUnit은 이미 marker를 구현한 type만 검사하므로 marker를 쓰지 않은 신규 entrypoint를 놓친다. + +**실패 모드** + +transaction mode, repository access, external outbound, idempotency, permission metadata가 자동 fitness gate를 +우회한다. read/write를 한 class에 섞어 class-level capability도 정확히 선언할 수 없다. + +**구현 결정: CQRS-style explicit inbound use cases** + +submit, schedule, cancel, get, callback ingest, dispatch, reconcile, admin command/query를 각각 분리한다. +controller/worker는 해당 `port.in` interface만 의존한다. `NotificationOrchestrator`가 호환성 때문에 +필요하면 deprecated facade로 두고 분리 use case에 위임한다. + +**필수 인수 테스트** + +- 모든 public application orchestration entrypoint가 Command/Query marker와 capability를 보유. +- mutating/admin use case가 필요한 permission을 보유. +- controller/worker가 concrete application implementation을 참조하지 않음. +- marker 없는 위반 fixture가 ArchUnit에서 실제 실패. + +### NTF-020 — admin과 route business policy가 outbound adapter에 있다 + +**근거** + +- outbound `AdminAuthorizationGuard`, `DuplicateRiskGuard`가 actor/tenant/ambiguous redrive business rule을 + 구현한다. +- `NotificationAdminServiceImpl`은 authorization, idempotency, suppression, redrive, reconcile, transaction + orchestration과 provider state mutation을 한 class에서 수행한다. +- admin operation은 find → side effect → save라 concurrent 동일 operation ID에서 side effect가 중복될 수 + 있다. +- `ConfiguredRoutePlanner`가 delivery strategy/blocked channel eligibility를 해석한다. +- settings가 ambiguous fallback invariant를 소유한다. + +**구현 결정: application command handler + atomic admin claim** + +1. admin command별 application use case로 authorization/tenant/idempotency/duplicate-risk policy를 옮긴다. +2. outbound에는 좁은 `ProviderRuntimeControlPort`, catalog/status adapter만 남긴다. +3. admin store port를 `claim(operationId, commandFingerprint)`의 + `Claimed/Replay/InProgress/Conflict` 결과로 바꾼다. +4. 외부/DB side effect 전에 durable claim하고 exact result/phase를 저장한다. +5. route business eligibility/preference/fallback은 application에 두고 adapter는 configured profile catalog만 + 제공한다. +6. 범용 command bus/workflow engine은 필요 없다. 작은 phase state machine이면 충분하다. + +**필수 인수 테스트** + +- 동시 N개 동일 operation ID에서 side effect 1회. +- 동일 ID/다른 command payload는 conflict. +- 중간 failure 재시도는 완료 item을 반복하지 않음. +- outbound notification package에서 actor authorization/TransactionPort orchestration이 사라짐. + +### NTF-021 — runtime limiter/state/registry/credential compound operation이 원자적이지 않다 + +**근거** + +- `ProviderAttemptLimiter`는 window CAS와 count reset을 별도 atomic으로 수행해 rollover race에서 증가를 + 잃을 수 있다. +- provider permit close는 idempotent guard가 없어 double-close가 semaphore 한도를 늘릴 수 있다. +- `ProviderRuntime`은 state와 reason을 별도 AtomicReference로 두어 모순 snapshot이 가능하다. +- markHealthy는 일부 state만 전이하지만 admin은 실제 적용되지 않아도 성공을 기록할 수 있다. +- `ProviderRuntimeRegistry.register`는 duplicate current generation을 조용히 교체한다. +- draining cleanup과 replace가 경쟁하면 generation을 잃을 수 있다. +- credential manager의 get/validate/put 경쟁은 낮은 generation이 높은 generation 뒤에 활성화될 수 있다. + +**구현 결정: immutable atomic state + transition table** + +```java +record RuntimeHealth(ProviderRuntimeState state, Optional reason) {} +record RateWindow(long epochSecond, int used) {} +``` + +1. health는 하나의 `AtomicReference`와 명시적 operator/provider transition table로 관리한다. +2. limiter는 `(window,count)` 단일 CAS 또는 작은 synchronized token bucket을 사용하고 monotonic ticker를 + 주입한다. +3. permit은 `AtomicBoolean released`로 idempotent close. +4. 최초 registry register는 `putIfAbsent`로 duplicate fail; replace/drain/cleanup은 profile holder의 + `compute`/lock으로 원자화한다. +5. credential generation은 strictly increasing CAS로 검증한다. + +**필수 인수 테스트** + +- 100+ thread window boundary에서 configured rate 초과 없음. +- double close가 permit 수를 늘리지 않음. +- 모든 legal/illegal health transition과 reason 일관성. +- concurrent replace/cleanup/rotation에서 in-flight generation 보존, 최종 generation=max. + +### NTF-022 — logical module/package DAG와 public API 경계가 강제되지 않는다 + +**근거** + +- docs는 31 logical modules를 5개 leaf의 package로 매핑하지만 같은 Gradle project 내부 package edge와 + cycle은 registry가 검사하지 않는다. +- application platform 272개, outbound 110개, persistence 36개 등 거의 모든 top-level type이 public이다. +- `PublicApiBoundaryTest`는 facade 직접 parameter만 보고 record 내부 `Map`와 visibility를 + 놓친다. +- `NotificationPlatformRuntimeConfig` 506줄, persistence config 220줄이 codec/security/template/policy/ + worker/entity/repository/mapper 조립을 한곳에 모은다. +- bootstrap이 persistence entity/repository/mapper internals를 직접 import하므로 해당 type이 public이어야 + 한다. +- root controller/mapper ArchUnit 일부는 package 문자열 기준이라 현재 callback controller와 + `NotificationRecordMapper`를 선택하지 못한다. + +**구현 결정: package DAG + internal-by-default** + +1. 아래 6절의 package 구조로 이동한다. +2. `api`와 `port` allowlist 외 type은 package-private 또는 `.internal`로 둔다. +3. ArchUnit에 exact allowed package edge, cycle 금지, public API allowlist를 추가한다. +4. controller는 `@RestController`, mapper는 suffix/annotation, entity는 JPA annotation처럼 semantic + predicate로 선택한다. +5. persistence leaf 내부 configuration facade 하나만 application port bean을 노출하고 bootstrap은 + entity/repository/mapper를 직접 import하지 않는다. + +**필수 인수 테스트** + +- package graph cycle 0, 허용되지 않은 edge fixture 실패. +- internal type 외부 접근 0. +- public path snapshot이 의도한 API/SPI만 승인. +- package 위치와 무관하게 bad controller/mapper/entity fixture 실패. + +### NTF-023 — readiness, metrics, audit가 실제 serving 상태를 반영하지 않는다 + +**근거** + +- health reporter가 queue를 빈 map으로 반환하고 actual oldest-due age/depth를 조회하지 않는다. +- scheduler `QUEUE_DEPTH`는 전체 backlog가 아니라 이번 claim 수에 가깝다. +- schema activation, provider route 0개, projection/reconciliation lag가 application readiness에 연결되지 + 않는다. +- tag guard는 key allowlist 위주이고 raw category/callback path/provider 같은 값 cardinality를 닫지 않는다. +- audit actorRef/attributes에 contact/credential/OTP가 들어가는 것을 타입/mandatory redactor가 막지 않는다. + +**구현 결정: serving readiness + low-cardinality vocabulary** + +enabled platform의 readiness는 최소 다음을 함께 확인한다. + +- notification schema capability ACTIVE +- enabled profile마다 runtime+route+required callback/projector 존재 +- dispatch oldest due age/depth와 stuck lease 수 +- pending/failed/unmatched projection lag +- reconciliation due/oldest age +- secret/key generation load 상태 + +metric tag value는 enum/profile alias/bucket만 허용하고 tenant/recipient/category/path의 raw 값을 tag로 쓰지 +않는다. audit builder는 sensitivity classifier/redactor를 반드시 통과하게 한다. + +**필수 인수 테스트** + +- schema 누락, route 0, provider unavailable, queue/projection lag SLO 초과 시 readiness DOWN. +- 임의 category/path 10,000개 입력 후 meter series 수가 상수 bound. +- email/phone/token/OTP/credential이 모든 logger/audit sink에 없음. + +### NTF-024 — CI와 release gate가 실행한 것보다 강한 증거를 만든다 + +**근거** + +- support matrix는 SMTP/SES/Twilio/FCM/APNs/WebPush를 Stable로 표시한다. +- PR workflow는 unit/architecture subset은 실행하지만 notification PostgreSQL migration, full configured + context, multi-replica lease, restart recovery가 없다. +- nightly job 이름은 ambiguity/restart recovery/callback burst지만 실제로는 일부 unit suite와 전체 test를 + 실행한다. +- provider sandbox job은 `continue-on-error`이며 실제 provider 호출 없이 echo 두 줄만 실행한다. +- release gate 일부는 docs file/문구 존재를 확인할 뿐 runtime artifact와 직접 연결하지 않는다. +- workflow path filter가 bootstrap notification/configuration surface 변경을 완전하게 포괄하지 않는다. + +**구현 결정: evidence manifest** + +support grade마다 필요한 executable artifact를 선언한다. + +| 주장 | 필요한 최소 증거 | +|---|---| +| Durable | PostgreSQL migration+CRUD+restart | +| Multi-worker safe | real DB 2-worker claim/fencing race | +| Recoverable | process kill phase matrix | +| Callback supported | signature+burst+duplicate+late-bind E2E | +| Provider Stable | secret-protected real sandbox wire+correlation artifact | + +1. 위 artifact가 없으면 grade를 `Contract implemented / runtime unqualified` 또는 Experimental로 낮춘다. +2. provider sandbox는 실제 test를 실행하고 immutable evidence artifact를 업로드한다. +3. `failOnNoDiscoveredTests`, skip reason, artifact digest를 gate에 포함한다. +4. workflow path에 application/bootstrap/settings/migration/docs 전체 notification surface를 포함한다. + +### NTF-025 — template의 configuration/env surface에 신규 platform이 없다 + +**근거** + +- `application.yml`과 local/sample 설정은 기존 `ca-skeleton.notification`/`app.notification` provider만 + 보여 주고 신규 `ca-skeleton.notification.platform` tree를 제공하지 않는다. +- configuration reference에 property 이름은 있으나 실제 env placeholder와 env-key registry가 동기화되지 + 않았다. +- Boot relaxed binding으로 직접 환경변수를 넣을 수는 있지만 템플릿 사용자가 필요한 profile/secret/ + callback/schema activation 조합을 안전하게 발견할 수 없다. + +**개선** + +NTF-001/002/016 설계가 고정된 뒤 disabled-safe 기본 tree, 명시적 env placeholders, env validation registry, +configuration reference를 한 source에서 동기화한다. 지금 미완성 property를 먼저 문서화해 enable을 +유도하지 않는다. + +### NTF-026 — execution evidence certainty가 persistence에서 소실된다 + +**근거** + +- `ProviderExecutionEvidence`는 각 milestone을 `EvidenceFact(value, certainty)`로 표현한다. +- docs도 requestStarted/bodyCommitted/responseReceived 각각의 certainty를 저장한다고 설명한다. +- `DispatchOutcomeRecorder.java:55-58`은 `.value()` boolean만 `DeliveryAttemptRecord`에 넣고 certainty를 + 버린다. +- V1 attempt table도 세 boolean만 저장하고 certainty/providerAcceptance fact를 저장하지 않는다. + +**실패 모드** + +restart 뒤 `PROVEN false`와 `UNKNOWN false`를 구분할 수 없어 safe retry와 reconciliation 판단이 +불가능해진다. + +**구현 결정** + +각 milestone을 value+certainty 컬럼으로 저장하거나 versioned evidence JSON을 저장한다. NTF-005 recovery +state machine은 persisted certainty만 사용하고 추측하지 않는다. + +**필수 인수 테스트** + +- UNKNOWN false와 PROVEN false가 서로 다른 DB representation으로 round-trip. +- every `ProviderExecutionEvidence` combination의 entity/record round-trip. +- restart 뒤 recovery decision이 원래 decision과 동일. + +### NTF-027 — reconciliation synthetic event fingerprint가 hash가 아니다 + +**근거** + +- `ReconciliationService.java:126-135`는 seed 문자열 bytes를 `BigInteger` hex로 바꾸고 앞 64자를 + substring한다. +- attempt UUID prefix가 seed 앞부분을 차지하므로 같은 attempt의 서로 다른 event type/native type이 같은 + 64-char prefix를 만들 수 있다. +- ledger unique fingerprint에 걸려 later accepted/delivered 같은 별도 reconciliation event가 duplicate로 + 사라질 수 있다. + +**구현 결정** + +기존 `MessageDigestPort`를 사용해 provider profile, attempt ID, normalized event type, native type, +providerOccurredAt, stable native identity를 versioned length-framed encoding한 뒤 SHA-256한다. + +**필수 인수 테스트** + +- 같은 attempt의 accepted와 delivered는 다른 fingerprint. +- 동일 event replay는 같은 fingerprint. +- delimiter/Unicode/timezone representation collision 없음. + +## 6. 권장 폴더 구조 + +아래 구조는 현재 19개 Gradle leaf를 유지한다. package 경계와 visibility를 먼저 강제하고, 실제 build-time +독립성이 필요해질 때만 registry/Gradle leaf를 늘린다. + +### 6.1 application-core + +```text +dev.caskeleton.application.notification/ + api/ # 의도한 public DTO/value만 + port/in/ + submission/ + query/ + cancellation/ + callback/ + reconciliation/ + admin/ + port/out/ + persistence/ + provider/ + security/ + observation/ + usecase/ # package-private implementation + submission/ + dispatch/ + callback/ + reconciliation/ + admin/ + model/ + request/ + delivery/ + event/ + routing/ + policy/ + template/ + contact/ + compatibility/r1/ # 임시 ACL만, 신규 기능 금지 +``` + +원칙: + +- `api`와 `port`만 public allowlist에 넣는다. +- JPA/HTTP/provider SDK/Spring DTO는 들어오지 않는다. +- use case 구현과 policy implementation은 package-private를 기본으로 한다. +- submit/read/cancel/callback/admin을 한 orchestrator interface에 합치지 않는다. + +### 6.2 adapter:outbound:notification + +```text +dev.caskeleton.adapter.outbound.notification.platform/ + configuration/ # ProviderRuntimeAssembler contribution만 노출 + runtime/ + dispatch/ + lease/ + lifecycle/ + provider/ + shared/http/ + apns/internal/ + fcm/internal/ + ses/internal/ + smtp/internal/ + twilio/internal/ + webpush/internal/ + webhook/internal/ + callback/ + ses/ + twilio/ + template/ + security/ + observation/ +``` + +원칙: + +- provider package 외부에는 assembler/configuration facade와 application port 구현만 보인다. +- business eligibility/authorization/idempotency는 application으로 이동한다. +- 공통 HTTP/security code가 provider-specific rule을 삼키지 않는다. +- provider capability는 8개 positional boolean보다 `EnumSet` + typed limits를 + 선호한다. + +### 6.3 adapter:outbound:persistence-jpa + +```text +dev.caskeleton.adapter.outbound.persistence.notification.platform/ + configuration/NotificationPersistenceAdapters + request/ + delivery/ + attempt/ + event/ + contact/ + template/ + policy/ + admin/ + inbox/ +``` + +원칙: + +- entity/repository/mapper는 package-private/internal. +- bootstrap에는 `NotificationPersistenceAdapters` 한 facade만 노출한다. +- claim은 새 JPA platform의 registered fixed statement executor를 재사용하되 notification fencing을 + 추가한다. +- JSON/crypto schema mapping과 migration owner를 slice별 integration test로 고정한다. + +### 6.4 adapter:inbound:web + +```text +notification/platform/callback/ + controller/ # MVC + servlet/ + reactive/ + shared/ # request factory/canonical URL contract +``` + +MVC/WebFlux는 같은 application inbound port, provider/profile validation, canonical external URL contract, +body size semantics를 사용한다. framework별 buffer/security configuration만 분리한다. + +### 6.5 app-bootstrap + +```text +notification/ + NotificationPlatformConfiguration + NotificationProviderGraphConfiguration + NotificationDispatchRuntimeConfiguration + NotificationCallbackRuntimeConfiguration + NotificationObservationConfiguration + NotificationSchemaActivationConfiguration +``` + +각 configuration은 100~200줄 이하를 목표로 하되 줄 수 자체를 gate로 만들지는 않는다. 핵심은 bootstrap이 +entity/repository/mapper를 알지 않고, provider graph가 worker 시작 전에 완성·검증된다는 점이다. + +## 7. 디자인 패턴 적용 판단 + +패턴은 이름을 붙이기 위해 도입하지 않고 현재 실패 경계를 닫는 데만 사용한다. + +| 문제 | 적용할 패턴 | 이유 | 피할 것 | +|---|---|---|---| +| provider별 조립 | Abstract Factory / Contribution | profile 하나의 adapter·runtime·callback graph를 원자적으로 구성 | reflection/범용 plugin framework | +| dispatch 단계 | Pipeline + typed Result | pre-wire/committed/response milestone을 명시 | 모든 예외를 catch해 AMBIGUOUS 추측 | +| lease/recovery | State Machine + Fencing Token | stale worker write를 데이터로 차단 | owner 문자열만 사용 | +| DB orchestration | Unit of Work | claim/attempt/ledger append 원자성 | repository별 암묵 transaction 의존 | +| callback | Transactional Inbox + Projector | 빠른 honest 2xx와 재생 가능성 | HTTP thread에서 synchronous projection | +| provider error/route/template | Strategy | provider/slot별 정책 차이를 닫힌 계약으로 표현 | 거대한 if/switch god service | +| R1 migration | Anti-Corruption Layer | 두 모델의 임시 변환을 한곳에 격리 | 양방향 자유 import | +| runtime health | Immutable State + transition table | state/reason 일관성 | enum마다 class를 만드는 과도한 State hierarchy | +| cross-cutting provider call | Decorator | metric/rate/circuit/auth를 순서대로 합성 | adapter 내부 곳곳의 중복 try/catch | + +## 8. 구현 순서 + +### Wave 0 — 사실성 및 release 차단 + +1. platform default disabled 유지. +2. support matrix를 `Contract implemented / runtime unqualified`로 정정. +3. fake provider echo sandbox와 과장된 nightly 이름/문구 수정. +4. R0/R1/platform canonical ADR 작성. + +완료 기준: 문서/CI가 현재 executable evidence보다 강한 주장을 하지 않는다. + +### Wave 1 — boot graph와 schema + +1. typed provider settings + assembler/contribution. +2. 실제 runtime/route/callback/projector/reconciliation registry 조립. +3. notification schema stream/history/readiness activation. +4. entity/migration/JSONB mapping 정합화. +5. full application context fake-provider smoke. + +완료 기준: enabled context가 실제 provider 한 건을 보내거나, 구성 불완전 시 boot fail한다. + +### Wave 2 — durable queue + +1. scheduled due-time state 수정. +2. atomic CTE claim + lease generation/fencing. +3. unique worker identity, capacity-aware claim. +4. SmartLifecycle dispatch/recovery worker. +5. PostgreSQL two-worker/restart/expiry tests. + +완료 기준: duplicate provider call을 만드는 stale worker race가 DB 조건으로 차단된다. + +### Wave 3 — evidence와 ledger + +1. evidence certainty persistence. +2. complete projection snapshot/version. +3. transactional callback append + conflict-safe duplicate. +4. late binding + pending/failed projection worker. +5. durable reconciliation queue/worker + SHA-256 fingerprint. + +완료 기준: out-of-order/restart/replay 후에도 ledger fold와 snapshot이 같고 side effect가 1회다. + +### Wave 4 — security와 provider correctness + +1. WebPush versioned contact codec/VAPID keyring. +2. capability-aware secret keyring/startup validation. +3. callback payload envelope/body bound. +4. dynamic HTTP/SNS/response bound. +5. template slot modes/deep-link allowlist. +6. webhook/attachment/FCM/APNs wire contract. + +완료 기준: security negative suite와 provider final-wire tests가 모두 통과한다. + +### Wave 5 — API와 architecture + +1. typed variables + canonical encoder/fingerprint. +2. dedup/collapse/preference E2E. +3. CQRS-style inbound use cases/capability marker. +4. admin/routing policy application 이동. +5. package 구조/internal visibility/ArchUnit/public snapshot. + +완료 기준: dead public contract가 없고 새 consumer가 canonical API 하나만 사용한다. + +### Wave 6 — qualification + +1. real provider sandbox test와 correlation evidence. +2. PostgreSQL performance/lease/callback burst/chaos/restart lane. +3. readiness/metrics/audit cardinality/PII test. +4. evidence manifest 충족 provider만 Stable 승격. + +## 9. 권장 테스트 구조와 명령 + +### 9.1 새 테스트 source set/fixture + +```text +application-core:test + - request canonicalization property tests + - use-case/policy/state-machine unit tests + +adapter:outbound:notification:test + - provider final-wire contract + - transport milestone/error classification + - template/security/concurrency tests + +adapter:outbound:persistence-jpa:notificationPostgresqlIntegrationTest + - migration + Hibernate validate + - CRUD/projection/ledger + - claim/fencing/concurrent duplicate + +app-bootstrap:test + - full configured context + - provider contribution/schema activation/readiness + +notificationChaosTest + - process kill/restart + - response loss and callback burst +``` + +### 9.2 구현 중 focused 검증 + +```bash +cd src +./gradlew :application-core:test --console=plain +./gradlew :adapter:outbound:notification:test --console=plain +./gradlew :adapter:outbound:persistence-jpa:test --console=plain +./gradlew :adapter:inbound:web:test --console=plain +./gradlew :app-bootstrap:test --tests '*Notification*' --console=plain +./gradlew verifyCleanArchitectureDependencies verifyPublicPathSnapshot verifyEnvKeys --console=plain +``` + +### 9.3 release 전 필수 검증 + +```bash +cd src +./gradlew notificationPostgresqlIntegrationTest --console=plain +./gradlew notificationChaosTest --console=plain +./gradlew test --console=plain +./gradlew check --console=plain +``` + +task는 실제 source set을 만든 뒤 정확한 Gradle 이름으로 확정한다. 존재하지 않는 이름을 문서만 먼저 +추가하지 않는다. 모든 lane은 `failOnNoDiscoveredTests`와 skip reason을 강제한다. + +## 10. 완료 정의 + +다음 질문에 모두 코드·DB·실행 artifact로 “예”라고 답할 수 있을 때만 notification platform을 Stable로 +판정한다. + +- enabled profile이 full application context에서 실제 runtime/route/transport로 조립되는가? +- notification schema가 별도 history로 적용·승격되고 Hibernate validate를 통과하는가? +- 예약 요청이 due 시 정확히 한 번 claim되는가? +- 두 replica와 stale worker가 같은 notification을 provider에 중복 제출하지 못하는가? +- crash 지점별 recovery가 safe retry와 ambiguity를 evidence로 구분하는가? +- callback-before-outcome, duplicate, out-of-order, projector failure가 재생 가능한가? +- engagement/suppression/evidence certainty가 restart 뒤 보존되는가? +- WebPush/contact/callback ciphertext가 key rotation 뒤 복원되는가? +- SSRF, oversized request/response, malicious SNS URL/topic/replay가 provider/ledger 전에 거부되는가? +- fingerprint가 저장한 plan의 정확한 semantic bytes를 대표하는가? +- dedup/collapse/preference/attachment/webhook 공개 계약이 final wire까지 적용되는가? +- 모든 orchestration entrypoint가 repository의 UseCase/permission/architecture fitness gate를 통과하는가? +- support matrix의 각 Stable 주장이 실제 실행 evidence artifact에 연결되는가? + +하나라도 아니면 해당 기능은 Stable이 아니라 `Experimental`, `Unwired`, 또는 `Contract-only`로 표시한다. + +## 11. 이번 리뷰에서 실행한 검증 + +모든 최종 성공 결과는 HEAD `539e3eb58bed5db63e3a17f47eec213db2d2df79`에서 build 산출물을 +재생성한 뒤 얻었다. + +| 명령 | 결과 | 관측 범위 | +|---|---|---| +| `./gradlew :application-core:clean :application-core:test :application-core:jar :adapter:outbound:notification:clean :adapter:outbound:notification:test --rerun-tasks --console=plain` | BUILD SUCCESSFUL, 3m 16s | application 98 suites/656 tests, notification 31 suites/176 tests, failure/skip 0 | +| `./gradlew :adapter:outbound:persistence-jpa:clean :adapter:outbound:persistence-jpa:test --rerun-tasks --console=plain` | BUILD SUCCESSFUL, 46s | 78 suites/384 tests, failure/skip 0 | +| `./gradlew :adapter:inbound:web:clean :adapter:inbound:web:test --rerun-tasks --console=plain` | BUILD SUCCESSFUL, 44s | 57 suites/333 tests, failure/skip 0 | +| `./gradlew :app-bootstrap:clean :app-bootstrap:test --tests '*NotificationAutoConfigurationTest' --tests '*NotificationArchitectureTest' --tests '*CleanArchitectureTest' verifyCleanArchitectureDependencies --rerun-tasks --console=plain` | BUILD SUCCESSFUL, 3m 25s | 선택된 7 suites/83 tests와 module dependency gate, failure/skip 0 | +| `./gradlew verifyPublicPathSnapshot verifyEnvKeys --rerun-tasks --console=plain` | BUILD SUCCESSFUL, 1m 2s | public path unchanged, env registry gate OK | +| `git diff --no-index --check /dev/null docs/reviews/2026-08-14-notification-module-code-review.md 2>&1 \| wc -c` | `0` | untracked 신규 문서의 whitespace 오류 출력 없음 | + +첫 통합 실행 +`./gradlew :application-core:test :adapter:outbound:notification:test :adapter:outbound:persistence-jpa:test :adapter:inbound:web:test --rerun-tasks --console=plain`은 +`application-core-0.0.1+539e3eb58bed.jar: zip END header not found` 때문에 +`:adapter:outbound:persistence-jpa:compileTestJava`에서 101개 연쇄 symbol error로 실패했다. 리뷰 중 병렬 +빌드가 같은 공유 build output을 사용한 뒤 남은 손상으로 판단했고, 위 표처럼 대상 build directory를 +clean한 뒤 모듈별 순차 실행하여 모두 fresh 통과했다. 이 최초 실패를 notification assertion 실패로 +분류하지는 않지만, 공유 workspace에서 병렬 Gradle build output을 격리해야 한다는 도구 운영상 주의점은 +남는다. + +다음 검증은 실행하지 못한 것이 아니라 **현재 repository에 해당 executable test lane이 존재하지 않아** +검증할 수 없었다. + +- notification 전용 PostgreSQL migration/Hibernate validate/CRUD integration task +- 두 process/connection을 사용한 notification lease fencing task +- process kill/restart recovery 및 callback burst chaos task +- secret-protected real provider sandbox test + +일반 unit/contract/ArchUnit 통과는 위 네 운영 계약을 대신하지 않는다. NTF-024의 핵심은 이 공백을 +release evidence에 정직하게 반영하고 실제 lane으로 채우는 것이다. + +### 11.1 최종 작업공간 상태 주의 + +위 검증을 마친 뒤, 이 리뷰 작업이 만들지 않은 messaging 모듈 병합 변경이 같은 worktree에 추가됐고 +`src/config/spotbugs/exclude.xml`은 현재 unmerged(`UU`) 상태가 됐다. 최종 대조 시 Git HEAD는 여전히 +`539e3eb58bed5db63e3a17f47eec213db2d2df79`였으며, notification 검토 대상 경로에는 HEAD 대비 staged 또는 +unstaged 변경이 없었다. 따라서 notification source finding은 유지되지만, 다음 두 범위는 구분해야 한다. + +- 위 표의 성공 결과는 messaging 병합 충돌이 나타나기 전, HEAD `539e3eb`의 notification 관련 graph에서 + 얻은 fresh evidence다. +- 현재 충돌이 남은 worktree 전체가 build/check를 통과한다는 뜻은 아니다. 충돌을 사용자 작업에서 + 해소한 뒤 `verifyCleanArchitectureDependencies`, notification focused test, 전체 `check`를 다시 + 실행해야 한다. + +또한 이 문서의 “19개 leaf” 판단은 committed HEAD와 이 저장소의 현재 `AGENTS.md` 정책을 기준으로 한다. +진행 중인 messaging 병합이 `modules.json`에 다수 leaf를 추가하므로, 그 변경이 최종 정책이라면 +`AGENTS.md`의 정확히 19개 leaf 계약과 registry/settings/build 검증을 함께 개정·승인해야 한다. 이번 +notification read-only 리뷰에서는 그 별도 병합이나 충돌을 수정하지 않았다. diff --git a/docs/runbooks/outbox-publish-failed.md b/docs/runbooks/outbox-publish-failed.md index 06c14dea..52e29b55 100644 --- a/docs/runbooks/outbox-publish-failed.md +++ b/docs/runbooks/outbox-publish-failed.md @@ -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 — 임시 격리 diff --git a/scripts/verify-mongodb-advanced.sh b/scripts/verify-mongodb-advanced.sh index 39abdc39..78aa42f3 100755 --- a/scripts/verify-mongodb-advanced.sh +++ b/scripts/verify-mongodb-advanced.sh @@ -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 "" diff --git a/scripts/verify-mongodb-platform.sh b/scripts/verify-mongodb-platform.sh index f51255fc..2808fcf2 100755 --- a/scripts/verify-mongodb-platform.sh +++ b/scripts/verify-mongodb-platform.sh @@ -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 diff --git a/src/adapter/inbound/graphql/CLAUDE.md b/src/adapter/inbound/graphql/CLAUDE.md index c49e1a47..ad9f7e10 100644 --- a/src/adapter/inbound/graphql/CLAUDE.md +++ b/src/adapter/inbound/graphql/CLAUDE.md @@ -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//*.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`. diff --git a/src/adapter/inbound/graphql/README.md b/src/adapter/inbound/graphql/README.md index 61d942ec..7e3fc1f6 100644 --- a/src/adapter/inbound/graphql/README.md +++ b/src/adapter/inbound/graphql/README.md @@ -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 없이 아키텍처 규칙을 강제한 방법 diff --git a/src/adapter/inbound/graphql/build.gradle b/src/adapter/inbound/graphql/build.gradle index c7e8a9c9..9e201cc6 100644 --- a/src/adapter/inbound/graphql/build.gradle +++ b/src/adapter/inbound/graphql/build.gradle @@ -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 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 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 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 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 committedTypes = committed.readLines().findAll { !it.startsWith('#') } + List renderedTypes = rendered.readLines().findAll { !it.startsWith('#') } + List added = (renderedTypes - committedTypes).toSorted() + List 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') +} diff --git a/src/adapter/inbound/graphql/gradle.lockfile b/src/adapter/inbound/graphql/gradle.lockfile index 9c4de66b..28d2aeea 100644 --- a/src/adapter/inbound/graphql/gradle.lockfile +++ b/src/adapter/inbound/graphql/gradle.lockfile @@ -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= diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/GraphqlExceptionResolver.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/GraphqlExceptionResolver.java deleted file mode 100644 index 2d88b309..00000000 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/GraphqlExceptionResolver.java +++ /dev/null @@ -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}. - * - *

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; - }; - } -} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlAdminPrincipal.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlAdminPrincipal.java new file mode 100644 index 00000000..904b6ff8 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlAdminPrincipal.java @@ -0,0 +1,37 @@ +package dev.caskeleton.adapter.inbound.graphql.advanced.admin; + +import java.util.Objects; + +/** + * A verified administrator, as the transport established them. + * + *

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 may administer the registry while + * proving nothing about who actually did. The audit trail then recorded that name as fact. + * + *

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); + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationAdminAuthorization.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationAdminAuthorization.java index 18e683cd..8fbb6478 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationAdminAuthorization.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationAdminAuthorization.java @@ -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. + *

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(); } } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationAdminPort.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationAdminPort.java new file mode 100644 index 00000000..e3246752 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationAdminPort.java @@ -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. + * + *

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. + * + *

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 auditTrail(); +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationAdminService.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationAdminService.java index 00203e65..fb080c60 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationAdminService.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationAdminService.java @@ -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). * - *

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. + *

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. + * + *

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 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 { * *

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. + * + *

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. + * + *

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 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); } } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedCapability.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedCapability.java index d9a78f7f..ac1a7f99 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedCapability.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedCapability.java @@ -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), diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedDependencyRules.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedDependencyRules.java index f466befa..16d3371f 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedDependencyRules.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedDependencyRules.java @@ -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 advanced = GraphQlBuildModel.advancedModules(); + Set advanced = GraphQlAdvancedModule.moduleIds(); List 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. */ diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlClientOperationGenerator.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlClientOperationGenerator.java index 75ac6798..4e7a9a2d 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlClientOperationGenerator.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlClientOperationGenerator.java @@ -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. * - *

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. + *

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. + * + *

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 generatedTypes() { profile.generatedTypes().forEach(GraphQlGeneratedSourceBoundary.standard()::requireAllowed); return profile.generatedTypes(); diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlOperationValidator.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlOperationValidator.java new file mode 100644 index 00000000..c4e0daee --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlOperationValidator.java @@ -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. + * + *

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. + * + *

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 errors = new Validator().validateDocument(schema, document, Locale.ROOT); + if (!errors.isEmpty()) { + List 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. + * + *

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 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); + } + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryAllowlist.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryAllowlist.java deleted file mode 100644 index efbc1399..00000000 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryAllowlist.java +++ /dev/null @@ -1,39 +0,0 @@ -package dev.caskeleton.adapter.inbound.graphql.advanced.compat; - -import java.util.Set; - -/** - * The repositories permitted to back GraphQL fields. - * - *

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 repositoryNames; - - private GraphQlRepositoryAllowlist(Set 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 repositoryNames() { - return repositoryNames; - } -} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryArgumentPolicy.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryArgumentPolicy.java deleted file mode 100644 index e7242ef8..00000000 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryArgumentPolicy.java +++ /dev/null @@ -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. - * - *

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 allowedFilterFields, Set 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 filterFields, Set sortFields) { - var rejected = new TreeSet(); - 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); - } - } -} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryExposure.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryExposure.java deleted file mode 100644 index 1e37bc19..00000000 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryExposure.java +++ /dev/null @@ -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"); - } - } -} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryExposureRejectedException.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryExposureRejectedException.java deleted file mode 100644 index 657773f2..00000000 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryExposureRejectedException.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.caskeleton.adapter.inbound.graphql.advanced.compat; - -/** - * Raised when a repository would be exposed without being allowlisted. - * - *

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); - } -} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryExposureValidator.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryExposureValidator.java deleted file mode 100644 index 544acb15..00000000 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryExposureValidator.java +++ /dev/null @@ -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). - * - *

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"); - } -} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryPaginationPolicy.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryPaginationPolicy.java deleted file mode 100644 index d8fe606b..00000000 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryPaginationPolicy.java +++ /dev/null @@ -1,35 +0,0 @@ -package dev.caskeleton.adapter.inbound.graphql.advanced.compat; - -/** - * Pagination for an exposed repository, stated rather than inherited. - * - *

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; - } -} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryProjectionPolicy.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryProjectionPolicy.java deleted file mode 100644 index b2a40147..00000000 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryProjectionPolicy.java +++ /dev/null @@ -1,39 +0,0 @@ -package dev.caskeleton.adapter.inbound.graphql.advanced.compat; - -import java.util.Set; - -/** - * Which projection an exposed repository returns. - * - *

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 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 persistenceTypeNames) { - if (persistenceTypeNames.contains(projectionType)) { - throw new GraphQlRepositoryExposureRejectedException( - projectionType + " is a persistence type and must not be returned directly"); - } - } -} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/GraphQlPersistedOperationNotFoundException.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/GraphQlPersistedOperationNotFoundException.java new file mode 100644 index 00000000..2b10e30f --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/GraphQlPersistedOperationNotFoundException.java @@ -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. + * + *

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); + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/GraphQlPersistedOperationRecordMapping.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/GraphQlPersistedOperationRecordMapping.java new file mode 100644 index 00000000..3cc2a8dd --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/GraphQlPersistedOperationRecordMapping.java @@ -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. + * + *

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. + * + *

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 fields = readAll(record.value()); + if (fields.size() != FIELDS) { + throw new IllegalArgumentException("stored persisted operation has an unexpected shape"); + } + Set 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 readAll(String encoded) { + List 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); + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/GraphQlPersistedOperationRegistry.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/GraphQlPersistedOperationRegistry.java index 393c2def..5273dfa4 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/GraphQlPersistedOperationRegistry.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/GraphQlPersistedOperationRegistry.java @@ -23,9 +23,17 @@ public interface GraphQlPersistedOperationRegistry { Optional find(GraphQlPersistedOperationId id); /** - * Replaces an operation's lifecycle state. + * Applies a lifecycle transition and returns the operation as it now stands. * - *

Used by the admin plane to block an operation during an incident. + *

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); } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/GraphQlPersistedOperationTransition.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/GraphQlPersistedOperationTransition.java new file mode 100644 index 00000000..f67ae6a6 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/GraphQlPersistedOperationTransition.java @@ -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. + * + *

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. + * + *

{@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> + 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()); + } + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/OperationalStoreGraphQlPersistedOperationRegistry.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/OperationalStoreGraphQlPersistedOperationRegistry.java new file mode 100644 index 00000000..23921050 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/OperationalStoreGraphQlPersistedOperationRegistry.java @@ -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. + * + *

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. + * + *

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 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 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; + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/replay/GraphQlReplayAuthorization.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/replay/GraphQlReplayAuthorization.java index 973774a2..f8eab8e7 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/replay/GraphQlReplayAuthorization.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/replay/GraphQlReplayAuthorization.java @@ -5,9 +5,14 @@ import dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocket /** * Authorizes a replay before any history is delivered. * - *

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. + *

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. + * + *

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(); } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/replay/GraphQlSubscriptionCursor.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/replay/GraphQlSubscriptionCursor.java index bbe79e43..9d1029f8 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/replay/GraphQlSubscriptionCursor.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/replay/GraphQlSubscriptionCursor.java @@ -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. * - *

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. + *

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. + * + *

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. * *

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. + * + *

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(); + } } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/rsocket/GraphQlRSocketHandlerFactory.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/rsocket/GraphQlRSocketAdmission.java similarity index 81% rename from src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/rsocket/GraphQlRSocketHandlerFactory.java rename to src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/rsocket/GraphQlRSocketAdmission.java index 539d7d69..75b8f859 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/rsocket/GraphQlRSocketHandlerFactory.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/rsocket/GraphQlRSocketAdmission.java @@ -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. + * + *

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. * *

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); diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/sse/GraphQlSseHandlerFactory.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/sse/GraphQlSseAdmission.java similarity index 84% rename from src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/sse/GraphQlSseHandlerFactory.java rename to src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/sse/GraphQlSseAdmission.java index 589cc0ed..710fb5ef 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/sse/GraphQlSseHandlerFactory.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/sse/GraphQlSseAdmission.java @@ -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. + * + *

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. * *

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); diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/subscription/GraphQlSubscriptionCancellation.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/subscription/GraphQlSubscriptionCancellation.java index 252ea3ba..73f10bbe 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/subscription/GraphQlSubscriptionCancellation.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/subscription/GraphQlSubscriptionCancellation.java @@ -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; *

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. + * + *

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(); } } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/subscription/GraphQlSubscriptionDrainCoordinator.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/subscription/GraphQlSubscriptionDrainCoordinator.java index 0ac09585..75e1853d 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/subscription/GraphQlSubscriptionDrainCoordinator.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/subscription/GraphQlSubscriptionDrainCoordinator.java @@ -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. + * + *

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 = 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 + *

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. + * + *

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; + } + } } } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/subscription/GraphQlSubscriptionDrainPhase.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/subscription/GraphQlSubscriptionDrainPhase.java new file mode 100644 index 00000000..1e42eb3c --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/subscription/GraphQlSubscriptionDrainPhase.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.inbound.graphql.advanced.subscription; + +/** + * Where a node is in its subscription lifetime. + * + *

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 +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/subscription/GraphQlSubscriptionEvent.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/subscription/GraphQlSubscriptionEvent.java index 259dafa0..591005f4 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/subscription/GraphQlSubscriptionEvent.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/subscription/GraphQlSubscriptionEvent.java @@ -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 payload, long sequenc } } - /** An estimate of this event's serialized size, for the byte budget. */ + /** + * This event's serialized size, for the byte budget. + * + *

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); } } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/subscription/GraphQlSubscriptionLease.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/subscription/GraphQlSubscriptionLease.java new file mode 100644 index 00000000..964529f0 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/subscription/GraphQlSubscriptionLease.java @@ -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. + * + *

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(); + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/websocket/GraphQlWebSocketHandlerFactory.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/websocket/GraphQlWebSocketAdmission.java similarity index 76% rename from src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/websocket/GraphQlWebSocketHandlerFactory.java rename to src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/websocket/GraphQlWebSocketAdmission.java index 4e89216e..d6cf4d43 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/websocket/GraphQlWebSocketHandlerFactory.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/websocket/GraphQlWebSocketAdmission.java @@ -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. + * + *

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. * *

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); diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/architecture/GraphQlAsyncReturnShape.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/architecture/GraphQlAsyncReturnShape.java new file mode 100644 index 00000000..d1abaaae --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/architecture/GraphQlAsyncReturnShape.java @@ -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. + * + *

The distinction is what makes the subscription rule correct. The rule used to reject every + * {@code Publisher} outside a subscription, and {@code Mono} is a {@code Publisher} — so {@code + * Mono 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. + * + *

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 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 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 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); + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/architecture/GraphQlControllerInspector.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/architecture/GraphQlControllerInspector.java index 5b9fd271..419ab399 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/architecture/GraphQlControllerInspector.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/architecture/GraphQlControllerInspector.java @@ -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 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` 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); + } } } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/architecture/GraphQlResolverBoundaryRules.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/architecture/GraphQlResolverBoundaryRules.java index 32c3f4d5..8986f2fb 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/architecture/GraphQlResolverBoundaryRules.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/architecture/GraphQlResolverBoundaryRules.java @@ -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. + * + *

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}. * *

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 FORBIDDEN_TYPE_SUFFIXES = Set.of("Repository", "Dao"); + /** Package name fragments that make a repository-suffixed type a repository in fact. */ + public static final Set 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 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 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. + * + *

Every declared type is walked as a generic type graph. Checking the erased type would let + * {@code Optional}, {@code List} and {@code Mono} + * through — the wrapper is what a leak looks like once someone has tidied the signature. + */ public static List persistenceViolations(Collection> classes) { List 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 violations, java.lang.reflect.Type declared, Supplier 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. + * + *

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. + * + *

The pair of name-based rules above is what demoting the suffix heuristic looks like in + * practice. A repository-named interface 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. + * + *

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 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 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 into) + throws IOException { + try (Stream 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 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 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('/', '.')); + } + } } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/architecture/GraphQlTypeGraph.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/architecture/GraphQlTypeGraph.java new file mode 100644 index 00000000..a32cb82a --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/architecture/GraphQlTypeGraph.java @@ -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. + * + *

The boundary rules used to inspect {@code Method#getReturnType} and {@code Parameter#getType}, + * which erase to the container: {@code List} reports {@code List}, {@code + * Mono} reports {@code Mono}, and {@code Optional} 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. + * + *

Traversal is bounded and cycle-guarded. A type variable can refer to its own bound ({@code >}), 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. + * + *

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> referencedTypes(Type type) { + Set> found = new LinkedHashSet<>(); + if (type == null) { + return found; + } + Set visited = new LinkedHashSet<>(); + Deque 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}. + * + *

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; + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformActuatorEndpoint.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformActuatorEndpoint.java index d4bfe843..2dc5b00a 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformActuatorEndpoint.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformActuatorEndpoint.java @@ -59,7 +59,7 @@ public final class GraphQlPlatformActuatorEndpoint { properties.environment().name(), GraphQlHttpProfile.V1.name(), supportedCapabilities, - properties.cursorKeyIds(), + properties.cursor().keyIds(), registeredOperations, registeredFetchProfiles); } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformAutoConfiguration.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformAutoConfiguration.java index 3333de07..ad317f92 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformAutoConfiguration.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformAutoConfiguration.java @@ -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; *

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. + * + *

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. + * + *

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 { * *

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. + * + *

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 transport, + ObjectProvider 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. + * + *

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> 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. + * + *

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> graphQlControllerClasses(ApplicationContext context) { + List> 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. + * + *

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. + * + *

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. + * + *

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( + GraphQlClientPolicy clientPolicy, ObjectProvider 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 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. + * + *

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. + * + *

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. + * + *

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. + * + *

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. + * + *

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. + * + *

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. + * + *

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. + * + *

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 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. + * + *

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 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. + * + *

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 cache, + ObjectProvider 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 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())); } } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformProperties.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformProperties.java index 141e28b5..5c172afd 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformProperties.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformProperties.java @@ -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). * + *

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. + * *

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 cursorKeyIds, - boolean multipartUploadEnabled, - boolean httpArrayBatchEnabled, - boolean requestWideTransactionEnabled, - boolean repositoryAutoExposureEnabled, - boolean responseCacheEnabled, - boolean advancedCapabilitiesOnStableStarter, - Set 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 observedOperationNames, + @DefaultValue Set 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 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 keyIds) { + return new Cursor(keyIds); + } + } + + /** + * Capabilities the platform does not implement. + * + *

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); } } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformRuntime.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformRuntime.java new file mode 100644 index 00000000..32e8dcde --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformRuntime.java @@ -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. + * + *

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; + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformStartupValidator.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformStartupValidator.java index 51c7443b..ad60f268 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformStartupValidator.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformStartupValidator.java @@ -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 problems = runtimeProblems(runtime); + if (!problems.isEmpty()) { + throw new GraphQlPlatformConfigurationException(problems); + } + } + + /** + * Problems with the assembled runtime, in a deterministic order. + * + *

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 runtimeProblems(GraphQlPlatformRuntime runtime) { + List 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); + } } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlRuntimeTransport.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlRuntimeTransport.java new file mode 100644 index 00000000..2281a828 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlRuntimeTransport.java @@ -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. + * + *

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. + * + *

{@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. + * + *

{@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; + }; + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlChangeKind.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlChangeKind.java index a51a539c..e47efeef 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlChangeKind.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlChangeKind.java @@ -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. + * + *

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}. + * + *

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. + * + *

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. + * + *

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( diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlSchemaComparator.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlSchemaComparator.java index 5d91e018..97407e15 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlSchemaComparator.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlSchemaComparator.java @@ -58,12 +58,14 @@ public final class GraphQlSchemaComparator { List 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. + * + *

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 changes) { + + Map previousTypes = previous.types(); + Map 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. + * + *

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 changes) { + + Map previousTypes = previous.types(); + Map 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 changes) { + + Map 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 appliedDirectives(graphql.language.Node node) { + List 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. + * + *

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 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() diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/context/GraphQlCommandAttribution.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/context/GraphQlCommandAttribution.java new file mode 100644 index 00000000..f174f511 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/context/GraphQlCommandAttribution.java @@ -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. + * + *

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. + * + *

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 actor() { + return Optional.ofNullable(actorId); + } + + /** When the caller stops waiting. */ + public Instant deadlineAt() { + return deadline; + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/cost/GraphQlDocumentComplexityScorer.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/cost/GraphQlDocumentComplexityScorer.java new file mode 100644 index 00000000..666e6463 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/cost/GraphQlDocumentComplexityScorer.java @@ -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. + * + *

{@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. + * + *

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. + * + *

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 variables) { + + Objects.requireNonNull(schema, "schema is required"); + Objects.requireNonNull(document, "document is required"); + Objects.requireNonNull(operation, "operation is required"); + + Map 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 fragments, + Map variables, + Counter counter, + Deque 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 fragments, + Map variables, + Counter counter, + Deque 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 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 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); + } + } + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/cost/GraphQlDocumentShapeAnalyzer.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/cost/GraphQlDocumentShapeAnalyzer.java index 0248a649..ae284384 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/cost/GraphQlDocumentShapeAnalyzer.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/cost/GraphQlDocumentShapeAnalyzer.java @@ -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. + * + *

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 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 measured = + operation != null ? List.of(operation) : operations(document); + Set 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. + * + *

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 fragments = new LinkedHashMap<>(); + for (Definition definition : document.getDefinitions()) { + if (definition instanceof FragmentDefinition fragment) { + fragments.put(fragment.getName(), fragment); + } + } + List 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 operations(Document document) { + List operations = new ArrayList<>(); + for (Definition definition : document.getDefinitions()) { + if (definition instanceof OperationDefinition operation) { + operations.add(operation); + } + } + return operations; + } + + private boolean selectsIntrospection( + SelectionSet selectionSet, + Map fragments, + Deque 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 fragments, Counters counters, int depth, - Deque expansionPath) { + Deque expansionPath, + Set 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(); } } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlBatchExecutor.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlBatchExecutor.java index 9354a0cb..26565d0c 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlBatchExecutor.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlBatchExecutor.java @@ -13,9 +13,13 @@ import java.util.function.BiFunction; * Runs a batch load in ordered chunks under one context and budget (design §13). * *

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. + * + *

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 loaded = new LinkedHashMap<>(); for (List 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. * diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlBatchResultMapper.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlBatchResultMapper.java index 046f9087..71650774 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlBatchResultMapper.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlBatchResultMapper.java @@ -11,6 +11,12 @@ import java.util.Set; *

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. + * + *

A null value means missing, 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 GraphQlBatchResult map( List keys, Map loaded, Set failedKeys, String errorCode) { + requireOnlyRequestedKeys(keys, loaded); + var result = new LinkedHashMap>(); 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. + * + *

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 void requireOnlyRequestedKeys(List keys, Map loaded) { + Set requested = new java.util.LinkedHashSet<>(keys); + Set 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>(); 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)); diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/BoundedPreparsedDocumentProvider.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/BoundedPreparsedDocumentProvider.java index 9b867f0a..b2bc4995 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/BoundedPreparsedDocumentProvider.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/BoundedPreparsedDocumentProvider.java @@ -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. * *

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. + * + *

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 + * same 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 the cached parsed-document type */ @@ -22,7 +35,11 @@ public final class BoundedPreparsedDocumentProvider { private final GraphQlPreparsedCachePolicy policy; private final GraphQlPreparsedCacheMetrics metrics; + private final Clock clock; private final Map> cache; + private final ConcurrentHashMap> inFlight = + new ConcurrentHashMap<>(); + private final Object lock = new Object(); private long weight; /** @@ -30,11 +47,13 @@ public final class BoundedPreparsedDocumentProvider { * * @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 { * @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 parseAndValidate) { - Entry 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 mine = new CompletableFuture<>(); + CompletableFuture 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 { return metrics; } + private D lookup(GraphQlPreparsedCacheKey key, Instant now) { + synchronized (lock) { + expireIdle(now); + Entry 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 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> 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 { } } - private record Entry(D document, long weight) {} + private D await(CompletableFuture 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 document, long weight, Instant lastAccessAt) {} } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlExecutionPipeline.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlExecutionPipeline.java index 31906e93..1b1eb372 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlExecutionPipeline.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlExecutionPipeline.java @@ -18,13 +18,21 @@ public record GraphQlExecutionPipeline(List stages) { stages = List.copyOf(stages); } - /** The Stable pipeline: context, authorization, parse/validate, cost, execute. */ + /** + * The Stable pipeline: context, parse/validate, authorization, cost, execute. + * + *

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)); } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlExecutionPipelineValidator.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlExecutionPipelineValidator.java index 8955384c..fa6cc343 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlExecutionPipelineValidator.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlExecutionPipelineValidator.java @@ -15,12 +15,20 @@ public final class GraphQlExecutionPipelineValidator { /** Ordering constraints every pipeline must satisfy, as (earlier, later) pairs. */ private static final List 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 }, diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlExecutionStage.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlExecutionStage.java index abf69779..ef8b3ec8 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlExecutionStage.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlExecutionStage.java @@ -3,10 +3,14 @@ package dev.caskeleton.adapter.inbound.graphql.execution; /** * The ordered stages of GraphQL request execution (design §9, §18). * - *

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. + *

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. + * + *

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), diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlPreparsedCacheMetrics.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlPreparsedCacheMetrics.java index 0d1b79e6..78d305f7 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlPreparsedCacheMetrics.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlPreparsedCacheMetrics.java @@ -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. + * + *

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(); diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlAcceptHeader.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlAcceptHeader.java new file mode 100644 index 00000000..6e9f4cd0 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlAcceptHeader.java @@ -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. + * + *

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. + * + *

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. + * + *

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 parse(String accept) { + if (accept == null || accept.isBlank()) { + return List.of(); + } + List 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.comparingDouble(GraphQlAcceptHeader::quality) + .reversed() + .thenComparing( + Comparator.comparingInt(GraphQlAcceptHeader::specificity) + .reversed()) + .thenComparingInt(GraphQlAcceptHeader::order)); + return List.copyOf(entries); + } + + /** + * Whether this entry explicitly refuses a media type. + * + *

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; + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlHttpRequestEnvelope.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlHttpRequestEnvelope.java index 680c4c67..e37342c1 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlHttpRequestEnvelope.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlHttpRequestEnvelope.java @@ -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 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. */ diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlJsonStructurePolicy.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlJsonStructurePolicy.java new file mode 100644 index 00000000..56a9fb40 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlJsonStructurePolicy.java @@ -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}. + * + *

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. + * + *

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. + * + *

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 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); + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlJsonValues.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlJsonValues.java new file mode 100644 index 00000000..c3b9a6ca --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlJsonValues.java @@ -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. + * + *

{@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. + * + *

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 immutableObject(Map value) { + if (value == null || value.isEmpty()) { + return Map.of(); + } + Map 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) map); + } + if (value instanceof List list) { + List 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; + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlMediaTypes.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlMediaTypes.java index d3bf325b..20d965e9 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlMediaTypes.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlMediaTypes.java @@ -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. * + *

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. + * + *

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. + * + *

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 offered = List.of(accept.split(",")); - for (String candidate : PRODUCIBLE) { - for (String entry : offered) { - if (baseType(entry).equals(candidate)) { - return candidate; - } + + Set bestTier = new LinkedHashSet<>(); + double tierQuality = 0; + int tierSpecificity = -1; + + for (GraphQlAcceptHeader entry : GraphQlAcceptHeader.parse(accept)) { + List 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) { diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlRequestSize.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlRequestSize.java index 28fec383..6b042033 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlRequestSize.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlRequestSize.java @@ -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. + * + *

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; } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/mvc/GraphQlMvcAutoConfiguration.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/mvc/GraphQlMvcAutoConfiguration.java deleted file mode 100644 index 1baeb2cf..00000000 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/mvc/GraphQlMvcAutoConfiguration.java +++ /dev/null @@ -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. - * - *

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. - * - *

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()); - } -} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/mvc/GraphQlMvcExecutorPolicy.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/mvc/GraphQlMvcExecutorPolicy.java deleted file mode 100644 index a22a5cd2..00000000 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/mvc/GraphQlMvcExecutorPolicy.java +++ /dev/null @@ -1,49 +0,0 @@ -package dev.caskeleton.adapter.inbound.graphql.http.mvc; - -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.ThreadFactory; - -/** - * How the blocking MVC profile provides threads for resolver work (design §10). - * - *

Both options allow blocking resolvers, which is the entire point of the {@code BLOCKING_MVC} - * profile: JPA, blocking Mongo and blocking SDKs are legitimate here. What is not allowed is - * unbounded concurrency — a virtual thread per request still has a bounded connection pool behind - * it, and a platform-thread executor is explicitly bounded. - */ -public enum GraphQlMvcExecutorPolicy { - - /** Java 21 virtual threads: one carrier-light thread per request. */ - VIRTUAL_THREAD(true), - - /** A bounded platform-thread pool, for deployments not yet on virtual threads. */ - BOUNDED_PLATFORM_THREAD(true); - - private final boolean blockingAllowed; - - GraphQlMvcExecutorPolicy(boolean blockingAllowed) { - this.blockingAllowed = blockingAllowed; - } - - /** Whether a blocking resolver may run under this policy. */ - public boolean blockingAllowed() { - return blockingAllowed; - } - - /** - * Creates the executor this policy describes. - * - * @param boundedPoolSize thread count used by {@link #BOUNDED_PLATFORM_THREAD} - */ - public ExecutorService createExecutor(int boundedPoolSize) { - if (this == VIRTUAL_THREAD) { - return Executors.newVirtualThreadPerTaskExecutor(); - } - if (boundedPoolSize < 1) { - throw new IllegalArgumentException("bounded pool size must be positive"); - } - ThreadFactory threadFactory = Thread.ofPlatform().name("graphql-mvc-", 0).factory(); - return Executors.newFixedThreadPool(boundedPoolSize, threadFactory); - } -} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/mvc/GraphQlMvcTransportAdapter.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/mvc/GraphQlMvcTransportAdapter.java deleted file mode 100644 index 3bbf8f17..00000000 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/mvc/GraphQlMvcTransportAdapter.java +++ /dev/null @@ -1,157 +0,0 @@ -package dev.caskeleton.adapter.inbound.graphql.http.mvc; - -import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlExecutionOutcome; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpContractException; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpExecutor; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpProfile; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpRequestEnvelope; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponse; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponseFactory; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestEnvelopeValidator; -import java.time.Clock; -import java.time.Duration; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CancellationException; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; - -/** - * The blocking MVC transport (design §10, Stable plan Task 18). - * - *

Runs execution on the configured executor and bounds it by the request deadline rather than - * waiting indefinitely. When the deadline passes the task is cancelled with an interrupt, so a - * resolver that respects interruption stops; the design is explicit that interruption alone is not - * sufficient, which is why the deadline is also propagated down to the database and HTTP client - * budgets. - * - *

No Reactor or WebFlux type appears in this contract, and no transaction is opened here — the - * transaction belongs to the Application service the resolver calls. - */ -public final class GraphQlMvcTransportAdapter { - - private final GraphQlHttpProfile profile; - private final GraphQlRequestEnvelopeValidator validator; - private final GraphQlHttpExecutor executor; - private final ExecutorService executorService; - private final GraphQlMvcExecutorPolicy executorPolicy; - private final Clock clock; - - /** - * Creates the adapter. - * - * @param profile transport profile in force - * @param validator pre-parse envelope limits - * @param executor GraphQL execution seam - * @param executorService threads resolver work runs on - * @param executorPolicy which thread policy {@code executorService} implements - * @param clock clock used to compute the remaining request budget - */ - public GraphQlMvcTransportAdapter( - GraphQlHttpProfile profile, - GraphQlRequestEnvelopeValidator validator, - GraphQlHttpExecutor executor, - ExecutorService executorService, - GraphQlMvcExecutorPolicy executorPolicy, - Clock clock) { - if (profile == null - || validator == null - || executor == null - || executorService == null - || executorPolicy == null - || clock == null) { - throw new IllegalArgumentException("MVC transport adapter dependencies are required"); - } - if (!executorPolicy.blockingAllowed()) { - throw new IllegalArgumentException("the MVC transport requires a blocking-capable executor"); - } - this.profile = profile; - this.validator = validator; - this.executor = executor; - this.executorService = executorService; - this.executorPolicy = executorPolicy; - this.clock = clock; - } - - /** The thread policy resolver work runs under. */ - public GraphQlMvcExecutorPolicy executorPolicy() { - return executorPolicy; - } - - /** - * Handles one HTTP exchange. - * - *

Never throws for a client-caused failure: a transport violation becomes a response with the - * status the profile mandates, so the error contract stays in one place. - */ - public GraphQlHttpResponse handle( - String method, - String contentType, - String accept, - GraphQlHttpRequestEnvelope envelope, - GraphQlRequestContext context) { - - GraphQlHttpResponseFactory responses = GraphQlHttpResponseFactory.preferredV1(); - try { - profile.validateMethod(method); - profile.validateContentType(contentType); - String negotiated = profile.negotiateResponseContentType(accept); - responses = GraphQlHttpResponseFactory.v1(negotiated); - validator.validateEnvelope(envelope); - - GraphQlExecutionOutcome outcome = executeWithinDeadline(envelope, context); - return outcome.failed() - ? responses.fieldError(outcome.data(), outcome.errors()) - : responses.success(outcome.data()); - } catch (GraphQlHttpContractException ex) { - return responses.requestError(ex); - } - } - - private GraphQlExecutionOutcome executeWithinDeadline( - GraphQlHttpRequestEnvelope envelope, GraphQlRequestContext context) { - - Duration remaining = context.deadline().remaining(clock); - if (remaining.isZero() || remaining.isNegative()) { - return timedOut(); - } - - Future pending = - executorService.submit(() -> executor.execute(envelope, context)); - try { - return pending.get(remaining.toMillis(), TimeUnit.MILLISECONDS); - } catch (TimeoutException ex) { - // Interrupt the in-flight work; the propagated deadline is what actually stops the - // downstream database and HTTP calls. - pending.cancel(true); - return timedOut(); - } catch (CancellationException ex) { - return timedOut(); - } catch (ExecutionException ex) { - Throwable cause = ex.getCause(); - if (cause instanceof GraphQlHttpContractException contractFailure) { - throw contractFailure; - } - throw new IllegalStateException("GraphQL execution failed", cause); - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - pending.cancel(true); - return timedOut(); - } - } - - private static GraphQlExecutionOutcome timedOut() { - return GraphQlExecutionOutcome.partial( - Map.of(), - List.of( - Map.of( - "message", - "요청을 처리할 수 없습니다.", - "extensions", - Map.of("code", "REQUEST_TIMEOUT", "category", "TIMEOUT", "retryable", true)))); - } -} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/webflux/GraphQlEventLoopGuard.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/webflux/GraphQlEventLoopGuard.java deleted file mode 100644 index d354a0ad..00000000 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/webflux/GraphQlEventLoopGuard.java +++ /dev/null @@ -1,55 +0,0 @@ -package dev.caskeleton.adapter.inbound.graphql.http.webflux; - -import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfileException; -import dev.caskeleton.adapter.inbound.graphql.policy.ResolverExecutionType; - -/** - * Refuses blocking resolver work on a reactive event loop (design §10). - * - *

A blocking call on an event-loop thread does not fail — it holds one of a handful of threads - * that serve every connection, so the symptom is latency across unrelated requests rather than an - * error on the offending one. The guard turns that into an explicit failure at the point of - * registration or dispatch, and the only way past it is an approved scheduler bridge that moves the - * work off the loop. - */ -public final class GraphQlEventLoopGuard { - - private GraphQlEventLoopGuard() {} - - /** - * Verifies one resolver dispatch. - * - * @param type how the resolver executes - * @param eventLoopThread whether the current thread is a reactive event-loop thread - * @param approvedBridge whether an approved executor or scheduler bridge is in place - * @throws GraphQlExecutionProfileException when blocking work would run on the loop unbridged - */ - public static void verify( - ResolverExecutionType type, boolean eventLoopThread, boolean approvedBridge) { - if (eventLoopThread && type == ResolverExecutionType.BLOCKING && !approvedBridge) { - throw new GraphQlExecutionProfileException("blocking resolver on event loop"); - } - } - - /** - * Whether a thread name belongs to a known reactive event loop. - * - *

Name-based detection keeps the guard usable from a module that does not depend on a specific - * server: Reactor Netty, Netty and Undertow all name their loop threads predictably. - */ - public static boolean isEventLoopThread(String threadName) { - if (threadName == null) { - return false; - } - return threadName.startsWith("reactor-http-nio") - || threadName.startsWith("reactor-tcp-nio") - || threadName.startsWith("nioEventLoopGroup") - || threadName.startsWith("XNIO") - || threadName.contains("-eventLoop-"); - } - - /** Whether the calling thread is a reactive event-loop thread. */ - public static boolean onEventLoop() { - return isEventLoopThread(Thread.currentThread().getName()); - } -} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/webflux/GraphQlWebFluxAutoConfiguration.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/webflux/GraphQlWebFluxAutoConfiguration.java deleted file mode 100644 index 4e138f66..00000000 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/webflux/GraphQlWebFluxAutoConfiguration.java +++ /dev/null @@ -1,64 +0,0 @@ -package dev.caskeleton.adapter.inbound.graphql.http.webflux; - -import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlExecutionOutcome; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpExecutor; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpProfile; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpRequestEnvelope; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestEnvelopeValidator; -import java.time.Clock; -import java.util.function.BiFunction; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; -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; -import org.springframework.web.reactive.function.server.ServerResponse; -import reactor.core.publisher.Mono; - -/** - * Wires the reactive transport when the application runs the WebFlux stack. - * - *

Conditional on a reactive web application, on WebFlux being present and on {@code - * REACTIVE_WEBFLUX} being the selected execution profile — a blocking deployment must never acquire - * a reactive transport implicitly, and vice versa. - */ -@Configuration(proxyBeanMethods = false) -@ConditionalOnClass(ServerResponse.class) -@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) -@ConditionalOnProperty( - prefix = "backend.graphql", - name = "execution-profile", - havingValue = "REACTIVE_WEBFLUX") -public class GraphQlWebFluxAutoConfiguration { - - /** - * Adapts the blocking execution seam onto a reactive one. - * - *

Only registered when the application supplied a {@link GraphQlHttpExecutor} and no reactive - * seam of its own. Execution is deferred rather than invoked eagerly, so subscription — and - * therefore cancellation — controls when the work starts. - */ - @Bean - @ConditionalOnMissingBean(name = "graphQlReactiveExecutor") - @ConditionalOnBean(GraphQlHttpExecutor.class) - public BiFunction< - GraphQlHttpRequestEnvelope, GraphQlRequestContext, Mono> - graphQlReactiveExecutor(GraphQlHttpExecutor executor) { - return (envelope, context) -> Mono.fromCallable(() -> executor.execute(envelope, context)); - } - - /** The reactive transport adapter. */ - @Bean - @ConditionalOnMissingBean - @ConditionalOnBean(GraphQlRequestEnvelopeValidator.class) - public GraphQlWebFluxTransportAdapter graphQlWebFluxTransportAdapter( - GraphQlRequestEnvelopeValidator validator, - BiFunction> - graphQlReactiveExecutor) { - return new GraphQlWebFluxTransportAdapter( - GraphQlHttpProfile.V1, validator, graphQlReactiveExecutor, Clock.systemUTC()); - } -} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/webflux/GraphQlWebFluxTransportAdapter.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/webflux/GraphQlWebFluxTransportAdapter.java deleted file mode 100644 index b2475094..00000000 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/http/webflux/GraphQlWebFluxTransportAdapter.java +++ /dev/null @@ -1,110 +0,0 @@ -package dev.caskeleton.adapter.inbound.graphql.http.webflux; - -import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlExecutionOutcome; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpContractException; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpProfile; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpRequestEnvelope; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponse; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponseFactory; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestEnvelopeValidator; -import java.time.Clock; -import java.time.Duration; -import java.util.List; -import java.util.Map; -import java.util.function.BiFunction; -import reactor.core.publisher.Mono; - -/** - * The reactive transport (design §10, Stable plan Task 19). - * - *

Reactive rather than blocking all the way through: the request deadline is applied with {@code - * timeout}, which cancels the upstream chain, and cancellation is what actually reaches reactive - * data fetchers and downstream publishers. A blocking {@code Future.get} would leave that work - * running after the client had already been answered. - * - *

{@code block()} is never called here, and the request context is carried in the Reactor - * context so it survives operator boundaries and thread hops. - */ -public final class GraphQlWebFluxTransportAdapter { - - private final GraphQlHttpProfile profile; - private final GraphQlRequestEnvelopeValidator validator; - private final BiFunction< - GraphQlHttpRequestEnvelope, GraphQlRequestContext, Mono> - executor; - private final Clock clock; - - /** - * Creates the adapter. - * - * @param profile transport profile in force - * @param validator pre-parse envelope limits - * @param executor reactive GraphQL execution seam - * @param clock clock used to compute the remaining request budget - */ - public GraphQlWebFluxTransportAdapter( - GraphQlHttpProfile profile, - GraphQlRequestEnvelopeValidator validator, - BiFunction> - executor, - Clock clock) { - if (profile == null || validator == null || executor == null || clock == null) { - throw new IllegalArgumentException("reactive transport adapter dependencies are required"); - } - this.profile = profile; - this.validator = validator; - this.executor = executor; - this.clock = clock; - } - - /** - * Handles one HTTP exchange reactively. - * - *

A transport violation becomes a response rather than an error signal, so the status contract - * stays identical to the MVC transport. - */ - public Mono handle( - String method, - String contentType, - String accept, - GraphQlHttpRequestEnvelope envelope, - GraphQlRequestContext context) { - - GraphQlHttpResponseFactory preferred = GraphQlHttpResponseFactory.preferredV1(); - GraphQlHttpResponseFactory responses; - try { - profile.validateMethod(method); - profile.validateContentType(contentType); - responses = GraphQlHttpResponseFactory.v1(profile.negotiateResponseContentType(accept)); - validator.validateEnvelope(envelope); - } catch (GraphQlHttpContractException ex) { - return Mono.just(preferred.requestError(ex)); - } - - GraphQlHttpResponseFactory negotiated = responses; - Duration remaining = context.deadline().remaining(clock); - if (remaining.isZero() || remaining.isNegative()) { - return Mono.just(negotiated.fieldError(Map.of(), timeoutErrors())); - } - - return Mono.defer(() -> executor.apply(envelope, context)) - .timeout(remaining, Mono.just(GraphQlExecutionOutcome.partial(Map.of(), timeoutErrors()))) - .map( - outcome -> - outcome.failed() - ? negotiated.fieldError(outcome.data(), outcome.errors()) - : negotiated.success(outcome.data())) - .contextWrite( - reactorContext -> reactorContext.put(GraphQlRequestContext.CONTEXT_KEY, context)); - } - - private static List> timeoutErrors() { - return List.of( - Map.of( - "message", - "요청을 처리할 수 없습니다.", - "extensions", - Map.of("code", "REQUEST_TIMEOUT", "category", "TIMEOUT", "retryable", true))); - } -} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlAdvancedModule.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlAdvancedModule.java new file mode 100644 index 00000000..4cedce4e --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlAdvancedModule.java @@ -0,0 +1,156 @@ +package dev.caskeleton.adapter.inbound.graphql.moduleboundary; + +import java.util.Arrays; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.stream.Collectors; + +/** + * The Advanced GraphQL capability modules and their allowed internal dependencies. + * + *

Advanced consumes Stable, never the other way round. If a Stable module depended on an + * Advanced one, every Stable deployment would carry the Advanced capability's code and + * configuration surface, and the feature flag would be the only thing standing between an ordinary + * service and a subscription runtime. {@code GraphQlAdvancedDependencyRules} checks that direction + * against this declaration; {@code GraphQlModuleBoundaryTest} checks it against the real imports. + * + *

Advanced modules are {@link GraphQlModulePurity#CORE} by default: the capabilities are state + * machines and policies, and the transport binding for the ones that need it stays in the Stable + * {@code http} seam. Exceptions are declared per module rather than assumed — {@code + * advanced.codegen} is the one that has to compile a schema to do its job. + */ +public enum GraphQlAdvancedModule { + + /** Persisted operation administration and removal gating. */ + ADMIN("advanced.admin", "advanced.admin", "advanced.persisted"), + + /** Advanced capability grades, feature flags and the activation guard. */ + BOOTSTRAP("advanced.bootstrap", "advanced.bootstrap", "moduleboundary"), + + /** DataLoader chaining and cycle detection. */ + CHAINING("advanced.chaining", "advanced.chaining", "advanced.bootstrap"), + + /** + * Client code generation planning, operation validation and generated-source boundary rules. + * + *

The one Advanced module that is not framework free. Validating a client operation means + * compiling the schema and running GraphQL Java's validator — there is no way to answer "does + * this field exist" without the type system, and a hand-rolled approximation would be the kind of + * check that passes on exactly the documents that break. + */ + CODEGEN("advanced.codegen", "advanced.codegen", GraphQlModulePurity.FRAMEWORK_BOUND, "compat"), + + /** Federated schema composition gating. */ + COMPOSITION("advanced.composition", "advanced.composition", "advanced.federation"), + + /** Federation entity resolution. */ + FEDERATION("advanced.federation", "advanced.federation", "advanced.bootstrap"), + + /** The draft HTTP GET operation profile. */ + GET("advanced.get", "advanced.get", "http"), + + /** Incremental delivery (`@defer`/`@stream`) compatibility gating. */ + INCREMENTAL("advanced.incremental", "advanced.incremental", "cost", "execution"), + + /** Persisted operation registry and the interceptor that enforces it. */ + PERSISTED( + "advanced.persisted", "advanced.persisted", "advanced.bootstrap", "execution", "policy"), + + /** The Advanced release gate. */ + RELEASE("advanced.release", "advanced.release", "advanced.bootstrap", "release"), + + /** Subscription snapshot replay and live handoff. */ + REPLAY( + "advanced.replay", + "advanced.replay", + "advanced.security", + "advanced.subscription", + "pagination"), + + /** The experimental RSocket transport route policy. */ + RSOCKET("advanced.rsocket", "advanced.rsocket", "advanced.bootstrap", "error", "policy"), + + /** Transport authentication for long-lived Advanced connections. */ + SECURITY("advanced.security", "advanced.security"), + + /** Server-sent event connection policy. */ + SSE("advanced.sse", "advanced.sse", "advanced.bootstrap", "advanced.subscription", "http"), + + /** Subscription execution policy, buffering and backpressure. */ + SUBSCRIPTION( + "advanced.subscription", + "advanced.subscription", + "advanced.security", + "api", + "http", + "security"), + + /** The GraphQL over WebSocket protocol state machine. */ + WEBSOCKET("advanced.websocket", "advanced.websocket", "advanced.bootstrap"); + + private final String id; + private final String packageSuffix; + private final GraphQlModulePurity purity; + + /** + * Populated only from {@link Set#of}, which is genuinely immutable. Error Prone's {@code + * ImmutableEnumChecker} recognises Guava's {@code ImmutableSet} but not the JDK's unmodifiable + * factories, and this leaf has no Guava dependency to add for one field. + */ + @SuppressWarnings("ImmutableEnumChecker") + private final Set allowedDependencies; + + GraphQlAdvancedModule(String id, String packageSuffix, String... allowedDependencies) { + this(id, packageSuffix, GraphQlModulePurity.CORE, allowedDependencies); + } + + GraphQlAdvancedModule( + String id, String packageSuffix, GraphQlModulePurity purity, String... allowedDependencies) { + this.id = id; + this.packageSuffix = packageSuffix; + this.purity = purity; + this.allowedDependencies = Set.of(allowedDependencies); + } + + /** The module identifier used on both sides of a declared dependency edge. */ + public String id() { + return id; + } + + /** The fully qualified package that carries this module, including its sub-packages. */ + public String packageName() { + return GraphQlModuleBoundary.PACKAGE_ROOT + "." + packageSuffix; + } + + /** + * Whether this capability may reference framework types. + * + *

Advanced capabilities are policy and state machines, so they are framework free by default + * and the exceptions are declared one by one rather than assumed. + */ + public GraphQlModulePurity purity() { + return purity; + } + + /** The module identifiers this module is allowed to import. */ + public Set allowedDependencies() { + return allowedDependencies; + } + + /** Every Advanced module identifier. */ + public static Set moduleIds() { + return Arrays.stream(values()) + .map(GraphQlAdvancedModule::id) + .collect(Collectors.toUnmodifiableSet()); + } + + /** The declared Advanced dependency edges, keyed by module identifier in deterministic order. */ + public static Map> dependencyEdges() { + Map> edges = new TreeMap<>(); + for (GraphQlAdvancedModule module : values()) { + edges.put(module.id(), module.allowedDependencies()); + } + return Map.copyOf(edges); + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlModuleBoundary.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlModuleBoundary.java new file mode 100644 index 00000000..8d7b467d --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlModuleBoundary.java @@ -0,0 +1,126 @@ +package dev.caskeleton.adapter.inbound.graphql.moduleboundary; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * The declared module map of the GraphQL platform: identities, purity grades and allowed edges. + * + *

This type is the read side of {@link GraphQlStableModule} and {@link GraphQlAdvancedModule}. + * It answers the two questions every boundary check needs — "which module owns this package?" and + * "is this edge declared?" — so the rules never re-implement package-prefix arithmetic. + * + *

Note what it deliberately does not do: it never reads the source tree. Scanning the checkout + * is build-time work and lives in the test source set, because a running application cannot + * meaningfully react to its own source layout and a runtime scan only fails in the environments + * where the sources are absent. + */ +public final class GraphQlModuleBoundary { + + /** The package that carries the whole platform. */ + public static final String PACKAGE_ROOT = deriveRootPackage(); + + private GraphQlModuleBoundary() {} + + /** Every declared module identifier, Stable and Advanced. */ + public static Set allModuleIds() { + return Stream.concat( + GraphQlStableModule.moduleIds().stream(), GraphQlAdvancedModule.moduleIds().stream()) + .collect(Collectors.toUnmodifiableSet()); + } + + /** Every declared dependency edge, Stable and Advanced, in deterministic order. */ + public static Map> dependencyEdges() { + Map> edges = new TreeMap<>(); + edges.putAll(GraphQlStableModule.dependencyEdges()); + edges.putAll(GraphQlAdvancedModule.dependencyEdges()); + return Map.copyOf(edges); + } + + /** Every module that must not reference a framework type. */ + public static Set coreModuleIds() { + return Stream.concat( + GraphQlStableModule.coreModuleIds().stream(), + Arrays.stream(GraphQlAdvancedModule.values()) + .filter(module -> module.purity() == GraphQlModulePurity.CORE) + .map(GraphQlAdvancedModule::id)) + .collect(Collectors.toUnmodifiableSet()); + } + + /** The declared package of every module, keyed by identifier. */ + public static Map packagesById() { + Map packages = new LinkedHashMap<>(GraphQlStableModule.packagesById()); + for (GraphQlAdvancedModule module : GraphQlAdvancedModule.values()) { + packages.put(module.id(), module.packageName()); + } + return Map.copyOf(packages); + } + + /** + * The module owning a package. + * + *

Ownership is by longest declared package prefix, so {@code ...graphql.http.webflux} belongs + * to {@code http} and {@code ...graphql.advanced.sse} belongs to {@code advanced.sse} rather than + * to a hypothetical {@code advanced} module. + * + *

The root module is the exception: it owns the platform root package itself and nothing below + * it. Letting it absorb descendants would make every possible package "declared", and the + * unregistered-package rule — the one that forces a new sub-package to be given an identity and + * an edge set before it can ship — would silently never fire. + * + * @param packageName a fully qualified package name + * @return the owning module identifier, or empty when the package is outside the platform or + * belongs to no declared module + */ + public static Optional moduleIdForPackage(String packageName) { + if (packageName == null || !insidePlatform(packageName)) { + return Optional.empty(); + } + String best = null; + String bestPackage = null; + for (Map.Entry candidate : packagesById().entrySet()) { + String candidatePackage = candidate.getValue(); + boolean owns = + candidatePackage.equals(PACKAGE_ROOT) + ? packageName.equals(PACKAGE_ROOT) + : matches(packageName, candidatePackage); + if (!owns) { + continue; + } + if (bestPackage == null || candidatePackage.length() > bestPackage.length()) { + best = candidate.getKey(); + bestPackage = candidatePackage; + } + } + return Optional.ofNullable(best); + } + + /** Whether a package name belongs to the platform at all. */ + public static boolean insidePlatform(String packageName) { + return packageName != null && matches(packageName, PACKAGE_ROOT); + } + + /** Whether an edge between two declared modules is allowed. */ + public static boolean edgeAllowed(String from, String to) { + if (from == null || to == null || from.equals(to)) { + return true; + } + Set allowed = dependencyEdges().get(from); + return allowed != null && allowed.contains(to); + } + + private static boolean matches(String packageName, String candidate) { + return packageName.equals(candidate) || packageName.startsWith(candidate + "."); + } + + private static String deriveRootPackage() { + String self = GraphQlModuleBoundary.class.getPackageName(); + return self.substring(0, self.lastIndexOf('.')); + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlModulePurity.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlModulePurity.java new file mode 100644 index 00000000..e86b13a0 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlModulePurity.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.inbound.graphql.moduleboundary; + +/** + * Whether a platform module is allowed to reference transport, framework or GraphQL engine types. + * + *

The split is what keeps the policy model portable. A {@link #CORE} module holds the decision + * ("this document is too deep", "this cursor is out of scope") as plain Java, so the same rule can + * be exercised by a unit test, reused from a different transport, or promoted to its own leaf + * without dragging a servlet container along. A {@link #FRAMEWORK_BOUND} module is the seam where + * that decision meets Spring, GraphQL Java or Reactor. + */ +public enum GraphQlModulePurity { + + /** Java standard library only: no Spring, GraphQL Java, Reactor, Micrometer or Jakarta types. */ + CORE, + + /** May bind to the framework, because it is the adapter seam that has to. */ + FRAMEWORK_BOUND +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlStableModule.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlStableModule.java new file mode 100644 index 00000000..ed535918 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlStableModule.java @@ -0,0 +1,224 @@ +package dev.caskeleton.adapter.inbound.graphql.moduleboundary; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.stream.Collectors; + +/** + * The Stable GraphQL platform modules, their purity grade and their allowed internal dependencies. + * + *

The platform splits into bounded sub-packages inside one registered leaf rather than into + * Gradle leaves (see {@code CLAUDE.md}). That choice only holds if the boundaries are machine + * checked, so this enum is the declared identity: each constant names a module, the package that + * carries it, whether it may touch the framework, and exactly which other modules it may import. + * {@code GraphQlModuleBoundaryTest} scans the real source tree and fails when the tree and this + * declaration disagree in either direction — an undeclared edge, or an undeclared package. + * + *

Declaring the edge set is what makes the leaf split reversible: each constant is already + * shaped like a leaf specification, so promoting a module to its own Gradle path is a registry edit + * rather than an archaeology exercise. + */ +public enum GraphQlStableModule { + + /** Skeleton transport surface: health schema controller and the Spring exception resolver. */ + TRANSPORT_ROOT("root", "", GraphQlModulePurity.FRAMEWORK_BOUND), + + /** Shared identifiers and value types every other module is allowed to speak. */ + API("api", "api", GraphQlModulePurity.CORE), + + /** Resolver and controller boundary rules enforced by reflection over the compiled package. */ + ARCHITECTURE("architecture", "architecture", GraphQlModulePurity.FRAMEWORK_BOUND), + + /** Spring auto-configuration, configuration properties and startup validation. */ + AUTOCONFIGURE( + "autoconfigure", + "autoconfigure", + GraphQlModulePurity.FRAMEWORK_BOUND, + "api", + "architecture", + "context", + "cost", + "error", + "execution", + "http", + "observation", + "policy", + "runtime", + "scalar", + "schema", + "security"), + + /** Schema compatibility comparison and deprecation gating. */ + COMPAT("compat", "compat", GraphQlModulePurity.FRAMEWORK_BOUND, "schema"), + + /** Per-request execution context: actor, tenant and deadline. */ + CONTEXT("context", "context", GraphQlModulePurity.CORE, "api"), + + /** Document shape analysis, complexity scoring and runtime budget tracking. */ + COST("cost", "cost", GraphQlModulePurity.FRAMEWORK_BOUND, "api", "execution", "policy"), + + /** Batch loading, chunking and per-request DataLoader registry. */ + DATALOADER("dataloader", "dataloader", GraphQlModulePurity.CORE, "context"), + + /** Wire error shape, error categories and null propagation contract. */ + ERROR("error", "error", GraphQlModulePurity.CORE, "api", "http"), + + /** Execution pipeline stages, operation naming, timeouts and document caching. */ + EXECUTION("execution", "execution", GraphQlModulePurity.CORE, "api", "context", "policy"), + + /** Fetch profile classification and registry. */ + FETCH("fetch", "fetch", GraphQlModulePurity.CORE, "api"), + + /** + * HTTP profile, request envelope validation, media negotiation and status mapping. + * + *

{@code CORE} since the custom MVC and WebFlux transport adapters were removed: what remains + * is the platform's opinion about the HTTP contract, expressed as plain values. The route belongs + * to Spring, and the seam that applies these decisions to it lives in {@code runtime}. + */ + HTTP("http", "http", GraphQlModulePurity.CORE, "api", "context", "execution", "policy"), + + /** This module: the declared module identities and their allowed edges. */ + MODULE_BOUNDARY("moduleboundary", "moduleboundary", GraphQlModulePurity.CORE), + + /** Mutation idempotency context and result mapping. */ + MUTATION("mutation", "mutation", GraphQlModulePurity.CORE, "api", "context", "http"), + + /** Observation conventions, metric cardinality policy and sensitive attribute filtering. */ + OBSERVATION( + "observation", + "observation", + GraphQlModulePurity.CORE, + "api", + "cost", + "dataloader", + "policy"), + + /** Connection assembly and signed keyset cursors. */ + PAGINATION("pagination", "pagination", GraphQlModulePurity.CORE, "policy"), + + /** Client and operation policy values shared by the enforcement modules. */ + POLICY("policy", "policy", GraphQlModulePurity.CORE, "api"), + + /** Release gate, performance and fault scenario catalogues. */ + RELEASE("release", "release", GraphQlModulePurity.CORE), + + /** The executable pipeline and the Spring GraphQL seams that run it on a real request. */ + RUNTIME( + "runtime", + "runtime", + GraphQlModulePurity.FRAMEWORK_BOUND, + "api", + "context", + "cost", + "dataloader", + "error", + "execution", + "http", + "policy", + "security"), + + /** Custom scalar coercions and the runtime wiring configurer that registers them. */ + SCALAR("scalar", "scalar", GraphQlModulePurity.FRAMEWORK_BOUND, "schema"), + + /** Schema assembly, mapping inspection and scalar manifest. */ + SCHEMA("schema", "schema", GraphQlModulePurity.FRAMEWORK_BOUND, "api"), + + /** Authentication context, authorization policy and tenant isolation. */ + SECURITY("security", "security", GraphQlModulePurity.CORE, "api", "context", "error"), + + /** Cross-module contract suites and integration fixtures. */ + TESTKIT( + "testkit", + "testkit", + GraphQlModulePurity.CORE, + "api", + "compat", + "context", + "dataloader", + "error", + "execution", + "http", + "pagination", + "policy", + "schema", + "security"); + + private final String id; + private final String packageSuffix; + private final GraphQlModulePurity purity; + + /** + * Populated only from {@link Set#of}, which is genuinely immutable. Error Prone's {@code + * ImmutableEnumChecker} recognises Guava's {@code ImmutableSet} but not the JDK's unmodifiable + * factories, and this leaf has no Guava dependency to add for one field. + */ + @SuppressWarnings("ImmutableEnumChecker") + private final Set allowedDependencies; + + GraphQlStableModule( + String id, String packageSuffix, GraphQlModulePurity purity, String... allowedDependencies) { + this.id = id; + this.packageSuffix = packageSuffix; + this.purity = purity; + this.allowedDependencies = Set.of(allowedDependencies); + } + + /** The module identifier used on both sides of a declared dependency edge. */ + public String id() { + return id; + } + + /** The fully qualified package that carries this module, including its sub-packages. */ + public String packageName() { + return packageSuffix.isEmpty() + ? GraphQlModuleBoundary.PACKAGE_ROOT + : GraphQlModuleBoundary.PACKAGE_ROOT + "." + packageSuffix; + } + + /** Whether this module may reference framework types. */ + public GraphQlModulePurity purity() { + return purity; + } + + /** The module identifiers this module is allowed to import. */ + public Set allowedDependencies() { + return allowedDependencies; + } + + /** Every Stable module identifier. */ + public static Set moduleIds() { + return Arrays.stream(values()) + .map(GraphQlStableModule::id) + .collect(Collectors.toUnmodifiableSet()); + } + + /** The declared Stable dependency edges, keyed by module identifier in deterministic order. */ + public static Map> dependencyEdges() { + Map> edges = new TreeMap<>(); + for (GraphQlStableModule module : values()) { + edges.put(module.id(), module.allowedDependencies()); + } + return Map.copyOf(edges); + } + + /** Every Stable module that must not reference a framework type. */ + public static Set coreModuleIds() { + return Arrays.stream(values()) + .filter(module -> module.purity() == GraphQlModulePurity.CORE) + .map(GraphQlStableModule::id) + .collect(Collectors.toUnmodifiableSet()); + } + + /** The declared package name of every Stable module, keyed by identifier. */ + public static Map packagesById() { + Map packages = new LinkedHashMap<>(); + for (GraphQlStableModule module : values()) { + packages.put(module.id(), module.packageName()); + } + return Map.copyOf(packages); + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlCanonicalInput.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlCanonicalInput.java new file mode 100644 index 00000000..58abd9df --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlCanonicalInput.java @@ -0,0 +1,87 @@ +package dev.caskeleton.adapter.inbound.graphql.mutation; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/** + * The one canonical serialization a mutation input is fingerprinted from. + * + *

The previous form sorted top-level keys and joined {@code key=value;}, which two different + * inputs could produce identically: {@code {a: "b;c=d"}} and {@code {a: "b", c: "d"}} both + * canonicalise to {@code a=b;c=d;}. Two different requests sharing a fingerprint is an idempotency + * collision — the second one is answered with the first one's result. + * + *

Three properties remove the ambiguity. Every value carries a type tag, so the string {@code + * "1"} and the number {@code 1} never collide. Every string is length-prefixed, so no character is + * a separator and no value can imitate the framing. And nesting is sorted recursively, so a map + * inside a list inside a map still canonicalises the same way whatever order it arrived in. + * + *

Numbers normalise through {@link BigDecimal}, because {@code 1}, {@code 1.0} and {@code 1e0} + * are the same value and a client library is free to pick any of them for the same field. + */ +public final class GraphQlCanonicalInput { + + /** Deepest input nesting this serializer will walk before refusing. */ + public static final int MAXIMUM_DEPTH = 64; + + private GraphQlCanonicalInput() {} + + /** + * Canonicalises a decoded input value. + * + * @param value a decoded JSON value: map, list, string, number, boolean or {@code null} + * @throws GraphQlMutationContractException when nesting exceeds {@link #MAXIMUM_DEPTH} + */ + public static String of(Object value) { + StringBuilder canonical = new StringBuilder(); + write(canonical, value, 0); + return canonical.toString(); + } + + private static void write(StringBuilder out, Object value, int depth) { + if (depth > MAXIMUM_DEPTH) { + throw new GraphQlMutationContractException( + "mutation input nesting exceeds the canonical limit"); + } + if (value == null) { + out.append("z;"); + return; + } + if (value instanceof Map map) { + out.append("m").append(map.size()).append(';'); + // Recursive, not just top level: an unsorted nested map would fingerprint differently for + // the same request depending on the client's serialization order. + Map sorted = new TreeMap<>(); + map.forEach((key, entry) -> sorted.put(String.valueOf(key), entry)); + sorted.forEach( + (key, entry) -> { + writeString(out, key); + write(out, entry, depth + 1); + }); + return; + } + if (value instanceof List list) { + // Order-sensitive on purpose: a list is a sequence, and two orders are two different inputs. + out.append("l").append(list.size()).append(';'); + list.forEach(element -> write(out, element, depth + 1)); + return; + } + if (value instanceof Boolean flag) { + out.append('b').append(flag ? '1' : '0').append(';'); + return; + } + if (value instanceof Number number) { + out.append('n'); + writeString(out, new BigDecimal(number.toString()).stripTrailingZeros().toPlainString()); + return; + } + out.append('s'); + writeString(out, String.valueOf(value)); + } + + private static void writeString(StringBuilder out, String value) { + out.append(value.length()).append(':').append(value).append(';'); + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationContractValidator.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationContractValidator.java index de2163b3..e260e2d0 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationContractValidator.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationContractValidator.java @@ -23,12 +23,16 @@ public final class GraphQlMutationContractValidator { */ public static void requireSingleUseCase( GraphQlMutationCoordinate coordinate, int useCaseInvocations) { - if (useCaseInvocations > 1) { + // Exactly one, not "at most one". Zero invocations means the mutation resolved without going + // through the Application at all, which is the shape where transaction and authorization + // decisions end up in the resolver — the thing this contract exists to prevent. It read as a + // passing check because the interesting number is on the other side of the boundary. + if (useCaseInvocations != 1) { throw new GraphQlMutationContractException( coordinate.value() + " calls " + useCaseInvocations - + " use cases; model the atomic operation as one use case instead"); + + " use cases; an atomic mutation is exactly one use case in the Application layer"); } } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationFingerprint.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationFingerprint.java index bc7e8f5e..f200f388 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationFingerprint.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationFingerprint.java @@ -5,7 +5,6 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.HexFormat; import java.util.Map; -import java.util.TreeMap; /** * A fingerprint of a mutation's normalised input. @@ -25,12 +24,16 @@ public record GraphQlMutationFingerprint(String value) { } } - /** Fingerprints a normalised input map. */ + /** + * Fingerprints a decoded input map. + * + *

Canonicalised by {@link GraphQlCanonicalInput}, which type-tags and length-frames every + * value and sorts nesting recursively. The previous form joined {@code key=value;} over top-level + * keys only, so {@code {a: "b;c=d"}} and {@code {a: "b", c: "d"}} produced the same fingerprint — + * and an idempotent retry of one returned the other's result. + */ public static GraphQlMutationFingerprint of(Map normalizedInput) { - StringBuilder canonical = new StringBuilder(); - new TreeMap<>(normalizedInput) - .forEach((key, value) -> canonical.append(key).append('=').append(value).append(';')); - return sha256(canonical.toString()); + return sha256(GraphQlCanonicalInput.of(normalizedInput)); } /** Fingerprints already-canonical text. */ diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationIdempotencyContext.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationIdempotencyContext.java index acf8ff4e..b379c2f1 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationIdempotencyContext.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationIdempotencyContext.java @@ -3,22 +3,32 @@ package dev.caskeleton.adapter.inbound.graphql.mutation; /** * The full scope one idempotency key applies to (design §15). * - *

Scoped by actor, mutation and normalised input together — not by key alone. A key scoped only - * to itself would let one client's retry return another client's result, and would let the same key - * stand for two different requests. + *

Scoped by actor, tenant, mutation, contract version and normalised input together — not by key + * alone. A key scoped only to itself would let one client's retry return another client's result, + * and would let the same key stand for two different requests. + * + *

Tenant is part of the scope because actor identity does not imply it: the same service account + * acting for two tenants would otherwise share one idempotency namespace, and a retry issued for + * one tenant could be answered with the other tenant's stored result. Contract version is part of + * it because a mutation whose input or semantics changed is a different operation — replaying the + * old result against the new contract is the silent wrong answer versioning exists to prevent. * *

This is context handed to the Application's idempotency capability. The platform does not * implement replay, record storage or locking: those need transactional guarantees the transport * layer cannot give. * * @param actorFingerprint non-reversible actor identity + * @param tenantFingerprint non-reversible tenant identity * @param coordinate the mutation the key belongs to + * @param contractVersion the mutation contract version the key was issued under * @param key the client-supplied key * @param fingerprint fingerprint of the normalised input */ public record GraphQlMutationIdempotencyContext( String actorFingerprint, + String tenantFingerprint, GraphQlMutationCoordinate coordinate, + String contractVersion, GraphQlIdempotencyKey key, GraphQlMutationFingerprint fingerprint) { @@ -26,6 +36,12 @@ public record GraphQlMutationIdempotencyContext( if (actorFingerprint == null || actorFingerprint.isBlank()) { throw new IllegalArgumentException("actor fingerprint is required"); } + if (tenantFingerprint == null || tenantFingerprint.isBlank()) { + throw new IllegalArgumentException("tenant fingerprint is required"); + } + if (contractVersion == null || contractVersion.isBlank()) { + throw new IllegalArgumentException("mutation contract version is required"); + } if (coordinate == null || key == null || fingerprint == null) { throw new IllegalArgumentException( "idempotency scope requires coordinate, key and fingerprint"); @@ -35,10 +51,13 @@ public record GraphQlMutationIdempotencyContext( /** Creates the scope. */ public static GraphQlMutationIdempotencyContext of( String actorFingerprint, + String tenantFingerprint, GraphQlMutationCoordinate coordinate, + String contractVersion, GraphQlIdempotencyKey key, GraphQlMutationFingerprint fingerprint) { - return new GraphQlMutationIdempotencyContext(actorFingerprint, coordinate, key, fingerprint); + return new GraphQlMutationIdempotencyContext( + actorFingerprint, tenantFingerprint, coordinate, contractVersion, key, fingerprint); } /** @@ -55,9 +74,22 @@ public record GraphQlMutationIdempotencyContext( /** * The storage scope for the Application's idempotency record. * - *

Uses the actor fingerprint rather than the actor, so the scope can be persisted and logged. + *

Uses fingerprints rather than the raw actor and tenant, so the scope can be persisted and + * logged. Length-framed for the same reason the canonical input form is: joining five + * caller-influenced values with a separator lets one of them contain the separator and collide + * with a different scope. */ public String scope() { - return actorFingerprint + "|" + coordinate.value() + "|" + key.value(); + StringBuilder scope = new StringBuilder(); + frame(scope, actorFingerprint); + frame(scope, tenantFingerprint); + frame(scope, coordinate.value()); + frame(scope, contractVersion); + frame(scope, key.value()); + return scope.toString(); + } + + private static void frame(StringBuilder out, String value) { + out.append(value.length()).append(':').append(value).append('|'); } } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationIdempotencyInterceptor.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationIdempotencyInterceptor.java index 2cc29589..ae63dea8 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationIdempotencyInterceptor.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationIdempotencyInterceptor.java @@ -31,6 +31,7 @@ public final class GraphQlMutationIdempotencyInterceptor { public static Optional from( GraphQlRequestContext context, GraphQlMutationCoordinate coordinate, + String contractVersion, Map extensions, Map normalizedInput) { @@ -45,7 +46,9 @@ public final class GraphQlMutationIdempotencyInterceptor { return Optional.of( GraphQlMutationIdempotencyContext.of( context.actor().fingerprint(), + context.tenant().fingerprint(), coordinate, + contractVersion, new GraphQlIdempotencyKey(key), GraphQlMutationFingerprint.of(normalizedInput))); } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlOperationNameCardinality.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlOperationNameCardinality.java new file mode 100644 index 00000000..4fb7086a --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlOperationNameCardinality.java @@ -0,0 +1,74 @@ +package dev.caskeleton.adapter.inbound.graphql.observation; + +import dev.caskeleton.adapter.inbound.graphql.api.GraphQlOperationName; +import java.util.Set; + +/** + * Decides which operation names are allowed to become metric labels. + * + *

{@link GraphQlOperationName} bounds an operation name's syntax and length, which is a + * different property from bounding how many distinct ones exist. A client is free to send {@code + * Query0000001}, {@code Query0000002} and so on indefinitely: every one is valid, and every one + * used to become its own time series. That is a metrics backend brought down by a well-formed + * client, and the tag that did it looked bounded because a regular expression was checking it. + * + *

So the label is drawn from a set the deployment declares, not from the request. Anything + * outside it collapses to {@link #UNREGISTERED} — the request is still counted, still timed and + * still attributed to its type, profile and outcome; only the one unbounded coordinate is dropped. + * + *

The default is an empty registry, which collapses every named operation. Defaulting the other + * way would mean every deployment that never thought about this ships the unbounded behaviour, and + * a cardinality bound that is opt-in is a cardinality bound nobody has. + */ +public final class GraphQlOperationNameCardinality { + + /** The label used for any operation the deployment did not register. */ + public static final String UNREGISTERED = "other"; + + private final Set registered; + + /** + * Creates the policy. + * + * @param registered operation names the deployment knows, typically the persisted registry's + */ + public GraphQlOperationNameCardinality(Set registered) { + if (registered == null) { + throw new IllegalArgumentException("registered operation names are required"); + } + this.registered = Set.copyOf(registered); + } + + /** A policy that collapses every named operation, for a deployment with no registry. */ + public static GraphQlOperationNameCardinality collapsingAll() { + return new GraphQlOperationNameCardinality(Set.of()); + } + + /** + * The bounded label for one operation. + * + * @param operationName the validated name, or {@code null} for a permitted anonymous operation + * @return the registered name, the anonymous value, or {@link #UNREGISTERED} + */ + public String labelFor(GraphQlOperationName operationName) { + if (operationName == null) { + return GraphQlOperationName.ANONYMOUS_OBSERVATION_VALUE; + } + return registered.contains(operationName.value()) ? operationName.value() : UNREGISTERED; + } + + /** + * The number of distinct labels this policy can ever produce. + * + *

Stated as a number because "bounded" is a claim an operator should be able to check against + * their backend's series budget before enabling the tag. + */ + public int distinctLabels() { + return registered.size() + 2; + } + + /** The registered operation names. */ + public Set registered() { + return registered; + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlRequestObservationConvention.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlRequestObservationConvention.java index 1bc2e35b..af5d6ada 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlRequestObservationConvention.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlRequestObservationConvention.java @@ -10,32 +10,57 @@ import java.util.Map; /** * Tags for the {@code graphql.request} observation (design §22). * - *

Everything here is bounded by construction: a validated operation name, an enum, a bounded - * client profile and pre-computed buckets. Depth and complexity are bucketed rather than reported - * exactly, because the exact numbers are effectively continuous and would create a new time series - * per request. + *

Everything here is bounded by construction: an enum, a bounded client profile, pre-computed + * buckets, and an operation name drawn from the deployment's registry rather than from the request. + * Depth and complexity are bucketed rather than reported exactly, because the exact numbers are + * effectively continuous and would create a new time series per request. + * + *

The operation name was the exception, and it was the one that mattered: it was tagged raw, on + * the strength of a regular expression that bounds its syntax and says nothing about how many + * distinct names a client may invent. {@link GraphQlOperationNameCardinality} closes it. */ public final class GraphQlRequestObservationConvention { private final GraphQlSensitiveAttributeFilter filter; + private final GraphQlOperationNameCardinality operationNames; + + /** + * Creates the convention, collapsing every operation name. + * + * @param filter attribute allowlist and sensitivity filter + */ + public GraphQlRequestObservationConvention(GraphQlSensitiveAttributeFilter filter) { + this(filter, GraphQlOperationNameCardinality.collapsingAll()); + } /** * Creates the convention. * * @param filter attribute allowlist and sensitivity filter + * @param operationNames which operation names may become labels */ - public GraphQlRequestObservationConvention(GraphQlSensitiveAttributeFilter filter) { + public GraphQlRequestObservationConvention( + GraphQlSensitiveAttributeFilter filter, GraphQlOperationNameCardinality operationNames) { if (filter == null) { throw new IllegalArgumentException("attribute filter is required"); } + if (operationNames == null) { + throw new IllegalArgumentException("operation name cardinality policy is required"); + } this.filter = filter; + this.operationNames = operationNames; } - /** A convention using the standard filter. */ + /** A convention using the standard filter and no registered operation names. */ public static GraphQlRequestObservationConvention standard() { return new GraphQlRequestObservationConvention(GraphQlSensitiveAttributeFilter.standard()); } + /** How many distinct operation-name labels this convention can produce. */ + public int distinctOperationNameLabels() { + return operationNames.distinctLabels(); + } + /** The observation name. */ public String name() { return GraphQlObservationNames.REQUEST; @@ -65,11 +90,7 @@ public final class GraphQlRequestObservationConvention { int depth) { Map tags = new LinkedHashMap<>(); - tags.put( - "graphql.operation.name", - operationName == null - ? GraphQlOperationName.ANONYMOUS_OBSERVATION_VALUE - : operationName.value()); + tags.put("graphql.operation.name", operationNames.labelFor(operationName)); tags.put("graphql.operation.type", operationType.name()); tags.put("graphql.client.profile", clientProfile.value()); tags.put("graphql.persisted", Boolean.toString(persisted)); diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlConnectionAssembler.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlConnectionAssembler.java index f194e2c0..b8532097 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlConnectionAssembler.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlConnectionAssembler.java @@ -17,6 +17,7 @@ public final class GraphQlConnectionAssembler { private final GraphQlCursorCodec codec; private final String queryProfile; private final String filterFingerprint; + private final String tenantScope; /** * Creates the assembler. @@ -24,23 +25,14 @@ public final class GraphQlConnectionAssembler { * @param codec signs the cursors it issues * @param queryProfile the query cursors will be bound to * @param filterFingerprint the filter cursors will be bound to + * @param tenantScope opaque fingerprint of the caller scope cursors will be bound to */ public GraphQlConnectionAssembler( - GraphQlCursorCodec codec, String queryProfile, String filterFingerprint) { + GraphQlCursorCodec codec, String queryProfile, String filterFingerprint, String tenantScope) { this.codec = Objects.requireNonNull(codec); this.queryProfile = Objects.requireNonNull(queryProfile); this.filterFingerprint = Objects.requireNonNull(filterFingerprint); - } - - /** An assembler with a fixed test key, for contract tests. */ - public static GraphQlConnectionAssembler forTests() { - return new GraphQlConnectionAssembler( - HmacGraphQlCursorCodec.testCodec( - GraphQlCursorPayload.DEFAULT_KEY_ID, - "test-cursor-secret-test-cursor-secret" - .getBytes(java.nio.charset.StandardCharsets.UTF_8)), - "test-profile", - "test-filter"); + this.tenantScope = Objects.requireNonNull(tenantScope, "cursor tenant scope is required"); } /** @@ -93,16 +85,24 @@ public final class GraphQlConnectionAssembler { private String cursorFor( T node, Function> keysetOf, String direction) { return codec.encode( - GraphQlCursorPayload.of(queryProfile, direction, keysetOf.apply(node), filterFingerprint)); + GraphQlCursorPayload.issue( + queryProfile, direction, keysetOf.apply(node), filterFingerprint, tenantScope)); } /** - * Decodes the cursor a request supplied, checking it belongs to this query and filter. + * Decodes the cursor a request supplied, checking it belongs to this query, filter, direction and + * caller scope. + * + *

The direction comes from the request rather than from the cursor: a forward cursor replayed + * on a backward request used to be accepted, and the page it resumed from was the wrong side of + * the boundary. * * @throws GraphQlCursorException when it does not */ public java.util.Optional decodeRequestCursor( GraphQlConnectionRequest request) { - return request.cursor().map(cursor -> codec.decode(cursor, queryProfile, filterFingerprint)); + GraphQlCursorScope expected = + new GraphQlCursorScope(queryProfile, filterFingerprint, request.direction(), tenantScope); + return request.cursor().map(cursor -> codec.decode(cursor, expected)); } } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorCodec.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorCodec.java index 7d6f1db4..0ea21e61 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorCodec.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorCodec.java @@ -3,23 +3,25 @@ package dev.caskeleton.adapter.inbound.graphql.pagination; /** * Encodes and decodes cursors. * - *

Decoding takes the expected query profile and filter fingerprint, because verifying a - * signature only proves the server issued the cursor — not that it issued it for this - * query. Both checks together are what make a cursor safe to accept. + *

Decoding takes the whole expected scope, because verifying a signature only proves the server + * issued the cursor — not that it issued it for this query, this filter, this direction and this + * tenant. The signature and the scope together are what make a cursor safe to accept. */ public interface GraphQlCursorCodec { - /** Encodes and signs a payload into an opaque cursor. */ + /** + * Encodes and signs a payload into an opaque cursor. + * + *

The signing key is the codec's active one; a payload cannot choose it. + */ String encode(GraphQlCursorPayload payload); /** * Verifies and decodes a cursor. * * @param cursor the opaque cursor - * @param expectedQueryProfile the query the cursor is being used for - * @param expectedFilterFingerprint the filter the cursor is being used under - * @throws GraphQlCursorException on any mismatch, bad signature or unknown version or key + * @param expected what this request requires the cursor to have been issued for + * @throws GraphQlCursorException on any mismatch, bad signature, unknown version or unknown key */ - GraphQlCursorPayload decode( - String cursor, String expectedQueryProfile, String expectedFilterFingerprint); + GraphQlCursorPayload decode(String cursor, GraphQlCursorScope expected); } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorFraming.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorFraming.java new file mode 100644 index 00000000..97c3e9f8 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorFraming.java @@ -0,0 +1,67 @@ +package dev.caskeleton.adapter.inbound.graphql.pagination; + +import java.util.ArrayList; +import java.util.List; + +/** + * Length-prefixed framing for the signed cursor envelope. + * + *

The v1 envelope joined fields with {@code |} and pairs with {@code ;} and {@code =}, escaping + * those characters inside keyset values. It did not survive a round trip: the decoder split on the + * delimiters before unescaping, so an escaped separator in a sort value tore the field + * apart, and three fields — query profile, filter fingerprint, key id — were never escaped at all. + * A sort value containing a pipe was enough to make a legitimately issued cursor unreadable. + * + *

Framing removes the problem rather than escaping around it. Each field is written as its + * length, a colon, and the value, so the reader knows exactly how far to read and no character is + * special. Lengths count {@code char} units, which is what {@code substring} consumes, so a + * surrogate pair frames and reads back identically. + */ +public final class GraphQlCursorFraming { + + /** Ceiling on fields in one envelope, so a hostile token cannot allocate without bound. */ + public static final int MAXIMUM_FIELDS = 256; + + private GraphQlCursorFraming() {} + + /** Appends one length-prefixed field. */ + public static void write(StringBuilder out, String value) { + String safe = value == null ? "" : value; + out.append(safe.length()).append(':').append(safe); + } + + /** + * Reads every length-prefixed field, requiring the body to be consumed exactly. + * + * @param framed the framed body, without the version prefix + * @throws GraphQlCursorException when the framing is malformed, truncated or over-long + */ + public static List readAll(String framed) { + List fields = new ArrayList<>(); + int cursor = 0; + while (cursor < framed.length()) { + if (fields.size() == MAXIMUM_FIELDS) { + throw new GraphQlCursorException("cursor envelope has too many fields"); + } + int separator = framed.indexOf(':', cursor); + if (separator < 0) { + throw new GraphQlCursorException("malformed cursor"); + } + int length; + try { + length = Integer.parseInt(framed.substring(cursor, separator)); + } catch (NumberFormatException malformed) { + throw new GraphQlCursorException("malformed cursor"); + } + int valueStart = separator + 1; + // A declared length longer than what remains is the truncation case; reading it would throw + // StringIndexOutOfBounds instead of rejecting the token. + if (length < 0 || valueStart + length > framed.length()) { + throw new GraphQlCursorException("malformed cursor"); + } + fields.add(framed.substring(valueStart, valueStart + length)); + cursor = valueStart + length; + } + return List.copyOf(fields); + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorKeyRing.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorKeyRing.java index 531442b8..52a1850d 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorKeyRing.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorKeyRing.java @@ -55,9 +55,14 @@ public final class GraphQlCursorKeyRing { return activeKeyId; } - /** Key identities that can still verify a cursor. */ + /** + * Key identities that can still verify a cursor. + * + *

A copy. {@code keySet()} is a live view of the backing map, so handing it out let a caller + * remove a key identity from the ring — retiring a signing key by accident, through a getter. + */ public Set keyIds() { - return keys.keySet(); + return Set.copyOf(keys.keySet()); } /** diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorPayload.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorPayload.java index e9b7d936..15afcaf1 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorPayload.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorPayload.java @@ -12,12 +12,19 @@ import java.util.Map; * *

The payload deliberately holds sort values and identifiers only: no credential, no raw tenant. * + *

{@code tenantScope} binds the cursor to the caller it was issued for. Without it a cursor is a + * position in a result set and nothing more, so one tenant's cursor replayed by another resumes a + * scan the second tenant was never entitled to start. It is an opaque fingerprint supplied by the + * caller, never a raw tenant identifier: this module must stay free of the context types, and a + * cursor is a value a client holds and can read. + * * @param version envelope version * @param queryProfile the query this cursor belongs to * @param direction {@code FORWARD} or {@code BACKWARD} * @param keyset sort position * @param filterFingerprint fingerprint of the filter the cursor was issued under - * @param keyId signing key identity, so keys can rotate + * @param tenantScope opaque fingerprint of the tenant and actor scope the cursor was issued for + * @param keyId signing key identity, stamped by the codec so keys can rotate */ public record GraphQlCursorPayload( int version, @@ -25,6 +32,7 @@ public record GraphQlCursorPayload( String direction, Map keyset, String filterFingerprint, + String tenantScope, String keyId) { /** Forward pagination. */ @@ -47,52 +55,69 @@ public record GraphQlCursorPayload( if (filterFingerprint == null || filterFingerprint.isBlank()) { throw new GraphQlCursorException("cursor filter fingerprint is required"); } + if (tenantScope == null || tenantScope.isBlank()) { + throw new GraphQlCursorException("cursor tenant scope is required"); + } if (keyId == null || keyId.isBlank()) { throw new GraphQlCursorException("cursor key id is required"); } keyset = GraphQlCursorKeyset.of(keyset).values(); } - /** Creates a current-version payload signed by the default key. */ - public static GraphQlCursorPayload of( - String queryProfile, String direction, Map keyset, String filterFingerprint) { - return new GraphQlCursorPayload( - GraphQlCursorVersion.CURRENT, - queryProfile, - direction, - Map.copyOf(keyset), - filterFingerprint, - DEFAULT_KEY_ID); - } - - /** Creates a current-version payload signed by a named key. */ - public static GraphQlCursorPayload of( + /** + * Creates a payload to be issued. + * + *

No key identity: the codec stamps the active one. A caller that could name the signing key + * could pin every cursor to a retired key and quietly opt out of rotation. + */ + public static GraphQlCursorPayload issue( String queryProfile, String direction, Map keyset, String filterFingerprint, - String keyId) { + String tenantScope) { return new GraphQlCursorPayload( GraphQlCursorVersion.CURRENT, queryProfile, direction, Map.copyOf(keyset), filterFingerprint, - keyId); + tenantScope, + PENDING_KEY_ID); } - /** Deterministic encoding of everything the signature covers. */ - public String canonicalForm() { - return version - + "|" - + queryProfile - + "|" - + direction - + "|" - + new GraphQlCursorKeyset(keyset).canonicalForm() - + "|" - + filterFingerprint - + "|" - + keyId; + /** Placeholder key identity on a payload the codec has not signed yet. */ + public static final String PENDING_KEY_ID = "pending"; + + /** Returns a copy stamped with the key that signed it. */ + public GraphQlCursorPayload signedWith(String activeKeyId) { + return new GraphQlCursorPayload( + version, queryProfile, direction, keyset, filterFingerprint, tenantScope, activeKeyId); } + + /** + * Deterministic encoding of everything the signature covers. + * + *

Length-prefixed rather than delimiter-joined, so no field needs escaping and none can be + * confused with the framing. Keyset entries are sorted by {@link GraphQlCursorKeyset}, so the + * same position always signs to the same bytes. + */ + public String canonicalForm() { + StringBuilder canonical = new StringBuilder(); + canonical.append(version).append('|'); + GraphQlCursorFraming.write(canonical, queryProfile); + GraphQlCursorFraming.write(canonical, direction); + GraphQlCursorFraming.write(canonical, filterFingerprint); + GraphQlCursorFraming.write(canonical, tenantScope); + GraphQlCursorFraming.write(canonical, keyId); + keyset.forEach( + (key, value) -> { + GraphQlCursorFraming.write(canonical, key); + GraphQlCursorFraming.write(canonical, value); + }); + return canonical.toString(); + } + + /** How many framed fields precede the keyset pairs. */ + static final int FIXED_FIELDS = 5; } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorScope.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorScope.java new file mode 100644 index 00000000..da994e0b --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorScope.java @@ -0,0 +1,57 @@ +package dev.caskeleton.adapter.inbound.graphql.pagination; + +import java.util.Objects; + +/** + * Everything a presented cursor has to match before it may be used. + * + *

Verifying the signature proves the server issued the cursor. It does not prove the server + * issued it for this request, and each field here is a way that gap was exploitable: a + * forward cursor replayed as a backward one walks the page boundary in the wrong direction, and a + * cursor from one tenant replayed by another resumes a scan the second tenant could never have + * started. Passing the whole expectation as one value is what stops a new caller from checking + * three of the four and looking correct. + * + * @param queryProfile the query the cursor is being used for + * @param filterFingerprint the filter the cursor is being used under + * @param direction the direction this request is paginating in + * @param tenantScope opaque fingerprint of the caller's tenant and actor scope + */ +public record GraphQlCursorScope( + String queryProfile, String filterFingerprint, String direction, String tenantScope) { + + /** + * Tenant scope carried by a v1 cursor, which bound none. + * + *

A distinct value rather than {@code null}, so a v1 cursor presented against a real scope + * fails the comparison like any other mismatch instead of skipping the check. + */ + public static final String LEGACY_UNSCOPED = "legacy-unscoped"; + + public GraphQlCursorScope { + Objects.requireNonNull(queryProfile, "query profile is required"); + Objects.requireNonNull(filterFingerprint, "filter fingerprint is required"); + Objects.requireNonNull(direction, "direction is required"); + Objects.requireNonNull(tenantScope, "tenant scope is required"); + } + + /** + * Verifies a decoded payload against this scope. + * + * @throws GraphQlCursorException naming the first field that does not match + */ + public void verify(GraphQlCursorPayload payload) { + if (!payload.queryProfile().equals(queryProfile)) { + throw new GraphQlCursorException("cursor was issued for a different query profile"); + } + if (!payload.filterFingerprint().equals(filterFingerprint)) { + throw new GraphQlCursorException("cursor was issued for a different filter"); + } + if (!payload.direction().equals(direction)) { + throw new GraphQlCursorException("cursor was issued for a different pagination direction"); + } + if (!payload.tenantScope().equals(tenantScope)) { + throw new GraphQlCursorException("cursor was issued for a different tenant scope"); + } + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorVersion.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorVersion.java index bf6a0703..38340619 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorVersion.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorVersion.java @@ -11,11 +11,24 @@ import java.util.Set; */ public final class GraphQlCursorVersion { - /** Current envelope version. */ - public static final int CURRENT = 1; + /** + * Current envelope version: length-prefixed framing, tenant scope, codec-stamped key. + * + *

Only this version is issued. + */ + public static final int CURRENT = 2; + + /** + * The delimiter-escaped envelope, still decodable during migration. + * + *

Kept readable because cursors already in clients' hands outlive a deploy. It is never + * issued: its framing could not round-trip a sort value containing a delimiter, and it binds no + * tenant scope. + */ + public static final int LEGACY_DELIMITED = 1; /** Versions this deployment will still decode. */ - public static final Set SUPPORTED = Set.of(CURRENT); + public static final Set SUPPORTED = Set.of(LEGACY_DELIMITED, CURRENT); private GraphQlCursorVersion() {} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/HmacGraphQlCursorCodec.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/HmacGraphQlCursorCodec.java index 3e2012e4..23f6f323 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/HmacGraphQlCursorCodec.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/pagination/HmacGraphQlCursorCodec.java @@ -4,6 +4,7 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.Base64; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Objects; import javax.crypto.Mac; @@ -18,9 +19,15 @@ import javax.crypto.spec.SecretKeySpec; * *

Signatures are compared with {@link MessageDigest#isEqual}, whose timing does not depend on * where the first differing byte is — a normal string comparison would leak that position. + * + *

The codec chooses the signing key. Letting the payload name it meant a caller could pin every + * new cursor to a key that was being retired, which is rotation that never completes. */ public final class HmacGraphQlCursorCodec implements GraphQlCursorCodec { + /** Largest cursor token accepted, before any decoding work is done. */ + public static final int MAXIMUM_CURSOR_CHARS = 4096; + private static final String ALGORITHM = "HmacSHA256"; private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding(); private static final Base64.Decoder DECODER = Base64.getUrlDecoder(); @@ -36,27 +43,29 @@ public final class HmacGraphQlCursorCodec implements GraphQlCursorCodec { this.keyRing = Objects.requireNonNull(keyRing); } - /** A single-key codec for tests and single-key deployments. */ - public static HmacGraphQlCursorCodec testCodec(String keyId, byte[] secret) { - return new HmacGraphQlCursorCodec(GraphQlCursorKeyRing.single(keyId, secret)); - } - @Override public String encode(GraphQlCursorPayload payload) { - String canonical = payload.canonicalForm(); - byte[] signature = sign(canonical, keyRing.secret(payload.keyId())); + if (payload.version() != GraphQlCursorVersion.CURRENT) { + throw new GraphQlCursorException("only the current cursor version is issued"); + } + GraphQlCursorPayload signed = payload.signedWith(keyRing.activeKeyId()); + String canonical = signed.canonicalForm(); + byte[] signature = sign(canonical, keyRing.secret(signed.keyId())); return ENCODER.encodeToString(canonical.getBytes(StandardCharsets.UTF_8)) + "." + ENCODER.encodeToString(signature); } @Override - public GraphQlCursorPayload decode( - String cursor, String expectedQueryProfile, String expectedFilterFingerprint) { - + public GraphQlCursorPayload decode(String cursor, GraphQlCursorScope expected) { + Objects.requireNonNull(expected, "expected cursor scope is required"); if (cursor == null || cursor.isBlank()) { throw new GraphQlCursorException("cursor is required"); } + // Bounded before any decode: an oversized token is refused without allocating a copy of it. + if (cursor.length() > MAXIMUM_CURSOR_CHARS) { + throw new GraphQlCursorException("cursor is too large"); + } int separator = cursor.indexOf('.'); if (separator < 0) { throw new GraphQlCursorException("malformed cursor"); @@ -68,7 +77,7 @@ public final class HmacGraphQlCursorCodec implements GraphQlCursorCodec { canonical = new String(DECODER.decode(cursor.substring(0, separator)), StandardCharsets.UTF_8); presentedSignature = DECODER.decode(cursor.substring(separator + 1)); - } catch (IllegalArgumentException ex) { + } catch (IllegalArgumentException malformed) { throw new GraphQlCursorException("malformed cursor"); } @@ -77,34 +86,63 @@ public final class HmacGraphQlCursorCodec implements GraphQlCursorCodec { if (!MessageDigest.isEqual(expectedSignature, presentedSignature)) { throw new GraphQlCursorException("cursor signature mismatch"); } - if (!payload.queryProfile().equals(expectedQueryProfile)) { - throw new GraphQlCursorException("cursor was issued for a different query profile"); - } - if (!payload.filterFingerprint().equals(expectedFilterFingerprint)) { - throw new GraphQlCursorException("cursor was issued for a different filter"); - } + expected.verify(payload); return payload; } private static GraphQlCursorPayload parse(String canonical) { - String[] parts = canonical.split("\\|", -1); - if (parts.length != 6) { + int versionEnd = canonical.indexOf('|'); + if (versionEnd < 0) { throw new GraphQlCursorException("malformed cursor"); } int version; try { - version = Integer.parseInt(parts[0]); - } catch (NumberFormatException ex) { + version = Integer.parseInt(canonical.substring(0, versionEnd)); + } catch (NumberFormatException malformed) { throw new GraphQlCursorException("malformed cursor"); } GraphQlCursorVersion.require(version); - return new GraphQlCursorPayload( - version, parts[1], parts[2], parseKeyset(parts[3]), parts[4], parts[5]); + String body = canonical.substring(versionEnd + 1); + return version == GraphQlCursorVersion.CURRENT ? parseFramed(body) : parseLegacy(body); } - private static Map parseKeyset(String encoded) { + private static GraphQlCursorPayload parseFramed(String body) { + List fields = GraphQlCursorFraming.readAll(body); + int pairFields = fields.size() - GraphQlCursorPayload.FIXED_FIELDS; + if (pairFields < 2 || pairFields % 2 != 0) { + throw new GraphQlCursorException("malformed cursor"); + } Map keyset = new LinkedHashMap<>(); - for (String entry : encoded.split(";", -1)) { + for (int index = GraphQlCursorPayload.FIXED_FIELDS; index < fields.size(); index += 2) { + keyset.put(fields.get(index), fields.get(index + 1)); + } + return new GraphQlCursorPayload( + GraphQlCursorVersion.CURRENT, + fields.get(0), + fields.get(1), + keyset, + fields.get(2), + fields.get(3), + fields.get(4)); + } + + /** + * Reads the delimiter-escaped v1 envelope. + * + *

Unescaping happens per field after the split, which is the ordering v1's own decoder got + * wrong. A v1 cursor whose sort value contained a delimiter was never readable, so there is no + * correct behaviour to preserve for that case — it is rejected here rather than mis-parsed. + * + *

v1 bound no tenant scope, so the decoded payload carries the sentinel below and any caller + * expecting a real scope rejects it. + */ + private static GraphQlCursorPayload parseLegacy(String body) { + String[] parts = body.split("\\|", -1); + if (parts.length != 5) { + throw new GraphQlCursorException("malformed cursor"); + } + Map keyset = new LinkedHashMap<>(); + for (String entry : parts[2].split(";", -1)) { if (entry.isBlank()) { continue; } @@ -117,7 +155,14 @@ public final class HmacGraphQlCursorCodec implements GraphQlCursorCodec { if (keyset.isEmpty()) { throw new GraphQlCursorException("malformed cursor"); } - return keyset; + return new GraphQlCursorPayload( + GraphQlCursorVersion.LEGACY_DELIMITED, + parts[0], + parts[1], + keyset, + parts[3], + GraphQlCursorScope.LEGACY_UNSCOPED, + parts[4]); } private static String unescape(String value) { diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/policy/GraphQlClientPolicy.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/policy/GraphQlClientPolicy.java index fd95306f..1fad7643 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/policy/GraphQlClientPolicy.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/policy/GraphQlClientPolicy.java @@ -69,6 +69,40 @@ public record GraphQlClientPolicy( } } + /** + * The policy an adopter gets before tuning anything, driven by the platform's own settings. + * + *

Page size, complexity budget and introspection come from configuration because those are the + * three an operator actually sets; the remaining ceilings are starting points meant to be + * calibrated against measured latency and statement counts, as the design says. They are + * deliberately generous enough that a correct client is never refused and tight enough that a + * hostile document is, which is the only property a default can honestly claim. + * + * @param maximumPageSize largest page size a client may request + * @param maximumComplexity largest accepted pre-execution complexity score + * @param introspectionAllowed whether this deployment answers introspection + */ + public static GraphQlClientPolicy defaults( + int maximumPageSize, long maximumComplexity, boolean introspectionAllowed) { + return new GraphQlClientPolicy( + 16 * 1024, + 64 * 1024, + 12, + 500, + 50, + 50, + 1_000, + Math.min(20, Math.max(1, maximumPageSize)), + Math.max(1, maximumPageSize), + Math.max(1, maximumComplexity), + 10_000, + 5L * 1024 * 1024, + Duration.ofSeconds(10), + introspectionAllowed, + false, + false); + } + /** * The effective page size for a connection request. * diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/release/GraphQlStableCapabilityManifest.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/release/GraphQlStableCapabilityManifest.java index 13c332dc..105c54e6 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/release/GraphQlStableCapabilityManifest.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/release/GraphQlStableCapabilityManifest.java @@ -38,8 +38,7 @@ public final class GraphQlStableCapabilityManifest { "SSE_SUBSCRIPTION", "FEDERATION_SUBGRAPH", "DATALOADER_CHAINING", - "CODE_GENERATION", - "SPRING_DATA_COMPAT"); + "CODE_GENERATION"); /** Capabilities that remain experimental until a promotion decision is recorded. */ public static final Set EXPERIMENTAL = @@ -52,6 +51,11 @@ public final class GraphQlStableCapabilityManifest { "HTTP_ARRAY_BATCH", "ARBITRARY_JSON_INPUT_GATEWAY", "PERSISTENCE_ENTITY_AUTO_EXPOSURE", + // Was an allowlisted Advanced capability. An allowlist that lets a repository back a + // GraphQL field is still a controller reaching a repository — the second canonical + // hard-stop in AGENTS.md — and a capability flag cannot make an architectural rule + // conditional. Resolvers reach storage through an application use case or not at all. + "SPRING_DATA_REPOSITORY_AUTO_EXPOSURE", "REQUEST_WIDE_DB_TRANSACTION", "DURABLE_SUBSCRIPTION_GUARANTEE", "EXACTLY_ONCE_SUBSCRIPTION_DELIVERY", diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlBatchLoaderRegistrar.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlBatchLoaderRegistrar.java new file mode 100644 index 00000000..6e44ef83 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlBatchLoaderRegistrar.java @@ -0,0 +1,105 @@ +package dev.caskeleton.adapter.inbound.graphql.runtime; + +import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; +import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchContext; +import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchExecutor; +import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchTimeoutException; +import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory; +import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderName; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.BiFunction; +import org.springframework.graphql.execution.BatchLoaderRegistry; + +/** + * Registers the platform's batch loaders with Spring, so they are the ones a field actually uses. + * + *

The platform had a request registry keyed by name holding {@code Object}, and nothing + * connected it to Spring's {@link BatchLoaderRegistry} or to java-dataloader. A field resolving + * through {@code @BatchMapping} or {@code DataLoader} therefore never met the chunking, the budget + * or the scope the platform had defined — the N+1 protection existed as a set of well-tested + * objects that no request could reach. + * + *

The chunking, budget and scope arrive as a decorator around the adopter's loader rather than + * as something the adopter has to remember. What the adopter supplies is the downstream call; what + * this adds is everything that makes it safe to run on a shared request budget. + */ +public final class GraphQlBatchLoaderRegistrar { + + private final GraphQlDataLoaderFactory factory; + private final GraphQlBlockingBridge blockingBridge; + + /** + * Creates a registrar that runs loaders on the calling thread. + * + *

No scheduler of its own. Spring has already put the request on a thread; handing the work to + * a second pool adds a queue, a wait and a context hop, and buys nothing on a servlet stack. + * + * @param factory supplies the per-loader batch policy and executor + */ + public GraphQlBatchLoaderRegistrar(GraphQlDataLoaderFactory factory) { + this(factory, null); + } + + /** + * Creates a registrar that hands blocking loads to a bounded bridge. + * + * @param factory supplies the per-loader batch policy and executor + * @param blockingBridge the bounded hand-off, for a runtime where blocking in place is unsafe + */ + public GraphQlBatchLoaderRegistrar( + GraphQlDataLoaderFactory factory, GraphQlBlockingBridge blockingBridge) { + this.factory = Objects.requireNonNull(factory, "data loader factory is required"); + this.blockingBridge = blockingBridge; + } + + /** + * Registers one loader under its platform name. + * + *

The registration is per request by construction: Spring builds a fresh {@code + * DataLoaderRegistry} for every execution, so the loader's cache never outlives the request and + * one caller's cached row can never be served to the next. + * + * @param key type + * @param value type + * @param registry Spring's batch loader registry + * @param loaderName the platform loader name, which must have a registered policy + * @param loadChunk the adopter's downstream call for one chunk + */ + public void register( + BatchLoaderRegistry registry, + GraphQlDataLoaderName loaderName, + BiFunction, GraphQlBatchContext, Map> loadChunk) { + + GraphQlBatchExecutor executor = factory.executorFor(loaderName); + + registry + .forName(loaderName.value()) + .registerMappedBatchLoader( + (keys, environment) -> { + GraphQlRequestContext context = + environment.getContext() instanceof graphql.GraphQLContext graphQlContext + ? graphQlContext.get(GraphQlRequestContext.CONTEXT_KEY) + : null; + if (context == null) { + // Fail closed. A loader running without the platform context has no deadline, no + // tenant and no actor, and would happily batch across whatever the caller was. + return reactor.core.publisher.Mono.error( + new GraphQlBatchTimeoutException(loaderName.value())); + } + GraphQlBatchContext batchContext = factory.batchContext(context); + reactor.core.publisher.Mono> load = + reactor.core.publisher.Mono.fromCallable( + () -> executor.load(List.copyOf(keys), batchContext, loadChunk)); + // Inline unless an adopter asked for the bridge. `supplyAsync` would have used the + // common ForkJoinPool: a shared, unbounded-admission scheduler with no relationship + // to the request budget, which is the second queue this platform exists to avoid. + return blockingBridge == null + ? load + : load.subscribeOn( + reactor.core.scheduler.Schedulers.fromExecutor( + blockingBridge.executorFor(context))); + }); + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlBlockingBridge.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlBlockingBridge.java new file mode 100644 index 00000000..439a787b --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlBlockingBridge.java @@ -0,0 +1,93 @@ +package dev.caskeleton.adapter.inbound.graphql.runtime; + +import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; +import dev.caskeleton.adapter.inbound.graphql.security.GraphQlContextPropagator; +import java.util.Objects; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** + * An opt-in, bounded hand-off for blocking work, carrying the request context with it. + * + *

Bounded on both axes, because a pool is only half of it. A virtual-thread-per-task executor + * limits nothing: it accepts every task and the bound becomes whatever the downstream system will + * tolerate. A fixed pool bounds threads and then queues without limit, which converts an overload + * into unbounded memory and latency rather than into a refusal. Here both the pool and the queue + * are finite and a full bridge refuses immediately, which is the answer a caller can act on. + * + *

Opt-in on purpose. The platform schedules nothing by default: work runs on the thread Spring + * already gave it, so there is one scheduler and one queue rather than two of each with a wait + * between them. A bridge exists for the case an adopter genuinely has — blocking work that must + * leave an event loop — and then it is explicit, sized, and visible. + * + *

The context travels through {@link GraphQlContextPropagator}, so the thread local is bound on + * the bridge thread and unbound afterwards. Reading the thread local is what a blocking library + * does; the GraphQL context remains the source of truth, and this only mirrors it for the duration + * of the hand-off. + */ +public final class GraphQlBlockingBridge implements AutoCloseable { + + private final ThreadPoolExecutor executor; + + private GraphQlBlockingBridge(ThreadPoolExecutor executor) { + this.executor = executor; + } + + /** + * Creates a bridge with a finite pool and a finite queue. + * + * @param threads how many blocking calls may run at once + * @param queueDepth how many may wait; beyond this the bridge refuses + */ + public static GraphQlBlockingBridge bounded(int threads, int queueDepth) { + if (threads < 1 || queueDepth < 1) { + throw new IllegalArgumentException("a blocking bridge needs a positive pool and queue"); + } + ThreadPoolExecutor executor = + new ThreadPoolExecutor( + threads, + threads, + 0, + TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(queueDepth), + runnable -> Thread.ofVirtual().name("graphql-blocking-bridge-", 0).unstarted(runnable), + new ThreadPoolExecutor.AbortPolicy()); + return new GraphQlBlockingBridge(executor); + } + + /** How many blocking calls may run at once. */ + public int threads() { + return executor.getMaximumPoolSize(); + } + + /** How many may wait before the bridge refuses. */ + public int queueDepth() { + return executor.getQueue().remainingCapacity() + executor.getQueue().size(); + } + + /** + * The executor, with the context bound around every task. + * + * @param context the request context to bind on the bridge thread + */ + public Executor executorFor(GraphQlRequestContext context) { + Objects.requireNonNull(context, "request context is required"); + return task -> { + try { + executor.execute(() -> GraphQlContextPropagator.wrap(context, task).run()); + } catch (RejectedExecutionException full) { + // Refusing is the point. Growing the queue here would turn an overload into latency that + // the request deadline has to discover later, by which time the work is already queued. + throw new GraphQlBlockingBridgeFullException(threads(), queueDepth()); + } + }; + } + + @Override + public void close() { + executor.shutdownNow(); + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlBlockingBridgeFullException.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlBlockingBridgeFullException.java new file mode 100644 index 00000000..17dd13d1 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlBlockingBridgeFullException.java @@ -0,0 +1,28 @@ +package dev.caskeleton.adapter.inbound.graphql.runtime; + +/** + * Raised when the blocking bridge has no capacity left. + * + *

A refusal rather than a wait. An unbounded queue turns an overload into latency that only the + * request deadline discovers, by which time the work is already queued behind everything else and + * the client has been waiting for all of it. + * + *

Carries the configured bounds, never a key, an actor or a tenant. + */ +public class GraphQlBlockingBridgeFullException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** Stable error code clients and operators see. */ + public static final String CODE = "GRAPHQL_BLOCKING_BRIDGE_FULL"; + + /** + * Creates the failure. + * + * @param threads configured concurrency + * @param queueDepth configured queue depth + */ + public GraphQlBlockingBridgeFullException(int threads, int queueDepth) { + super(CODE + ": threads=" + threads + ", queue=" + queueDepth); + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlCostBudgetHandler.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlCostBudgetHandler.java new file mode 100644 index 00000000..1ba55497 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlCostBudgetHandler.java @@ -0,0 +1,83 @@ +package dev.caskeleton.adapter.inbound.graphql.runtime; + +import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityCalculator; +import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityResult; +import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentComplexityScorer; +import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentShape; +import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentShapeAnalyzer; +import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimitPolicy; +import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionStage; +import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlRequestCancelledException; +import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy; +import java.time.Clock; +import java.util.Objects; + +/** + * Enforces the structural and cost budgets, before any resolver runs. + * + *

A budget checked after execution has already been spent, so this is the last stage before + * graphql-java starts fetching. It also re-checks the deadline: the request may have queued behind + * other work since the transport set it, and starting an operation whose budget is already gone + * spends downstream capacity on a response nobody will read. + */ +public final class GraphQlCostBudgetHandler implements GraphQlExecutionHandler { + + private final GraphQlDocumentShapeAnalyzer analyzer; + private final GraphQlStructuralLimitPolicy structuralLimits; + private final GraphQlComplexityCalculator calculator; + private final GraphQlDocumentComplexityScorer scorer; + private final GraphQlClientPolicy policy; + private final Clock clock; + + /** + * Creates the handler. + * + * @param analyzer document structure measurement + * @param structuralLimits per-profile structural ceilings + * @param calculator per-field pricing + * @param policy the client policy carrying the complexity budget + * @param clock clock used for the deadline re-check + */ + public GraphQlCostBudgetHandler( + GraphQlDocumentShapeAnalyzer analyzer, + GraphQlStructuralLimitPolicy structuralLimits, + GraphQlComplexityCalculator calculator, + GraphQlClientPolicy policy, + Clock clock) { + this.analyzer = Objects.requireNonNull(analyzer, "document analyzer is required"); + this.structuralLimits = Objects.requireNonNull(structuralLimits, "structural limits required"); + this.calculator = Objects.requireNonNull(calculator, "complexity calculator is required"); + this.scorer = new GraphQlDocumentComplexityScorer(this.calculator); + this.policy = Objects.requireNonNull(policy, "client policy is required"); + this.clock = Objects.requireNonNull(clock, "clock is required"); + } + + @Override + public GraphQlExecutionStage stage() { + return GraphQlExecutionStage.COST; + } + + @Override + public GraphQlExecutionContext handle(GraphQlExecutionContext context) { + if (context.requestContext().deadline().expired(clock)) { + throw new GraphQlRequestCancelledException(); + } + + // Scoped to the operation that will actually run. Measuring every operation in the document + // charges a client for selections this request never executes, which turns a document holding + // three cheap queries into one expensive one. + GraphQlDocumentShape shape = + analyzer.analyze(context.requireDocument(), context.requireOperation()); + structuralLimits.verify(shape); + + GraphQlComplexityResult complexity = + scorer.score( + context.schema(), + context.requireDocument(), + context.requireOperation(), + context.request().variables()); + calculator.verify(complexity, policy.maxComplexity()); + + return context.withCost(shape, complexity); + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlDataFetcherExceptionResolver.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlDataFetcherExceptionResolver.java new file mode 100644 index 00000000..d875ed2f --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlDataFetcherExceptionResolver.java @@ -0,0 +1,70 @@ +package dev.caskeleton.adapter.inbound.graphql.runtime; + +import dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorContext; +import dev.caskeleton.adapter.inbound.graphql.error.GraphQlWireError; +import graphql.GraphQLError; +import graphql.schema.DataFetchingEnvironment; +import java.util.List; +import java.util.Objects; +import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter; + +/** + * Adapts the canonical wire-error mapper onto Spring's data-fetcher exception contract. + * + *

An adapter and nothing more. What a client is told — code, category, retryability, masking — + * is decided by {@link GraphQlWireErrorMapper}, so a failure raised inside a resolver and the same + * failure raised before execution produce the same answer. The previous arrangement had that + * decision in two classes whose names differed by one letter's case, only one of which Spring + * actually called. + */ +public final class GraphQlDataFetcherExceptionResolver extends DataFetcherExceptionResolverAdapter { + + private final GraphQlWireErrorMapper mapper; + + /** + * Creates the resolver. + * + * @param mapper the canonical mapper + */ + public GraphQlDataFetcherExceptionResolver(GraphQlWireErrorMapper mapper) { + this.mapper = Objects.requireNonNull(mapper, "wire error mapper is required"); + // Spring resolves a failing field, not the whole request, so the response keeps sibling data. + setThreadLocalContextAware(false); + } + + @Override + protected GraphQLError resolveToSingleError(Throwable failure, DataFetchingEnvironment env) { + GraphQlWireError wireError = mapper.mapFieldFailure(failure, errorContext(env)); + // A carrier still knows its operational category, which is finer than the six wire categories. + // Classifying from it keeps `NOT_FOUND` distinguishable from any other business outcome. + if (failure instanceof dev.caskeleton.shared.error.ApiErrorCarrier carrier) { + return GraphQlWireErrors.toGraphQlError( + wireError, GraphQlWireErrors.classificationOf(carrier.errorCode().category())); + } + return GraphQlWireErrors.toGraphQlError(wireError); + } + + /** + * Correlation context for a failing field. + * + *

A real execution always supplies the environment; a unit test may not, and a null-hostile + * mapper would then be untestable in isolation. + */ + private static GraphQlErrorContext errorContext(DataFetchingEnvironment env) { + if (env == null) { + return GraphQlErrorContext.of("unknown-execution"); + } + String executionId = String.valueOf(env.getExecutionId()); + List path = + env.getExecutionStepInfo() == null + ? List.of() + : List.copyOf(env.getExecutionStepInfo().getPath().toList()); + String coordinate = + env.getExecutionStepInfo() == null + ? null + : env.getExecutionStepInfo().getObjectType().getName() + + "." + + env.getExecutionStepInfo().getFieldDefinition().getName(); + return GraphQlErrorContext.field(executionId, path, coordinate); + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlDocumentAuthorizationHandler.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlDocumentAuthorizationHandler.java new file mode 100644 index 00000000..9b384bec --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlDocumentAuthorizationHandler.java @@ -0,0 +1,139 @@ +package dev.caskeleton.adapter.inbound.graphql.runtime; + +import dev.caskeleton.adapter.inbound.graphql.api.GraphQlSchemaCoordinate; +import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentShapeAnalyzer; +import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionStage; +import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy; +import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationInterceptor; +import graphql.language.Field; +import graphql.language.FragmentDefinition; +import graphql.language.FragmentSpread; +import graphql.language.InlineFragment; +import graphql.language.OperationDefinition; +import graphql.language.Selection; +import graphql.language.SelectionSet; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Authorizes the operation and its root coordinates, before any resolver runs. + * + *

Running before execution is the whole point: a mutation denied halfway through has already + * performed the side effect it was denied for. So the decision is made on the document, and a + * denial means zero data fetchers were invoked. + * + *

The introspection gate lives here rather than in the cost stage because it is an authorization + * question — whether this client profile may see the schema — and not a budget one. + */ +public final class GraphQlDocumentAuthorizationHandler implements GraphQlExecutionHandler { + + private final GraphQlAuthorizationInterceptor authorization; + private final GraphQlDocumentShapeAnalyzer analyzer; + private final GraphQlClientPolicy policy; + + /** + * Creates the handler. + * + * @param authorization coordinate authorization + * @param analyzer document analyzer used for the introspection gate + * @param policy the client policy that decides whether introspection is permitted + */ + public GraphQlDocumentAuthorizationHandler( + GraphQlAuthorizationInterceptor authorization, + GraphQlDocumentShapeAnalyzer analyzer, + GraphQlClientPolicy policy) { + this.authorization = Objects.requireNonNull(authorization, "authorization is required"); + this.analyzer = Objects.requireNonNull(analyzer, "document analyzer is required"); + this.policy = Objects.requireNonNull(policy, "client policy is required"); + } + + @Override + public GraphQlExecutionStage stage() { + return GraphQlExecutionStage.AUTHORIZATION; + } + + @Override + public GraphQlExecutionContext handle(GraphQlExecutionContext context) { + OperationDefinition operation = context.requireOperation(); + analyzer.verifyIntrospection( + context.requireDocument(), operation, policy.introspectionAllowed()); + + String rootTypeName = rootTypeName(operation); + for (String fieldName : rootFieldNames(context, operation)) { + authorization.authorize( + context.requestContext(), new GraphQlSchemaCoordinate(rootTypeName, fieldName)); + } + return context; + } + + private static String rootTypeName(OperationDefinition operation) { + OperationDefinition.Operation kind = + operation.getOperation() == null + ? OperationDefinition.Operation.QUERY + : operation.getOperation(); + return switch (kind) { + case QUERY -> "Query"; + case MUTATION -> "Mutation"; + case SUBSCRIPTION -> "Subscription"; + }; + } + + /** + * Root field names the operation actually reaches, fragments included. + * + *

Fragments are expanded rather than skipped: a root field reached only through a named + * fragment is executed exactly like one written inline, so authorizing only the inline ones would + * make {@code query { ...Hidden }} a bypass. + */ + private static Set rootFieldNames( + GraphQlExecutionContext context, OperationDefinition operation) { + + Map fragments = new LinkedHashMap<>(); + context + .requireDocument() + .getDefinitions() + .forEach( + definition -> { + if (definition instanceof FragmentDefinition fragment) { + fragments.put(fragment.getName(), fragment); + } + }); + + Set names = new LinkedHashSet<>(); + collect(operation.getSelectionSet(), fragments, names, new ArrayDeque<>()); + return names; + } + + private static void collect( + SelectionSet selectionSet, + Map fragments, + Set names, + Deque expansionPath) { + + if (selectionSet == null) { + return; + } + for (Selection selection : selectionSet.getSelections()) { + if (selection instanceof Field field) { + if (!field.getName().startsWith(GraphQlDocumentShapeAnalyzer.INTROSPECTION_FIELD_PREFIX)) { + names.add(field.getName()); + } + } else if (selection instanceof InlineFragment inlineFragment) { + collect(inlineFragment.getSelectionSet(), fragments, names, expansionPath); + } else if (selection instanceof FragmentSpread spread) { + FragmentDefinition fragment = fragments.get(spread.getName()); + if (fragment == null || expansionPath.contains(spread.getName())) { + continue; + } + expansionPath.push(spread.getName()); + collect(fragment.getSelectionSet(), fragments, names, expansionPath); + expansionPath.pop(); + } + } + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlExecutionChain.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlExecutionChain.java new file mode 100644 index 00000000..b753ae2f --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlExecutionChain.java @@ -0,0 +1,82 @@ +package dev.caskeleton.adapter.inbound.graphql.runtime; + +import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipeline; +import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipelineValidator; +import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionStage; +import java.util.ArrayList; +import java.util.List; + +/** + * The handlers the platform actually runs, in the order it runs them. + * + *

This is the executable pipeline; {@link GraphQlExecutionPipeline} is a view derived from it. + * The direction matters: a stage catalogue written by hand can drift from the code without anything + * failing, whereas a catalogue derived from the registered handlers cannot describe a stage that is + * not there. + * + *

Two stages are owned by the framework seams rather than by handlers, and the derived pipeline + * says so explicitly. {@code CONTEXT} happens in the transport interceptor, which is the only layer + * that can see the authenticated principal, and {@code EXECUTE} is graphql-java running the + * operation. Including them in the derived view is what lets the ordering validator judge the whole + * request path instead of the middle of it. + */ +public final class GraphQlExecutionChain { + + private final List handlers; + + /** + * Creates the chain and validates the order it produces. + * + * @param handlers handlers in execution order + * @throws dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipelineException when + * the resulting pipeline drops or reorders a required stage + */ + public GraphQlExecutionChain(List handlers) { + if (handlers == null || handlers.isEmpty()) { + throw new IllegalArgumentException("an execution chain needs at least one handler"); + } + this.handlers = List.copyOf(handlers); + GraphQlExecutionPipelineValidator.validate(pipeline()); + } + + /** The Stable chain: select the operation, authorize it, then judge its cost. */ + public static GraphQlExecutionChain stable( + GraphQlOperationSelectionHandler selection, + GraphQlDocumentAuthorizationHandler authorization, + GraphQlCostBudgetHandler cost) { + return new GraphQlExecutionChain(List.of(selection, authorization, cost)); + } + + /** The pipeline this chain realises, including the two framework-owned stages. */ + public GraphQlExecutionPipeline pipeline() { + List stages = new ArrayList<>(); + stages.add(GraphQlExecutionStage.CONTEXT); + handlers.forEach(handler -> stages.add(handler.stage())); + stages.add(GraphQlExecutionStage.EXECUTE); + return new GraphQlExecutionPipeline(stages); + } + + /** The registered handlers, in execution order. */ + public List handlers() { + return handlers; + } + + /** + * Runs every handler in order. + * + * @param start state produced by the transport + * @return the state after the last handler + * @throws RuntimeException the first rejection, unwrapped, for the caller to map + */ + public GraphQlExecutionContext run(GraphQlExecutionContext start) { + GraphQlExecutionContext current = start; + for (GraphQlExecutionHandler handler : handlers) { + current = handler.handle(current); + if (current == null) { + throw new IllegalStateException( + "handler for stage " + handler.stage() + " returned no state"); + } + } + return current; + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlExecutionContext.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlExecutionContext.java new file mode 100644 index 00000000..a5596da1 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlExecutionContext.java @@ -0,0 +1,94 @@ +package dev.caskeleton.adapter.inbound.graphql.runtime; + +import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; +import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityResult; +import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentShape; +import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlOperationSelection; +import graphql.language.Document; +import graphql.language.OperationDefinition; +import graphql.schema.GraphQLSchema; +import java.util.Objects; + +/** + * The state threaded through the executable pipeline. + * + *

Each stage adds a field and never removes one, so "has this stage run?" is answerable by + * looking at the value rather than by trusting the order someone registered handlers in. A stage + * that needs an earlier result asks for it and fails loudly when it is absent — the alternative is + * a chain that silently authorizes {@code null}. + * + * @param request the request as received + * @param requestContext identity, tenant, client policy and deadline, produced by the transport + * @param schema the schema the document was validated against + * @param document the parsed document + * @param operation the selected operation, produced by the parse/select stage + * @param selection what was selected and out of how many + * @param shape measured document structure, produced by the cost stage + * @param complexity the scored complexity, produced by the cost stage + */ +public record GraphQlExecutionContext( + GraphQlExecutionRequest request, + GraphQlRequestContext requestContext, + GraphQLSchema schema, + Document document, + OperationDefinition operation, + GraphQlOperationSelection selection, + GraphQlDocumentShape shape, + GraphQlComplexityResult complexity) { + + public GraphQlExecutionContext { + Objects.requireNonNull(request, "request is required"); + Objects.requireNonNull(requestContext, "request context is required"); + } + + /** The state a chain starts from, once the transport has established identity and parsed. */ + public static GraphQlExecutionContext starting( + GraphQlExecutionRequest request, + GraphQlRequestContext requestContext, + GraphQLSchema schema, + Document document) { + return new GraphQlExecutionContext( + request, requestContext, schema, document, null, null, null, null); + } + + /** Returns a copy carrying the selected operation. */ + public GraphQlExecutionContext withSelection( + OperationDefinition selected, + GraphQlOperationSelection operationSelection, + GraphQlRequestContext refinedContext) { + return new GraphQlExecutionContext( + request, refinedContext, schema, document, selected, operationSelection, shape, complexity); + } + + /** Returns a copy carrying the measured shape and score. */ + public GraphQlExecutionContext withCost( + GraphQlDocumentShape measuredShape, GraphQlComplexityResult scored) { + return new GraphQlExecutionContext( + request, requestContext, schema, document, operation, selection, measuredShape, scored); + } + + /** + * The selected operation. + * + * @throws IllegalStateException when the parse/select stage has not run + */ + public OperationDefinition requireOperation() { + if (operation == null) { + throw new IllegalStateException( + "no operation has been selected; PARSE_VALIDATE must run before this stage"); + } + return operation; + } + + /** + * The parsed document. + * + * @throws IllegalStateException when the document is absent + */ + public Document requireDocument() { + if (document == null) { + throw new IllegalStateException("no parsed document is available for this stage"); + } + return document; + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlExecutionHandler.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlExecutionHandler.java new file mode 100644 index 00000000..cc7ae0a4 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlExecutionHandler.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.inbound.graphql.runtime; + +import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionStage; + +/** + * One executable stage of the platform pipeline. + * + *

A handler takes the state produced so far and returns the state it produced, so a stage that + * needs a previous stage's output cannot run without it — the missing value is a missing field, not + * a convention. This is what separates the pipeline from a list of stage names: the names could be + * in any order and nothing would notice, while a chain that authorizes before selecting an + * operation has nothing to authorize. + */ +public interface GraphQlExecutionHandler { + + /** The stage this handler implements, used to derive and validate the pipeline order. */ + GraphQlExecutionStage stage(); + + /** + * Runs the stage. + * + * @param context state produced by the preceding stages + * @return state including whatever this stage produced + * @throws RuntimeException when the request is rejected; the caller maps it onto the wire + */ + GraphQlExecutionContext handle(GraphQlExecutionContext context); +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlExecutionRequest.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlExecutionRequest.java new file mode 100644 index 00000000..604c1597 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlExecutionRequest.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.inbound.graphql.runtime; + +import dev.caskeleton.adapter.inbound.graphql.http.GraphQlJsonValues; +import java.util.Map; + +/** + * The request as it entered the platform, before anything was decided about it. + * + *

Sizes are carried rather than recomputed. The transport is the only layer that sees the raw + * bytes, and a limit re-derived downstream from a re-serialised map measures the platform's own + * encoder rather than what the client actually sent. + * + * @param document the GraphQL document text + * @param operationName the requested operation name, or {@code null} + * @param variables the request variables, never {@code null} + * @param documentBytes size of the document as received + * @param variablesBytes size of the serialised variables as received + */ +public record GraphQlExecutionRequest( + String document, + String operationName, + Map variables, + long documentBytes, + long variablesBytes) { + + public GraphQlExecutionRequest { + if (document == null) { + throw new IllegalArgumentException("document is required"); + } + // Null-valued variables are legal GraphQL input, so this cannot be Map.copyOf. + variables = GraphQlJsonValues.immutableObject(variables); + if (documentBytes < 0 || variablesBytes < 0) { + throw new IllegalArgumentException("request sizes cannot be negative"); + } + } + + /** Whether the request named the operation to run. */ + public boolean named() { + return operationName != null && !operationName.isBlank(); + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlOperationSelectionHandler.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlOperationSelectionHandler.java new file mode 100644 index 00000000..26592a47 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlOperationSelectionHandler.java @@ -0,0 +1,125 @@ +package dev.caskeleton.adapter.inbound.graphql.runtime; + +import dev.caskeleton.adapter.inbound.graphql.api.GraphQlOperationId; +import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlAnonymousOperationException; +import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionStage; +import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlOperationSelection; +import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy; +import graphql.language.Definition; +import graphql.language.OperationDefinition; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Selects the one operation this request runs, and binds the context to it. + * + *

Everything after this stage is keyed by the selected operation: authorization rules, cost + * budgets, metrics and the operation identity in the request context. Selecting here rather than + * letting each consumer re-derive it is what stops two stages disagreeing about which operation + * they are judging. + * + *

A multi-operation document with no {@code operationName} is refused rather than defaulted. + * Picking the first would let a client change what executes by reordering the document, which turns + * document order into an authorization bypass. + */ +public final class GraphQlOperationSelectionHandler implements GraphQlExecutionHandler { + + private final GraphQlClientPolicy policy; + + /** + * Creates the handler. + * + * @param policy the client policy that decides whether naming the operation is mandatory + */ + public GraphQlOperationSelectionHandler(GraphQlClientPolicy policy) { + this.policy = Objects.requireNonNull(policy, "client policy is required"); + } + + @Override + public GraphQlExecutionStage stage() { + return GraphQlExecutionStage.PARSE_VALIDATE; + } + + @Override + public GraphQlExecutionContext handle(GraphQlExecutionContext context) { + List operations = new ArrayList<>(); + for (Definition definition : context.requireDocument().getDefinitions()) { + if (definition instanceof OperationDefinition operation) { + operations.add(operation); + } + } + if (operations.isEmpty()) { + throw new GraphQlAnonymousOperationException("the document declares no operation"); + } + + String requestedName = context.request().operationName(); + OperationDefinition selected; + if (requestedName != null && !requestedName.isBlank()) { + selected = + operations.stream() + .filter(operation -> requestedName.equals(operation.getName())) + .findFirst() + .orElseThrow( + () -> + new GraphQlAnonymousOperationException( + "the document declares no operation named " + requestedName)); + } else if (operations.size() > 1) { + throw new GraphQlAnonymousOperationException( + "operationName is required when the document declares " + + operations.size() + + " operations"); + } else { + selected = operations.get(0); + if (policy.namedOperationRequired() && selected.getName() == null) { + throw new GraphQlAnonymousOperationException( + "this client profile requires a named operation"); + } + } + + GraphQlOperationSelection selection = + new GraphQlOperationSelection(selected.getName(), operations.size(), false); + return context.withSelection( + selected, selection, context.requestContext().withOperationId(operationId(selected))); + } + + /** Identity used before parsing, and the fallback when a name cannot become a valid identity. */ + public static final String ANONYMOUS_OPERATION_ID = "anonymous"; + + private static final int MAXIMUM_OPERATION_ID_LENGTH = 128; + private static final int MINIMUM_OPERATION_ID_LENGTH = 3; + + /** + * Derives the bounded operation identity from the GraphQL operation name. + * + *

A {@code GraphQlOperationId} is a metric label and a registry key, so its alphabet is + * narrower than GraphQL's: {@code HealthQuery} is a legal operation name and not a legal + * identity. Normalising deterministically keeps the identity stable across requests, and any name + * that cannot survive normalisation becomes the anonymous identity rather than being rejected — a + * naming convention is not a reason to refuse an otherwise valid request. + */ + static GraphQlOperationId operationId(OperationDefinition selected) { + String name = selected.getName(); + if (name == null || name.isBlank()) { + return new GraphQlOperationId(ANONYMOUS_OPERATION_ID); + } + StringBuilder normalized = + new StringBuilder(Math.min(name.length(), MAXIMUM_OPERATION_ID_LENGTH)); + for (char raw : name.toLowerCase(java.util.Locale.ROOT).toCharArray()) { + if (normalized.length() == MAXIMUM_OPERATION_ID_LENGTH) { + break; + } + boolean acceptable = + (raw >= 'a' && raw <= 'z') || (raw >= '0' && raw <= '9') || raw == '.' || raw == '-'; + char mapped = acceptable ? raw : '-'; + if (normalized.isEmpty() && !(mapped >= 'a' && mapped <= 'z')) { + continue; + } + normalized.append(mapped); + } + if (normalized.length() < MINIMUM_OPERATION_ID_LENGTH) { + return new GraphQlOperationId(ANONYMOUS_OPERATION_ID); + } + return new GraphQlOperationId(normalized.toString()); + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlPlatformInstrumentation.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlPlatformInstrumentation.java new file mode 100644 index 00000000..7c2f4bac --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlPlatformInstrumentation.java @@ -0,0 +1,100 @@ +package dev.caskeleton.adapter.inbound.graphql.runtime; + +import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; +import dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorContext; +import dev.caskeleton.adapter.inbound.graphql.error.GraphQlWireError; +import graphql.ExecutionInput; +import graphql.ExecutionResult; +import graphql.execution.AbortExecutionException; +import graphql.execution.instrumentation.InstrumentationContext; +import graphql.execution.instrumentation.InstrumentationState; +import graphql.execution.instrumentation.SimpleInstrumentationContext; +import graphql.execution.instrumentation.SimplePerformantInstrumentation; +import graphql.execution.instrumentation.parameters.InstrumentationExecuteOperationParameters; +import java.util.List; +import java.util.Objects; + +/** + * The seam where the platform's policies meet a real GraphQL request. + * + *

{@code beginExecuteOperation} is the last hook before graphql-java starts the execution + * strategy: the document is parsed, validated and bound to one operation, and no data fetcher has + * run. Rejecting here is what makes "a denied request invokes zero resolvers" true rather than + * aspirational — an earlier hook has no operation to judge, and a later one is judging work that + * has already happened. + * + *

A request that arrives with no platform context is rejected, not waved through. The context is + * created by the transport interceptor, so its absence means the interceptor is not wired — exactly + * the configuration where every policy would otherwise silently do nothing. + */ +public final class GraphQlPlatformInstrumentation extends SimplePerformantInstrumentation { + + /** Constraint reported when the request arrived without a platform context. */ + public static final String MISSING_CONTEXT_CONSTRAINT = "PLATFORM_CONTEXT"; + + private final GraphQlExecutionChain chain; + + /** + * Creates the instrumentation. + * + * @param chain the executable policy chain + */ + public GraphQlPlatformInstrumentation(GraphQlExecutionChain chain) { + this.chain = Objects.requireNonNull(chain, "execution chain is required"); + } + + /** The pipeline this instrumentation enforces, derived from the chain. */ + public GraphQlExecutionChain chain() { + return chain; + } + + @Override + public InstrumentationContext beginExecuteOperation( + InstrumentationExecuteOperationParameters parameters, InstrumentationState state) { + + graphql.execution.ExecutionContext execution = parameters.getExecutionContext(); + String executionId = String.valueOf(execution.getExecutionId()); + GraphQlErrorContext errorContext = GraphQlErrorContext.of(executionId); + + GraphQlRequestContext requestContext = + execution.getGraphQLContext().get(GraphQlRequestContext.CONTEXT_KEY); + if (requestContext == null) { + throw new AbortExecutionException( + List.of( + GraphQlWireErrors.toGraphQlError( + GraphQlWireError.internal(executionId) + .withConstraint(MISSING_CONTEXT_CONSTRAINT)))); + } + + ExecutionInput input = execution.getExecutionInput(); + GraphQlExecutionRequest request = + new GraphQlExecutionRequest( + input.getQuery() == null ? "" : input.getQuery(), + input.getOperationName(), + input.getVariables(), + input.getQuery() == null ? 0 : input.getQuery().length(), + 0); + + GraphQlExecutionContext completed; + try { + completed = + chain.run( + GraphQlExecutionContext.starting( + request, requestContext, execution.getGraphQLSchema(), execution.getDocument())); + } catch (RuntimeException rejection) { + throw new AbortExecutionException( + List.of( + GraphQlWireErrors.toGraphQlError( + GraphQlPlatformRejectionMapper.map(rejection, errorContext)))); + } + + // Publish the context the pipeline actually settled on. The transport could only build a + // pre-parse context — the operation was not selected yet — so a resolver reading the original + // value would see the placeholder operation identity and any deadline the pipeline tightened + // would be invisible to everything downstream of it. + execution + .getGraphQLContext() + .put(GraphQlRequestContext.CONTEXT_KEY, completed.requestContext()); + return SimpleInstrumentationContext.noOp(); + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlPlatformRejectionMapper.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlPlatformRejectionMapper.java new file mode 100644 index 00000000..fe101744 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlPlatformRejectionMapper.java @@ -0,0 +1,136 @@ +package dev.caskeleton.adapter.inbound.graphql.runtime; + +import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityRejectedException; +import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimitViolation; +import dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorCategory; +import dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorCode; +import dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorContext; +import dev.caskeleton.adapter.inbound.graphql.error.GraphQlWireError; +import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlAnonymousOperationException; +import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlRequestCancelledException; +import dev.caskeleton.adapter.inbound.graphql.http.GraphQlJsonStructurePolicy; +import dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestFormatException; +import dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestTooLargeException; +import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationException; +import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationDeniedException; +import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationInterceptor; +import dev.caskeleton.adapter.inbound.graphql.security.GraphQlTenantIsolationException; + +/** + * Turns a pipeline rejection into the one wire error the client sees. + * + *

Every stage rejects by throwing, and every rejection has to arrive on the wire with a stable + * code, a bounded category and no internal detail. Doing that per stage would produce as many error + * vocabularies as there are stages, and the one that forgot would leak an exception message. + * + *

An unrecognised exception maps to the opaque internal error rather than to its own message. + * That is the fail-closed direction: a new rejection type that nobody mapped shows up as {@code + * INTERNAL_ERROR} in the client's response and in full in the logs, instead of publishing whatever + * the exception happened to say. + */ +public final class GraphQlPlatformRejectionMapper { + + private GraphQlPlatformRejectionMapper() {} + + /** + * Maps a rejection thrown by a pipeline stage. + * + * @param failure the exception a handler threw + * @param context correlation identity for the response + */ + public static GraphQlWireError map(Throwable failure, GraphQlErrorContext context) { + GraphQlWireError known = mapKnown(failure, context); + return known != null ? known : GraphQlWireError.internal(context.executionId()); + } + + /** + * Maps a rejection this platform raised, or {@code null} when it did not raise it. + * + *

Returning {@code null} rather than the opaque internal error is what lets {@code + * GraphQlWireErrorMapper} try the application's own mappings next. Collapsing an unknown failure + * to {@code INTERNAL_ERROR} here would mask exactly the failures the application deliberately + * modelled. + */ + public static GraphQlWireError mapKnown(Throwable failure, GraphQlErrorContext context) { + if (failure instanceof GraphQlAuthorizationDeniedException denied) { + return GraphQlAuthorizationInterceptor.toWireError(denied.decision(), context); + } + if (failure instanceof GraphQlAuthenticationException) { + return GraphQlWireError.of( + "인증이 필요합니다.", + GraphQlErrorCode.of("AUTHENTICATION_REQUIRED"), + GraphQlErrorCategory.AUTHORIZATION, + context); + } + if (failure instanceof GraphQlTenantIsolationException) { + // Deliberately the same shape as an authorization denial: telling a caller that the resource + // exists in another tenant is the disclosure the isolation rule exists to prevent. + return GraphQlWireError.of( + "요청한 리소스를 찾을 수 없습니다.", + GraphQlErrorCode.of("RESOURCE_NOT_FOUND"), + GraphQlErrorCategory.AUTHORIZATION, + context); + } + if (failure instanceof GraphQlStructuralLimitViolation violation) { + return GraphQlWireError.of( + "요청 문서가 허용된 구조 한계를 초과했습니다.", + GraphQlErrorCode.of(GraphQlStructuralLimitViolation.CODE), + GraphQlErrorCategory.REQUEST, + context) + .withConstraint(violation.limitName()); + } + if (failure instanceof GraphQlComplexityRejectedException) { + return GraphQlWireError.of( + "요청의 예상 비용이 허용 한도를 초과했습니다.", + GraphQlErrorCode.of(GraphQlComplexityRejectedException.CODE), + GraphQlErrorCategory.REQUEST, + context); + } + if (failure instanceof GraphQlAnonymousOperationException) { + return GraphQlWireError.of( + "실행할 operation 을 하나로 특정할 수 없습니다.", + GraphQlErrorCode.of(GraphQlAnonymousOperationException.CODE), + GraphQlErrorCategory.REQUEST, + context); + } + if (failure instanceof GraphQlRequestFormatException malformed) { + // The message names a field and, for a shape rejection, a dimension and two counts. It never + // carries a variable value — that invariant is the reason it can be surfaced at all, and it + // is enforced where the exception is constructed rather than trusted here. + return GraphQlWireError.of( + "요청 형식이 올바르지 않습니다.", + GraphQlErrorCode.of(GraphQlErrorCode.REQUEST_ERROR), + GraphQlErrorCategory.REQUEST, + context) + .withConstraint(constraintOf(malformed)); + } + if (failure instanceof GraphQlRequestTooLargeException) { + return GraphQlWireError.of( + "요청이 허용된 크기를 초과했습니다.", + GraphQlErrorCode.of(GraphQlErrorCode.REQUEST_ERROR), + GraphQlErrorCategory.REQUEST, + context); + } + if (failure instanceof GraphQlRequestCancelledException) { + return GraphQlWireError.of( + "요청 처리 시간이 초과되었습니다.", + GraphQlErrorCode.of(GraphQlErrorCode.REQUEST_TIMEOUT), + GraphQlErrorCategory.TIMEOUT, + context); + } + return null; + } + + /** + * The stable constraint identity behind a format rejection. + * + *

A shape rejection already prefixes its own code; anything else is reported under the generic + * envelope constraint rather than by echoing a message that was written for a log. + */ + private static String constraintOf(GraphQlRequestFormatException failure) { + String message = failure.getMessage(); + return message != null && message.startsWith(GraphQlJsonStructurePolicy.CODE) + ? GraphQlJsonStructurePolicy.CODE + : "GRAPHQL_REQUEST_ENVELOPE"; + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlPlatformWebInterceptor.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlPlatformWebInterceptor.java new file mode 100644 index 00000000..4257efe1 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlPlatformWebInterceptor.java @@ -0,0 +1,167 @@ +package dev.caskeleton.adapter.inbound.graphql.runtime; + +import dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile; +import dev.caskeleton.adapter.inbound.graphql.context.GraphQlDeadline; +import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; +import dev.caskeleton.adapter.inbound.graphql.context.TenantContext; +import dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorContext; +import dev.caskeleton.adapter.inbound.graphql.error.GraphQlWireError; +import dev.caskeleton.adapter.inbound.graphql.http.GraphQlJsonStructurePolicy; +import dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestSize; +import dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestTooLargeException; +import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy; +import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticatedPrincipal; +import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationContextFactory; +import graphql.ExecutionInput; +import graphql.ExecutionResult; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.springframework.graphql.ExecutionGraphQlResponse; +import org.springframework.graphql.server.WebGraphQlInterceptor; +import org.springframework.graphql.server.WebGraphQlRequest; +import org.springframework.graphql.server.WebGraphQlResponse; +import org.springframework.graphql.support.DefaultExecutionGraphQlResponse; +import reactor.core.publisher.Mono; + +/** + * Establishes the platform's request context on the real {@code /graphql} endpoint. + * + *

This is the {@code CONTEXT} stage, and it has to live here because this is the only layer that + * can see the transport: headers, the authenticated principal, the request locale. Everything + * downstream — authorization, tenant isolation, cost budgets, deadlines, DataLoader batches — reads + * the context this interceptor puts into the GraphQL context, so a deployment that omits it gets a + * rejected request rather than an unpoliced one. + * + *

The document size check is here rather than in a policy object for the same reason: the + * transport is where the size is known, and refusing a large document before parsing is the only + * place the refusal is cheap. + */ +public final class GraphQlPlatformWebInterceptor implements WebGraphQlInterceptor { + + private final GraphQlPrincipalResolver principalResolver; + private final GraphQlAuthenticationContextFactory contextFactory; + private final GraphQlClientPolicy policy; + private final GraphQlJsonStructurePolicy structurePolicy; + private final GraphQlClientProfile anonymousProfile; + private final TenantContext anonymousTenant; + private final boolean anonymousProfileProtected; + private final Clock clock; + + /** + * Creates the interceptor. + * + * @param principalResolver how this deployment authenticates a request + * @param contextFactory the only factory allowed to build a request context + * @param policy the client policy applied to this endpoint + * @param anonymousProfile profile used when no credential is present + * @param anonymousTenant tenant used when no credential is present + * @param anonymousProfileProtected whether an anonymous request is refused outright + * @param clock clock used for the request deadline + */ + public GraphQlPlatformWebInterceptor( + GraphQlPrincipalResolver principalResolver, + GraphQlAuthenticationContextFactory contextFactory, + GraphQlClientPolicy policy, + GraphQlJsonStructurePolicy structurePolicy, + GraphQlClientProfile anonymousProfile, + TenantContext anonymousTenant, + boolean anonymousProfileProtected, + Clock clock) { + this.principalResolver = Objects.requireNonNull(principalResolver, "principal resolver"); + this.contextFactory = Objects.requireNonNull(contextFactory, "context factory is required"); + this.policy = Objects.requireNonNull(policy, "client policy is required"); + this.structurePolicy = Objects.requireNonNull(structurePolicy, "structure policy is required"); + this.anonymousProfile = Objects.requireNonNull(anonymousProfile, "anonymous profile"); + this.anonymousTenant = Objects.requireNonNull(anonymousTenant, "anonymous tenant"); + this.anonymousProfileProtected = anonymousProfileProtected; + this.clock = Objects.requireNonNull(clock, "clock is required"); + } + + @Override + public Mono intercept(WebGraphQlRequest request, Chain chain) { + GraphQlRequestContext context; + try { + verifyRequestBounds(request); + context = requestContext(request); + } catch (RuntimeException rejection) { + return Mono.just(reject(request, rejection)); + } + + request.configureExecutionInput( + (executionInput, builder) -> + builder + .graphQLContext( + graphQlContext -> + graphQlContext.put(GraphQlRequestContext.CONTEXT_KEY, context)) + .build()); + + // Also on the Reactor context, so a reactive resolver reads the same value rather than a + // second one assembled from whatever it can reach. + return chain.next(request).contextWrite(view -> view.put(GraphQlRequestContext.class, context)); + } + + /** + * Bounds the decoded request by size and by shape. + * + *

Measured in UTF-8 bytes rather than characters: the limit exists to bound memory and parser + * work, and one multi-byte character costs more of both than its single {@code length()} + * suggests. A raw-body cap upstream of the decoder is a separate concern and belongs to the + * transport — {@code GraphQlRequestBodyLimitFilter} on a servlet stack. + */ + private void verifyRequestBounds(WebGraphQlRequest request) { + String document = request.getDocument(); + if (document != null) { + int bytes = document.getBytes(StandardCharsets.UTF_8).length; + if (bytes > policy.maxDocumentBytes()) { + throw new GraphQlRequestTooLargeException( + "graphql document exceeds maxDocumentBytes", bytes, policy.maxDocumentBytes()); + } + } + + Map variables = request.getVariables(); + int variableBytes = GraphQlRequestSize.jsonBytes(variables); + if (variableBytes > policy.maxVariablesBytes()) { + throw new GraphQlRequestTooLargeException( + "graphql variables exceed maxVariablesBytes", variableBytes, policy.maxVariablesBytes()); + } + structurePolicy.verify("variables", variables); + structurePolicy.verify("extensions", request.getExtensions()); + } + + private GraphQlRequestContext requestContext(WebGraphQlRequest request) { + GraphQlDeadline deadline = GraphQlDeadline.after(policy.maxExecutionTime(), clock); + Optional principal = principalResolver.resolve(request); + if (principal.isPresent()) { + return contextFactory.create(principal.get(), deadline); + } + return contextFactory.createAnonymous( + anonymousProfile, anonymousTenant, traceId(request), deadline, anonymousProfileProtected); + } + + /** + * A bounded correlation identity for an anonymous request. + * + *

Derived from the execution id Spring already assigned rather than from a client header: a + * caller-supplied trace id becomes a log and metric value, and this one has to stay bounded. + */ + private static String traceId(WebGraphQlRequest request) { + String id = request.getId(); + return id == null || id.isBlank() ? "graphql" : id; + } + + private WebGraphQlResponse reject(WebGraphQlRequest request, RuntimeException rejection) { + ExecutionInput input = request.toExecutionInput(); + String executionId = traceId(request); + GraphQlWireError wireError = + GraphQlPlatformRejectionMapper.map(rejection, GraphQlErrorContext.of(executionId)); + ExecutionResult result = + ExecutionResult.newExecutionResult() + .addError(GraphQlWireErrors.toGraphQlError(wireError)) + .build(); + ExecutionGraphQlResponse response = new DefaultExecutionGraphQlResponse(input, result); + return new WebGraphQlResponse(response); + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlPreparsedDocumentAdapter.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlPreparsedDocumentAdapter.java new file mode 100644 index 00000000..ca09b45e --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlPreparsedDocumentAdapter.java @@ -0,0 +1,117 @@ +package dev.caskeleton.adapter.inbound.graphql.runtime; + +import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; +import dev.caskeleton.adapter.inbound.graphql.execution.BoundedPreparsedDocumentProvider; +import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlPreparsedCacheKey; +import graphql.ExecutionInput; +import graphql.execution.preparsed.PreparsedDocumentEntry; +import graphql.execution.preparsed.PreparsedDocumentProvider; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Makes the platform's bounded document cache the one graphql-java actually uses. + * + *

The bounded cache existed as a well-tested object that no request could reach: graphql-java + * asks a {@link PreparsedDocumentProvider} whether a document is already parsed, and nothing + * supplied one, so every request re-parsed and re-validated its document while the platform's cache + * sat empty. Bounds, weights and metrics that describe a cache nobody consults describe nothing. + * + *

The key is built from the document text, the schema, the validation policy version and the + * caller's client profile, because validation is not a property of the document alone — see {@link + * GraphQlPreparsedCacheKey}. The client profile comes from the request context rather than from the + * request, so a caller cannot select another profile's cached validation by asking for it. + */ +public final class GraphQlPreparsedDocumentAdapter implements PreparsedDocumentProvider { + + private final BoundedPreparsedDocumentProvider cache; + private final Supplier schemaContractHash; + private final String validationPolicyVersion; + + /** + * Creates the adapter. + * + * @param cache the platform's bounded cache + * @param schemaContractHash identity of the schema documents are validated against, resolved on + * first use because the schema is built after this provider is handed to the builder + * @param validationPolicyVersion version of the validation rules in force + */ + public GraphQlPreparsedDocumentAdapter( + BoundedPreparsedDocumentProvider cache, + Supplier schemaContractHash, + String validationPolicyVersion) { + this.cache = Objects.requireNonNull(cache, "preparsed cache is required"); + Objects.requireNonNull(schemaContractHash, "schema contract hash supplier is required"); + // Memoised: the schema does not change within a running application, and hashing a printed + // schema on every request would cost more than the parse this cache exists to avoid. + this.schemaContractHash = + new Supplier<>() { + private volatile String resolved; + + @Override + public String get() { + String current = resolved; + if (current == null) { + synchronized (this) { + current = resolved; + if (current == null) { + current = Objects.requireNonNull(schemaContractHash.get(), "schema hash"); + resolved = current; + } + } + } + return current; + } + }; + this.validationPolicyVersion = + Objects.requireNonNull(validationPolicyVersion, "validation policy version is required"); + } + + @Override + public CompletableFuture getDocumentAsync( + ExecutionInput executionInput, + Function parseAndValidate) { + + String document = executionInput.getQuery(); + GraphQlPreparsedCacheKey key = + new GraphQlPreparsedCacheKey( + sha256(document), + schemaContractHash.get(), + validationPolicyVersion, + clientProfile(executionInput)); + + // Completed, never scheduled: the parse runs on the caller's thread, so this adds no queue and + // no second scheduler to a path Spring has already placed on one. + return CompletableFuture.completedFuture( + cache.getDocument( + key, document.length(), ignored -> parseAndValidate.apply(executionInput))); + } + + private static String clientProfile(ExecutionInput executionInput) { + GraphQlRequestContext context = + executionInput.getGraphQLContext().get(GraphQlRequestContext.CONTEXT_KEY); + // Anonymous is its own cache partition rather than a shared default: an entry validated for a + // named profile must never be handed to a caller who proved nothing. + return context == null ? "anonymous" : context.clientProfile().value(); + } + + /** + * The hex SHA-256 of a text, used for both the document and the schema part of the key. + * + * @param value the text to hash + */ + public static String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException absent) { + throw new IllegalStateException("SHA-256 is required by every Java platform", absent); + } + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlPrincipalResolver.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlPrincipalResolver.java new file mode 100644 index 00000000..f03317c3 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlPrincipalResolver.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.inbound.graphql.runtime; + +import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticatedPrincipal; +import java.util.Optional; +import org.springframework.graphql.server.WebGraphQlRequest; + +/** + * How this deployment turns an authenticated transport request into a platform principal. + * + *

The platform deliberately does not implement this. Authentication is a composition-root + * concern — which provider, which claims, which tenant claim is trusted — and an inbound adapter + * that shipped its own would either be wrong for most adopters or would have to grow a + * configuration surface for every scheme in existence. + * + *

Returning empty means "no verified credential", not "allow". The caller decides what an + * anonymous request is permitted to do, using the client profile's own rules. + */ +@FunctionalInterface +public interface GraphQlPrincipalResolver { + + /** + * Resolves the verified principal for a request. + * + * @param request the transport request, including headers and attributes + * @return the principal, or empty when the request carries no verified credential + * @throws dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationException when a + * credential is present but not acceptable + */ + Optional resolve(WebGraphQlRequest request); + + /** A resolver that treats every request as anonymous. */ + static GraphQlPrincipalResolver anonymous() { + return request -> Optional.empty(); + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlWireErrorMapper.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlWireErrorMapper.java new file mode 100644 index 00000000..8b022e2a --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlWireErrorMapper.java @@ -0,0 +1,124 @@ +package dev.caskeleton.adapter.inbound.graphql.runtime; + +import dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorCategory; +import dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorCode; +import dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorContext; +import dev.caskeleton.adapter.inbound.graphql.error.GraphQlExceptionResolver; +import dev.caskeleton.adapter.inbound.graphql.error.GraphQlWireError; +import dev.caskeleton.shared.error.ApiErrorCarrier; +import dev.caskeleton.shared.error.ApiErrorCode; +import dev.caskeleton.shared.error.Category; +import java.util.Objects; + +/** + * The one place a failure becomes something a client can see. + * + *

There were two error contracts. The Spring-wired resolver mapped {@link ApiErrorCarrier} to a + * code and a category; a second, richer resolver did masking and registered mappings but was never + * on the Spring path; and the platform's own rejections had a third vocabulary. Which code, + * category, retryability and execution id a client received depended on which of the three paths + * the failure happened to take — and the two resolver classes differed only in the case of one + * letter, so importing the wrong one compiled. + * + *

Everything routes through here now. Request-level rejections and field-level failures are + * different strategies, because they are genuinely different situations — one has no response path + * and produces a 4xx, the other is a field inside a 200 — but both draw codes, categories, + * retryability and masking from this catalogue. + * + *

An unrecognised failure never contributes its own message. That is the fail-closed direction: + * a new exception type nobody mapped becomes an opaque internal error with a correlation id, and + * the detail stays in the logs. + */ +public final class GraphQlWireErrorMapper { + + private final GraphQlExceptionResolver applicationMappings; + + /** + * Creates the mapper. + * + * @param applicationMappings deliberate application failure mappings; may map nothing + */ + public GraphQlWireErrorMapper(GraphQlExceptionResolver applicationMappings) { + this.applicationMappings = + Objects.requireNonNull(applicationMappings, "application mappings are required"); + } + + /** A mapper with no registered application mappings: everything unknown is masked. */ + public static GraphQlWireErrorMapper masking() { + return new GraphQlWireErrorMapper(GraphQlExceptionResolver.defaults()); + } + + /** + * Maps a rejection raised before execution started. + * + *

These carry no response path and become a transport-level failure, so the strategy differs + * from {@link #mapFieldFailure}; the vocabulary does not. + */ + public GraphQlWireError mapRequestRejection(Throwable failure, GraphQlErrorContext context) { + GraphQlWireError platform = GraphQlPlatformRejectionMapper.mapKnown(failure, context); + return platform != null ? platform : mapCommon(failure, context); + } + + /** + * Maps a failure thrown by a data fetcher. + * + *

Platform rejections are consulted first here too: an authorization denial raised from inside + * a resolver means the same thing to a client as one raised before execution, and giving it a + * different code because of where it was thrown would be an implementation detail on the wire. + */ + public GraphQlWireError mapFieldFailure(Throwable failure, GraphQlErrorContext context) { + GraphQlWireError platform = GraphQlPlatformRejectionMapper.mapKnown(failure, context); + return platform != null ? platform : mapCommon(failure, context); + } + + private GraphQlWireError mapCommon(Throwable failure, GraphQlErrorContext context) { + if (failure instanceof ApiErrorCarrier carrier) { + return fromApiError(carrier.errorCode(), context); + } + // The registered application mappings, which mask anything they do not recognise. + return applicationMappings.resolve(failure, context); + } + + /** + * Maps the shared operational error code onto the GraphQL wire vocabulary. + * + *

Only the stable {@link ApiErrorCode#code()} reaches the client; the exception message never + * does, because it may carry a SQLState or an upstream body. + */ + public static GraphQlWireError fromApiError(ApiErrorCode code, GraphQlErrorContext context) { + return GraphQlWireError.of( + clientMessage(code.category()), + GraphQlErrorCode.of(code.code()), + category(code.category()), + context); + } + + /** + * Maps the ten-value operational category SSOT onto the six-value wire category. + * + *

Exhaustive, so a new operational category fails to compile until someone decides what a + * client should be told about it. + */ + public static GraphQlErrorCategory category(Category category) { + return switch (category) { + case VALIDATION -> GraphQlErrorCategory.REQUEST; + case AUTH, AUTHZ -> GraphQlErrorCategory.AUTHORIZATION; + case NOT_FOUND, CONFLICT, RATE_LIMIT -> GraphQlErrorCategory.BUSINESS; + case TRANSIENT_DEPENDENCY -> GraphQlErrorCategory.DEPENDENCY; + case PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL -> GraphQlErrorCategory.INTERNAL; + }; + } + + private static String clientMessage(Category category) { + return switch (category) { + case VALIDATION -> "요청 값이 올바르지 않습니다."; + case AUTH -> "인증이 필요합니다."; + case AUTHZ -> "이 작업을 수행할 권한이 없습니다."; + case NOT_FOUND -> "요청한 리소스를 찾을 수 없습니다."; + case CONFLICT -> "현재 상태에서는 요청을 처리할 수 없습니다."; + case RATE_LIMIT -> "요청이 너무 잦습니다."; + case TRANSIENT_DEPENDENCY -> "일시적인 오류입니다. 잠시 후 다시 시도해 주세요."; + case PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL -> GraphQlWireError.OPAQUE_MESSAGE; + }; + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlWireErrors.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlWireErrors.java new file mode 100644 index 00000000..06e24e47 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlWireErrors.java @@ -0,0 +1,82 @@ +package dev.caskeleton.adapter.inbound.graphql.runtime; + +import dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorCategory; +import dev.caskeleton.adapter.inbound.graphql.error.GraphQlWireError; +import graphql.ErrorClassification; +import graphql.ErrorType; +import graphql.GraphQLError; +import graphql.GraphqlErrorBuilder; + +/** + * Renders the platform's wire error as a graphql-java error. + * + *

One direction only. The platform decides what a client may see in {@link GraphQlWireError}, + * whose extensions are allowlisted at construction; this class does nothing but hand that decision + * to the engine. Building errors directly with the engine's builder anywhere else would bypass the + * allowlist, which is the single check standing between an exception message and the response. + */ +public final class GraphQlWireErrors { + + private GraphQlWireErrors() {} + + /** Converts an allowlisted wire error into the engine's error type. */ + public static GraphQLError toGraphQlError(GraphQlWireError error) { + return toGraphQlError(error, classification(error)); + } + + /** + * Converts an allowlisted wire error, classified explicitly. + * + *

The overload exists because the caller sometimes knows more than the wire error does. The + * platform's six wire categories are what a client branches on, but the shared operational + * catalogue has ten, and collapsing {@code NOT_FOUND} into {@code BUSINESS} before choosing the + * engine classification would throw away a distinction the previous resolver did preserve. + */ + public static GraphQLError toGraphQlError( + GraphQlWireError error, ErrorClassification classification) { + GraphqlErrorBuilder builder = + GraphqlErrorBuilder.newError() + .message(error.message()) + .errorType(classification) + .extensions(error.extensions()); + if (!error.path().isEmpty()) { + builder.path(error.path()); + } + return builder.build(); + } + + /** + * The engine classification for a shared operational category. + * + *

Exhaustive, so a new operational category fails to compile until someone decides what the + * engine should call it. + */ + public static ErrorClassification classificationOf( + dev.caskeleton.shared.error.Category category) { + return switch (category) { + case VALIDATION, CONFLICT, RATE_LIMIT -> + org.springframework.graphql.execution.ErrorType.BAD_REQUEST; + case AUTH -> org.springframework.graphql.execution.ErrorType.UNAUTHORIZED; + case AUTHZ -> org.springframework.graphql.execution.ErrorType.FORBIDDEN; + case NOT_FOUND -> org.springframework.graphql.execution.ErrorType.NOT_FOUND; + case TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL -> + org.springframework.graphql.execution.ErrorType.INTERNAL_ERROR; + }; + } + + /** + * The engine classification for a category. + * + *

Everything the platform rejects before execution is a {@code ValidationError} to the engine: + * the request never became an operation, so it is not a data-fetching failure. Mapping an + * authorization denial to {@code DataFetchingException} would place it in the part of the + * response reserved for fields that actually ran. + */ + private static ErrorClassification classification(GraphQlWireError error) { + Object category = error.extensions().get("category"); + if (GraphQlErrorCategory.INTERNAL.name().equals(category)) { + return ErrorType.DataFetchingException; + } + return ErrorType.ValidationError; + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/servlet/GraphQlRequestBodyLimitFilter.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/servlet/GraphQlRequestBodyLimitFilter.java new file mode 100644 index 00000000..fec1cfb7 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/runtime/servlet/GraphQlRequestBodyLimitFilter.java @@ -0,0 +1,213 @@ +package dev.caskeleton.adapter.inbound.graphql.runtime.servlet; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import jakarta.servlet.http.HttpServletResponse; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.nio.charset.IllegalCharsetNameException; +import java.nio.charset.StandardCharsets; +import java.nio.charset.UnsupportedCharsetException; + +/** + * Refuses an oversize GraphQL request body before anything decodes it. + * + *

The platform's other size checks run on a decoded envelope, which means the JSON parser has + * already allocated proportionally to whatever the client sent. That is the cost the limit exists + * to avoid, so the raw body needs a cap of its own, and on a servlet stack a filter is the only + * place upstream of the decoder. + * + *

Two enforcement points, because either alone is bypassable. {@code Content-Length} is checked + * first and rejects the common case without reading a byte — but it is client-supplied and absent + * on a chunked request, so the stream is also wrapped in a counter that fails the moment the cap is + * passed. A client that declares a small length and sends more is stopped by the second check. + * + *

Registered only when the application actually runs servlets. The class references the servlet + * API, which this leaf takes as {@code compileOnly}: an adopter on a reactive stack never loads it, + * and no server reaches their runtime classpath because of it. + */ +public final class GraphQlRequestBodyLimitFilter implements Filter { + + /** Status returned for an oversize body. */ + public static final int PAYLOAD_TOO_LARGE = 413; + + private final String graphQlPath; + private final long maxBodyBytes; + + /** + * Creates the filter. + * + * @param graphQlPath the endpoint path to guard, for example {@code /graphql} + * @param maxBodyBytes largest accepted raw body, in bytes + */ + public GraphQlRequestBodyLimitFilter(String graphQlPath, long maxBodyBytes) { + if (graphQlPath == null || graphQlPath.isBlank()) { + throw new IllegalArgumentException("graphql path is required"); + } + if (maxBodyBytes < 1) { + throw new IllegalArgumentException("maximum body size must be positive"); + } + this.graphQlPath = graphQlPath; + this.maxBodyBytes = maxBodyBytes; + } + + /** The cap this filter enforces. */ + public long maxBodyBytes() { + return maxBodyBytes; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + if (!(request instanceof HttpServletRequest httpRequest) + || !(response instanceof HttpServletResponse httpResponse) + || !guards(httpRequest)) { + chain.doFilter(request, response); + return; + } + + if (httpRequest.getContentLengthLong() > maxBodyBytes) { + reject(httpResponse); + return; + } + + try { + chain.doFilter(new BoundedBodyRequest(httpRequest, maxBodyBytes), response); + } catch (BodyTooLargeException tooLarge) { + if (!httpResponse.isCommitted()) { + reject(httpResponse); + } + } + } + + private boolean guards(HttpServletRequest request) { + String path = request.getRequestURI(); + return path != null && path.equals(request.getContextPath() + graphQlPath); + } + + private static void reject(HttpServletResponse response) throws IOException { + response.resetBuffer(); + response.setStatus(PAYLOAD_TOO_LARGE); + response.setContentType("application/json"); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + // Byte counts only: the body that was refused is exactly the input that must not be echoed. + response + .getWriter() + .write( + "{\"errors\":[{\"message\":\"요청이 허용된 크기를 초과했습니다.\"," + + "\"extensions\":{\"code\":\"REQUEST_ERROR\",\"category\":\"REQUEST\"}}]}"); + response.getWriter().flush(); + } + + /** Wraps the body so the cap holds even when {@code Content-Length} lied or was absent. */ + private static final class BoundedBodyRequest extends HttpServletRequestWrapper { + + private final long limit; + + BoundedBodyRequest(HttpServletRequest request, long limit) { + super(request); + this.limit = limit; + } + + @Override + public ServletInputStream getInputStream() throws IOException { + return new BoundedServletInputStream(super.getInputStream(), limit); + } + + @Override + public BufferedReader getReader() throws IOException { + return new BufferedReader(new InputStreamReader(getInputStream(), requestCharset())); + } + + private Charset requestCharset() { + String encoding = getCharacterEncoding(); + if (encoding == null || encoding.isBlank()) { + return StandardCharsets.UTF_8; + } + try { + return Charset.forName(encoding); + } catch (UnsupportedCharsetException | IllegalCharsetNameException unknown) { + // An unusable charset is the container's problem to report, not a reason for the size cap + // to fail open; UTF-8 still counts the same bytes. + return StandardCharsets.UTF_8; + } + } + } + + /** Counts bytes as they are read and fails the read that crosses the cap. */ + private static final class BoundedServletInputStream extends ServletInputStream { + + private final ServletInputStream delegate; + private final long limit; + private long consumed; + + BoundedServletInputStream(ServletInputStream delegate, long limit) { + this.delegate = delegate; + this.limit = limit; + } + + @Override + public int read() throws IOException { + int value = delegate.read(); + if (value >= 0) { + count(1); + } + return value; + } + + @Override + public int read(byte[] buffer, int offset, int length) throws IOException { + int read = delegate.read(buffer, offset, length); + if (read > 0) { + count(read); + } + return read; + } + + private void count(int read) { + consumed += read; + if (consumed > limit) { + throw new BodyTooLargeException(consumed, limit); + } + } + + @Override + public boolean isFinished() { + return delegate.isFinished(); + } + + @Override + public boolean isReady() { + return delegate.isReady(); + } + + @Override + public void setReadListener(jakarta.servlet.ReadListener readListener) { + delegate.setReadListener(readListener); + } + + @Override + public void close() throws IOException { + delegate.close(); + } + } + + /** Unchecked so it can escape {@code InputStream.read}, which the decoder calls. */ + private static final class BodyTooLargeException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + BodyTooLargeException(long observed, long allowed) { + super("graphql request body too large: " + observed + " > " + allowed + " bytes"); + } + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/scalar/BigDecimalScalar.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/scalar/BigDecimalScalar.java index 3619ec2e..8d2e51a9 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/scalar/BigDecimalScalar.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/scalar/BigDecimalScalar.java @@ -14,6 +14,7 @@ import graphql.schema.GraphQLScalarType; import java.math.BigDecimal; import java.math.BigInteger; import java.util.Locale; +import java.util.Objects; /** * The Stable {@code BigDecimal} scalar: exact decimals, serialized as a string. @@ -25,26 +26,40 @@ import java.util.Locale; * *

Output is a JSON string, because a JSON number would be re-parsed as a double by most clients * and lose the precision again on the way back. + * + *

Bounded in both directions by {@link GraphQlDecimalBounds}. Unbounded, this scalar was an + * amplifier: {@code "1E+1000000"} is eleven characters of input, costs nothing to parse, and then + * {@code toPlainString()} allocated a million-character response. Input size was never the quantity + * that needed limiting. */ public final class BigDecimalScalar { /** GraphQL scalar name. */ public static final String NAME = "BigDecimal"; - private static final GraphQLScalarType TYPE = - GraphQLScalarType.newScalar() - .name(NAME) - .description("Arbitrary-precision decimal serialized as a string") - .coercing(new BigDecimalCoercing()) - .build(); + private static final GraphQLScalarType TYPE = type(GraphQlDecimalBounds.defaults()); private BigDecimalScalar() {} - /** The wired scalar type. */ + /** The wired scalar type using the default bounds. */ public static GraphQLScalarType type() { return TYPE; } + /** + * A wired scalar type using explicit bounds. + * + * @param bounds the input and output limits + */ + public static GraphQLScalarType type(GraphQlDecimalBounds bounds) { + Objects.requireNonNull(bounds, "decimal bounds are required"); + return GraphQLScalarType.newScalar() + .name(NAME) + .description("Arbitrary-precision decimal serialized as a string") + .coercing(new BigDecimalCoercing(bounds)) + .build(); + } + /** * Parses an exact decimal. * @@ -52,10 +67,32 @@ public final class BigDecimalScalar { * floating-point value that could not be represented without loss */ public static BigDecimal parse(Object value) { + return parseWithin(value, GraphQlDecimalBounds.defaults()); + } + + /** + * Parses an exact decimal within explicit bounds. + * + * @param value the input to coerce + * @param bounds the input and output limits + * @throws CoercingParseValueException when the value is not an exact decimal, is a binary + * floating-point value, or exceeds a bound + */ + public static BigDecimal parseWithin(Object value, GraphQlDecimalBounds bounds) { + Objects.requireNonNull(bounds, "decimal bounds are required"); + return requireWithinBounds(coerce(value, bounds), bounds); + } + + private static BigDecimal coerce(Object value, GraphQlDecimalBounds bounds) { if (value instanceof BigDecimal decimal) { return decimal; } if (value instanceof BigInteger integer) { + // Checked before conversion: a BigInteger arrives already materialised, and its digit count + // is the precision the bound is about. + if (integer.bitLength() > bounds.maximumPrecision() * 4L) { + throw new CoercingParseValueException("BigDecimal exceeds the configured precision"); + } return new BigDecimal(integer); } if (value instanceof Integer @@ -69,8 +106,13 @@ public final class BigDecimalScalar { "binary floating point cannot represent an exact decimal; supply BigDecimal as a string"); } if (value instanceof String text) { + String stripped = text.strip(); + if (stripped.length() > bounds.maximumLexicalLength()) { + // Refused before parsing, so a long digit string never becomes an allocation at all. + throw new CoercingParseValueException("BigDecimal input exceeds the configured length"); + } try { - return new BigDecimal(text.strip()); + return new BigDecimal(stripped); } catch (NumberFormatException ex) { throw new CoercingParseValueException("invalid BigDecimal"); } @@ -78,53 +120,92 @@ public final class BigDecimalScalar { throw new CoercingParseValueException("invalid BigDecimal"); } - /** Serializes an exact decimal as its plain string form. */ - public static String serialize(Object value) { - if (value instanceof BigDecimal decimal) { - return decimal.toPlainString(); + private static BigDecimal requireWithinBounds(BigDecimal value, GraphQlDecimalBounds bounds) { + if (!bounds.permitsValue(value)) { + // Never echoes the input: a coercion error is returned to the caller, and the caller does not + // need their own payload read back to them to know it was rejected. + throw new CoercingParseValueException("BigDecimal exceeds the configured precision or scale"); } - if (value instanceof BigInteger + if (!bounds.permitsOutput(value)) { + throw new CoercingParseValueException("BigDecimal would exceed the configured output length"); + } + return value; + } + + /** Serializes an exact decimal as its plain string form, within the default bounds. */ + public static String serialize(Object value) { + return serializeWithin(value, GraphQlDecimalBounds.defaults()); + } + + /** + * Serializes an exact decimal within explicit bounds. + * + *

The output bound is checked against the computed plain-string length before the string is + * produced. Producing it first and measuring afterwards would have performed the allocation the + * bound exists to refuse. + * + * @param value the resolver's value + * @param bounds the input and output limits + */ + public static String serializeWithin(Object value, GraphQlDecimalBounds bounds) { + Objects.requireNonNull(bounds, "decimal bounds are required"); + BigDecimal decimal; + if (value instanceof BigDecimal alreadyDecimal) { + decimal = alreadyDecimal; + } else if (value instanceof BigInteger || value instanceof Integer || value instanceof Long || value instanceof Short || value instanceof Byte || value instanceof String) { - return parse(value).toPlainString(); + decimal = coerce(value, bounds); + } else { + throw new CoercingSerializeException("value is not a BigDecimal"); } - throw new CoercingSerializeException("value is not a BigDecimal"); + if (!bounds.permitsOutput(decimal)) { + throw new CoercingSerializeException("BigDecimal would exceed the configured output length"); + } + return decimal.toPlainString(); } private static final class BigDecimalCoercing implements Coercing { + private final GraphQlDecimalBounds bounds; + + BigDecimalCoercing(GraphQlDecimalBounds bounds) { + this.bounds = bounds; + } + @Override public String serialize(Object dataFetcherResult, GraphQLContext context, Locale locale) { - return BigDecimalScalar.serialize(dataFetcherResult); + return serializeWithin(dataFetcherResult, bounds); } @Override public BigDecimal parseValue(Object input, GraphQLContext context, Locale locale) { - return parse(input); + return parseWithin(input, bounds); } @Override public BigDecimal parseLiteral( Value input, CoercedVariables variables, GraphQLContext context, Locale locale) { if (input instanceof StringValue stringValue) { - return parse(stringValue.getValue()); + return parseWithin(stringValue.getValue(), bounds); } if (input instanceof IntValue intValue) { - return new BigDecimal(intValue.getValue()); + return parseWithin(intValue.getValue(), bounds); } if (input instanceof FloatValue floatValue) { - // A FloatValue literal is exact decimal text in the document, not a binary double. - return floatValue.getValue(); + // A FloatValue literal is exact decimal text in the document, not a binary double — but it + // is still client-supplied, so it meets the same bounds as every other input. + return requireWithinBounds(floatValue.getValue(), bounds); } throw new CoercingParseLiteralException("invalid BigDecimal literal"); } @Override public Value valueToLiteral(Object input, GraphQLContext context, Locale locale) { - return StringValue.newStringValue(BigDecimalScalar.serialize(input)).build(); + return StringValue.newStringValue(serializeWithin(input, bounds)).build(); } } } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/scalar/GraphQlDecimalBounds.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/scalar/GraphQlDecimalBounds.java new file mode 100644 index 00000000..7a8907d3 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/scalar/GraphQlDecimalBounds.java @@ -0,0 +1,69 @@ +package dev.caskeleton.adapter.inbound.graphql.scalar; + +import java.math.BigDecimal; + +/** + * What a decimal is allowed to cost, on the way in and on the way out. + * + *

Four bounds because a decimal has four independent ways to be expensive, and the cheap ones + * hide the expensive ones. {@code 1E+1000000} is eleven characters, parses instantly, and holds a + * precision of one — and then {@code toPlainString()} materialises a million and one characters. + * Bounding the input text alone therefore proves nothing about the output; the output length is its + * own bound, checked before anything is allocated. + * + *

{@code maximumScale} is absolute: a scale of {@code -1000000} is as expensive as {@code + * 1000000}, because both describe a number whose plain form is a million digits long. + * + * @param maximumLexicalLength longest accepted input text + * @param maximumPrecision most significant digits a value may carry + * @param maximumScale largest absolute scale, in either direction + * @param maximumOutputLength longest plain-string form this scalar will produce + */ +public record GraphQlDecimalBounds( + int maximumLexicalLength, int maximumPrecision, int maximumScale, int maximumOutputLength) { + + public GraphQlDecimalBounds { + if (maximumLexicalLength < 1 + || maximumPrecision < 1 + || maximumScale < 0 + || maximumOutputLength < 1) { + throw new IllegalArgumentException("decimal bounds must be positive"); + } + } + + /** + * Bounds wide enough for money, quantities and scientific readings, and far short of an + * allocation attack. + */ + public static GraphQlDecimalBounds defaults() { + return new GraphQlDecimalBounds(64, 50, 30, 128); + } + + /** + * The length of a value's plain-string form, computed rather than produced. + * + *

Computed on purpose: asking {@code toPlainString().length()} would perform exactly the + * allocation this bound exists to refuse. + * + * @param value the decimal to measure + */ + public static long plainStringLength(BigDecimal value) { + int precision = value.precision(); + int scale = value.scale(); + long digits = + scale >= 0 + ? Math.max(precision, (long) scale + 1) + : (long) precision + Math.abs((long) scale); + return digits + (scale > 0 ? 1 : 0) + (value.signum() < 0 ? 1 : 0); + } + + /** Whether a value's plain form fits the output bound. */ + public boolean permitsOutput(BigDecimal value) { + return plainStringLength(value) <= maximumOutputLength; + } + + /** Whether a value's precision and scale fit the value bounds. */ + public boolean permitsValue(BigDecimal value) { + return value.precision() <= maximumPrecision && Math.abs((long) value.scale()) <= maximumScale; + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/scalar/LongScalar.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/scalar/LongScalar.java index 08103f05..48356a0f 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/scalar/LongScalar.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/scalar/LongScalar.java @@ -23,6 +23,11 @@ import java.util.Locale; * *

Serialized as a JSON string, so the value survives a JSON parser that treats numbers as * doubles. + * + *

The range applies to output as well as input. It used to apply only on the way in, so a + * deployment that configured the double-safe range to protect its JavaScript clients still sent + * them {@code 9223372036854775807} whenever a resolver produced one — the exact value the range + * existed to keep off the wire, arriving by the one path nobody checked. */ public final class LongScalar { @@ -84,17 +89,36 @@ public final class LongScalar { return parsed; } - /** Serializes a long as a string. */ + /** Serializes a long as a string, within the default double-safe range. */ public static String serialize(Object value) { - if (value instanceof Long + return serializeWithin(value, JS_SAFE_MINIMUM, JS_SAFE_MAXIMUM); + } + + /** + * Serializes a long as a string, within an explicit range. + * + * @param value the resolver's value + * @param minimum smallest value this deployment's clients can represent + * @param maximum largest value this deployment's clients can represent + * @throws CoercingSerializeException when the value falls outside the range + */ + public static String serializeWithin(Object value, long minimum, long maximum) { + if (!(value instanceof Long || value instanceof Integer || value instanceof Short || value instanceof Byte || value instanceof BigInteger - || value instanceof String) { - return Long.toString(toLong(value)); + || value instanceof String)) { + throw new CoercingSerializeException("value is not a Long"); } - throw new CoercingSerializeException("value is not a Long"); + long serialized = toLong(value); + if (serialized < minimum || serialized > maximum) { + // A serialize-time range breach is the server's bug, not the client's: the field is declared + // as a value this client can hold, and it is not one. Failing the field says so; rounding it + // to fit would hand the client a different number and call it the answer. + throw new CoercingSerializeException("Long value is outside the configured client range"); + } + return Long.toString(serialized); } private static long toLong(Object value) { @@ -137,7 +161,7 @@ public final class LongScalar { @Override public String serialize(Object dataFetcherResult, GraphQLContext context, Locale locale) { - return LongScalar.serialize(dataFetcherResult); + return serializeWithin(dataFetcherResult, minimum, maximum); } @Override @@ -159,7 +183,7 @@ public final class LongScalar { @Override public Value valueToLiteral(Object input, GraphQLContext context, Locale locale) { - return StringValue.newStringValue(LongScalar.serialize(input)).build(); + return StringValue.newStringValue(serializeWithin(input, minimum, maximum)).build(); } } } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlAuthenticationContextFactory.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlAuthenticationContextFactory.java index 8f35f459..d790cf39 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlAuthenticationContextFactory.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlAuthenticationContextFactory.java @@ -7,7 +7,6 @@ import dev.caskeleton.adapter.inbound.graphql.context.GraphQlDeadline; import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; import dev.caskeleton.adapter.inbound.graphql.context.TenantContext; import java.time.Clock; -import java.time.Duration; import java.util.Locale; /** @@ -88,18 +87,4 @@ public final class GraphQlAuthenticationContextFactory { traceId, deadline); } - - /** A fixed context for contract tests, with the tenant taken from verified data. */ - public static GraphQlRequestContext testContext(String tenant) { - return new GraphQlAuthenticationContextFactory(Clock.systemUTC()) - .create( - new GraphQlAuthenticatedPrincipal( - ActorRef.authenticated("actor-test"), - TenantContext.fromAuthenticatedCredential(tenant), - new GraphQlClientProfile("first-party"), - Locale.ROOT, - "trace-test", - null), - GraphQlDeadline.after(Duration.ofSeconds(5), Clock.systemUTC())); - } } diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlContextCleanup.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlContextCleanup.java index 0c71197a..3ed876be 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlContextCleanup.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlContextCleanup.java @@ -31,8 +31,10 @@ public final class GraphQlContextCleanup { /** * Runs every registered action, in reverse registration order. * - *

A failing action never prevents the others from running; the first failure is rethrown once - * everything has been attempted. + *

A failing action never prevents the others from running. The first failure is rethrown once + * everything has been attempted, with every later failure attached to it as suppressed — losing + * them would mean a second broken cleanup is invisible until the first one is fixed, which is + * exactly when nobody is looking for it. */ public void close() { RuntimeException firstFailure = null; @@ -40,9 +42,11 @@ public final class GraphQlContextCleanup { Runnable action = actions.pop(); try { action.run(); - } catch (RuntimeException ex) { + } catch (RuntimeException failure) { if (firstFailure == null) { - firstFailure = ex; + firstFailure = failure; + } else { + firstFailure.addSuppressed(failure); } } } diff --git a/src/adapter/inbound/graphql/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/adapter/inbound/graphql/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..c8252ffe --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformAutoConfiguration diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/GraphqlExceptionResolverTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/GraphqlExceptionResolverTest.java deleted file mode 100644 index 98ce99cf..00000000 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/GraphqlExceptionResolverTest.java +++ /dev/null @@ -1,103 +0,0 @@ -package dev.caskeleton.adapter.inbound.graphql; - -import static org.assertj.core.api.Assertions.assertThat; - -import dev.caskeleton.shared.error.ApiErrorCarrier; -import dev.caskeleton.shared.error.ApiErrorCode; -import dev.caskeleton.shared.error.Category; -import graphql.GraphQLError; -import java.util.EnumSet; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; -import org.springframework.graphql.execution.ErrorType; - -/** - * Pins the 10-value {@link Category} → {@link ErrorType} classification table (design - * Error-Mapping) and the {@code code} / {@code category} extensions. One assertion per Category - * value guards against a silent remap on a Spring/graphql-java upgrade. Driven directly against - * {@code resolveToSingleError} with a null environment (no GraphQL engine needed), so it is a pure - * mapping unit test — the boot-level wiring is covered by {@link HealthGraphqlControllerTest}. - */ -class GraphqlExceptionResolverTest { - - private final GraphqlExceptionResolver resolver = new GraphqlExceptionResolver(); - - @ParameterizedTest - @CsvSource({ - "VALIDATION,BAD_REQUEST", - "AUTH,UNAUTHORIZED", - "AUTHZ,FORBIDDEN", - "NOT_FOUND,NOT_FOUND", - "CONFLICT,BAD_REQUEST", - "RATE_LIMIT,BAD_REQUEST", - "TRANSIENT_DEPENDENCY,INTERNAL_ERROR", - "PERMANENT_DEPENDENCY,INTERNAL_ERROR", - "DATA_INTEGRITY,INTERNAL_ERROR", - "INTERNAL,INTERNAL_ERROR", - }) - void mapsEachCategoryToItsErrorTypeWithExtensions(Category category, ErrorType expected) { - GraphQLError error = - resolver.resolveToSingleError(new CarrierException("SOME_CODE", category), null); - - assertThat(error).isNotNull(); - assertThat(error.getErrorType()).isEqualTo(expected); - assertThat(error.getExtensions()) - .containsEntry("code", "SOME_CODE") - .containsEntry("category", category.name()); - } - - @Test - void coversEveryCategoryValue() { - // Fails the moment a new Category is added without a mapping decision (switch is exhaustive). - for (Category category : EnumSet.allOf(Category.class)) { - assertThat(resolver.resolveToSingleError(new CarrierException("C", category), null)) - .isNotNull(); - } - } - - @Test - void surfacesOnlyTheStableCodeAsTheMessageNotTheRawException() { - GraphQLError error = - resolver.resolveToSingleError( - new CarrierException("WORKLOG_NOT_FOUND", Category.NOT_FOUND), null); - - assertThat(error.getMessage()).isEqualTo("WORKLOG_NOT_FOUND"); - } - - @Test - void returnsNullForNonCarrierExceptionSoOtherResolversHandleIt() { - assertThat(resolver.resolveToSingleError(new IllegalStateException("boom"), null)).isNull(); - } - - /** - * Feature-style throwable carrying an {@link ApiErrorCode} through the {@link ApiErrorCarrier}. - */ - private static final class CarrierException extends RuntimeException implements ApiErrorCarrier { - private static final long serialVersionUID = 1L; - private final ApiErrorCode errorCode; - - CarrierException(String code, Category category) { - super(code); - this.errorCode = new TestErrorCode(code, category); - } - - @Override - public ApiErrorCode errorCode() { - return errorCode; - } - } - - /** Minimal {@link ApiErrorCode} — only {@code code} / {@code category} matter for the mapping. */ - private record TestErrorCode(String code, Category category) implements ApiErrorCode { - @Override - public int httpStatus() { - return 0; - } - - @Override - public boolean retryable() { - return false; - } - } -} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/GraphqlHttpBoundaryQualificationTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/GraphqlHttpBoundaryQualificationTest.java index e223bd54..89b834e0 100644 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/GraphqlHttpBoundaryQualificationTest.java +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/GraphqlHttpBoundaryQualificationTest.java @@ -122,10 +122,12 @@ class GraphqlHttpBoundaryQualificationTest { ResponseEntity response = graphql("{ carrierFailure }", true, null); assertThat(response.getStatusCode().value()).isEqualTo(200); + // One vocabulary now: the stable code lives in `extensions.code`, the message is written for a + // human, and the engine classification still distinguishes NOT_FOUND from other outcomes. assertThat(response.getBody()) - .contains("\"message\":\"" + STABLE_CODE + "\"") .contains("\"code\":\"" + STABLE_CODE + "\"") - .contains("\"category\":\"NOT_FOUND\"") + .contains("\"classification\":\"NOT_FOUND\"") + .contains("\"category\":\"BUSINESS\"") .doesNotContain(CARRIER_SECRET, UNKNOWN_SECRET); } @@ -135,7 +137,10 @@ class GraphqlHttpBoundaryQualificationTest { assertThat(response.getStatusCode().value()).isEqualTo(200); assertThat(response.getBody()) - .contains("\"classification\":\"INTERNAL_ERROR\"") + .contains("\"code\":\"INTERNAL_ERROR\"") + .contains("\"category\":\"INTERNAL\"") + .as("an unmapped failure is masked on every path, and carries a correlation id instead") + .contains("\"executionId\"") .doesNotContain(CARRIER_SECRET, UNKNOWN_SECRET); } @@ -162,7 +167,6 @@ class GraphqlHttpBoundaryQualificationTest { @EnableAutoConfiguration @Import({ HealthGraphqlController.class, - GraphqlExceptionResolver.class, QualificationController.class, TestSecurityConfiguration.class }) diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationRemovalGateTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationRemovalGateTest.java index 9b50e3b8..4a1c7c94 100644 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationRemovalGateTest.java +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationRemovalGateTest.java @@ -6,6 +6,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; 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.GraphQlPersistedOperationNotFoundException; import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationStatus; import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.InMemoryGraphQlPersistedOperationRegistry; import java.time.Clock; @@ -49,11 +50,24 @@ class GraphQlPersistedOperationRemovalGateTest { void applicationCredentialsCannotAdministerTheRegistry() { var authorization = new GraphQlPersistedOperationAdminAuthorization(Set.of("release-manager")); - assertThatThrownBy(() -> authorization.requireAdministrator("checkout-service")) + assertThatThrownBy( + () -> + authorization.requireAdministrator( + GraphQlAdminPrincipal.operations("checkout-service"))) + .as("not an administrator") .isInstanceOf(GraphQlPersistedOperationAdminDeniedException.class); - assertThatThrownBy(() -> authorization.rejectApplicationCredential(true)) + assertThatThrownBy( + () -> + authorization.requireAdministrator( + GraphQlAdminPrincipal.fromApplicationCredential("release-manager"))) + .as("an allowlisted name presented with a request-path credential is still refused") .isInstanceOf(GraphQlPersistedOperationAdminDeniedException.class); - assertThatCode(() -> authorization.requireAdministrator("release-manager")) + assertThatThrownBy(() -> authorization.requireAdministrator(null)) + .isInstanceOf(GraphQlPersistedOperationAdminDeniedException.class); + assertThatCode( + () -> + authorization.requireAdministrator( + GraphQlAdminPrincipal.operations("release-manager"))) .doesNotThrowAnyException(); } @@ -70,7 +84,8 @@ class GraphQlPersistedOperationRemovalGateTest { new GraphQlPersistedOperationId("dangerous-v1"), "release-manager", "incident-4711", - "trace-1")); + "trace-1"), + ADMIN); assertThat( registry.find(new GraphQlPersistedOperationId("dangerous-v1")).orElseThrow().status()) @@ -81,7 +96,7 @@ class GraphQlPersistedOperationRemovalGateTest { audit -> { assertThat(audit.operator()).isEqualTo("release-manager"); assertThat(audit.reason()).isEqualTo("incident-4711"); - assertThat(audit.before()).isEqualTo("ACTIVE"); + assertThat(audit.before()).isEqualTo("BLOCK"); assertThat(audit.after()).isEqualTo("BLOCKED"); assertThat(audit.at()).isEqualTo(NOW); }); @@ -97,10 +112,10 @@ class GraphQlPersistedOperationRemovalGateTest { assertThatThrownBy( () -> - service.remove( + service.retireAndBlock( new GraphQlPersistedOperationId("get-order-v1"), new GraphQlPersistedOperationUsage(NOW.minus(Duration.ofDays(1)), 5), - "release-manager", + ADMIN, "cleanup", "trace-1")) .isInstanceOf(GraphQlPersistedOperationRemovalRejectedException.class); @@ -116,10 +131,7 @@ class GraphQlPersistedOperationRemovalGateTest { var service = service(registry); service.deprecate( - new GraphQlPersistedOperationId("get-order-v1"), - "release-manager", - "superseded", - "trace-1"); + new GraphQlPersistedOperationId("get-order-v1"), ADMIN, "superseded", "trace-1"); assertThat( registry @@ -137,10 +149,74 @@ class GraphQlPersistedOperationRemovalGateTest { .containsExactly("operationId", "operator", "reason", "before", "after", "at", "traceId"); } + @Test + void aCommandForAnOperationThatDoesNotExistFailsAndIsNotAudited() { + var registry = new InMemoryGraphQlPersistedOperationRegistry(); + var service = service(registry); + + assertThatThrownBy( + () -> + service.block( + new GraphQlPersistedOperationBlockCommand( + new GraphQlPersistedOperationId("never-existed-v1"), + "release-manager", + "incident-4711", + "trace-1"), + ADMIN)) + .isInstanceOf(GraphQlPersistedOperationNotFoundException.class); + assertThat(service.auditTrail()) + .as("an incident response that changed nothing must not read as one that did") + .isEmpty(); + } + + @Test + void aBlockedOperationCannotBeMadeExecutableByDeprecatingIt() { + var registry = new InMemoryGraphQlPersistedOperationRegistry(); + registry.register( + GraphQlPersistedOperation.active( + "dangerous-v1", "Dangerous", "sha256:a", "query Dangerous { expensive }", "schema-a")); + var service = service(registry); + var id = new GraphQlPersistedOperationId("dangerous-v1"); + service.block( + new GraphQlPersistedOperationBlockCommand(id, "release-manager", "incident", "trace-1"), + ADMIN); + + assertThatThrownBy(() -> service.deprecate(id, ADMIN, "tidy-up", "trace-2")) + .as("leaving the emergency state must be deliberate, not a side effect") + .isInstanceOf( + dev.caskeleton.adapter.inbound.graphql.advanced.persisted + .GraphQlPersistedOperationConflictException.class); + assertThat(registry.find(id).orElseThrow().status().executable()).isFalse(); + } + + @Test + void unblockingIsItsOwnAuditedCommand() { + var registry = new InMemoryGraphQlPersistedOperationRegistry(); + registry.register( + GraphQlPersistedOperation.active( + "dangerous-v1", "Dangerous", "sha256:a", "query Dangerous { expensive }", "schema-a")); + var service = service(registry); + var id = new GraphQlPersistedOperationId("dangerous-v1"); + service.block( + new GraphQlPersistedOperationBlockCommand(id, "release-manager", "incident", "trace-1"), + ADMIN); + + service.unblock(id, ADMIN, "incident resolved", "trace-2"); + + assertThat(registry.find(id).orElseThrow().status().executable()).isTrue(); + assertThat(service.auditTrail()) + .hasSize(2) + .last() + .satisfies(audit -> assertThat(audit.before()).isEqualTo("UNBLOCK")); + } + + private static final GraphQlAdminPrincipal ADMIN = + GraphQlAdminPrincipal.operations("release-manager"); + private static GraphQlPersistedOperationAdminService service( InMemoryGraphQlPersistedOperationRegistry registry) { return new GraphQlPersistedOperationAdminService( - registry, + new InMemoryGraphQlPersistedOperationAdminPort(registry), new GraphQlPersistedOperationAdminAuthorization(Set.of("release-manager")), new GraphQlPersistedOperationRemovalGate(Duration.ofDays(30)), CLOCK); diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlOperationValidatorTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlOperationValidatorTest.java new file mode 100644 index 00000000..09b90238 --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlOperationValidatorTest.java @@ -0,0 +1,128 @@ +package dev.caskeleton.adapter.inbound.graphql.advanced.codegen; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The validator has to read the operation it is validating. + * + *

It used to check that two strings were non-blank and then compare the schema with itself, + * which holds for every schema ever written. Every case below passed that check and became + * generated client code that fails against the very schema it was generated from. + */ +@Tag("graphql-advanced") +class GraphQlOperationValidatorTest { + + private static final String SDL = + """ + type Query { + order(id: ID!): Order + orders: [Order!]! + } + type Order { + id: ID! + total: Int! + } + """; + + @Test + void aValidOperationPasses() { + assertThatCode( + () -> + GraphQlOperationValidator.validate( + SDL, "query GetOrder($id: ID!) { order(id: $id) { id total } }", null)) + .doesNotThrowAnyException(); + } + + @Test + void aValidOperationUsingAFragmentPasses() { + assertThatCode( + () -> + GraphQlOperationValidator.validate( + SDL, + "query GetOrders { orders { ...OrderFields } } " + + "fragment OrderFields on Order { id total }", + null)) + .doesNotThrowAnyException(); + } + + @Test + void invalidSyntaxIsRejected() { + assertThatThrownBy( + () -> GraphQlOperationValidator.validate(SDL, "query GetOrder { order(id: }", null)) + .isInstanceOf(GraphQlCodegenBoundaryException.class) + .hasMessageContaining("does not parse"); + } + + @Test + void anUnknownFieldIsRejected() { + assertThatThrownBy( + () -> GraphQlOperationValidator.validate(SDL, "{ order(id: \"1\") { missing } }", null)) + .isInstanceOf(GraphQlCodegenBoundaryException.class); + } + + @Test + void anUnknownArgumentIsRejected() { + assertThatThrownBy( + () -> + GraphQlOperationValidator.validate( + SDL, "{ order(id: \"1\", nope: 1) { id } }", null)) + .isInstanceOf(GraphQlCodegenBoundaryException.class); + } + + @Test + void anUnknownTypeInAVariableIsRejected() { + assertThatThrownBy( + () -> + GraphQlOperationValidator.validate( + SDL, "query Q($id: Missing!) { order(id: $id) { id } }", null)) + .isInstanceOf(GraphQlCodegenBoundaryException.class); + } + + @Test + void anAmbiguousOperationIsRejected() { + String twoOperations = "query A { orders { id } } query B { orders { total } }"; + + assertThatThrownBy(() -> GraphQlOperationValidator.validate(SDL, twoOperations, null)) + .isInstanceOf(GraphQlCodegenBoundaryException.class) + .hasMessageContaining("declares 2 operations"); + assertThatCode(() -> GraphQlOperationValidator.validate(SDL, twoOperations, "B")) + .as("naming the operation resolves the ambiguity") + .doesNotThrowAnyException(); + assertThatThrownBy(() -> GraphQlOperationValidator.validate(SDL, twoOperations, "C")) + .hasMessageContaining("no operation named C"); + } + + @Test + void aDocumentWithNoOperationIsRejected() { + assertThatThrownBy( + () -> GraphQlOperationValidator.validate(SDL, "fragment F on Order { id }", null)) + .isInstanceOf(GraphQlCodegenBoundaryException.class) + .hasMessageContaining("declares no operation"); + } + + @Test + void aSchemaThatDoesNotCompileIsRejected() { + assertThatThrownBy( + () -> GraphQlOperationValidator.validate("type Query { a: Missing }", "{ a }", null)) + .isInstanceOf(GraphQlCodegenBoundaryException.class) + .hasMessageContaining("schema does not compile"); + } + + @Test + void thePlanDelegatesToTheValidator() { + GraphQlClientOperationGenerator plan = + new GraphQlClientOperationGenerator( + new GraphQlCodegenProfile( + "com.example.generated", + "build/generated/graphql", + java.util.Set.of("CLIENT_REQUEST"), + java.util.List.of())); + + assertThatThrownBy(() -> plan.validateOperation(SDL, "{ order(id: \"1\") { missing } }")) + .isInstanceOf(GraphQlCodegenBoundaryException.class); + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryExposureValidatorTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryExposureValidatorTest.java deleted file mode 100644 index 3fa7136b..00000000 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryExposureValidatorTest.java +++ /dev/null @@ -1,100 +0,0 @@ -package dev.caskeleton.adapter.inbound.graphql.advanced.compat; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import java.util.Set; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; - -/** Allowlisted Spring Data GraphQL compatibility (Advanced plan Task 15). */ -@Tag("graphql-advanced") -class GraphQlRepositoryExposureValidatorTest { - - @Test - void unregisteredRepositoryIsRejected() { - var validator = new GraphQlRepositoryExposureValidator(GraphQlRepositoryAllowlist.empty()); - - assertThatThrownBy( - () -> - validator.verify(new GraphQlRepositoryExposure("OrderRepository", "Query.orders"))) - .isInstanceOf(GraphQlRepositoryExposureRejectedException.class); - } - - @Test - void anAllowlistedRepositoryIsAccepted() { - var validator = - new GraphQlRepositoryExposureValidator(GraphQlRepositoryAllowlist.of("OrderRepository")); - - assertThatCode( - () -> - validator.verify(new GraphQlRepositoryExposure("OrderRepository", "Query.orders"))) - .doesNotThrowAnyException(); - } - - @Test - void filterAndSortFieldsAreEnumeratedPerCoordinate() { - var policy = - new GraphQlRepositoryArgumentPolicy(Set.of("status", "createdAt"), Set.of("createdAt")); - - assertThatCode(() -> policy.verify(Set.of("status"), Set.of("createdAt"))) - .doesNotThrowAnyException(); - assertThatThrownBy(() -> policy.verify(Set.of("internalNote"), Set.of())) - .isInstanceOf(GraphQlRepositoryExposureRejectedException.class) - .hasMessageContaining("internalNote"); - assertThatThrownBy(() -> policy.verify(Set.of(), Set.of("status"))) - .isInstanceOf(GraphQlRepositoryExposureRejectedException.class); - } - - @Test - void theImplicitOffsetPaginationDefaultIsRefused() { - var implicitDefault = new GraphQlRepositoryPaginationPolicy(false, 20, 100); - var chosen = GraphQlRepositoryPaginationPolicy.keyset(20, 100); - - assertThat(implicitDefault.implicitSpringDataDefault()).isTrue(); - assertThat(chosen.implicitSpringDataDefault()).isFalse(); - assertThat(GraphQlRepositoryPaginationPolicy.SPRING_DATA_DEFAULT_PAGE_SIZE).isEqualTo(20); - - var validator = - new GraphQlRepositoryExposureValidator(GraphQlRepositoryAllowlist.of("OrderRepository")); - assertThatThrownBy( - () -> - validator.verifyConfiguration( - new GraphQlRepositoryExposure("OrderRepository", "Query.orders"), - implicitDefault, - projection())) - .isInstanceOf(GraphQlRepositoryExposureRejectedException.class) - .hasMessageContaining("implicit offset pagination"); - assertThatCode( - () -> - validator.verifyConfiguration( - new GraphQlRepositoryExposure("OrderRepository", "Query.orders"), - chosen, - projection())) - .doesNotThrowAnyException(); - } - - @Test - void entitiesAndDocumentsAreNeverReturnedDirectly() { - assertThatThrownBy(() -> projection().verifyNotPersistenceType(Set.of("OrderSummary"))) - .isInstanceOf(GraphQlRepositoryExposureRejectedException.class); - assertThatCode(() -> projection().verifyNotPersistenceType(Set.of("OrderEntity"))) - .doesNotThrowAnyException(); - } - - @Test - void aProjectionMustExposeAtLeastOneField() { - assertThatThrownBy(() -> new GraphQlRepositoryProjectionPolicy("OrderSummary", Set.of())) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - void nothingIsExposedByDefault() { - assertThat(GraphQlRepositoryAllowlist.empty().repositoryNames()).isEmpty(); - } - - private static GraphQlRepositoryProjectionPolicy projection() { - return new GraphQlRepositoryProjectionPolicy("OrderSummary", Set.of("id", "status")); - } -} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/GraphQlPersistedOperationRegistryTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/GraphQlPersistedOperationRegistryTest.java index 55d928ea..2ee7874d 100644 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/GraphQlPersistedOperationRegistryTest.java +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/GraphQlPersistedOperationRegistryTest.java @@ -65,7 +65,7 @@ class GraphQlPersistedOperationRegistryTest { GraphQlPersistedOperation.active( "get-order-v1", "GetOrder", "sha256:a", "query GetOrder { order { id } }", "schema-a")); - registry.updateStatus(id, GraphQlPersistedOperationStatus.BLOCKED); + registry.apply(id, GraphQlPersistedOperationTransition.BLOCK); assertThat(registry.find(id)).isPresent(); assertThat(registry.find(id).orElseThrow().status().executable()).isFalse(); diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/GraphQlPersistedOperationStorageDirectionTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/GraphQlPersistedOperationStorageDirectionTest.java new file mode 100644 index 00000000..6de6744c --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/GraphQlPersistedOperationStorageDirectionTest.java @@ -0,0 +1,182 @@ +package dev.caskeleton.adapter.inbound.graphql.advanced.persisted; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.shared.opstore.OperationalRecord; +import dev.caskeleton.shared.opstore.OperationalRecordConflictException; +import dev.caskeleton.shared.opstore.OperationalRecordStorePort; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; + +/** + * Durable storage for persisted operations without inverting the dependency. + * + *

The registry is an inbound interface, so a Postgres or Redis adapter implementing it would + * point the dependency from infrastructure back at a transport boundary. Both sides depend on the + * neutral {@link OperationalRecordStorePort} instead, and this leaf owns only the translation. + */ +class GraphQlPersistedOperationStorageDirectionTest { + + private final FakeStore store = new FakeStore(); + private final OperationalStoreGraphQlPersistedOperationRegistry registry = + new OperationalStoreGraphQlPersistedOperationRegistry(store); + + @Test + void anOperationSurvivesAStoreRoundTripUnchanged() { + GraphQlPersistedOperation operation = operation(GraphQlPersistedOperationStatus.ACTIVE); + + registry.register(operation); + + assertThat(registry.find(operation.id())).contains(operation); + } + + @Test + void theStoredRecordCarriesNoGraphQlTypes() { + registry.register(operation(GraphQlPersistedOperationStatus.ACTIVE)); + + OperationalRecord stored = + store.find(GraphQlPersistedOperationRecordMapping.NAMESPACE, "op-1").orElseThrow(); + + assertThat(stored.value()) + .as("the store holds text it never has to understand") + .isInstanceOf(String.class); + assertThat(stored.namespace()).isEqualTo("graphql.persisted-operation"); + } + + @Test + void aDocumentContainingTheFramingCharactersStillRoundTrips() { + GraphQlPersistedOperation awkward = + new GraphQlPersistedOperation( + new GraphQlPersistedOperationId("op-2"), + "GetOrder", + "hash-2", + "query GetOrder { order { id } }\n# 12:not-a-frame\n", + "schema-1", + Set.of("first-party", "3:weird"), + 1_000, + 2_048, + GraphQlPersistedOperationStatus.ACTIVE); + + registry.register(awkward); + + assertThat(registry.find(awkward.id())) + .as("length framing means a value may contain anything, including a frame header") + .contains(awkward); + } + + @Test + void aTransitionThatLostTheRaceIsReportedRatherThanSilentlyDropped() { + GraphQlPersistedOperation operation = operation(GraphQlPersistedOperationStatus.ACTIVE); + registry.register(operation); + store.letAnotherWriterWinAfterTheNextRead("op-1"); + + assertThatThrownBy( + () -> registry.apply(operation.id(), GraphQlPersistedOperationTransition.BLOCK)) + .as("two admin planes a second apart must not resolve by arrival order in silence") + .isInstanceOf(GraphQlPersistedOperationConflictException.class); + } + + @Test + void aTransitionOnAnUnknownOperationFails() { + assertThatThrownBy( + () -> + registry.apply( + new GraphQlPersistedOperationId("never-registered"), + GraphQlPersistedOperationTransition.BLOCK)) + .isInstanceOf(GraphQlPersistedOperationNotFoundException.class); + } + + @Test + void reRegisteringADifferentDocumentUnderOneIdIsAConflict() { + registry.register(operation(GraphQlPersistedOperationStatus.ACTIVE)); + + assertThatThrownBy( + () -> + registry.register( + new GraphQlPersistedOperation( + new GraphQlPersistedOperationId("op-1"), + "GetOrder", + "hash-other", + "query GetOrder { somethingElse }", + "schema-1", + Set.of("first-party"), + 1_000, + 2_048, + GraphQlPersistedOperationStatus.ACTIVE))) + .isInstanceOf(GraphQlPersistedOperationConflictException.class); + } + + private static GraphQlPersistedOperation operation(GraphQlPersistedOperationStatus status) { + return new GraphQlPersistedOperation( + new GraphQlPersistedOperationId("op-1"), + "GetOrder", + "hash-1", + "query GetOrder { order { id } }", + "schema-1", + Set.of("first-party"), + 1_000, + 2_048, + status); + } + + /** A store that behaves like a real one: versioned, and unforgiving about stale writes. */ + private static final class FakeStore implements OperationalRecordStorePort { + + private final Map records = new HashMap<>(); + private String interleaveAfterRead; + + @Override + public Optional find(String namespace, String key) { + Optional found = Optional.ofNullable(records.get(namespace + '/' + key)); + String pending = interleaveAfterRead; + if (pending != null && pending.equals(key)) { + // The other admin plane's write lands here: after this caller read, before it writes. + interleaveAfterRead = null; + bump(namespace + '/' + key); + } + return found; + } + + @Override + public OperationalRecord compareAndSet(OperationalRecord record, long expectedVersion) { + String id = record.namespace() + '/' + record.key(); + long current = + records.containsKey(id) ? records.get(id).version() : OperationalRecord.ABSENT_VERSION; + if (current != expectedVersion) { + throw new OperationalRecordConflictException( + record.namespace(), record.key(), expectedVersion, current); + } + OperationalRecord stored = + new OperationalRecord(record.namespace(), record.key(), record.value(), current + 1); + records.put(id, stored); + return stored; + } + + @Override + public void compareAndRemove(String namespace, String key, long expectedVersion) { + String id = namespace + '/' + key; + OperationalRecord current = records.get(id); + long version = current == null ? OperationalRecord.ABSENT_VERSION : current.version(); + if (version != expectedVersion) { + throw new OperationalRecordConflictException(namespace, key, expectedVersion, version); + } + records.remove(id); + } + + void letAnotherWriterWinAfterTheNextRead(String key) { + interleaveAfterRead = key; + } + + private void bump(String id) { + OperationalRecord current = records.get(id); + records.put( + id, + new OperationalRecord( + current.namespace(), current.key(), current.value(), current.version() + 1)); + } + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/replay/GraphQlSnapshotLiveHandoffTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/replay/GraphQlSnapshotLiveHandoffTest.java index 77e11afb..6922d77d 100644 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/replay/GraphQlSnapshotLiveHandoffTest.java +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/replay/GraphQlSnapshotLiveHandoffTest.java @@ -7,9 +7,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketPrincipal; import dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionEvent; import dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorException; -import dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorPayload; import dev.caskeleton.adapter.inbound.graphql.pagination.HmacGraphQlCursorCodec; -import java.nio.charset.StandardCharsets; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.reactivestreams.Publisher; @@ -18,9 +16,6 @@ import org.reactivestreams.Publisher; @Tag("graphql-advanced") class GraphQlSnapshotLiveHandoffTest { - private static final byte[] SECRET = - "replay-secret-replay-secret".getBytes(StandardCharsets.UTF_8); - @Test void gapBetweenSnapshotAndLivePositionIsRejected() { var handoff = @@ -72,14 +67,60 @@ class GraphQlSnapshotLiveHandoffTest { } @Test - void aResumeCursorIsSignedAndBoundToItsActor() { - var codec = HmacGraphQlCursorCodec.testCodec(GraphQlCursorPayload.DEFAULT_KEY_ID, SECRET); + void aResumeCursorIsSignedAndBoundToItsTenantActorAndSubscription() { + var codec = + new HmacGraphQlCursorCodec( + dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorFixtures + .rotatingKeyRing()); + var issuedTo = new GraphQlWebSocketPrincipal("actor-1", "tenant-1", null); String cursor = GraphQlSubscriptionCursor.issue( - codec, "order-events", "actor-1", new GraphQlReplayPosition(42)); + codec, "order-events", issuedTo, new GraphQlReplayPosition(42)); - assertThat(GraphQlSubscriptionCursor.resume(codec, cursor, "actor-1").sequence()).isEqualTo(42); - assertThatThrownBy(() -> GraphQlSubscriptionCursor.resume(codec, cursor, "actor-2")) + assertThat(GraphQlSubscriptionCursor.resume(codec, cursor, "order-events", issuedTo).sequence()) + .isEqualTo(42); + assertThatThrownBy( + () -> + GraphQlSubscriptionCursor.resume( + codec, + cursor, + "order-events", + new GraphQlWebSocketPrincipal("actor-2", "tenant-1", null))) + .as("a resume cursor is bound to the actor it was issued to") + .isInstanceOf(GraphQlCursorException.class); + assertThatThrownBy( + () -> + GraphQlSubscriptionCursor.resume( + codec, + cursor, + "order-events", + new GraphQlWebSocketPrincipal("actor-1", "tenant-2", null))) + .as("the same actor identity in another tenant must not replay this history") + .isInstanceOf(GraphQlCursorException.class); + assertThatThrownBy( + () -> GraphQlSubscriptionCursor.resume(codec, cursor, "other-events", issuedTo)) + .as("and to the subscription it was issued for") + .isInstanceOf(GraphQlCursorException.class); + } + + @Test + void aFramedScopeCannotBeForgedByShiftingTheTenantActorSplit() { + var codec = + new HmacGraphQlCursorCodec( + dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorFixtures + .rotatingKeyRing()); + String cursor = + GraphQlSubscriptionCursor.issue( + codec, + "order-events", + new GraphQlWebSocketPrincipal("ab", "t", null), + new GraphQlReplayPosition(7)); + + assertThatThrownBy( + () -> + GraphQlSubscriptionCursor.resume( + codec, cursor, "order-events", new GraphQlWebSocketPrincipal("b", "ta", null))) + .as("plain concatenation would render both (t, ab) and (ta, b) as \"tab\"") .isInstanceOf(GraphQlCursorException.class); } @@ -87,11 +128,18 @@ class GraphQlSnapshotLiveHandoffTest { void replayIsAuthorizedAgainstTheActorAndCurrentAccess() { var principal = new GraphQlWebSocketPrincipal("actor-1", "tenant-1", null); - assertThatCode(() -> GraphQlReplayAuthorization.verify("actor-1", principal, true)) + assertThatCode(() -> GraphQlReplayAuthorization.verify("actor-1", "tenant-1", principal, true)) .doesNotThrowAnyException(); - assertThatThrownBy(() -> GraphQlReplayAuthorization.verify("actor-2", principal, true)) + assertThatThrownBy( + () -> GraphQlReplayAuthorization.verify("actor-2", "tenant-1", principal, true)) .isInstanceOf(GraphQlReplayAuthorizationException.class); - assertThatThrownBy(() -> GraphQlReplayAuthorization.verify("actor-1", principal, false)) + assertThatThrownBy( + () -> GraphQlReplayAuthorization.verify("actor-1", "tenant-2", principal, true)) + .as( + "the same actor fingerprint in another tenant is not the actor the cursor was issued to") + .isInstanceOf(GraphQlReplayAuthorizationException.class); + assertThatThrownBy( + () -> GraphQlReplayAuthorization.verify("actor-1", "tenant-1", principal, false)) .isInstanceOf(GraphQlReplayAuthorizationException.class); } diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/rsocket/GraphQlRSocketRoutePolicyTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/rsocket/GraphQlRSocketRoutePolicyTest.java index 3fc0bcf8..9a72838b 100644 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/rsocket/GraphQlRSocketRoutePolicyTest.java +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/rsocket/GraphQlRSocketRoutePolicyTest.java @@ -57,12 +57,12 @@ class GraphQlRSocketRoutePolicyTest { var flags = GraphQlAdvancedFeatureFlags.enabling(GraphQlAdvancedCapability.RSOCKET); var production = - new GraphQlRSocketHandlerFactory(new GraphQlAdvancedModuleGuard(flags, true), properties); + new GraphQlRSocketAdmission(new GraphQlAdvancedModuleGuard(flags, true), properties); assertThatThrownBy(() -> production.accept("graphql", GraphQlOperationType.QUERY)) .isInstanceOf(GraphQlAdvancedCapabilityDisabledException.class); var approved = - new GraphQlRSocketHandlerFactory( + new GraphQlRSocketAdmission( new GraphQlAdvancedModuleGuard(flags.withExperimentalApproval(), true), properties); assertThat(approved.accept("graphql", GraphQlOperationType.QUERY)) .isEqualTo(GraphQlRSocketCapability.REQUEST_RESPONSE); diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/sse/GraphQlSseConnectionPolicyTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/sse/GraphQlSseConnectionPolicyTest.java index d66cd97a..c66d4a6c 100644 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/sse/GraphQlSseConnectionPolicyTest.java +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/sse/GraphQlSseConnectionPolicyTest.java @@ -78,7 +78,7 @@ class GraphQlSseConnectionPolicyTest { @Test void theCapabilityFlagGovernsWhetherSseServesAtAll() { var disabled = - new GraphQlSseHandlerFactory( + new GraphQlSseAdmission( new GraphQlAdvancedModuleGuard(GraphQlAdvancedFeatureFlags.disabled()), GraphQlSseProperties.defaults(), CLOCK); @@ -86,7 +86,7 @@ class GraphQlSseConnectionPolicyTest { .isInstanceOf(GraphQlAdvancedCapabilityDisabledException.class); var enabled = - new GraphQlSseHandlerFactory( + new GraphQlSseAdmission( new GraphQlAdvancedModuleGuard( GraphQlAdvancedFeatureFlags.enabling(GraphQlAdvancedCapability.SSE_SUBSCRIPTION)), GraphQlSseProperties.defaults(), diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/subscription/GraphQlSubscriptionExecutionPolicyTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/subscription/GraphQlSubscriptionExecutionPolicyTest.java index 0f901eac..f58b33a1 100644 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/subscription/GraphQlSubscriptionExecutionPolicyTest.java +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/subscription/GraphQlSubscriptionExecutionPolicyTest.java @@ -43,14 +43,109 @@ class GraphQlSubscriptionExecutionPolicyTest { @Test void drainingRefusesNewSubscriptionsAndWaitsForExisting() { var coordinator = new GraphQlSubscriptionDrainCoordinator(Duration.ofSeconds(30)); - coordinator.register(); + var lease = coordinator.register(); coordinator.startDraining(NOW); assertThatThrownBy(coordinator::register) .isInstanceOf(GraphQlSubscriptionDrainingException.class); assertThat(coordinator.drained(NOW)).isFalse(); - coordinator.deregister(); + lease.close(); + assertThat(coordinator.drained(NOW)).isTrue(); + assertThat(coordinator.phase()).isEqualTo(GraphQlSubscriptionDrainPhase.CLOSED); + } + + @Test + void aLeaseReleasedTwiceDoesNotDecrementSomeoneElsesSubscription() { + var coordinator = new GraphQlSubscriptionDrainCoordinator(Duration.ofSeconds(30)); + var first = coordinator.register(); + coordinator.register(); + + first.close(); + first.close(); + + assertThat(first.released()).isTrue(); + assertThat(coordinator.activeSubscriptions()) + .as("a double release must not drop the other subscription's claim") + .isEqualTo(1); + } + + @Test + void drainingWithNothingActiveClosesImmediately() { + var coordinator = new GraphQlSubscriptionDrainCoordinator(Duration.ofSeconds(30)); + + coordinator.startDraining(NOW); + + assertThat(coordinator.phase()).isEqualTo(GraphQlSubscriptionDrainPhase.CLOSED); + assertThat(coordinator.drained(NOW)).isTrue(); + } + + @Test + void aSecondDrainSignalCannotExtendTheWindowItBounds() { + var coordinator = new GraphQlSubscriptionDrainCoordinator(Duration.ofSeconds(30)); + coordinator.register(); + + coordinator.startDraining(NOW); + coordinator.startDraining(NOW.plusSeconds(60)); + + assertThat(coordinator.drained(NOW.plusSeconds(31))) + .as("the deadline is measured from the first signal, not the last") + .isTrue(); + } + + @Test + void noSubscriptionIsAdmittedAfterDrainingBeginsUnderContention() throws Exception { + var coordinator = new GraphQlSubscriptionDrainCoordinator(Duration.ofSeconds(30)); + var admitted = new java.util.concurrent.atomic.AtomicInteger(); + var refused = new java.util.concurrent.atomic.AtomicInteger(); + var start = new java.util.concurrent.CountDownLatch(1); + var done = new java.util.concurrent.CountDownLatch(33); + var leases = new java.util.concurrent.ConcurrentLinkedQueue(); + + for (int index = 0; index < 32; index++) { + Thread.ofVirtual() + .start( + () -> { + try { + start.await(); + leases.add(coordinator.register()); + admitted.incrementAndGet(); + } catch (GraphQlSubscriptionDrainingException refusal) { + refused.incrementAndGet(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } finally { + done.countDown(); + } + }); + } + Thread.ofVirtual() + .start( + () -> { + try { + start.await(); + coordinator.startDraining(NOW); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } finally { + done.countDown(); + } + }); + + start.countDown(); + assertThat(done.await(10, java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + + assertThat(admitted.get() + refused.get()).isEqualTo(32); + assertThat(coordinator.activeSubscriptions()) + .as( + "every admitted registration is counted; a lost increment is a subscription that " + + "streams while the node believes it has drained") + .isEqualTo(admitted.get()); + assertThat(coordinator.draining()).isTrue(); + + // The deadline is readable from every thread the moment draining is visible. + assertThatCode(() -> coordinator.drained(NOW)).doesNotThrowAnyException(); + leases.forEach(GraphQlSubscriptionLease::close); assertThat(coordinator.drained(NOW)).isTrue(); } @@ -71,6 +166,7 @@ class GraphQlSubscriptionExecutionPolicyTest { var coordinator = new GraphQlSubscriptionDrainCoordinator(Duration.ofSeconds(1)); assertThatCode(coordinator::register).doesNotThrowAnyException(); + assertThat(coordinator.phase()).isEqualTo(GraphQlSubscriptionDrainPhase.ACCEPTING); assertThat(coordinator.drained(NOW)).isFalse(); } diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/websocket/GraphQlWebSocketProtocolTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/websocket/GraphQlWebSocketProtocolTest.java index 2d614aa0..1da06e3e 100644 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/websocket/GraphQlWebSocketProtocolTest.java +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/websocket/GraphQlWebSocketProtocolTest.java @@ -84,7 +84,7 @@ class GraphQlWebSocketProtocolTest { @Test void theCapabilityFlagGovernsWhetherConnectionsOpenAtAll() { var disabled = - new GraphQlWebSocketHandlerFactory( + new GraphQlWebSocketAdmission( new GraphQlAdvancedModuleGuard(GraphQlAdvancedFeatureFlags.disabled()), GraphQlWebSocketProperties.defaults(), Clock.fixed(CONNECTED_AT, ZoneOffset.UTC)); @@ -114,8 +114,8 @@ class GraphQlWebSocketProtocolTest { .isInstanceOf(IllegalArgumentException.class); } - private static GraphQlWebSocketHandlerFactory factory(GraphQlAdvancedCapability capability) { - return new GraphQlWebSocketHandlerFactory( + private static GraphQlWebSocketAdmission factory(GraphQlAdvancedCapability capability) { + return new GraphQlWebSocketAdmission( new GraphQlAdvancedModuleGuard(GraphQlAdvancedFeatureFlags.enabling(capability)), GraphQlWebSocketProperties.defaults(), Clock.fixed(CONNECTED_AT, ZoneOffset.UTC)); diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/architecture/GraphQlControllerInspectorTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/architecture/GraphQlControllerInspectorTest.java index 6b8d0cf0..d6e0f92c 100644 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/architecture/GraphQlControllerInspectorTest.java +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/architecture/GraphQlControllerInspectorTest.java @@ -55,7 +55,9 @@ class GraphQlControllerInspectorTest { GraphQlControllerInspector.inspect( BadController.class.getDeclaredMethod("streamingQuery"))) .isInstanceOf(GraphQlControllerContractException.class) - .hasMessageContaining("Publisher outside a subscription"); + // A stream, specifically. The rule used to say "Publisher", which also covered `Mono` — + // a single-value container Spring for GraphQL supports on queries. + .hasMessageContaining("multi-value publisher outside a subscription"); assertThatCode( () -> diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/architecture/GraphQlGeneratedResolverBoundaryTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/architecture/GraphQlGeneratedResolverBoundaryTest.java new file mode 100644 index 00000000..62b18a7b --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/architecture/GraphQlGeneratedResolverBoundaryTest.java @@ -0,0 +1,114 @@ +package dev.caskeleton.adapter.inbound.graphql.architecture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapability; +import dev.caskeleton.adapter.inbound.graphql.architecture.fixture.compliant.FindOrderUseCase; +import dev.caskeleton.adapter.inbound.graphql.architecture.fixture.compliant.OrderView; +import dev.caskeleton.adapter.inbound.graphql.architecture.fixture.violating.OrderRepository; +import dev.caskeleton.adapter.inbound.graphql.release.GraphQlStableCapabilityManifest; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.graphql.data.method.annotation.QueryMapping; + +/** + * A resolver reaches storage through an application use case, or not at all. + * + *

The platform used to ship an Advanced capability that allowlisted Spring Data repositories to + * back GraphQL fields directly, with tests fixing that as correct behaviour. An allowlist does not + * change what the code does: a controller was still calling a repository, which is the second + * canonical hard-stop in {@code AGENTS.md}. A capability flag can make a feature optional; it + * cannot make an architectural rule optional. + */ +class GraphQlGeneratedResolverBoundaryTest { + + @Test + void theRepositoryExposureCapabilityNoLongerExists() { + assertThat(Arrays.stream(GraphQlAdvancedCapability.values()).map(Enum::name)) + .as("an allowlisted repository exposure capability must not be offerable at all") + .doesNotContain("SPRING_DATA_COMPAT"); + assertThat(GraphQlStableCapabilityManifest.ADVANCED).doesNotContain("SPRING_DATA_COMPAT"); + } + + @Test + void repositoryAutoExposureIsDeclaredUnsupported() { + assertThat(GraphQlStableCapabilityManifest.UNSUPPORTED) + .as("removing the code is not enough; the manifest has to say the answer is no") + .contains("SPRING_DATA_REPOSITORY_AUTO_EXPOSURE"); + } + + @Test + void aGeneratedResolverBackedByARepositoryIsRefused() { + assertThatThrownBy( + () -> + GraphQlResolverBoundaryRules.assertNoPersistenceAccess( + List.of(GeneratedRepositoryResolver.class))) + .isInstanceOf(GraphQlControllerContractException.class) + .hasMessageContaining("OrderRepository"); + } + + @Test + void aGeneratedResolverBackedByARepositoryIsRefusedThroughAnyWrapper() { + List violations = + GraphQlResolverBoundaryRules.persistenceViolations( + List.of(GeneratedWrappedRepositoryResolver.class)); + + assertThat(violations) + .as("there is no allowlist left to consult, and a wrapper is not a loophole") + .isNotEmpty(); + } + + @Test + void aGeneratedResolverBackedByAUseCaseIsAccepted() { + assertThat( + GraphQlResolverBoundaryRules.persistenceViolations( + List.of(GeneratedUseCaseResolver.class))) + .isEmpty(); + } + + /** What a generated resolver must never look like. */ + @SuppressWarnings("unused") + static final class GeneratedRepositoryResolver { + + private final OrderRepository orders; + + GeneratedRepositoryResolver(OrderRepository orders) { + this.orders = orders; + } + + @QueryMapping + OrderView order(String id) { + return null; + } + } + + /** The same violation, tidied behind a container. */ + @SuppressWarnings("unused") + static final class GeneratedWrappedRepositoryResolver { + + private final Optional orders; + + GeneratedWrappedRepositoryResolver(Optional orders) { + this.orders = orders; + } + } + + /** The only shape a generated resolver may take. */ + @SuppressWarnings("unused") + static final class GeneratedUseCaseResolver { + + private final FindOrderUseCase findOrder; + + GeneratedUseCaseResolver(FindOrderUseCase findOrder) { + this.findOrder = findOrder; + } + + @QueryMapping + OrderView order(String id) { + return findOrder.find(id); + } + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/architecture/GraphQlResolverGenericBoundaryTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/architecture/GraphQlResolverGenericBoundaryTest.java new file mode 100644 index 00000000..68b6e280 --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/architecture/GraphQlResolverGenericBoundaryTest.java @@ -0,0 +1,189 @@ +package dev.caskeleton.adapter.inbound.graphql.architecture; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.graphql.architecture.fixture.compliant.OrderView; +import dev.caskeleton.adapter.inbound.graphql.architecture.fixture.violating.OrderRepository; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.reactivestreams.Publisher; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.graphql.data.method.annotation.SubscriptionMapping; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * What the boundary rules could not see, and what they wrongly refused. + * + *

Both failures came from inspecting the erased type. {@code List} reports {@code + * List} and passed every persistence rule; {@code Mono} reports {@code Mono}, which is a + * {@code Publisher}, and was refused on a query that Spring for GraphQL supports. One rule was + * blind, the other was superstitious, and both looked correct from the signature. + */ +class GraphQlResolverGenericBoundaryTest { + + @Test + void aRepositoryHiddenInsideAGenericIsFound() { + assertThat(GraphQlTypeGraph.referencedTypes(fieldType("repositories"))) + .contains(OrderRepository.class); + assertThat(GraphQlTypeGraph.referencedTypes(fieldType("maybeRepository"))) + .contains(OrderRepository.class); + assertThat(GraphQlTypeGraph.referencedTypes(fieldType("asyncRepository"))) + .contains(OrderRepository.class); + } + + @Test + void aGenericConstructorInjectionOfARepositoryIsAViolation() { + List violations = + GraphQlResolverBoundaryRules.persistenceViolations(List.of(GenericLeak.class)); + + assertThat(violations) + .as("Optional is an injected repository however it is wrapped") + .anySatisfy(violation -> assertThat(violation).contains("OrderRepository")); + } + + @Test + void aCompliantResolverWithGenericsIsNotAViolation() { + assertThat(GraphQlResolverBoundaryRules.persistenceViolations(List.of(GenericCompliant.class))) + .isEmpty(); + } + + @Test + void aQueryMayCompleteAsynchronouslyWithASingleValue() { + assertThat(GraphQlControllerInspector.violations(method(AsyncResolvers.class, "monoQuery"))) + .as("Spring for GraphQL supports Mono on a query; the platform must not refuse it") + .isEmpty(); + assertThat(GraphQlControllerInspector.violations(method(AsyncResolvers.class, "futureQuery"))) + .isEmpty(); + } + + @Test + void aQueryMayNotEmitAStream() { + assertThat(GraphQlControllerInspector.violations(method(AsyncResolvers.class, "fluxQuery"))) + .anySatisfy( + violation -> + assertThat(violation).contains("multi-value publisher outside a subscription")); + assertThat(GraphQlControllerInspector.violations(method(AsyncResolvers.class, "streamQuery"))) + .isNotEmpty(); + } + + @Test + void aSubscriptionMayEmitAStream() { + assertThat( + GraphQlControllerInspector.violations(method(AsyncResolvers.class, "fluxSubscription"))) + .isEmpty(); + } + + @Test + void theAsyncShapeIsClassifiedByWhatItYields() { + assertThat(GraphQlAsyncReturnShape.of(Mono.class)) + .isEqualTo(GraphQlAsyncReturnShape.SINGLE_VALUE); + assertThat(GraphQlAsyncReturnShape.of(CompletableFuture.class)) + .isEqualTo(GraphQlAsyncReturnShape.SINGLE_VALUE); + assertThat(GraphQlAsyncReturnShape.of(Flux.class)) + .isEqualTo(GraphQlAsyncReturnShape.MULTI_VALUE); + assertThat(GraphQlAsyncReturnShape.of(Publisher.class)) + .isEqualTo(GraphQlAsyncReturnShape.MULTI_VALUE); + assertThat(GraphQlAsyncReturnShape.of(OrderView.class)) + .isEqualTo(GraphQlAsyncReturnShape.SYNCHRONOUS); + } + + @Test + void repositoryEvidenceNamesTheStrongestReason() { + assertThat(GraphQlResolverBoundaryRules.repositoryEvidence(OrderRepository.class)) + .isEqualTo("is a repository-named interface"); + assertThat(GraphQlResolverBoundaryRules.repositoryEvidence(OrderView.class)).isNull(); + } + + @Test + void aConcreteClassIsNotARepositoryJustBecauseOfItsName() { + assertThat(GraphQlResolverBoundaryRules.repositoryEvidence(OrderRepositoryView.class)) + .as("a value object outside a persistence package must not be refused on its name alone") + .isNull(); + } + + @Test + void theRecursiveScanReachesSubPackages() { + List> classes = + GraphQlResolverBoundaryRules.classesIn( + "dev.caskeleton.adapter.inbound.graphql.architecture.fixture"); + + assertThat(classes) + .as("the scan used to list direct children only, so every nested package went unchecked") + .contains(OrderRepository.class, OrderView.class); + } + + private static java.lang.reflect.Type fieldType(String name) { + try { + return GenericLeak.class.getDeclaredField(name).getGenericType(); + } catch (NoSuchFieldException ex) { + throw new AssertionError(ex); + } + } + + private static java.lang.reflect.Method method(Class type, String name) { + for (java.lang.reflect.Method candidate : type.getDeclaredMethods()) { + if (candidate.getName().equals(name)) { + return candidate; + } + } + throw new AssertionError("no method " + name); + } + + /** A value object whose name ends in Repository but which is not one. */ + record OrderRepositoryView(String id) {} + + @SuppressWarnings("unused") + static final class GenericLeak { + + private final List repositories = List.of(); + private final Optional maybeRepository = Optional.empty(); + private final Mono asyncRepository = Mono.empty(); + + GenericLeak(Optional injected) { + // Constructor injection through a wrapper is the shape the erased check could not see. + } + } + + @SuppressWarnings("unused") + static final class GenericCompliant { + + private final List views = List.of(); + + GenericCompliant(Mono asyncView) { + // A transport type in a container is still a transport type. + } + } + + @SuppressWarnings("unused") + static final class AsyncResolvers { + + @QueryMapping + Mono monoQuery() { + return Mono.empty(); + } + + @QueryMapping + CompletableFuture futureQuery() { + return CompletableFuture.completedFuture(null); + } + + @QueryMapping + Flux fluxQuery() { + return Flux.empty(); + } + + @QueryMapping + Stream streamQuery() { + return Stream.of(); + } + + @SubscriptionMapping + Flux fluxSubscription() { + return Flux.empty(); + } + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformAutoConfigurationTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformAutoConfigurationTest.java new file mode 100644 index 00000000..8815faf9 --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformAutoConfigurationTest.java @@ -0,0 +1,221 @@ +package dev.caskeleton.adapter.inbound.graphql.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipeline; +import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionStage; +import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy; +import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformInstrumentation; +import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformWebInterceptor; +import dev.caskeleton.adapter.inbound.graphql.scalar.GraphQlScalarWiringConfigurer; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.graphql.autoconfigure.GraphQlProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +/** + * The configuration contract: what boots with nothing set, and what refuses to boot. + * + *

Three defects this pins down at once. The class was named auto-configuration without being + * registered as one, so its conditions never behaved as documented. Its primitive properties bound + * to zero while the validator demanded positive numbers, so a zero-config boot was impossible. And + * the startup check validated a platform constant rather than the beans the context assembled, so + * an adopter's unsafe override passed a check that was not looking at it. + */ +class GraphQlPlatformAutoConfigurationTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GraphQlPlatformAutoConfiguration.class)); + + @Test + void aContextWithNoPlatformPropertiesBootsWithSafeDefaults() { + runner.run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(GraphQlPlatformWebInterceptor.class); + assertThat(context).hasSingleBean(GraphQlPlatformInstrumentation.class); + assertThat(context).hasSingleBean(GraphQlScalarWiringConfigurer.class); + + GraphQlPlatformProperties properties = context.getBean(GraphQlPlatformProperties.class); + assertThat(properties.limits().maximumPageSize()).isEqualTo(100); + assertThat(properties.limits().maximumComplexity()).isEqualTo(10_000); + assertThat(properties.console().graphiqlEnabled()).isFalse(); + assertThat(properties.console().introspectionEnabled()).isFalse(); + assertThat(properties.production()).isFalse(); + + GraphQlClientPolicy policy = context.getBean(GraphQlClientPolicy.class); + assertThat(policy.maxPageSize()).isEqualTo(100); + assertThat(policy.maxComplexity()).isEqualTo(10_000); + assertThat(policy.introspectionAllowed()).isFalse(); + }); + } + + @Test + void theDerivedPipelineIsTheOneTheChainActuallyRuns() { + runner.run( + context -> { + GraphQlExecutionPipeline pipeline = context.getBean(GraphQlExecutionPipeline.class); + + assertThat(pipeline.stageNames()) + .containsExactly("CONTEXT", "PARSE_VALIDATE", "AUTHORIZATION", "COST", "EXECUTE"); + }); + } + + @Test + void productionRefusesToStartWithoutAnExplicitAuthorizationPolicy() { + runner + .withPropertyValues( + "backend.graphql.production=true", + "backend.graphql.environment=PRODUCTION_INTERNAL", + "backend.graphql.cursor.key-ids=cursor-key-1") + .run( + context -> + assertThat(context) + .hasFailed() + .getFailure() + .rootCause() + .hasMessageContaining("requires an explicit GraphQlAuthorizationPolicy")); + } + + @Test + void anUnsafePipelineOverrideIsRejectedAtStartup() { + runner + .withBean( + GraphQlExecutionPipeline.class, + () -> + new GraphQlExecutionPipeline( + List.of( + GraphQlExecutionStage.CONTEXT, + GraphQlExecutionStage.PARSE_VALIDATE, + GraphQlExecutionStage.EXECUTE, + GraphQlExecutionStage.AUTHORIZATION, + GraphQlExecutionStage.COST))) + .run( + context -> + assertThat(context) + .hasFailed() + .getFailure() + .rootCause() + .hasMessageContaining("AUTHORIZATION must run before EXECUTE")); + } + + @Test + void aClientPolicyLooserThanTheConfiguredCeilingIsRejectedAtStartup() { + runner + .withPropertyValues("backend.graphql.limits.maximum-page-size=25") + .withBean(GraphQlClientPolicy.class, () -> GraphQlClientPolicy.defaults(500, 10_000, false)) + .run( + context -> + assertThat(context) + .hasFailed() + .getFailure() + .rootCause() + .hasMessageContaining("exceeds backend.graphql.limits.maximum-page-size")); + } + + @Test + void contradictingTheFrameworkIntrospectionFlagIsRejectedAtStartup() { + // Boot answers introspection by default, so the contradiction has to be built the other way: + // the platform says yes, the framework has been turned off, and a client would get the + // framework's answer. + GraphQlProperties framework = frameworkDefaults(); + framework.getSchema().getIntrospection().setEnabled(false); + + runner + .withPropertyValues( + "backend.graphql.environment=LOCAL", + "backend.graphql.console.introspection-enabled=true") + .withBean(GraphQlProperties.class, () -> framework) + .run( + context -> + assertThat(context) + .hasFailed() + .getFailure() + .rootCause() + .hasMessageContaining( + "contradicts spring.graphql.schema.introspection.enabled")); + } + + @Test + void contradictingTheFrameworkGraphiqlFlagIsRejectedAtStartup() { + GraphQlProperties framework = frameworkDefaults(); + framework.getGraphiql().setEnabled(true); + + runner + .withPropertyValues("backend.graphql.environment=LOCAL") + .withBean(GraphQlProperties.class, () -> framework) + .run( + context -> + assertThat(context) + .hasFailed() + .getFailure() + .rootCause() + .hasMessageContaining("contradicts spring.graphql.graphiql.enabled")); + } + + @Test + void agreeingWithTheFrameworkFlagsBoots() { + GraphQlProperties framework = frameworkDefaults(); + framework.getSchema().getIntrospection().setEnabled(true); + + runner + .withPropertyValues( + "backend.graphql.environment=LOCAL", + "backend.graphql.console.introspection-enabled=true") + .withBean(GraphQlProperties.class, () -> framework) + .run(context -> assertThat(context).hasNotFailed()); + } + + @Test + void theAutoConfigurationIsRegisteredInTheImportsMetadata() { + String imports = + readClasspathResource( + "META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports"); + + assertThat(imports) + .as("an unregistered auto-configuration never applies to an adopter's context") + .contains(GraphQlPlatformAutoConfiguration.class.getName()); + } + + @Test + void everyPlatformPropertyAppearsInTheGeneratedConfigurationMetadata() { + String metadata = readClasspathResource("META-INF/spring-configuration-metadata.json"); + + assertThat(metadata) + .contains("backend.graphql.production") + .contains("backend.graphql.environment") + .contains("backend.graphql.execution-profile") + .contains("backend.graphql.console.graphiql-enabled") + .contains("backend.graphql.console.introspection-enabled") + .contains("backend.graphql.limits.maximum-page-size") + .contains("backend.graphql.limits.maximum-complexity") + .contains("backend.graphql.cursor.key-ids") + .contains("backend.graphql.unsupported.multipart-upload") + .contains("backend.graphql.unsupported.http-array-batch") + .contains("backend.graphql.unsupported.request-wide-transaction") + .contains("backend.graphql.unsupported.repository-auto-exposure") + .contains("backend.graphql.unsupported.response-cache") + .contains("backend.graphql.unsupported.advanced-capabilities-on-stable-starter") + .contains("backend.graphql.unbridged-blocking-resolvers"); + } + + private static GraphQlProperties frameworkDefaults() { + return new GraphQlProperties(); + } + + private static String readClasspathResource(String name) { + ClassLoader loader = GraphQlPlatformAutoConfigurationTest.class.getClassLoader(); + try (InputStream stream = loader.getResourceAsStream(name)) { + assertThat(stream).as("missing classpath resource %s", name).isNotNull(); + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlTransportNeutralityTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlTransportNeutralityTest.java new file mode 100644 index 00000000..03317bd1 --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlTransportNeutralityTest.java @@ -0,0 +1,190 @@ +package dev.caskeleton.adapter.inbound.graphql.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfile; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; + +/** + * The leaf must not decide which server an adopter runs. + * + *

This artifact used to depend on {@code spring-boot-starter-web}, which put an embedded Tomcat + * on every adopter's runtime classpath — while the same artifact advertised a {@code + * REACTIVE_WEBFLUX} execution profile that could never have been honoured. Not one production file + * imports a servlet or {@code org.springframework.web} type, so the dependency bought nothing and + * cost adopters a server they may not have wanted. + * + *

Two checks, because each catches what the other cannot. The lockfile assertion catches a + * transitive server sneaking back onto the runtime classpath, which no test that boots a context + * can see. The context assertions catch a deployment whose declared profile does not match the + * server it is actually running on, which the lockfile has no opinion about. + */ +class GraphQlTransportNeutralityTest { + + private static final List SERVER_ARTIFACTS = + List.of( + "org.apache.tomcat.embed:tomcat-embed-core", + "org.springframework.boot:spring-boot-starter-web", + "org.springframework.boot:spring-boot-starter-webflux", + "org.springframework.boot:spring-boot-starter-tomcat", + "org.springframework:spring-webmvc", + "org.springframework:spring-webflux"); + + @Test + void noServerReachesTheProductionRuntimeClasspath() { + List offenders = + lockfileLines().stream() + .filter(line -> SERVER_ARTIFACTS.stream().anyMatch(line::startsWith)) + .filter(GraphQlTransportNeutralityTest::onProductionClasspath) + .toList(); + + assertThat(offenders) + .as("the composition root chooses the server; this leaf must not ship one") + .isEmpty(); + } + + @Test + void theLockfileAssertionIsNotVacuous() { + assertThat(lockfileLines()) + .as("a lockfile this test cannot read would make the check pass by finding nothing") + .anySatisfy( + line -> assertThat(line).startsWith("org.springframework.graphql:spring-graphql")); + } + + @Test + void aNonWebContextAcceptsAnyProfile() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GraphQlPlatformAutoConfiguration.class)) + .withPropertyValues("backend.graphql.execution-profile=REACTIVE_WEBFLUX") + .run(context -> assertThat(context).hasNotFailed()); + } + + @Test + void aServletContextRunningTheReactiveProfileRefusesToStart() { + new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GraphQlPlatformAutoConfiguration.class)) + .withPropertyValues("backend.graphql.execution-profile=REACTIVE_WEBFLUX") + .run( + context -> + assertThat(context) + .hasFailed() + .getFailure() + .rootCause() + .hasMessageContaining("this context runs on SERVLET")); + } + + @Test + void aReactiveContextRunningTheBlockingProfileRefusesToStart() { + new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GraphQlPlatformAutoConfiguration.class)) + .withPropertyValues("backend.graphql.execution-profile=BLOCKING_MVC") + .run( + context -> + assertThat(context) + .hasFailed() + .getFailure() + .rootCause() + .hasMessageContaining("this context runs on REACTIVE")); + } + + @Test + void aServletContextRunningTheBlockingProfileStarts() { + new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GraphQlPlatformAutoConfiguration.class)) + .withPropertyValues("backend.graphql.execution-profile=BLOCKING_MVC") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(GraphQlRuntimeTransport.class)) + .isEqualTo(GraphQlRuntimeTransport.SERVLET); + }); + } + + @Test + void aReactiveContextRunningTheReactiveProfileStarts() { + new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GraphQlPlatformAutoConfiguration.class)) + .withPropertyValues("backend.graphql.execution-profile=REACTIVE_WEBFLUX") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(GraphQlRuntimeTransport.class)) + .isEqualTo(GraphQlRuntimeTransport.REACTIVE); + }); + } + + @Test + void everyProfileNamesTheTransportItCanRunOn() { + assertThat(GraphQlRuntimeTransport.SERVLET.supports(GraphQlExecutionProfile.BLOCKING_MVC)) + .isTrue(); + assertThat(GraphQlRuntimeTransport.SERVLET.supports(GraphQlExecutionProfile.REACTIVE_WEBFLUX)) + .isFalse(); + assertThat(GraphQlRuntimeTransport.REACTIVE.supports(GraphQlExecutionProfile.REACTIVE_WEBFLUX)) + .isTrue(); + assertThat(GraphQlRuntimeTransport.REACTIVE.supports(GraphQlExecutionProfile.BLOCKING_MVC)) + .isFalse(); + + for (GraphQlRuntimeTransport transport : GraphQlRuntimeTransport.values()) { + assertThat(transport.supports(GraphQlExecutionProfile.MIXED_CONTROLLED)) + .as("MIXED_CONTROLLED is the profile for a deployment that runs on both") + .isTrue(); + } + } + + @Test + void aReactiveContextWithUnbridgedBlockingResolversRefusesToStart() { + new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GraphQlPlatformAutoConfiguration.class)) + .withPropertyValues( + "backend.graphql.execution-profile=MIXED_CONTROLLED", + "backend.graphql.unbridged-blocking-resolvers=Order.total") + .run( + context -> + assertThat(context) + .hasFailed() + .getFailure() + .rootCause() + .hasMessageContaining("block without an executor bridge on a reactive")); + } + + private static boolean onProductionClasspath(String line) { + int separator = line.indexOf('='); + if (separator < 0) { + return false; + } + String configurations = line.substring(separator + 1).toLowerCase(Locale.ROOT); + return List.of(configurations.split(",")).stream() + .anyMatch( + configuration -> + configuration.equals("runtimeclasspath") + || configuration.equals("compileclasspath")); + } + + private static List lockfileLines() { + Path lockfile = Path.of("gradle.lockfile"); + if (!Files.isRegularFile(lockfile)) { + throw new IllegalStateException( + "cannot read " + + lockfile.toAbsolutePath() + + "; the dependency check has nothing to read"); + } + try { + return Files.readAllLines(lockfile).stream() + .map(String::strip) + .filter(line -> !line.isEmpty() && !line.startsWith("#")) + .toList(); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlSchemaComparatorTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlSchemaComparatorTest.java index fa5e20bc..cc27d797 100644 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlSchemaComparatorTest.java +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlSchemaComparatorTest.java @@ -130,16 +130,79 @@ class GraphQlSchemaComparatorTest { } @Test - void scalarCoercionChangeRequiresANewScalarOrVersion() { + void aChangedScalarDeclarationIsReportedForReviewRatherThanCalledACoercionChange() { GraphQlCompatibilityReport report = GraphQlSchemaComparator.compare( "scalar Amount @specifiedBy(url: \"https://example.test/v1\")", "scalar Amount @specifiedBy(url: \"https://example.test/v2\")"); - assertThat(report.changesOf(GraphQlChangeKind.SCALAR_COERCION_CHANGED)).hasSize(1); + // The SDL cannot tell us whether the Coercing implementation changed: swapping the codec while + // leaving the SDL alone was invisible, and editing a description was reported as breaking. + // Coercion compatibility is the scalar manifest's codec version, not this. + assertThat(report.changesOf(GraphQlChangeKind.SCALAR_DECLARATION_CHANGED)).hasSize(1); + assertThat(report.reviewRequired()).isTrue(); + } + + @Test + void aTypeThatChangedKindIsBreaking() { + GraphQlCompatibilityReport report = + GraphQlSchemaComparator.compare( + "type Filter { status: String }", "input Filter { status: String }"); + + assertThat(report.changesOf(GraphQlChangeKind.TYPE_KIND_CHANGED)).hasSize(1); assertThat(report.breaking()).isTrue(); } + @Test + void removingAnInputDefaultIsBreaking() { + GraphQlCompatibilityReport report = + GraphQlSchemaComparator.compare( + "input Page { size: Int! = 20 }", "input Page { size: Int! }"); + + assertThat(report.changesOf(GraphQlChangeKind.INPUT_DEFAULT_REMOVED)).hasSize(1); + assertThat(report.breaking()).isTrue(); + } + + @Test + void changingAnInputDefaultNeedsReview() { + GraphQlCompatibilityReport report = + GraphQlSchemaComparator.compare( + "input Page { size: Int! = 20 }", "input Page { size: Int! = 100 }"); + + assertThat(report.changesOf(GraphQlChangeKind.INPUT_DEFAULT_CHANGED)).hasSize(1); + } + + @Test + void addingAnInputDefaultIsAcceptingButChangesGeneratedModels() { + GraphQlCompatibilityReport report = + GraphQlSchemaComparator.compare( + "input Page { size: Int! }", "input Page { size: Int! = 20 }"); + + assertThat(report.changesOf(GraphQlChangeKind.INPUT_DEFAULT_ADDED)).hasSize(1); + assertThat(report.breaking()).isFalse(); + } + + @Test + void aChangeToAppliedDirectivesIsReported() { + GraphQlCompatibilityReport report = + GraphQlSchemaComparator.compare( + "type Query { legacy: String }", + "type Query { legacy: String @deprecated(reason: \"use modern\") }"); + + assertThat(report.changesOf(GraphQlChangeKind.APPLIED_DIRECTIVE_CHANGED)) + .as("comparing directive definitions alone could never see this") + .hasSize(1); + } + + @Test + void aChangeToATypeLevelAppliedDirectiveIsReported() { + GraphQlCompatibilityReport report = + GraphQlSchemaComparator.compare( + "input Choice @oneOf { a: Int b: Int }", "input Choice { a: Int b: Int }"); + + assertThat(report.changesOf(GraphQlChangeKind.APPLIED_DIRECTIVE_CHANGED)).hasSize(1); + } + @Test void directiveSemanticsChangeRequiresBehaviouralReview() { GraphQlCompatibilityReport report = diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/context/GraphQlCommandAttributionTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/context/GraphQlCommandAttributionTest.java new file mode 100644 index 00000000..1c637747 --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/context/GraphQlCommandAttributionTest.java @@ -0,0 +1,61 @@ +package dev.caskeleton.adapter.inbound.graphql.context; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.graphql.testkit.GraphQlRequestContexts; +import java.lang.reflect.RecordComponent; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +/** + * What crosses into an application command, and what must not. + * + *

Handing {@link GraphQlRequestContext} to a use case would make {@code application-core}, and + * everything it reaches, compile against an inbound transport type — so a GraphQL concern would + * ripple into persistence, and a REST or scheduled caller could not construct the same command. + */ +class GraphQlCommandAttributionTest { + + @Test + void onlyTransportNeutralValuesCross() { + for (RecordComponent component : GraphQlCommandAttribution.class.getRecordComponents()) { + assertThat(component.getType().getName()) + .as("component %s", component.getName()) + .matches("java\\.lang\\.String|java\\.time\\.Instant"); + } + } + + @Test + void theActorTenantDeadlineAndTraceSurviveTheMapping() { + GraphQlRequestContext context = GraphQlRequestContexts.testContext("tenant-a"); + + GraphQlCommandAttribution attribution = GraphQlCommandAttribution.from(context); + + assertThat(attribution.tenantId()).isEqualTo("tenant-a"); + assertThat(attribution.actor()).contains("actor-test"); + assertThat(attribution.traceId()).isEqualTo("trace-test"); + assertThat(attribution.deadlineAt()).isAfter(Instant.EPOCH); + } + + @Test + void anUnauthenticatedCallerCarriesNoActor() { + GraphQlRequestContext anonymous = + new GraphQlRequestContext( + ActorRef.anonymous(), + TenantContext.system("public"), + new dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile("anonymous"), + java.util.Locale.ROOT, + new dev.caskeleton.adapter.inbound.graphql.api.GraphQlOperationId("op.pending"), + "trace-anon", + GraphQlDeadline.after(java.time.Duration.ofSeconds(5), java.time.Clock.systemUTC())); + + GraphQlCommandAttribution attribution = GraphQlCommandAttribution.from(anonymous); + + assertThat(attribution.actor()) + .as("an anonymous caller must not arrive downstream looking like an identity") + .isEmpty(); + assertThat(attribution.deadlineAt()) + .as("a deadline always crosses; the context cannot exist without one") + .isAfter(Instant.EPOCH); + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/cost/GraphQlFragmentReachabilityTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/cost/GraphQlFragmentReachabilityTest.java new file mode 100644 index 00000000..28498892 --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/cost/GraphQlFragmentReachabilityTest.java @@ -0,0 +1,113 @@ +package dev.caskeleton.adapter.inbound.graphql.cost; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import graphql.language.Definition; +import graphql.language.Document; +import graphql.language.OperationDefinition; +import graphql.parser.Parser; +import org.junit.jupiter.api.Test; + +/** + * The gate has to see what the request can reach, not what it wrote inline. + * + *

The introspection walk handled fields and inline fragments and stopped there, while the shape + * walk in the same class expanded named fragments. So the document below passed a check whose only + * purpose was to refuse it — the two walkers disagreed about what "the document selects" means, and + * the weaker one was the one guarding the schema. + */ +class GraphQlFragmentReachabilityTest { + + private static final String FRAGMENT_BYPASS = + "query Q { ...I } fragment I on Query { __schema { types { name } } }"; + + private final GraphQlDocumentShapeAnalyzer analyzer = new GraphQlDocumentShapeAnalyzer(); + + @Test + void introspectionReachedThroughANamedFragmentIsDetected() { + Document document = Parser.parse(FRAGMENT_BYPASS); + + assertThat(analyzer.selectsIntrospection(document)) + .as("the fragment reaches __schema, so the document selects introspection") + .isTrue(); + assertThatThrownBy(() -> analyzer.verifyIntrospection(document, false)) + .isInstanceOf(GraphQlStructuralLimitViolation.class) + .hasMessageContaining("INTROSPECTION"); + } + + @Test + void introspectionReachedThroughNestedFragmentsIsDetected() { + Document document = + Parser.parse( + "query Q { ...Outer } fragment Outer on Query { ...Inner } " + + "fragment Inner on Query { __type(name: \"X\") { name } }"); + + assertThat(analyzer.selectsIntrospection(document)).isTrue(); + } + + @Test + void aFragmentCycleTerminatesRatherThanRecursingForever() { + Document document = + Parser.parse("query Q { ...A } fragment A on Query { ...B } fragment B on Query { ...A }"); + + assertThatCode(() -> analyzer.selectsIntrospection(document)).doesNotThrowAnyException(); + assertThat(analyzer.selectsIntrospection(document)).isFalse(); + } + + @Test + void anUnreachableFragmentDoesNotTriggerTheGate() { + Document document = + Parser.parse( + "query Q { order { id } } fragment Unused on Query { __schema { types { name } } }"); + + assertThat(analyzer.selectsIntrospection(document, operation(document, "Q"))) + .as("the selected operation never spreads the fragment, so it cannot reach __schema") + .isFalse(); + } + + @Test + void theShapeCountsOnlyTheSelectedOperation() { + Document document = Parser.parse("query A { a b c } query B { d }"); + + GraphQlDocumentShape selected = analyzer.analyze(document, operation(document, "B")); + + assertThat(selected.operationCount()).isEqualTo(1); + assertThat(selected.fieldCount()) + .as("only operation B runs; A's three fields are not this request's cost") + .isEqualTo(1); + } + + @Test + void theShapeCountsOnlyReachableFragments() { + Document document = + Parser.parse( + "query Q { ...Used } fragment Used on Query { a } fragment Unused on Query { b }"); + + GraphQlDocumentShape selected = analyzer.analyze(document, operation(document, "Q")); + + assertThat(selected.fragmentCount()) + .as("an unreachable fragment must not consume the fragment budget") + .isEqualTo(1); + } + + @Test + void measuringTheWholeDocumentStillSumsEveryOperation() { + Document document = Parser.parse("query A { a b c } query B { d }"); + + GraphQlDocumentShape all = analyzer.analyze(document); + + assertThat(all.operationCount()).isEqualTo(2); + assertThat(all.fieldCount()).isEqualTo(4); + } + + private static OperationDefinition operation(Document document, String name) { + for (Definition definition : document.getDefinitions()) { + if (definition instanceof OperationDefinition candidate && name.equals(candidate.getName())) { + return candidate; + } + } + throw new IllegalArgumentException("no operation named " + name); + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlBatchChunkerTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlBatchChunkerTest.java index 588cb8b3..63a7fd65 100644 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlBatchChunkerTest.java +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlBatchChunkerTest.java @@ -9,7 +9,7 @@ import dev.caskeleton.adapter.inbound.graphql.context.ActorRef; import dev.caskeleton.adapter.inbound.graphql.context.GraphQlDeadline; import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; import dev.caskeleton.adapter.inbound.graphql.context.TenantContext; -import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationContextFactory; +import dev.caskeleton.adapter.inbound.graphql.testkit.GraphQlRequestContexts; import java.time.Clock; import java.time.Duration; import java.time.Instant; @@ -125,7 +125,7 @@ class GraphQlBatchChunkerTest { } private static GraphQlRequestContext context(Duration budget) { - GraphQlRequestContext base = GraphQlAuthenticationContextFactory.testContext("tenant-a"); + GraphQlRequestContext base = GraphQlRequestContexts.testContext("tenant-a"); return new GraphQlRequestContext( base.actor(), base.tenant(), diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlBatchContractTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlBatchContractTest.java new file mode 100644 index 00000000..0b33c80e --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlBatchContractTest.java @@ -0,0 +1,173 @@ +package dev.caskeleton.adapter.inbound.graphql.dataloader; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.graphql.testkit.GraphQlRequestContexts; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +/** + * What a batch loader promises about missing values, wrong answers and the request budget. + * + *

Two shapes of loader used to disagree about the same answer: a mapped loader returning {@code + * {k: null}} produced a present null while an ordered loader returning {@code [null]} produced a + * missing key, so whether the missing-key policy fired depended on which shape a field happened to + * use. And a loader that answered the right number of keys under the wrong names passed silently, + * rendering the field as null rather than as a fault. + */ +class GraphQlBatchContractTest { + + private static final Instant NOW = Instant.parse("2026-08-14T00:00:00Z"); + + private final GraphQlBatchResultMapper mapper = new GraphQlBatchResultMapper(); + + @Test + void aNullValueMeansMissingInBothLoaderShapes() { + Map mapped = new HashMap<>(); + mapped.put("k", null); + + GraphQlBatchResult fromMapped = mapper.map(List.of("k"), mapped); + GraphQlBatchResult fromOrdered = + mapper.mapOrdered(List.of("k"), java.util.Collections.singletonList(null)); + + assertThat(fromMapped.values().get("k")).isInstanceOf(GraphQlBatchValue.Missing.class); + assertThat(fromOrdered.values().get("k")) + .as("the same answer must mean the same thing whichever loader shape produced it") + .isInstanceOf(GraphQlBatchValue.Missing.class); + } + + @Test + void aLoaderThatAnswersUnrequestedKeysIsAContractViolation() { + assertThatThrownBy(() -> mapper.map(List.of("a"), Map.of("b", "value-b"))) + .as("matching cardinality is not matching keys") + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not requested"); + } + + @Test + void aFailedKeyStaysDistinctFromAMissingOne() { + GraphQlBatchResult result = + mapper.map(List.of("a", "b"), Map.of("a", "value-a"), java.util.Set.of("b"), "UPSTREAM"); + + assertThat(result.values().get("a")).isInstanceOf(GraphQlBatchValue.Present.class); + assertThat(result.values().get("b")).isInstanceOf(GraphQlBatchValue.Failed.class); + } + + @Test + void anOrderedLoaderReturningTheWrongCountIsRejected() { + assertThatThrownBy(() -> mapper.mapOrdered(List.of("a", "b"), List.of("only-one"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void fiftyKeysBecomeABoundedNumberOfDownstreamCalls() { + AtomicInteger downstreamCalls = new AtomicInteger(); + List keys = new ArrayList<>(); + for (int index = 0; index < 50; index++) { + keys.add("k-" + index); + } + + Map loaded = + executor(Duration.ofSeconds(5), 20) + .load( + keys, + batchContext(), + (chunk, context) -> { + downstreamCalls.incrementAndGet(); + Map values = new HashMap<>(); + chunk.forEach(key -> values.put(key, "value-" + key)); + return values; + }); + + assertThat(loaded).hasSize(50); + assertThat(downstreamCalls) + .as("fifty parents must not become fifty calls; the chunk size bounds it") + .hasValue(3); + } + + @Test + void aChunkThatOverrunsTheBudgetStopsTheBatch() { + AtomicInteger downstreamCalls = new AtomicInteger(); + List keys = List.of("a", "b", "c", "d"); + // The first chunk itself overruns the budget. A check that only ran before each chunk saw a + // healthy budget, issued the chunk, and then issued the next one too. + MutableClock clock = new MutableClock(NOW); + GraphQlBatchPolicy policy = policy(Duration.ofSeconds(5), 2); + + assertThatThrownBy( + () -> + new GraphQlBatchExecutor(policy, GraphQlBatchChunker.of(policy, 100), clock) + .load( + keys, + batchContext(), + (chunk, context) -> { + downstreamCalls.incrementAndGet(); + clock.advance(Duration.ofMinutes(1)); + Map values = new HashMap<>(); + chunk.forEach(key -> values.put(key, key)); + return values; + })) + .isInstanceOf(GraphQlBatchTimeoutException.class); + assertThat(downstreamCalls) + .as("the chunk after an exhausted budget must never be issued") + .hasValue(1); + } + + private static GraphQlBatchExecutor executor(Duration timeout, int chunkSize) { + GraphQlBatchPolicy policy = policy(timeout, chunkSize); + return new GraphQlBatchExecutor( + policy, GraphQlBatchChunker.of(policy, 100), Clock.fixed(NOW, ZoneOffset.UTC)); + } + + private static GraphQlBatchPolicy policy(Duration timeout, int chunkSize) { + return new GraphQlBatchPolicy( + new GraphQlDataLoaderName("order-by-id"), + chunkSize, + timeout, + true, + GraphQlMissingKeyPolicy.NULL_VALUE, + GraphQlBatchErrorPolicy.PER_KEY); + } + + private static GraphQlBatchContext batchContext() { + return GraphQlBatchContext.from(GraphQlRequestContexts.testContext("tenant-a")); + } + + /** A clock the test moves explicitly, so elapsed time is caused by the work under test. */ + private static final class MutableClock extends Clock { + + private Instant current; + + MutableClock(Instant start) { + this.current = start; + } + + void advance(Duration step) { + current = current.plus(step); + } + + @Override + public ZoneOffset getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(java.time.ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return current; + } + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlDataLoaderRequestRegistryTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlDataLoaderRequestRegistryTest.java index 50104e24..52c9588c 100644 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlDataLoaderRequestRegistryTest.java +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/dataloader/GraphQlDataLoaderRequestRegistryTest.java @@ -3,7 +3,7 @@ package dev.caskeleton.adapter.inbound.graphql.dataloader; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationContextFactory; +import dev.caskeleton.adapter.inbound.graphql.testkit.GraphQlRequestContexts; import java.time.Clock; import java.time.Duration; import java.util.function.Supplier; @@ -77,8 +77,8 @@ class GraphQlDataLoaderRequestRegistryTest { 100, Clock.systemUTC()); - var tenantA = factory.batchContext(GraphQlAuthenticationContextFactory.testContext("tenant-a")); - var tenantB = factory.batchContext(GraphQlAuthenticationContextFactory.testContext("tenant-b")); + var tenantA = factory.batchContext(GraphQlRequestContexts.testContext("tenant-a")); + var tenantB = factory.batchContext(GraphQlRequestContexts.testContext("tenant-b")); assertThat(tenantA.cacheScope()).isNotEqualTo(tenantB.cacheScope()); assertThat(factory.newRequestRegistry()).isNotSameAs(factory.newRequestRegistry()); diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/execution/BoundedPreparsedDocumentProviderTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/execution/BoundedPreparsedDocumentProviderTest.java index 23e0a8c6..70a47f85 100644 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/execution/BoundedPreparsedDocumentProviderTest.java +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/execution/BoundedPreparsedDocumentProviderTest.java @@ -3,13 +3,19 @@ package dev.caskeleton.adapter.inbound.graphql.execution; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.time.Clock; import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; /** Bounded preparsed document cache (Stable plan Task 35). */ class BoundedPreparsedDocumentProviderTest { + private static final Instant NOW = Instant.parse("2026-08-14T00:00:00Z"); + @Test void schemaHashSeparatesOtherwiseIdenticalDocuments() { var a = new GraphQlPreparsedCacheKey("doc", "schema-a", "policy-1", "FIRST_PARTY"); @@ -38,7 +44,9 @@ class BoundedPreparsedDocumentProviderTest { void parsingHappensOncePerKey() { var provider = new BoundedPreparsedDocumentProvider( - GraphQlPreparsedCachePolicy.defaults(), new GraphQlPreparsedCacheMetrics()); + GraphQlPreparsedCachePolicy.defaults(), + new GraphQlPreparsedCacheMetrics(), + Clock.fixed(NOW, ZoneOffset.UTC)); var key = new GraphQlPreparsedCacheKey("doc", "schema-a", "policy-1", "FIRST_PARTY"); AtomicInteger parses = new AtomicInteger(); @@ -55,7 +63,9 @@ class BoundedPreparsedDocumentProviderTest { void aSchemaChangeNeverReusesTheEarlierEntry() { var provider = new BoundedPreparsedDocumentProvider( - GraphQlPreparsedCachePolicy.defaults(), new GraphQlPreparsedCacheMetrics()); + GraphQlPreparsedCachePolicy.defaults(), + new GraphQlPreparsedCacheMetrics(), + Clock.fixed(NOW, ZoneOffset.UTC)); String first = provider.getDocument( @@ -77,7 +87,8 @@ class BoundedPreparsedDocumentProviderTest { var provider = new BoundedPreparsedDocumentProvider( new GraphQlPreparsedCachePolicy(2, 1_000, Duration.ofMinutes(1)), - new GraphQlPreparsedCacheMetrics()); + new GraphQlPreparsedCacheMetrics(), + Clock.fixed(NOW, ZoneOffset.UTC)); for (int index = 0; index < 5; index++) { provider.getDocument( @@ -92,7 +103,8 @@ class BoundedPreparsedDocumentProviderTest { var heavy = new BoundedPreparsedDocumentProvider( new GraphQlPreparsedCachePolicy(100, 50, Duration.ofMinutes(1)), - new GraphQlPreparsedCacheMetrics()); + new GraphQlPreparsedCacheMetrics(), + Clock.fixed(NOW, ZoneOffset.UTC)); heavy.getDocument( new GraphQlPreparsedCacheKey("a", "schema-a", "policy-1", "FIRST_PARTY"), 40, @@ -118,4 +130,184 @@ class BoundedPreparsedDocumentProviderTest { assertThat(GraphQlPreparsedCacheMetrics.class.getDeclaredMethods()) .allSatisfy(method -> assertThat(method.getParameterCount()).isZero()); } + + @Test + void anIdleEntryExpiresAndAFreshOneReplacesIt() { + var clock = new MutableClock(NOW); + var provider = + new BoundedPreparsedDocumentProvider( + new GraphQlPreparsedCachePolicy(100, 10_000, Duration.ofMinutes(10)), + new GraphQlPreparsedCacheMetrics(), + clock); + var key = new GraphQlPreparsedCacheKey("doc", "schema-a", "policy-1", "FIRST_PARTY"); + AtomicInteger parses = new AtomicInteger(); + + provider.getDocument(key, 10, ignored -> "parsed-" + parses.incrementAndGet()); + clock.advance(Duration.ofMinutes(11)); + + assertThat(provider.size()) + .as("expire-after-access was configured but nothing ever read it") + .isZero(); + provider.getDocument(key, 10, ignored -> "parsed-" + parses.incrementAndGet()); + assertThat(parses).hasValue(2); + assertThat(provider.metrics().expiries()).isEqualTo(1); + } + + @Test + void accessRefreshesTheIdleDeadline() { + var clock = new MutableClock(NOW); + var provider = + new BoundedPreparsedDocumentProvider( + new GraphQlPreparsedCachePolicy(100, 10_000, Duration.ofMinutes(10)), + new GraphQlPreparsedCacheMetrics(), + clock); + var key = new GraphQlPreparsedCacheKey("doc", "schema-a", "policy-1", "FIRST_PARTY"); + AtomicInteger parses = new AtomicInteger(); + + provider.getDocument(key, 10, ignored -> "parsed-" + parses.incrementAndGet()); + for (int step = 0; step < 5; step++) { + clock.advance(Duration.ofMinutes(9)); + provider.getDocument(key, 10, ignored -> "parsed-" + parses.incrementAndGet()); + } + + assertThat(parses) + .as("a document in continuous use must not expire after the first ten minutes") + .hasValue(1); + } + + @Test + void concurrentMissesOnOneKeyParseOnce() throws Exception { + var provider = + new BoundedPreparsedDocumentProvider( + GraphQlPreparsedCachePolicy.defaults(), + new GraphQlPreparsedCacheMetrics(), + Clock.fixed(NOW, ZoneOffset.UTC)); + var key = new GraphQlPreparsedCacheKey("doc", "schema-a", "policy-1", "FIRST_PARTY"); + AtomicInteger parses = new AtomicInteger(); + var parseStarted = new java.util.concurrent.CountDownLatch(1); + var releaseParse = new java.util.concurrent.CountDownLatch(1); + var done = new java.util.concurrent.CountDownLatch(8); + + for (int index = 0; index < 8; index++) { + Thread.ofVirtual() + .start( + () -> { + try { + provider.getDocument( + key, + 10, + ignored -> { + parses.incrementAndGet(); + parseStarted.countDown(); + try { + releaseParse.await(5, java.util.concurrent.TimeUnit.SECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + return "parsed"; + }); + } finally { + done.countDown(); + } + }); + } + + assertThat(parseStarted.await(5, java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + releaseParse.countDown(); + assertThat(done.await(10, java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + + assertThat(parses) + .as("a cold popular document parsed once per arriving request is how a deploy spikes CPU") + .hasValue(1); + } + + @Test + void aMissOnOneKeyDoesNotBlockAnotherKey() throws Exception { + var provider = + new BoundedPreparsedDocumentProvider( + GraphQlPreparsedCachePolicy.defaults(), + new GraphQlPreparsedCacheMetrics(), + Clock.fixed(NOW, ZoneOffset.UTC)); + var slow = new GraphQlPreparsedCacheKey("slow", "schema-a", "policy-1", "FIRST_PARTY"); + var fast = new GraphQlPreparsedCacheKey("fast", "schema-a", "policy-1", "FIRST_PARTY"); + var slowParseStarted = new java.util.concurrent.CountDownLatch(1); + var releaseSlowParse = new java.util.concurrent.CountDownLatch(1); + + Thread slowCaller = + Thread.ofVirtual() + .start( + () -> + provider.getDocument( + slow, + 10, + ignored -> { + slowParseStarted.countDown(); + try { + releaseSlowParse.await(10, java.util.concurrent.TimeUnit.SECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + return "slow-parsed"; + })); + + assertThat(slowParseStarted.await(5, java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + + // The whole method used to be synchronized, so this call could not even begin. + assertThat(provider.getDocument(fast, 10, ignored -> "fast-parsed")).isEqualTo("fast-parsed"); + + releaseSlowParse.countDown(); + slowCaller.join(java.time.Duration.ofSeconds(10)); + } + + @Test + void aFailedParseIsNotRememberedAndIsHandedToEveryWaiterUnwrapped() throws Exception { + var provider = + new BoundedPreparsedDocumentProvider( + GraphQlPreparsedCachePolicy.defaults(), + new GraphQlPreparsedCacheMetrics(), + Clock.fixed(NOW, ZoneOffset.UTC)); + var key = new GraphQlPreparsedCacheKey("doc", "schema-a", "policy-1", "FIRST_PARTY"); + + assertThatThrownBy( + () -> + provider.getDocument( + key, + 10, + ignored -> { + throw new IllegalStateException("invalid document"); + })) + .isInstanceOf(IllegalStateException.class); + + assertThat(provider.size()).as("a rejection must not be cached").isZero(); + assertThat(provider.getDocument(key, 10, ignored -> "parsed-later")).isEqualTo("parsed-later"); + } + + /** A clock the test moves explicitly, so elapsed time is caused by the test and not the wall. */ + private static final class MutableClock extends Clock { + + private Instant current; + + MutableClock(Instant start) { + this.current = start; + } + + void advance(Duration step) { + current = current.plus(step); + } + + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return current; + } + } } diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlExecutionPipelineTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlExecutionPipelineTest.java index 7de35504..2b99b43d 100644 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlExecutionPipelineTest.java +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlExecutionPipelineTest.java @@ -28,6 +28,31 @@ class GraphQlExecutionPipelineTest { .isLessThan(pipeline.indexOf(GraphQlExecutionStage.EXECUTE)); } + @Test + void authorizationRunsAfterTheDocumentHasBeenParsedAndSelected() { + GraphQlExecutionPipeline pipeline = GraphQlExecutionPipeline.stable(); + + assertThat(pipeline.indexOf(GraphQlExecutionStage.PARSE_VALIDATE)) + .as("a coordinate rule has no coordinate to check until an operation is selected") + .isLessThan(pipeline.indexOf(GraphQlExecutionStage.AUTHORIZATION)); + } + + @Test + void authorizingBeforeParsingIsRejected() { + GraphQlExecutionPipeline authorizeFirst = + new GraphQlExecutionPipeline( + List.of( + GraphQlExecutionStage.CONTEXT, + GraphQlExecutionStage.AUTHORIZATION, + GraphQlExecutionStage.PARSE_VALIDATE, + GraphQlExecutionStage.COST, + GraphQlExecutionStage.EXECUTE)); + + assertThatThrownBy(() -> GraphQlExecutionPipelineValidator.validate(authorizeFirst)) + .isInstanceOf(GraphQlExecutionPipelineException.class) + .hasMessageContaining("PARSE_VALIDATE must run before AUTHORIZATION"); + } + @Test void persistedLookupPrecedesParse() { GraphQlExecutionPipeline pipeline = GraphQlExecutionPipeline.withPersistedOperations(); @@ -53,8 +78,8 @@ class GraphQlExecutionPipelineTest { new GraphQlExecutionPipeline( List.of( GraphQlExecutionStage.CONTEXT, - GraphQlExecutionStage.AUTHORIZATION, GraphQlExecutionStage.PARSE_VALIDATE, + GraphQlExecutionStage.AUTHORIZATION, GraphQlExecutionStage.EXECUTE)); assertThatThrownBy(() -> GraphQlExecutionPipelineValidator.validate(missingCost)) @@ -68,8 +93,8 @@ class GraphQlExecutionPipelineTest { new GraphQlExecutionPipeline( List.of( GraphQlExecutionStage.CONTEXT, - GraphQlExecutionStage.AUTHORIZATION, GraphQlExecutionStage.PARSE_VALIDATE, + GraphQlExecutionStage.AUTHORIZATION, GraphQlExecutionStage.EXECUTE, GraphQlExecutionStage.COST)); @@ -85,8 +110,8 @@ class GraphQlExecutionPipelineTest { List.of( GraphQlExecutionStage.CONTEXT, GraphQlExecutionStage.CONTEXT, - GraphQlExecutionStage.AUTHORIZATION, GraphQlExecutionStage.PARSE_VALIDATE, + GraphQlExecutionStage.AUTHORIZATION, GraphQlExecutionStage.COST, GraphQlExecutionStage.EXECUTE)); @@ -97,6 +122,6 @@ class GraphQlExecutionPipelineTest { @Test void diagnosticsExposeStageNamesOnly() { assertThat(GraphQlExecutionPipeline.stable().stageNames()) - .containsExactly("CONTEXT", "AUTHORIZATION", "PARSE_VALIDATE", "COST", "EXECUTE"); + .containsExactly("CONTEXT", "PARSE_VALIDATE", "AUTHORIZATION", "COST", "EXECUTE"); } } diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlOperationNamePolicyTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlOperationNamePolicyTest.java index e9bf2341..6a694631 100644 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlOperationNamePolicyTest.java +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/execution/GraphQlOperationNamePolicyTest.java @@ -7,7 +7,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfileName; import dev.caskeleton.adapter.inbound.graphql.api.GraphQlOperationName; import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; -import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationContextFactory; +import dev.caskeleton.adapter.inbound.graphql.testkit.GraphQlRequestContexts; import org.junit.jupiter.api.Test; /** Production operation-name policy (Stable plan Task 34). */ @@ -92,7 +92,7 @@ class GraphQlOperationNamePolicyTest { @Test void theInterceptorPinsTheOperationIdentityOntoTheContext() { var interceptor = new GraphQlOperationNameInterceptor(GraphQlOperationNamePolicy.production()); - GraphQlRequestContext context = GraphQlAuthenticationContextFactory.testContext("tenant-a"); + GraphQlRequestContext context = GraphQlRequestContexts.testContext("tenant-a"); GraphQlRequestContext bound = interceptor.apply( diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlAcceptNegotiationTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlAcceptNegotiationTest.java new file mode 100644 index 00000000..43a11b86 --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlAcceptNegotiationTest.java @@ -0,0 +1,126 @@ +package dev.caskeleton.adapter.inbound.graphql.http; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +/** + * Content negotiation must answer with something the client asked for. + * + *

The previous implementation walked the server's own preference list and returned the first + * producible type named anywhere in the header. That reads {@code q} as decoration, so a client + * writing {@code application/graphql-response+json;q=0} — a refusal, not a preference — was sent + * exactly the media type it had refused. + */ +class GraphQlAcceptNegotiationTest { + + @Test + void anExplicitRefusalIsNotAPreference() { + String accept = "application/graphql-response+json;q=0, application/json;q=1"; + + assertThat(GraphQlMediaTypes.negotiateResponseContentType(accept)) + .isEqualTo(GraphQlMediaTypes.APPLICATION_JSON); + } + + @Test + void theClientRankingDecidesRatherThanTheServerPreference() { + String accept = "application/json;q=0.9, application/graphql-response+json;q=0.1"; + + assertThat(GraphQlMediaTypes.negotiateResponseContentType(accept)) + .as("the server prefers graphql-response+json; the client does not, and the client wins") + .isEqualTo(GraphQlMediaTypes.APPLICATION_JSON); + } + + @Test + void aConcreteTypeOutranksAWildcardOfEqualQuality() { + String accept = "*/*, application/json"; + + assertThat(GraphQlMediaTypes.negotiateResponseContentType(accept)) + .as("equal quality, so the more specific entry is the one the client meant") + .isEqualTo(GraphQlMediaTypes.APPLICATION_JSON); + } + + @Test + void aWildcardAcceptanceDoesNotOverrideANamedRefusal() { + String accept = "*/*, application/graphql-response+json;q=0"; + + assertThat(GraphQlMediaTypes.negotiateResponseContentType(accept)) + .isEqualTo(GraphQlMediaTypes.APPLICATION_JSON); + } + + @Test + void refusingEverythingProducibleLeavesNothingToSend() { + String accept = "application/graphql-response+json;q=0, application/json;q=0"; + + assertThat(GraphQlMediaTypes.negotiateResponseContentType(accept)).isNull(); + } + + @Test + void anAbsentHeaderMeansNoConstraint() { + assertThat(GraphQlMediaTypes.negotiateResponseContentType(null)) + .isEqualTo(GraphQlMediaTypes.GRAPHQL_RESPONSE_JSON); + assertThat(GraphQlMediaTypes.negotiateResponseContentType(" ")) + .isEqualTo(GraphQlMediaTypes.GRAPHQL_RESPONSE_JSON); + } + + @ParameterizedTest + @CsvSource({ + "'text/html', ", + "'text/*', ", + "'application/xml, text/plain', ", + "'*/*', application/graphql-response+json", + "'application/*', application/graphql-response+json", + "'application/json;charset=utf-8', application/json", + "'APPLICATION/JSON', application/json", + }) + void negotiationFollowsTheHeader(String accept, String expected) { + assertThat(GraphQlMediaTypes.negotiateResponseContentType(accept)).isEqualTo(expected); + } + + @Test + void aMalformedEntryIsDroppedRatherThanFailingTheRequest() { + assertThat(GraphQlMediaTypes.negotiateResponseContentType("garbage, application/json")) + .isEqualTo(GraphQlMediaTypes.APPLICATION_JSON); + assertThat(GraphQlMediaTypes.negotiateResponseContentType("application/json;q=notanumber")) + .as("an unparseable quality falls back to the default rather than dropping the entry") + .isEqualTo(GraphQlMediaTypes.APPLICATION_JSON); + } + + @Test + void anOutOfRangeQualityDoesNotOutrankAnHonestOne() { + String accept = "application/graphql-response+json;q=5, application/json;q=1"; + + assertThat(GraphQlMediaTypes.negotiateResponseContentType(accept)) + .as("q=5 is meaningless; it must not beat a well-formed q=1") + .isEqualTo(GraphQlMediaTypes.GRAPHQL_RESPONSE_JSON); + } + + @Test + void parsingRanksByQualityThenSpecificityThenOrder() { + var entries = GraphQlAcceptHeader.parse("*/*;q=0.5, application/json;q=0.5, text/html;q=0.9"); + + assertThat(entries).hasSize(3); + assertThat(entries.get(0).subtype()).isEqualTo("html"); + assertThat(entries.get(1).subtype()).as("equal quality, higher specificity").isEqualTo("json"); + assertThat(entries.get(2).specificity()).isZero(); + } + + @Test + void aRefusedEntryNeverAppearsInTheRanking() { + var entries = GraphQlAcceptHeader.parse("application/json;q=0, text/html"); + + assertThat(entries) + .singleElement() + .satisfies(entry -> assertThat(entry.subtype()).isEqualTo("html")); + } + + @Test + void theProfileTurnsAnUnsatisfiableHeaderIntoNotAcceptable() { + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> GraphQlHttpProfile.V1.negotiateResponseContentType("text/html")) + .isInstanceOf(GraphQlHttpContractException.class) + .hasMessageContaining("no acceptable GraphQL response media type"); + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlRequestBoundsTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlRequestBoundsTest.java new file mode 100644 index 00000000..77b18d0a --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/http/GraphQlRequestBoundsTest.java @@ -0,0 +1,173 @@ +package dev.caskeleton.adapter.inbound.graphql.http; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * The pre-execution bounds on a decoded request, and what they must not break. + * + *

Two failures sat next to each other here. Sizes were measured in characters, which lets a + * multi-byte document cost several times the limit it passed. And the envelope copied its inputs + * with {@code Map.copyOf}, which throws on a null value — so {@code {"id": null}}, a legal + * variables object with a meaning distinct from omitting the key, failed the request outright. + */ +class GraphQlRequestBoundsTest { + + @Test + void aMultiByteDocumentIsMeasuredInBytesNotCharacters() { + String korean = "요청"; + + assertThat(korean).hasSize(2); + assertThat(GraphQlRequestSize.ofDocument(korean).documentBytes()) + .as("two characters, six UTF-8 bytes; the limit exists to bound memory") + .isEqualTo(6); + } + + @Test + void theExactLimitPassesAndOneByteMoreDoesNot() { + GraphQlRequestEnvelopeValidator validator = + GraphQlRequestEnvelopeValidator.maxVariablesBytes(8); + + assertThatCode(() -> validator.validateVariables(new byte[8])).doesNotThrowAnyException(); + assertThatThrownBy(() -> validator.validateVariables(new byte[9])) + .isInstanceOf(GraphQlRequestTooLargeException.class); + } + + @Test + void theExactLimitPassesAndOneByteMoreDoesNotForMultiByteText() { + // Six characters of three UTF-8 bytes each. + String eighteenBytes = "요청요청요청"; + GraphQlRequestEnvelopeValidator validator = + GraphQlRequestEnvelopeValidator.maxVariablesBytes(18); + + assertThat(GraphQlRequestSize.ofDocument(eighteenBytes).documentBytes()).isEqualTo(18); + assertThatCode(() -> validator.validateVariables(new byte[18])).doesNotThrowAnyException(); + assertThatThrownBy(() -> validator.validateVariables(new byte[19])) + .isInstanceOf(GraphQlRequestTooLargeException.class); + } + + @Test + void anExplicitNullVariableSurvivesTheEnvelopeCopy() { + Map variables = new HashMap<>(); + variables.put("id", null); + + GraphQlHttpRequestEnvelope envelope = + new GraphQlHttpRequestEnvelope("{ q }", null, variables, Map.of()); + + assertThat(envelope.variables()).containsKey("id"); + assertThat(envelope.variables().get("id")).isNull(); + } + + @SuppressWarnings("unchecked") + private static List listAt(GraphQlHttpRequestEnvelope envelope, String key) { + return (List) envelope.variables().get(key); + } + + @SuppressWarnings("unchecked") + private static Map objectAt(GraphQlHttpRequestEnvelope envelope, String key) { + return (Map) envelope.variables().get(key); + } + + @Test + void aNestedNullSurvivesTheEnvelopeCopy() { + Map filter = new HashMap<>(); + filter.put("status", null); + Map variables = new LinkedHashMap<>(); + variables.put("filter", filter); + variables.put("ids", java.util.Arrays.asList("a", null)); + + GraphQlHttpRequestEnvelope envelope = + new GraphQlHttpRequestEnvelope("{ q }", null, variables, Map.of()); + + Map copiedFilter = objectAt(envelope, "filter"); + assertThat(copiedFilter).containsKey("status"); + assertThat(copiedFilter.get("status")).isNull(); + assertThat(listAt(envelope, "ids")).containsExactly("a", null); + } + + @Test + void mutatingTheOriginalNestedValueDoesNotChangeTheEnvelope() { + Map filter = new LinkedHashMap<>(); + filter.put("status", "OPEN"); + List ids = new ArrayList<>(List.of("a")); + Map variables = new LinkedHashMap<>(); + variables.put("filter", filter); + variables.put("ids", ids); + + GraphQlHttpRequestEnvelope envelope = + new GraphQlHttpRequestEnvelope("{ q }", null, variables, Map.of()); + filter.put("status", "CLOSED"); + ids.add("b"); + + Map copiedFilter = objectAt(envelope, "filter"); + assertThat(copiedFilter).containsEntry("status", "OPEN"); + assertThat(listAt(envelope, "ids")).containsExactly("a"); + } + + @Test + void theEnvelopeCopyIsUnmodifiableAllTheWayDown() { + Map variables = Map.of("filter", new LinkedHashMap<>(Map.of("status", "OPEN"))); + + GraphQlHttpRequestEnvelope envelope = + new GraphQlHttpRequestEnvelope("{ q }", null, variables, Map.of()); + + Map copiedFilter = objectAt(envelope, "filter"); + assertThatThrownBy(() -> copiedFilter.put("status", "CLOSED")) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void aDeeplyNestedInputIsRejected() { + GraphQlJsonStructurePolicy policy = new GraphQlJsonStructurePolicy(3, 10, 10); + Object nested = List.of(List.of(List.of(List.of("too deep")))); + + assertThatThrownBy(() -> policy.verify("variables", Map.of("value", nested))) + .isInstanceOf(GraphQlRequestFormatException.class) + .hasMessageContaining("DEPTH"); + } + + @Test + void anOversizeListIsRejected() { + GraphQlJsonStructurePolicy policy = new GraphQlJsonStructurePolicy(10, 3, 10); + + assertThatThrownBy(() -> policy.verify("variables", Map.of("ids", List.of("a", "b", "c", "d")))) + .isInstanceOf(GraphQlRequestFormatException.class) + .hasMessageContaining("LIST_ELEMENTS"); + } + + @Test + void anObjectWithTooManyKeysIsRejected() { + GraphQlJsonStructurePolicy policy = new GraphQlJsonStructurePolicy(10, 10, 2); + + assertThatThrownBy(() -> policy.verify("variables", Map.of("a", 1, "b", 2, "c", 3))) + .isInstanceOf(GraphQlRequestFormatException.class) + .hasMessageContaining("OBJECT_KEYS"); + } + + @Test + void theStructurePolicyFollowsTheClientPolicy() { + GraphQlClientPolicy client = GraphQlClientPolicy.defaults(100, 10_000, false); + GraphQlJsonStructurePolicy policy = GraphQlJsonStructurePolicy.from(client); + + assertThat(policy.maxDepth()).isEqualTo(client.maxDepth()); + } + + @Test + void jsonBytesCountsTheContentRatherThanTheReference() { + assertThat(GraphQlRequestSize.jsonBytes(null)).isEqualTo(4); + assertThat(GraphQlRequestSize.jsonBytes("ab")).isEqualTo(4); + assertThat(GraphQlRequestSize.jsonBytes("요")).as("three UTF-8 bytes plus quotes").isEqualTo(5); + assertThat(GraphQlRequestSize.jsonBytes(List.of(1, 2))) + .as("brackets, two single-digit numbers, one comma") + .isEqualTo(5); + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/http/mvc/GraphQlMvcTransportAdapterTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/http/mvc/GraphQlMvcTransportAdapterTest.java deleted file mode 100644 index 237452ea..00000000 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/http/mvc/GraphQlMvcTransportAdapterTest.java +++ /dev/null @@ -1,236 +0,0 @@ -package dev.caskeleton.adapter.inbound.graphql.http.mvc; - -import static org.assertj.core.api.Assertions.assertThat; - -import dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile; -import dev.caskeleton.adapter.inbound.graphql.api.GraphQlOperationId; -import dev.caskeleton.adapter.inbound.graphql.context.ActorRef; -import dev.caskeleton.adapter.inbound.graphql.context.GraphQlDeadline; -import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; -import dev.caskeleton.adapter.inbound.graphql.context.TenantContext; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlExecutionOutcome; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpExecutor; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpProfile; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpRequestEnvelope; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponse; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlMediaTypes; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestEnvelopeValidator; -import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.time.ZoneOffset; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import org.junit.jupiter.api.Test; - -/** MVC transport and virtual-thread execution path (Stable plan Task 18). */ -class GraphQlMvcTransportAdapterTest { - - private static final Clock CLOCK = - Clock.fixed(Instant.parse("2026-08-12T00:00:00Z"), ZoneOffset.UTC); - - @Test - void virtualThreadPolicyAllowsBlockingResolvers() { - assertThat(GraphQlMvcExecutorPolicy.VIRTUAL_THREAD.blockingAllowed()).isTrue(); - assertThat(GraphQlMvcExecutorPolicy.BOUNDED_PLATFORM_THREAD.blockingAllowed()).isTrue(); - } - - @Test - void executesOnTheConfiguredExecutorAndReturns200() throws Exception { - try (ExecutorService executorService = - GraphQlMvcExecutorPolicy.VIRTUAL_THREAD.createExecutor(4)) { - AtomicBoolean virtualThread = new AtomicBoolean(); - GraphQlMvcTransportAdapter adapter = - adapter( - executorService, - (envelope, context) -> { - virtualThread.set(Thread.currentThread().isVirtual()); - return GraphQlExecutionOutcome.success(Map.of("ping", "pong")); - }); - - GraphQlHttpResponse response = - adapter.handle( - "POST", - "application/json", - GraphQlMediaTypes.GRAPHQL_RESPONSE_JSON, - GraphQlHttpRequestEnvelope.of("query Ping { ping }", "Ping"), - context(Duration.ofSeconds(5))); - - assertThat(response.status()).isEqualTo(200); - assertThat(response.data()).containsEntry("ping", "pong"); - assertThat(virtualThread).isTrue(); - } - } - - @Test - void transportViolationBecomesAResponseRatherThanAnException() throws Exception { - try (ExecutorService executorService = - GraphQlMvcExecutorPolicy.BOUNDED_PLATFORM_THREAD.createExecutor(2)) { - GraphQlMvcTransportAdapter adapter = - adapter( - executorService, - (envelope, context) -> GraphQlExecutionOutcome.success(Map.of()), - GraphQlMvcExecutorPolicy.BOUNDED_PLATFORM_THREAD); - - GraphQlHttpResponse response = - adapter.handle( - "GET", - "application/json", - GraphQlMediaTypes.GRAPHQL_RESPONSE_JSON, - GraphQlHttpRequestEnvelope.of("query Ping { ping }", "Ping"), - context(Duration.ofSeconds(5))); - - assertThat(response.status()).isEqualTo(405); - assertThat(response.errors()).isNotEmpty(); - } - } - - @Test - void executionIsCancelledWhenTheDeadlinePasses() throws Exception { - try (ExecutorService executorService = - GraphQlMvcExecutorPolicy.VIRTUAL_THREAD.createExecutor(4)) { - CountDownLatch interrupted = new CountDownLatch(1); - GraphQlMvcTransportAdapter adapter = - adapter( - executorService, - (envelope, context) -> { - try { - Thread.sleep(Duration.ofSeconds(30)); - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - interrupted.countDown(); - } - return GraphQlExecutionOutcome.success(Map.of()); - }); - - GraphQlHttpResponse response = - adapter.handle( - "POST", - "application/json", - GraphQlMediaTypes.GRAPHQL_RESPONSE_JSON, - GraphQlHttpRequestEnvelope.of("query Slow { slow }", "Slow"), - context(Duration.ofMillis(120))); - - assertThat(interrupted.await(5, TimeUnit.SECONDS)).isTrue(); - assertThat(response.status()).isEqualTo(200); - assertThat(response.errors()) - .singleElement() - .satisfies( - error -> - assertThat(((Map) error.get("extensions")).get("code")) - .isEqualTo("REQUEST_TIMEOUT")); - } - } - - @Test - void anAlreadyExpiredBudgetSkipsExecutionEntirely() throws Exception { - try (ExecutorService executorService = - GraphQlMvcExecutorPolicy.VIRTUAL_THREAD.createExecutor(2)) { - AtomicBoolean executed = new AtomicBoolean(); - GraphQlMvcTransportAdapter adapter = - adapter( - executorService, - (envelope, context) -> { - executed.set(true); - return GraphQlExecutionOutcome.success(Map.of()); - }); - - GraphQlHttpResponse response = - adapter.handle( - "POST", - "application/json", - GraphQlMediaTypes.GRAPHQL_RESPONSE_JSON, - GraphQlHttpRequestEnvelope.of("query Ping { ping }", "Ping"), - expiredContext()); - - assertThat(executed).isFalse(); - assertThat(response.errors()).isNotEmpty(); - } - } - - @Test - void mvcContractExposesNoReactiveTypes() { - List> signatureTypes = - java.util.Arrays.stream(GraphQlMvcTransportAdapter.class.getDeclaredMethods()) - .flatMap( - method -> - java.util.stream.Stream.concat( - java.util.stream.Stream.of(method.getReturnType()), - java.util.Arrays.stream(method.getParameterTypes()))) - .toList(); - - assertThat(signatureTypes) - .allSatisfy( - type -> - assertThat(type.getName()) - .doesNotStartWith("reactor.") - .doesNotStartWith("org.springframework.web.reactive") - .doesNotStartWith("org.reactivestreams")); - } - - private static GraphQlMvcTransportAdapter adapter( - ExecutorService executorService, GraphQlHttpExecutor executor) { - return adapter(executorService, executor, GraphQlMvcExecutorPolicy.VIRTUAL_THREAD); - } - - private static GraphQlMvcTransportAdapter adapter( - ExecutorService executorService, - GraphQlHttpExecutor executor, - GraphQlMvcExecutorPolicy policy) { - return new GraphQlMvcTransportAdapter( - GraphQlHttpProfile.V1, - GraphQlRequestEnvelopeValidator.forPolicy(policy()), - executor, - executorService, - policy, - Clock.systemUTC()); - } - - private static GraphQlClientPolicy policy() { - return new GraphQlClientPolicy( - 65536, - 65536, - 12, - 500, - 50, - 50, - 1000, - 20, - 100, - 10000, - 10000, - 5_242_880, - Duration.ofSeconds(5), - false, - false, - true); - } - - private static GraphQlRequestContext context(Duration budget) { - return new GraphQlRequestContext( - ActorRef.authenticated("user-1"), - TenantContext.fromAuthenticatedCredential("tenant-a"), - new GraphQlClientProfile("first-party"), - Locale.ROOT, - new GraphQlOperationId("ping"), - "trace-1", - GraphQlDeadline.after(budget, Clock.systemUTC())); - } - - private static GraphQlRequestContext expiredContext() { - return new GraphQlRequestContext( - ActorRef.authenticated("user-1"), - TenantContext.fromAuthenticatedCredential("tenant-a"), - new GraphQlClientProfile("first-party"), - Locale.ROOT, - new GraphQlOperationId("ping"), - "trace-1", - new GraphQlDeadline(CLOCK.instant())); - } -} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/http/webflux/GraphQlEventLoopGuardTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/http/webflux/GraphQlEventLoopGuardTest.java deleted file mode 100644 index f841a67e..00000000 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/http/webflux/GraphQlEventLoopGuardTest.java +++ /dev/null @@ -1,182 +0,0 @@ -package dev.caskeleton.adapter.inbound.graphql.http.webflux; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile; -import dev.caskeleton.adapter.inbound.graphql.api.GraphQlOperationId; -import dev.caskeleton.adapter.inbound.graphql.context.ActorRef; -import dev.caskeleton.adapter.inbound.graphql.context.GraphQlDeadline; -import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; -import dev.caskeleton.adapter.inbound.graphql.context.TenantContext; -import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfileException; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlExecutionOutcome; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpProfile; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpRequestEnvelope; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponse; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlMediaTypes; -import dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestEnvelopeValidator; -import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy; -import dev.caskeleton.adapter.inbound.graphql.policy.ResolverExecutionType; -import java.time.Clock; -import java.time.Duration; -import java.util.Locale; -import java.util.Map; -import java.util.concurrent.atomic.AtomicBoolean; -import org.junit.jupiter.api.Test; -import reactor.core.publisher.Mono; - -/** Reactive transport and event-loop guard (Stable plan Task 19). */ -class GraphQlEventLoopGuardTest { - - @Test - void blockingResolverIsRejectedOnEventLoop() { - assertThatThrownBy( - () -> GraphQlEventLoopGuard.verify(ResolverExecutionType.BLOCKING, true, false)) - .isInstanceOf(GraphQlExecutionProfileException.class); - } - - @Test - void anApprovedBridgeAllowsBlockingWorkOffTheLoop() { - assertThatCode(() -> GraphQlEventLoopGuard.verify(ResolverExecutionType.BLOCKING, true, true)) - .doesNotThrowAnyException(); - assertThatCode(() -> GraphQlEventLoopGuard.verify(ResolverExecutionType.BLOCKING, false, false)) - .doesNotThrowAnyException(); - assertThatCode(() -> GraphQlEventLoopGuard.verify(ResolverExecutionType.REACTIVE, true, false)) - .doesNotThrowAnyException(); - } - - @Test - void eventLoopThreadsAreRecognisedByName() { - assertThat(GraphQlEventLoopGuard.isEventLoopThread("reactor-http-nio-3")).isTrue(); - assertThat(GraphQlEventLoopGuard.isEventLoopThread("nioEventLoopGroup-2-1")).isTrue(); - assertThat(GraphQlEventLoopGuard.isEventLoopThread("http-nio-8080-exec-1")).isFalse(); - assertThat(GraphQlEventLoopGuard.isEventLoopThread(null)).isFalse(); - assertThat(GraphQlEventLoopGuard.onEventLoop()).isFalse(); - } - - @Test - void reactiveTransportCarriesTheRequestContextInTheReactorContext() { - AtomicBoolean contextSeen = new AtomicBoolean(); - GraphQlWebFluxTransportAdapter adapter = - new GraphQlWebFluxTransportAdapter( - GraphQlHttpProfile.V1, - GraphQlRequestEnvelopeValidator.forPolicy(policy()), - (envelope, context) -> - Mono.deferContextual( - view -> { - contextSeen.set(view.hasKey(GraphQlRequestContext.CONTEXT_KEY)); - return Mono.just(GraphQlExecutionOutcome.success(Map.of("ping", "pong"))); - }), - Clock.systemUTC()); - - GraphQlHttpResponse response = - adapter - .handle( - "POST", - "application/json", - GraphQlMediaTypes.GRAPHQL_RESPONSE_JSON, - GraphQlHttpRequestEnvelope.of("query Ping { ping }", "Ping"), - context(Duration.ofSeconds(5))) - .block(Duration.ofSeconds(5)); - - assertThat(contextSeen).isTrue(); - assertThat(response).isNotNull(); - assertThat(response.status()).isEqualTo(200); - assertThat(response.data()).containsEntry("ping", "pong"); - } - - @Test - void deadlineCancelsTheUpstreamChain() { - AtomicBoolean cancelled = new AtomicBoolean(); - GraphQlWebFluxTransportAdapter adapter = - new GraphQlWebFluxTransportAdapter( - GraphQlHttpProfile.V1, - GraphQlRequestEnvelopeValidator.forPolicy(policy()), - (envelope, context) -> - Mono.never().doOnCancel(() -> cancelled.set(true)), - Clock.systemUTC()); - - GraphQlHttpResponse response = - adapter - .handle( - "POST", - "application/json", - GraphQlMediaTypes.GRAPHQL_RESPONSE_JSON, - GraphQlHttpRequestEnvelope.of("query Slow { slow }", "Slow"), - context(Duration.ofMillis(150))) - .block(Duration.ofSeconds(5)); - - assertThat(cancelled).isTrue(); - assertThat(response).isNotNull(); - assertThat(response.status()).isEqualTo(200); - assertThat(response.errors()) - .singleElement() - .satisfies( - error -> - assertThat(((Map) error.get("extensions")).get("code")) - .isEqualTo("REQUEST_TIMEOUT")); - } - - @Test - void transportViolationBecomesAResponseWithoutSubscribingExecution() { - AtomicBoolean executed = new AtomicBoolean(); - GraphQlWebFluxTransportAdapter adapter = - new GraphQlWebFluxTransportAdapter( - GraphQlHttpProfile.V1, - GraphQlRequestEnvelopeValidator.forPolicy(policy()), - (envelope, context) -> - Mono.fromCallable( - () -> { - executed.set(true); - return GraphQlExecutionOutcome.success(Map.of()); - }), - Clock.systemUTC()); - - GraphQlHttpResponse response = - adapter - .handle( - "GET", - "application/json", - GraphQlMediaTypes.GRAPHQL_RESPONSE_JSON, - GraphQlHttpRequestEnvelope.of("query Ping { ping }", "Ping"), - context(Duration.ofSeconds(5))) - .block(Duration.ofSeconds(5)); - - assertThat(executed).isFalse(); - assertThat(response).isNotNull(); - assertThat(response.status()).isEqualTo(405); - } - - private static GraphQlClientPolicy policy() { - return new GraphQlClientPolicy( - 65536, - 65536, - 12, - 500, - 50, - 50, - 1000, - 20, - 100, - 10000, - 10000, - 5_242_880, - Duration.ofSeconds(5), - false, - false, - true); - } - - private static GraphQlRequestContext context(Duration budget) { - return new GraphQlRequestContext( - ActorRef.authenticated("user-1"), - TenantContext.fromAuthenticatedCredential("tenant-a"), - new GraphQlClientProfile("first-party"), - Locale.ROOT, - new GraphQlOperationId("ping"), - "trace-1", - GraphQlDeadline.after(budget, Clock.systemUTC())); - } -} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlBuildModel.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlBuildModel.java new file mode 100644 index 00000000..aca33a89 --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlBuildModel.java @@ -0,0 +1,293 @@ +package dev.caskeleton.adapter.inbound.graphql.moduleboundary; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * Reads a GraphQL platform source tree and reports where it disagrees with the declared module map. + * + *

This deliberately works on source text rather than on compiled classes. An import that a + * boundary forbids has usually been erased by the compiler — a constant is inlined, a type is only + * named in a signature that is not retained — so a bytecode scan would report a boundary as clean + * while the source that a reviewer reads still crosses it. + * + *

It lives in the test source set on purpose. Scanning a checkout is build-time work: a running + * application has no source tree to scan, and a runtime scan would fail exactly in the packaged + * environments where nothing is wrong. + * + *

Every entry point refuses to report success on an empty scan. A boundary rule that passes + * because it found no files is the failure mode this whole model exists to prevent. + */ +public final class GraphQlBuildModel { + + /** + * Import prefixes that make a module framework bound. + * + *

{@code jakarta} covers servlet and validation, {@code io.micrometer} covers observation: + * both bind a module to a runtime just as firmly as Spring does. + */ + public static final Pattern FRAMEWORK_IMPORT = + Pattern.compile("^(org\\.springframework|graphql|reactor|io\\.micrometer|jakarta)\\."); + + private static final Pattern IMPORT_STATEMENT = + Pattern.compile("^\\s*import\\s+(?:static\\s+)?([\\w.]+)\\s*;", Pattern.MULTILINE); + + private static final Pattern PACKAGE_STATEMENT = + Pattern.compile("^\\s*package\\s+([\\w.]+)\\s*;", Pattern.MULTILINE); + + private GraphQlBuildModel() {} + + /** + * Scans every source root the platform's module map governs. + * + *

Both {@code src/main/java} and {@code src/testFixtures/java}, because the module map is + * about package boundaries and those hold regardless of which artifact a package ships in. The + * testkit moved to test fixtures so it would stop shipping in the production jar; scanning only + * main would have quietly stopped checking its edges at the same moment, which is the wrong half + * of that change to keep. + */ + public static GraphQlSourceGraph scanPlatformSources() { + return scan(platformSourceRoots()); + } + + /** Scans this leaf's production sources only. */ + public static GraphQlSourceGraph scanMainSources() { + return scan(mainSourceRoot()); + } + + /** + * Scans a Java source root — the directory that directly contains the {@code dev} package folder. + * + * @throws IllegalStateException when the root holds no platform source at all + */ + public static GraphQlSourceGraph scan(Path sourceRoot) { + return scan(List.of(sourceRoot)); + } + + /** + * Scans several Java source roots as one platform. + * + * @throws IllegalStateException when the roots hold no platform source at all + */ + public static GraphQlSourceGraph scan(List sourceRoots) { + Map> edges = new TreeMap<>(); + Map> frameworkImports = new TreeMap<>(); + Set packages = new TreeSet<>(); + int fileCount = 0; + + List files = new ArrayList<>(); + for (Path sourceRoot : sourceRoots) { + files.addAll(javaFilesUnder(sourceRoot)); + } + for (Path file : files) { + String source = read(file); + String packageName = declaredPackage(source).orElse(null); + if (packageName == null || !GraphQlModuleBoundary.insidePlatform(packageName)) { + continue; + } + fileCount++; + packages.add(packageName); + String moduleId = GraphQlModuleBoundary.moduleIdForPackage(packageName).orElse(null); + if (moduleId == null) { + // An unregistered package still has to appear in `packages` so the rule can name it, but it + // owns no module identity and therefore contributes no edges. + continue; + } + edges.computeIfAbsent(moduleId, key -> new TreeSet<>()); + frameworkImports.computeIfAbsent(moduleId, key -> new TreeSet<>()); + + Matcher matcher = IMPORT_STATEMENT.matcher(source); + while (matcher.find()) { + String imported = matcher.group(1); + if (GraphQlModuleBoundary.insidePlatform(imported)) { + importedModule(imported) + .filter(target -> !target.equals(moduleId)) + .ifPresent(target -> edges.get(moduleId).add(target)); + } else if (FRAMEWORK_IMPORT.matcher(imported).find()) { + frameworkImports.get(moduleId).add(imported); + } + } + } + + if (fileCount == 0) { + throw new IllegalStateException( + "no GraphQL platform source was found under " + + sourceRoots.stream().map(root -> root.toAbsolutePath().toString()).toList() + + "; a boundary rule must never pass by scanning nothing"); + } + return new GraphQlSourceGraph(edges, frameworkImports, packages, fileCount); + } + + /** Packages that hold source but were never given a module identity. */ + public static List undeclaredPackages(GraphQlSourceGraph graph) { + return graph.packages().stream() + .filter(packageName -> GraphQlModuleBoundary.moduleIdForPackage(packageName).isEmpty()) + .sorted() + .toList(); + } + + /** Declared modules whose package holds no source, so the declaration describes nothing. */ + public static List declaredButAbsentModules(GraphQlSourceGraph graph) { + List absent = new ArrayList<>(); + GraphQlModuleBoundary.packagesById() + .forEach( + (moduleId, packageName) -> { + boolean present = + graph.packages().stream() + .anyMatch( + scanned -> + scanned.equals(packageName) || scanned.startsWith(packageName + ".")); + if (!present) { + absent.add(moduleId + " (" + packageName + ")"); + } + }); + absent.sort(String::compareTo); + return List.copyOf(absent); + } + + /** Imports that cross a module boundary the declaration does not allow. */ + public static List undeclaredEdges(GraphQlSourceGraph graph) { + List violations = new ArrayList<>(); + graph + .moduleEdges() + .forEach( + (from, targets) -> + targets.stream() + .filter(to -> !GraphQlModuleBoundary.edgeAllowed(from, to)) + .forEach(to -> violations.add(from + " -> " + to))); + violations.sort(String::compareTo); + return List.copyOf(violations); + } + + /** Stable modules that import an Advanced capability, in any direction-breaking form. */ + public static List stableToAdvancedImports(GraphQlSourceGraph graph) { + Set stable = GraphQlStableModule.moduleIds(); + Set advanced = GraphQlAdvancedModule.moduleIds(); + List violations = new ArrayList<>(); + graph + .moduleEdges() + .forEach( + (from, targets) -> { + if (!stable.contains(from)) { + return; + } + targets.stream() + .filter(advanced::contains) + .forEach(to -> violations.add(from + " -> " + to)); + }); + violations.sort(String::compareTo); + return List.copyOf(violations); + } + + /** Framework imports found in modules declared {@link GraphQlModulePurity#CORE}. */ + public static List frameworkImportsInCoreModules(GraphQlSourceGraph graph) { + Set core = GraphQlModuleBoundary.coreModuleIds(); + List violations = new ArrayList<>(); + graph + .frameworkImports() + .forEach( + (moduleId, imports) -> { + if (!core.contains(moduleId)) { + return; + } + imports.forEach(imported -> violations.add(moduleId + " imports " + imported)); + }); + violations.sort(String::compareTo); + return List.copyOf(violations); + } + + /** + * Locates this leaf's production source root. + * + * @throws IllegalStateException when it cannot be found, rather than returning a path that would + * scan to zero files + */ + /** + * Every source root the module map governs: production plus test fixtures. + * + * @throws IllegalStateException when the production root cannot be found + */ + public static List platformSourceRoots() { + Path main = mainSourceRoot(); + Path fixtures = main.getParent().getParent().resolve("testFixtures").resolve("java"); + return Files.isDirectory(fixtures) ? List.of(main, fixtures) : List.of(main); + } + + public static Path mainSourceRoot() { + String packagePath = GraphQlModuleBoundary.PACKAGE_ROOT.replace('.', '/'); + for (Path directory = Path.of("").toAbsolutePath(); + directory != null; + directory = directory.getParent()) { + Path candidate = directory.resolve("src").resolve("main").resolve("java"); + if (Files.isDirectory(candidate.resolve(packagePath))) { + return candidate; + } + } + throw new IllegalStateException( + "cannot locate src/main/java/" + + packagePath + + " from " + + Path.of("").toAbsolutePath() + + "; the module boundary rules have nothing to check"); + } + + private static Optional importedModule(String importedType) { + int lastDot = importedType.lastIndexOf('.'); + if (lastDot < 0) { + return Optional.empty(); + } + // A static import names a member, so peel qualifiers until one resolves to a declared module. + for (String candidate = importedType.substring(0, lastDot); + candidate.length() >= GraphQlModuleBoundary.PACKAGE_ROOT.length(); + candidate = candidate.substring(0, Math.max(candidate.lastIndexOf('.'), 0))) { + Optional moduleId = GraphQlModuleBoundary.moduleIdForPackage(candidate); + if (moduleId.isPresent()) { + return moduleId; + } + if (candidate.lastIndexOf('.') < 0) { + break; + } + } + return Optional.empty(); + } + + private static Optional declaredPackage(String source) { + Matcher matcher = PACKAGE_STATEMENT.matcher(source); + return matcher.find() ? Optional.of(matcher.group(1)) : Optional.empty(); + } + + private static List javaFilesUnder(Path root) { + if (!Files.isDirectory(root)) { + throw new IllegalStateException("source root does not exist: " + root.toAbsolutePath()); + } + try (Stream files = Files.walk(root)) { + return files + .filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".java")) + .sorted() + .toList(); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private static String read(Path file) { + try { + return Files.readString(file); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlModuleBoundaryTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlModuleBoundaryTest.java new file mode 100644 index 00000000..d324b1bc --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlModuleBoundaryTest.java @@ -0,0 +1,190 @@ +package dev.caskeleton.adapter.inbound.graphql.moduleboundary; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedDependencyRules; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * The machine check behind the "bounded packages instead of Gradle leaves" decision. + * + *

Splitting the platform into sub-packages of one leaf buys nothing unless the split is + * enforced, and a Gradle dependency gate cannot see inside a leaf. So this test does what the gate + * cannot: it scans the real production tree and compares it against the declared module map. + * + *

The negative fixtures matter as much as the positive assertions. A boundary test that has + * never been shown to fail is indistinguishable from one that scans the wrong directory, and this + * leaf has already lost a boundary model once by making it invisible to Git. Each rule therefore + * gets a synthetic tree that must be rejected. + */ +class GraphQlModuleBoundaryTest { + + private static final String ROOT = GraphQlModuleBoundary.PACKAGE_ROOT; + + private static GraphQlSourceGraph production; + + @BeforeAll + static void scanProductionTree() { + production = GraphQlBuildModel.scanPlatformSources(); + } + + @Test + void theScanActuallyReadsTheProductionTree() { + assertThat(production.fileCount()) + .as("the platform is a few hundred files; a scan far below that is reading the wrong tree") + .isGreaterThan(300); + assertThat(production.packages()).contains(ROOT, ROOT + ".runtime", ROOT + ".advanced.sse"); + } + + @Test + void everyProductionPackageHasADeclaredModuleIdentity() { + assertThat(GraphQlBuildModel.undeclaredPackages(production)) + .as("add the package to GraphQlStableModule or GraphQlAdvancedModule before shipping it") + .isEmpty(); + } + + @Test + void everyDeclaredModuleExistsInTheProductionTree() { + assertThat(GraphQlBuildModel.declaredButAbsentModules(production)) + .as("a declared module with no source describes nothing and hides a rename") + .isEmpty(); + } + + @Test + void everyCrossModuleImportIsADeclaredEdge() { + assertThat(GraphQlBuildModel.undeclaredEdges(production)).isEmpty(); + } + + @Test + void stableModulesNeverImportAnAdvancedCapability() { + assertThat(GraphQlBuildModel.stableToAdvancedImports(production)).isEmpty(); + assertThatCode(GraphQlAdvancedDependencyRules::verifyStableDoesNotDependOnAdvanced) + .as("the declared edges and the scanned tree must agree on the direction") + .doesNotThrowAnyException(); + } + + @Test + void coreModulesStayFrameworkFree() { + assertThat(GraphQlBuildModel.frameworkImportsInCoreModules(production)) + .as("a CORE module may not bind to Spring, GraphQL Java, Reactor, Micrometer or Jakarta") + .isEmpty(); + } + + @Test + void frameworkBoundModulesAreTheOnlyOnesThatTouchTheFramework() { + var frameworkBound = + production.frameworkImports().entrySet().stream() + .filter(entry -> !entry.getValue().isEmpty()) + .map(Map.Entry::getKey) + .collect(Collectors.toSet()); + + assertThat(frameworkBound) + .as( + "keep GraphQlModulePurity honest: these are the modules that actually import Spring etc") + .containsExactlyInAnyOrder( + "root", + "advanced.codegen", + "architecture", + "autoconfigure", + "compat", + "cost", + "runtime", + "scalar", + "schema"); + } + + @Test + void aStableModuleImportingAnAdvancedCapabilityIsRejected(@TempDir Path tree) { + writeType(tree, ROOT + ".policy", "LeakyPolicy", ROOT + ".advanced.persisted.SomeRegistry"); + + var graph = GraphQlBuildModel.scan(tree); + + assertThat(GraphQlBuildModel.stableToAdvancedImports(graph)) + .containsExactly("policy -> advanced.persisted"); + assertThat(GraphQlBuildModel.undeclaredEdges(graph)) + .containsExactly("policy -> advanced.persisted"); + } + + @Test + void aFrameworkImportInACoreModuleIsRejected(@TempDir Path tree) { + writeType( + tree, ROOT + ".execution", "BoundExecution", "org.springframework.stereotype.Component"); + + var graph = GraphQlBuildModel.scan(tree); + + assertThat(GraphQlBuildModel.frameworkImportsInCoreModules(graph)) + .containsExactly("execution imports org.springframework.stereotype.Component"); + } + + @Test + void aPackageWithNoDeclaredModuleIsRejected(@TempDir Path tree) { + writeType(tree, ROOT + ".unregistered", "SurpriseCapability"); + + var graph = GraphQlBuildModel.scan(tree); + + assertThat(GraphQlBuildModel.undeclaredPackages(graph)).containsExactly(ROOT + ".unregistered"); + } + + @Test + void anUndeclaredEdgeBetweenTwoDeclaredModulesIsRejected(@TempDir Path tree) { + writeType( + tree, ROOT + ".api", "ApiReachingIntoSchema", ROOT + ".schema.GraphQlSchemaAssembler"); + + var graph = GraphQlBuildModel.scan(tree); + + assertThat(GraphQlBuildModel.undeclaredEdges(graph)).containsExactly("api -> schema"); + assertThat(GraphQlBuildModel.stableToAdvancedImports(graph)) + .as("an undeclared Stable edge is not a Stable->Advanced violation") + .isEmpty(); + } + + @Test + void aScanThatFindsNothingFailsInsteadOfPassing(@TempDir Path empty) { + assertThatThrownBy(() -> GraphQlBuildModel.scan(empty)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("must never pass by scanning nothing"); + } + + @Test + void theRootModuleOwnsOnlyTheRootPackage() { + assertThat(GraphQlModuleBoundary.moduleIdForPackage(ROOT)).contains("root"); + assertThat(GraphQlModuleBoundary.moduleIdForPackage(ROOT + ".http.negotiation")) + .as("a descendant belongs to the module that owns its nearest declared ancestor") + .contains("http"); + assertThat(GraphQlModuleBoundary.moduleIdForPackage(ROOT + ".advanced.sse")) + .as("the longest declared prefix wins, so this is advanced.sse and not advanced") + .contains("advanced.sse"); + assertThat(GraphQlModuleBoundary.moduleIdForPackage(ROOT + ".unregistered")).isEmpty(); + assertThat(GraphQlModuleBoundary.moduleIdForPackage("dev.caskeleton.adapter.inbound.web")) + .isEmpty(); + } + + private static void writeType( + Path sourceRoot, String packageName, String typeName, String... imports) { + Path directory = sourceRoot.resolve(packageName.replace('.', '/')); + String body = + Stream.concat( + Stream.of("package " + packageName + ";", ""), + Stream.concat( + Stream.of(imports).map(imported -> "import " + imported + ";"), + Stream.of("", "final class " + typeName + " {}"))) + .collect(Collectors.joining("\n")); + try { + Files.createDirectories(directory); + Files.writeString(directory.resolve(typeName + ".java"), body + "\n"); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlSourceGraph.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlSourceGraph.java new file mode 100644 index 00000000..143beb79 --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlSourceGraph.java @@ -0,0 +1,30 @@ +package dev.caskeleton.adapter.inbound.graphql.moduleboundary; + +import java.util.Map; +import java.util.Set; + +/** + * What a scan of a GraphQL platform source tree actually found. + * + *

Everything here is observed, never declared: {@link GraphQlStableModule} and {@link + * GraphQlAdvancedModule} say what the module map is supposed to be, and this record says what the + * checkout is. The boundary rules are the comparison between the two. + * + * @param moduleEdges module identifier to the module identifiers it imports, self-edges excluded + * @param frameworkImports module identifier to the framework imports it uses, empty when pure + * @param packages every package that contained at least one Java file + * @param fileCount how many Java files were read, so a rule can refuse to pass on an empty scan + */ +public record GraphQlSourceGraph( + Map> moduleEdges, + Map> frameworkImports, + Set packages, + int fileCount) { + + /** Canonicalises the collections so callers cannot mutate a scan result. */ + public GraphQlSourceGraph { + moduleEdges = Map.copyOf(moduleEdges); + frameworkImports = Map.copyOf(frameworkImports); + packages = Set.copyOf(packages); + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationIdempotencyContextTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationIdempotencyContextTest.java index c6d48234..9a124ad0 100644 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationIdempotencyContextTest.java +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/mutation/GraphQlMutationIdempotencyContextTest.java @@ -6,22 +6,23 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; import dev.caskeleton.adapter.inbound.graphql.http.GraphQlExtensionsPolicy; -import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationContextFactory; +import dev.caskeleton.adapter.inbound.graphql.testkit.GraphQlRequestContexts; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; /** Mutation idempotency scope and fingerprint (Stable plan Task 43). */ class GraphQlMutationIdempotencyContextTest { + private static final String TENANT = "tenant-fingerprint"; + private static final String VERSION = "v1"; + private static final GraphQlMutationCoordinate CREATE = + new GraphQlMutationCoordinate("Mutation.createOrder"); + @Test void sameKeyWithDifferentFingerprintIsConflict() { - var key = new GraphQlIdempotencyKey("request-1"); - var first = - GraphQlMutationIdempotencyContext.of( - "actor-fingerprint", - new GraphQlMutationCoordinate("Mutation.createOrder"), - key, - new GraphQlMutationFingerprint("sha256:a")); + var first = context(CREATE, new GraphQlMutationFingerprint("sha256:a")); assertThatThrownBy(() -> first.assertCompatible(new GraphQlMutationFingerprint("sha256:b"))) .isInstanceOf(GraphQlIdempotencyConflictException.class); @@ -30,11 +31,8 @@ class GraphQlMutationIdempotencyContextTest { @Test void sameKeyWithTheSameFingerprintIsARetry() { var context = - GraphQlMutationIdempotencyContext.of( - "actor-fingerprint", - new GraphQlMutationCoordinate("Mutation.createOrder"), - new GraphQlIdempotencyKey("request-1"), - GraphQlMutationFingerprint.of(Map.of("customerId", "c-1", "total", "10.00"))); + context( + CREATE, GraphQlMutationFingerprint.of(Map.of("customerId", "c-1", "total", "10.00"))); assertThatCode( () -> @@ -44,36 +42,132 @@ class GraphQlMutationIdempotencyContextTest { } @Test - void theScopeIsActorPlusMutationPlusKey() { - var context = + void theScopeCoversActorTenantMutationVersionAndKey() { + String scope = context(CREATE, new GraphQlMutationFingerprint("sha256:a")).scope(); + + assertThat(scope) + .contains("actor-fingerprint") + .contains(TENANT) + .contains("Mutation.createOrder") + .contains(VERSION) + .contains("request-1"); + } + + @Test + void theSameKeyInADifferentTenantIsADifferentScope() { + var tenantA = context(CREATE, new GraphQlMutationFingerprint("sha256:a")); + var tenantB = GraphQlMutationIdempotencyContext.of( "actor-fingerprint", - new GraphQlMutationCoordinate("Mutation.createOrder"), + "other-tenant-fingerprint", + CREATE, + VERSION, new GraphQlIdempotencyKey("request-1"), new GraphQlMutationFingerprint("sha256:a")); - assertThat(context.scope()).isEqualTo("actor-fingerprint|Mutation.createOrder|request-1"); + assertThat(tenantA.scope()) + .as("one service account acting for two tenants must not share an idempotency namespace") + .isNotEqualTo(tenantB.scope()); + } + + @Test + void theSameKeyUnderADifferentContractVersionIsADifferentScope() { + var v1 = context(CREATE, new GraphQlMutationFingerprint("sha256:a")); + var v2 = + GraphQlMutationIdempotencyContext.of( + "actor-fingerprint", + TENANT, + CREATE, + "v2", + new GraphQlIdempotencyKey("request-1"), + new GraphQlMutationFingerprint("sha256:a")); + + assertThat(v1.scope()).isNotEqualTo(v2.scope()); } @Test void theSameKeyOnADifferentMutationIsADifferentScope() { - var create = - GraphQlMutationIdempotencyContext.of( - "actor", - new GraphQlMutationCoordinate("Mutation.createOrder"), - new GraphQlIdempotencyKey("request-1"), - new GraphQlMutationFingerprint("sha256:a")); + var create = context(CREATE, new GraphQlMutationFingerprint("sha256:a")); var cancel = - GraphQlMutationIdempotencyContext.of( - "actor", + context( new GraphQlMutationCoordinate("Mutation.cancelOrder"), - new GraphQlIdempotencyKey("request-1"), new GraphQlMutationFingerprint("sha256:a")); assertThatThrownBy(() -> GraphQlMutationIdempotencyInterceptor.verifyRetry(create, cancel)) .isInstanceOf(GraphQlIdempotencyConflictException.class); } + @Test + void aSeparatorInsideAValueCannotForgeADifferentScope() { + var honest = + GraphQlMutationIdempotencyContext.of( + "actor", + TENANT, + CREATE, + VERSION, + new GraphQlIdempotencyKey("request-0001"), + new GraphQlMutationFingerprint("sha256:a")); + var crafted = + GraphQlMutationIdempotencyContext.of( + "actor|" + TENANT, + TENANT, + CREATE, + VERSION, + new GraphQlIdempotencyKey("request-0001"), + new GraphQlMutationFingerprint("sha256:a")); + + assertThat(honest.scope()).isNotEqualTo(crafted.scope()); + } + + @Test + void twoDifferentInputsNeverShareAFingerprint() { + // The pair the previous canonical form collided on: joining `key=value;` made these identical. + var packed = GraphQlMutationFingerprint.of(Map.of("a", "b;c=d")); + var split = GraphQlMutationFingerprint.of(Map.of("a", "b", "c", "d")); + + assertThat(packed).isNotEqualTo(split); + } + + @Test + void aStringAndANumberWithTheSameTextAreDifferentInputs() { + assertThat(GraphQlMutationFingerprint.of(Map.of("total", "1"))) + .isNotEqualTo(GraphQlMutationFingerprint.of(Map.of("total", 1))); + } + + @Test + void equivalentNumbersFingerprintIdentically() { + assertThat(GraphQlMutationFingerprint.of(Map.of("total", new java.math.BigDecimal("1.0")))) + .as("1, 1.0 and 1e0 are the same value and a client library may send any of them") + .isEqualTo(GraphQlMutationFingerprint.of(Map.of("total", 1))); + } + + @Test + void nestedMapsAreSortedRecursively() { + Map first = new LinkedHashMap<>(); + first.put("filter", new LinkedHashMap<>(Map.of("status", "OPEN", "owner", "o-1"))); + Map second = new LinkedHashMap<>(); + second.put("filter", new LinkedHashMap<>(Map.of("owner", "o-1", "status", "OPEN"))); + + assertThat(GraphQlMutationFingerprint.of(first)) + .isEqualTo(GraphQlMutationFingerprint.of(second)); + } + + @Test + void listOrderIsPartOfTheInput() { + assertThat(GraphQlMutationFingerprint.of(Map.of("ids", List.of("a", "b")))) + .as("a list is a sequence; two orders are two different requests") + .isNotEqualTo(GraphQlMutationFingerprint.of(Map.of("ids", List.of("b", "a")))); + } + + @Test + void anExplicitNullIsNotAnAbsentField() { + Map explicitNull = new LinkedHashMap<>(); + explicitNull.put("note", null); + + assertThat(GraphQlMutationFingerprint.of(explicitNull)) + .isNotEqualTo(GraphQlMutationFingerprint.of(Map.of())); + } + @Test void onlyAMutationCoordinateCanCarryAnIdempotencyScope() { assertThatThrownBy(() -> new GraphQlMutationCoordinate("Query.order")) @@ -90,12 +184,13 @@ class GraphQlMutationIdempotencyContextTest { @Test void theInterceptorDerivesTheScopeFromTheIdempotencyExtension() { - GraphQlRequestContext context = GraphQlAuthenticationContextFactory.testContext("tenant-a"); + GraphQlRequestContext context = GraphQlRequestContexts.testContext("tenant-a"); var derived = GraphQlMutationIdempotencyInterceptor.from( context, - new GraphQlMutationCoordinate("Mutation.createOrder"), + CREATE, + VERSION, Map.of(GraphQlExtensionsPolicy.IDEMPOTENCY_KEY, "request-1"), Map.of("customerId", "c-1")); @@ -104,10 +199,12 @@ class GraphQlMutationIdempotencyContextTest { scope -> { assertThat(scope.key().value()).isEqualTo("request-1"); assertThat(scope.actorFingerprint()).isEqualTo(context.actor().fingerprint()); + assertThat(scope.tenantFingerprint()).isEqualTo(context.tenant().fingerprint()); + assertThat(scope.contractVersion()).isEqualTo(VERSION); }); assertThat( GraphQlMutationIdempotencyInterceptor.from( - context, new GraphQlMutationCoordinate("Mutation.createOrder"), Map.of(), Map.of())) + context, CREATE, VERSION, Map.of(), Map.of())) .isEmpty(); } @@ -116,13 +213,28 @@ class GraphQlMutationIdempotencyContextTest { var fingerprint = GraphQlMutationFingerprint.of(Map.of("card", "4111111111111111")); assertThat(fingerprint.value()).startsWith("sha256:").doesNotContain("4111"); - assertThat( - GraphQlMutationIdempotencyContext.of( - "actor-fingerprint", - new GraphQlMutationCoordinate("Mutation.createOrder"), - new GraphQlIdempotencyKey("request-1"), - fingerprint) - .scope()) - .doesNotContain("4111"); + assertThat(context(CREATE, fingerprint).scope()).doesNotContain("4111"); + } + + @Test + void anAtomicMutationIsExactlyOneUseCase() { + assertThatCode(() -> GraphQlMutationContractValidator.requireSingleUseCase(CREATE, 1)) + .doesNotThrowAnyException(); + assertThatThrownBy(() -> GraphQlMutationContractValidator.requireSingleUseCase(CREATE, 2)) + .isInstanceOf(GraphQlMutationContractException.class); + assertThatThrownBy(() -> GraphQlMutationContractValidator.requireSingleUseCase(CREATE, 0)) + .as("zero means the resolver never went through the Application at all") + .isInstanceOf(GraphQlMutationContractException.class); + } + + private static GraphQlMutationIdempotencyContext context( + GraphQlMutationCoordinate coordinate, GraphQlMutationFingerprint fingerprint) { + return GraphQlMutationIdempotencyContext.of( + "actor-fingerprint", + TENANT, + coordinate, + VERSION, + new GraphQlIdempotencyKey("request-1"), + fingerprint); } } diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlMetricCardinalityPolicyTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlMetricCardinalityPolicyTest.java index 828c848b..8a0a51af 100644 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlMetricCardinalityPolicyTest.java +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlMetricCardinalityPolicyTest.java @@ -49,7 +49,9 @@ class GraphQlMetricCardinalityPolicyTest { @Test void requestTagsAreBoundedAndBucketed() { Map tags = - GraphQlRequestObservationConvention.standard() + new GraphQlRequestObservationConvention( + GraphQlSensitiveAttributeFilter.standard(), + new GraphQlOperationNameCardinality(java.util.Set.of("GetOrder"))) .tags( new GraphQlOperationName("GetOrder"), GraphQlOperationType.QUERY, @@ -61,6 +63,7 @@ class GraphQlMetricCardinalityPolicyTest { 9); assertThat(tags) + .as("a name the deployment registered stays legible") .containsEntry("graphql.operation.name", "GetOrder") .containsEntry("graphql.complexity.bucket", "1001-10000") .containsEntry("graphql.depth.bucket", "7-12"); @@ -137,4 +140,23 @@ class GraphQlMetricCardinalityPolicyTest { assertThat(GraphQlProfilerAccessPolicy.values()) .allSatisfy(policy -> assertThat(policy.exposedInResponseExtensions()).isFalse()); } + + @Test + void anUnregisteredOperationNameCollapsesToTheBoundedLabel() { + Map tags = + GraphQlRequestObservationConvention.standard() + .tags( + new GraphQlOperationName("SomethingTheClientInvented"), + GraphQlOperationType.QUERY, + new GraphQlClientProfile("first-party"), + false, + "SUCCESS", + null, + null, + 2); + + assertThat(tags) + .as("syntax validation bounds the name's shape, not how many distinct ones exist") + .containsEntry("graphql.operation.name", GraphQlOperationNameCardinality.UNREGISTERED); + } } diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlOperationNameCardinalityTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlOperationNameCardinalityTest.java new file mode 100644 index 00000000..d79b5b8b --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/observation/GraphQlOperationNameCardinalityTest.java @@ -0,0 +1,129 @@ +package dev.caskeleton.adapter.inbound.graphql.observation; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile; +import dev.caskeleton.adapter.inbound.graphql.api.GraphQlOperationName; +import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlOperationType; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Tags; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +/** + * The operation-name tag, measured against a registry that actually stores series. + * + *

A validated operation name is not a bounded one. {@code [A-Za-z][_0-9A-Za-z]{2,127}} accepts + * an unlimited number of distinct names, so a client sending a fresh name per request used to + * create a fresh time series per request. This test counts the meters instead of trusting the + * regular expression. + */ +class GraphQlOperationNameCardinalityTest { + + private static final int ARBITRARY_NAMES = 10_000; + + @Test + void tenThousandArbitraryNamesProduceOneSeries() { + var registry = new SimpleMeterRegistry(); + var convention = GraphQlRequestObservationConvention.standard(); + + for (int index = 0; index < ARBITRARY_NAMES; index++) { + record(registry, convention, new GraphQlOperationName("Query" + index)); + } + + assertThat(seriesCount(registry)) + .as("one series per client-chosen name is a metrics backend taken down by a valid client") + .isEqualTo(1); + assertThat(operationNameLabels(registry)) + .containsExactly(GraphQlOperationNameCardinality.UNREGISTERED); + } + + @Test + void registeredOperationsKeepTheirOwnSeries() { + var registry = new SimpleMeterRegistry(); + var convention = + new GraphQlRequestObservationConvention( + GraphQlSensitiveAttributeFilter.standard(), + new GraphQlOperationNameCardinality(Set.of("OrderById", "OrdersByCustomer"))); + + record(registry, convention, new GraphQlOperationName("OrderById")); + record(registry, convention, new GraphQlOperationName("OrdersByCustomer")); + for (int index = 0; index < ARBITRARY_NAMES; index++) { + record(registry, convention, new GraphQlOperationName("Query" + index)); + } + record(registry, convention, null); + + assertThat(operationNameLabels(registry)) + .as("the names the deployment declared stay legible; the rest collapse") + .containsExactlyInAnyOrder( + "OrderById", + "OrdersByCustomer", + GraphQlOperationNameCardinality.UNREGISTERED, + GraphQlOperationName.ANONYMOUS_OBSERVATION_VALUE); + assertThat(seriesCount(registry)).isEqualTo(convention.distinctOperationNameLabels()); + } + + @Test + void theSeriesBoundIsStatedAsANumberAnOperatorCanCheck() { + var policy = new GraphQlOperationNameCardinality(Set.of("A", "B", "C")); + + assertThat(policy.distinctLabels()) + .as("three registered names, plus anonymous, plus the collapse label") + .isEqualTo(5); + assertThat(GraphQlOperationNameCardinality.collapsingAll().distinctLabels()).isEqualTo(2); + } + + @Test + void collapsingDoesNotDropTheRequestFromTheMetric() { + var registry = new SimpleMeterRegistry(); + var convention = GraphQlRequestObservationConvention.standard(); + + for (int index = 0; index < 5; index++) { + record(registry, convention, new GraphQlOperationName("Query" + index)); + } + + assertThat(registry.find(GraphQlObservationNames.REQUEST).counter().count()) + .as("the unbounded coordinate is dropped, not the observation") + .isEqualTo(5); + } + + private static void record( + SimpleMeterRegistry registry, + GraphQlRequestObservationConvention convention, + GraphQlOperationName operationName) { + + Map tags = + convention.tags( + operationName, + GraphQlOperationType.QUERY, + new GraphQlClientProfile("first-party"), + false, + "SUCCESS", + null, + null, + 2); + registry.counter(GraphQlObservationNames.REQUEST, toTags(tags)).increment(); + } + + private static Tags toTags(Map tags) { + return Tags.of(tags.entrySet().stream().map(e -> Tag.of(e.getKey(), e.getValue())).toList()); + } + + private static int seriesCount(SimpleMeterRegistry registry) { + return registry.getMeters().size(); + } + + private static List operationNameLabels(SimpleMeterRegistry registry) { + return registry.getMeters().stream() + .map(Meter::getId) + .map(id -> id.getTag("graphql.operation.name")) + .filter(java.util.Objects::nonNull) + .distinct() + .sorted() + .toList(); + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlConnectionAssemblerTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlConnectionAssemblerTest.java index 738295eb..4381f766 100644 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlConnectionAssemblerTest.java +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlConnectionAssemblerTest.java @@ -15,7 +15,7 @@ class GraphQlConnectionAssemblerTest { @Test void extraRowBecomesHasNextPageAndIsNotReturned() { var window = new GraphQlKeysetWindow<>(List.of("a", "b", "c"), 2, false); - var assembler = GraphQlConnectionAssembler.forTests(); + var assembler = GraphQlCursorFixtures.assembler(); var connection = assembler.forward(window, value -> Map.of("id", value)); @@ -26,7 +26,7 @@ class GraphQlConnectionAssemblerTest { @Test void aWindowWithoutTheExtraRowIsTheLastPage() { var connection = - GraphQlConnectionAssembler.forTests() + GraphQlCursorFixtures.assembler() .forward( new GraphQlKeysetWindow<>(List.of("a", "b"), 2, true), value -> Map.of("id", value)); @@ -45,7 +45,7 @@ class GraphQlConnectionAssemblerTest { @Test void backwardPagesReportTheirBoundariesTheOtherWayRound() { var connection = - GraphQlConnectionAssembler.forTests() + GraphQlCursorFixtures.assembler() .backward( new GraphQlKeysetWindow<>(List.of("c", "b", "a"), 2, true), value -> Map.of("id", value)); @@ -57,7 +57,7 @@ class GraphQlConnectionAssemblerTest { @Test void everyEdgeCarriesASignedCursorBoundToTheQuery() { - var assembler = GraphQlConnectionAssembler.forTests(); + var assembler = GraphQlCursorFixtures.assembler(); var connection = assembler.forward( new GraphQlKeysetWindow<>(List.of("a", "b"), 2, false), value -> Map.of("id", value)); @@ -94,7 +94,7 @@ class GraphQlConnectionAssemblerTest { @Test void totalCountIsOptInRatherThanAlwaysComputed() { var connection = - GraphQlConnectionAssembler.forTests() + GraphQlCursorFixtures.assembler() .forward( new GraphQlKeysetWindow<>(List.of("a"), 1, false), value -> Map.of("id", value)); @@ -106,7 +106,7 @@ class GraphQlConnectionAssemblerTest { @Test void anEmptyWindowProducesAnEmptyConnection() { var connection = - GraphQlConnectionAssembler.forTests() + GraphQlCursorFixtures.assembler() .forward(new GraphQlKeysetWindow<>(List.of(), 5, false), value -> Map.of("id", "x")); assertThat(connection.edges()).isEmpty(); @@ -117,7 +117,7 @@ class GraphQlConnectionAssemblerTest { void aKeysetProfileWithoutATieBreakerIsRejectedBeforeAnyCursorIsIssued() { assertThatThrownBy( () -> - GraphQlConnectionAssembler.forTests() + GraphQlCursorFixtures.assembler() .forward( new GraphQlKeysetWindow<>(List.of("a"), 1, false), value -> Map.of("createdAt", "2026-08-12T00:00:00Z"))) diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorFixtures.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorFixtures.java new file mode 100644 index 00000000..b0abe3e6 --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/pagination/GraphQlCursorFixtures.java @@ -0,0 +1,61 @@ +package dev.caskeleton.adapter.inbound.graphql.pagination; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +/** + * Fixed keys and pre-built codecs for cursor tests. + * + *

These used to be production API: {@code HmacGraphQlCursorCodec.testCodec} and {@code + * GraphQlConnectionAssembler.forTests()} shipped in the main jar with a hard-coded secret in them. + * A signing secret that is compiled into a released artifact is available to everyone who has the + * artifact, and a factory named "for tests" is exactly the one someone reaches for when wiring a + * demo that later becomes a deployment. + */ +public final class GraphQlCursorFixtures { + + /** The key new cursors are signed with in tests. */ + public static final String ACTIVE_KEY_ID = "cursor-key-2"; + + /** A key that has been rotated out but must still verify old cursors. */ + public static final String PREVIOUS_KEY_ID = "cursor-key-1"; + + /** Tenant scope used by fixtures that do not vary it. */ + public static final String TENANT_SCOPE = "tenant-scope-a"; + + private static final byte[] ACTIVE_SECRET = + "test-cursor-secret-active-0123456789".getBytes(StandardCharsets.UTF_8); + private static final byte[] PREVIOUS_SECRET = + "test-cursor-secret-previous-012345".getBytes(StandardCharsets.UTF_8); + + private GraphQlCursorFixtures() {} + + /** A key ring with one active key and one retired-but-verifiable key. */ + public static GraphQlCursorKeyRing rotatingKeyRing() { + return GraphQlCursorKeyRing.of( + Map.of(PREVIOUS_KEY_ID, PREVIOUS_SECRET, ACTIVE_KEY_ID, ACTIVE_SECRET), ACTIVE_KEY_ID); + } + + /** A key ring whose active key is the older one, for issuing "previous key" cursors. */ + public static GraphQlCursorKeyRing previousKeyRing() { + return GraphQlCursorKeyRing.of( + Map.of(PREVIOUS_KEY_ID, PREVIOUS_SECRET, ACTIVE_KEY_ID, ACTIVE_SECRET), PREVIOUS_KEY_ID); + } + + /** A key ring holding only a key that is not on the rotating ring. */ + public static GraphQlCursorKeyRing foreignKeyRing() { + return GraphQlCursorKeyRing.single( + "cursor-key-foreign", "test-cursor-secret-foreign-01234".getBytes(StandardCharsets.UTF_8)); + } + + /** A codec signing with the active key and verifying both. */ + public static HmacGraphQlCursorCodec rotatingCodec() { + return new HmacGraphQlCursorCodec(rotatingKeyRing()); + } + + /** An assembler bound to a fixed query, filter and tenant scope. */ + public static GraphQlConnectionAssembler assembler() { + return new GraphQlConnectionAssembler( + rotatingCodec(), "test-profile", "test-filter", TENANT_SCOPE); + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/pagination/HmacGraphQlCursorCodecTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/pagination/HmacGraphQlCursorCodecTest.java index 606ec97c..bad9cd32 100644 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/pagination/HmacGraphQlCursorCodecTest.java +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/pagination/HmacGraphQlCursorCodecTest.java @@ -4,155 +4,248 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import java.nio.charset.StandardCharsets; import java.util.Base64; +import java.util.LinkedHashMap; import java.util.Map; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; -/** Versioned HMAC cursor codec (Stable plan Task 41). */ +/** + * A signed cursor has to survive its own encoding and refuse everything it was not issued for. + * + *

The v1 envelope failed the first half: it split on {@code |}, {@code ;} and {@code =} before + * unescaping, so a sort value containing one of them tore the payload apart, and three fields were + * never escaped at all. It failed the second half too — direction and tenant scope were signed but + * never compared, so a forward cursor worked on a backward request and one tenant's cursor resumed + * another tenant's scan. + */ class HmacGraphQlCursorCodecTest { - private static final byte[] SECRET = "secret-secret-secret".getBytes(StandardCharsets.UTF_8); + private static final String PROFILE = "orders"; + private static final String FILTER = "filter-fingerprint"; - @Test - void rejectsCursorWhenFilterFingerprintChanges() { - var codec = HmacGraphQlCursorCodec.testCodec("cursor-key-1", SECRET); - var payload = - GraphQlCursorPayload.of( - "orders-by-created", - "FORWARD", - Map.of("createdAt", "2026-08-12T00:00:00Z", "id", "01J0"), - "filter-a"); - var encoded = codec.encode(payload); + private final HmacGraphQlCursorCodec codec = GraphQlCursorFixtures.rotatingCodec(); - assertThatThrownBy(() -> codec.decode(encoded, "orders-by-created", "filter-b")) - .isInstanceOf(GraphQlCursorException.class); + @ParameterizedTest + @ValueSource( + strings = { + "plain", + "with|pipe", + "with;semicolon", + "with=equals", + "with\\backslash", + "with:colon", + "all|of;them=at\\once", + "한글-값", + "emoji-🚀-value", + " leading and trailing ", + "" + }) + void everyStringFieldRoundTrips(String awkward) { + Map keyset = new LinkedHashMap<>(); + keyset.put("id", awkward.isEmpty() ? "id-1" : awkward); + keyset.put("sortedAt", awkward); + + GraphQlCursorPayload issued = + GraphQlCursorPayload.issue( + PROFILE + awkward, + GraphQlCursorPayload.FORWARD, + keyset, + FILTER + awkward, + GraphQlCursorFixtures.TENANT_SCOPE + awkward); + + GraphQlCursorPayload decoded = + codec.decode( + codec.encode(issued), + new GraphQlCursorScope( + PROFILE + awkward, + FILTER + awkward, + GraphQlCursorPayload.FORWARD, + GraphQlCursorFixtures.TENANT_SCOPE + awkward)); + + assertThat(decoded.keyset()).isEqualTo(issued.keyset()); + assertThat(decoded.queryProfile()).isEqualTo(issued.queryProfile()); + assertThat(decoded.filterFingerprint()).isEqualTo(issued.filterFingerprint()); + assertThat(decoded.tenantScope()).isEqualTo(issued.tenantScope()); } @Test - void roundTripsWhenTheQueryAndFilterMatch() { - var codec = HmacGraphQlCursorCodec.testCodec("cursor-key-1", SECRET); - var payload = - GraphQlCursorPayload.of( - "orders-by-created", - "FORWARD", - Map.of("createdAt", "2026-08-12T00:00:00Z", "id", "01J0"), - "filter-a"); + void aNewCursorIsSignedWithTheActiveKey() { + GraphQlCursorPayload decoded = codec.decode(codec.encode(payload()), scope()); - var decoded = codec.decode(codec.encode(payload), "orders-by-created", "filter-a"); - - assertThat(decoded).isEqualTo(payload); - assertThat(decoded.keyset()).containsEntry("id", "01J0"); + assertThat(decoded.keyId()) + .as("the codec stamps the active key; the payload cannot choose it") + .isEqualTo(GraphQlCursorFixtures.ACTIVE_KEY_ID); + assertThat(decoded.version()).isEqualTo(GraphQlCursorVersion.CURRENT); } @Test - void rejectsCursorIssuedForADifferentQueryProfile() { - var codec = HmacGraphQlCursorCodec.testCodec("cursor-key-1", SECRET); - var encoded = codec.encode(payload("orders-by-created", "filter-a")); + void aCursorIssuedUnderThePreviousKeyStillVerifies() { + HmacGraphQlCursorCodec previous = + new HmacGraphQlCursorCodec(GraphQlCursorFixtures.previousKeyRing()); + String olderCursor = previous.encode(payload()); - assertThatThrownBy(() -> codec.decode(encoded, "orders-by-total", "filter-a")) - .isInstanceOf(GraphQlCursorException.class) - .hasMessageContaining("different query profile"); + GraphQlCursorPayload decoded = codec.decode(olderCursor, scope()); + + assertThat(decoded.keyId()).isEqualTo(GraphQlCursorFixtures.PREVIOUS_KEY_ID); } @Test - void base64AloneDoesNotMakeATamperedCursorAcceptable() { - var codec = HmacGraphQlCursorCodec.testCodec("cursor-key-1", SECRET); - String encoded = codec.encode(payload("orders-by-created", "filter-a")); - String tamperedCanonical = - new String( - Base64.getUrlDecoder().decode(encoded.substring(0, encoded.indexOf('.'))), - StandardCharsets.UTF_8) - .replace("01J0", "01J9"); - String tampered = - Base64.getUrlEncoder() - .withoutPadding() - .encodeToString(tamperedCanonical.getBytes(StandardCharsets.UTF_8)) - + encoded.substring(encoded.indexOf('.')); + void aCursorSignedByAKeyThisRingDoesNotHoldIsRejected() { + HmacGraphQlCursorCodec foreign = + new HmacGraphQlCursorCodec(GraphQlCursorFixtures.foreignKeyRing()); + String foreignCursor = foreign.encode(payload()); - assertThatThrownBy(() -> codec.decode(tampered, "orders-by-created", "filter-a")) - .isInstanceOf(GraphQlCursorException.class) - .hasMessageContaining("signature mismatch"); - } - - @Test - void rejectsUnknownVersionAndUnknownKey() { - assertThatThrownBy( - () -> - new GraphQlCursorPayload( - 99, - "orders-by-created", - "FORWARD", - Map.of("id", "01J0"), - "filter-a", - "cursor-key-1")) - .isInstanceOf(GraphQlCursorException.class); - - var issuer = HmacGraphQlCursorCodec.testCodec("cursor-key-1", SECRET); - var verifier = HmacGraphQlCursorCodec.testCodec("cursor-key-2", SECRET); - String encoded = issuer.encode(payload("orders-by-created", "filter-a")); - - assertThatThrownBy(() -> verifier.decode(encoded, "orders-by-created", "filter-a")) + assertThatThrownBy(() -> codec.decode(foreignCursor, scope())) .isInstanceOf(GraphQlCursorException.class) .hasMessageContaining("unknown cursor key"); } @Test - void keyRotationKeepsPreviouslyIssuedCursorsValid() { - var oldSecret = "old-secret-old-secret".getBytes(StandardCharsets.UTF_8); - var newSecret = "new-secret-new-secret".getBytes(StandardCharsets.UTF_8); - var issuedUnderOldKey = - new HmacGraphQlCursorCodec(GraphQlCursorKeyRing.single("cursor-key-1", oldSecret)) - .encode(payload("orders-by-created", "filter-a")); - var rotated = - new HmacGraphQlCursorCodec( - GraphQlCursorKeyRing.of( - Map.of("cursor-key-1", oldSecret, "cursor-key-2", newSecret), "cursor-key-2")); + void aForwardCursorIsNotAcceptedOnABackwardRequest() { + String forward = codec.encode(payload()); - assertThatCode(() -> rotated.decode(issuedUnderOldKey, "orders-by-created", "filter-a")) - .doesNotThrowAnyException(); + assertThatThrownBy( + () -> + codec.decode( + forward, + new GraphQlCursorScope( + PROFILE, + FILTER, + GraphQlCursorPayload.BACKWARD, + GraphQlCursorFixtures.TENANT_SCOPE))) + .isInstanceOf(GraphQlCursorException.class) + .hasMessageContaining("different pagination direction"); } @Test - void aKeysetWithoutATieBreakerIsRejected() { - assertThatThrownBy(() -> GraphQlCursorKeyset.of(Map.of("createdAt", "2026-08-12T00:00:00Z"))) + void aCursorFromAnotherTenantIsRejected() { + String tenantA = codec.encode(payload()); + + assertThatThrownBy( + () -> + codec.decode( + tenantA, + new GraphQlCursorScope( + PROFILE, FILTER, GraphQlCursorPayload.FORWARD, "tenant-scope-b"))) .isInstanceOf(GraphQlCursorException.class) - .hasMessageContaining("tie-breaker"); + .hasMessageContaining("different tenant scope"); } @Test - void malformedCursorsAreRejectedWithoutEchoingTheirContent() { - var codec = HmacGraphQlCursorCodec.testCodec("cursor-key-1", SECRET); + void aCursorFromAnotherQueryOrFilterIsRejected() { + String issued = codec.encode(payload()); - assertThatThrownBy(() -> codec.decode("not-a-cursor", "orders-by-created", "filter-a")) - .isInstanceOf(GraphQlCursorException.class) - .hasMessageNotContaining("not-a-cursor"); - assertThatThrownBy(() -> codec.decode("", "orders-by-created", "filter-a")) + assertThatThrownBy( + () -> + codec.decode( + issued, + new GraphQlCursorScope( + "other-query", + FILTER, + GraphQlCursorPayload.FORWARD, + GraphQlCursorFixtures.TENANT_SCOPE))) + .hasMessageContaining("different query profile"); + assertThatThrownBy( + () -> + codec.decode( + issued, + new GraphQlCursorScope( + PROFILE, + "other-filter", + GraphQlCursorPayload.FORWARD, + GraphQlCursorFixtures.TENANT_SCOPE))) + .hasMessageContaining("different filter"); + } + + @Test + void aTamperedCursorIsRejected() { + String issued = codec.encode(payload()); + String body = issued.substring(0, issued.indexOf('.')); + String tamperedBody = + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString( + new String( + Base64.getUrlDecoder().decode(body), + java.nio.charset.StandardCharsets.UTF_8) + .replace("id-1", "id-9") + .getBytes(java.nio.charset.StandardCharsets.UTF_8)); + + assertThatThrownBy( + () -> codec.decode(tamperedBody + issued.substring(issued.indexOf('.')), scope())) + .isInstanceOf(GraphQlCursorException.class); + } + + @ParameterizedTest + @ValueSource(strings = {"not-a-cursor", "!!!.###", "abc.", ".abc", " "}) + void malformedTokensAreRejected(String malformed) { + assertThatThrownBy(() -> codec.decode(malformed, scope())) .isInstanceOf(GraphQlCursorException.class); } @Test - void aShortSigningKeyIsRefused() { - assertThatThrownBy( - () -> - GraphQlCursorKeyRing.single( - "cursor-key-1", "short".getBytes(StandardCharsets.UTF_8))) - .isInstanceOf(IllegalArgumentException.class); + void aTruncatedCursorIsRejected() { + String issued = codec.encode(payload()); + + assertThatThrownBy(() -> codec.decode(issued.substring(0, issued.length() / 2), scope())) + .isInstanceOf(GraphQlCursorException.class); } @Test - void thePayloadCarriesNoCredentialOrRawTenant() { - assertThat(GraphQlCursorPayload.class.getRecordComponents()) - .extracting(java.lang.reflect.RecordComponent::getName) - .containsExactly( - "version", "queryProfile", "direction", "keyset", "filterFingerprint", "keyId"); + void anOversizedTokenIsRejectedBeforeItIsDecoded() { + String oversized = "a".repeat(HmacGraphQlCursorCodec.MAXIMUM_CURSOR_CHARS + 1) + ".sig"; + + assertThatThrownBy(() -> codec.decode(oversized, scope())) + .isInstanceOf(GraphQlCursorException.class) + .hasMessageContaining("too large"); } - private static GraphQlCursorPayload payload(String queryProfile, String filterFingerprint) { - return GraphQlCursorPayload.of( - queryProfile, - "FORWARD", - Map.of("createdAt", "2026-08-12T00:00:00Z", "id", "01J0"), - filterFingerprint); + @Test + void onlyTheCurrentVersionIsIssued() { + GraphQlCursorPayload legacy = + new GraphQlCursorPayload( + GraphQlCursorVersion.LEGACY_DELIMITED, + PROFILE, + GraphQlCursorPayload.FORWARD, + Map.of("id", "id-1"), + FILTER, + GraphQlCursorFixtures.TENANT_SCOPE, + GraphQlCursorFixtures.ACTIVE_KEY_ID); + + assertThatThrownBy(() -> codec.encode(legacy)) + .isInstanceOf(GraphQlCursorException.class) + .hasMessageContaining("only the current cursor version is issued"); + } + + @Test + void aKeyRingDoesNotHandOutAMutableKeySet() { + var keyIds = GraphQlCursorFixtures.rotatingKeyRing().keyIds(); + + assertThatThrownBy(() -> keyIds.remove(GraphQlCursorFixtures.ACTIVE_KEY_ID)) + .as("retiring a signing key through a getter is not a supported operation") + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void framingRejectsADeclaredLengthLongerThanTheBody() { + assertThatThrownBy(() -> GraphQlCursorFraming.readAll("99:short")) + .isInstanceOf(GraphQlCursorException.class); + assertThatCode(() -> GraphQlCursorFraming.readAll("5:hello")).doesNotThrowAnyException(); + } + + private static GraphQlCursorPayload payload() { + return GraphQlCursorPayload.issue( + PROFILE, + GraphQlCursorPayload.FORWARD, + Map.of("id", "id-1"), + FILTER, + GraphQlCursorFixtures.TENANT_SCOPE); + } + + private static GraphQlCursorScope scope() { + return new GraphQlCursorScope( + PROFILE, FILTER, GraphQlCursorPayload.FORWARD, GraphQlCursorFixtures.TENANT_SCOPE); } } diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlBlockingBridgeTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlBlockingBridgeTest.java new file mode 100644 index 00000000..726795fc --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlBlockingBridgeTest.java @@ -0,0 +1,118 @@ +package dev.caskeleton.adapter.inbound.graphql.runtime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; +import dev.caskeleton.adapter.inbound.graphql.security.GraphQlContextPropagator; +import dev.caskeleton.adapter.inbound.graphql.testkit.GraphQlRequestContexts; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +/** + * A hand-off that refuses is bounded; one that queues is not. + * + *

The platform previously offered two "bounded" executors that were not: a + * virtual-thread-per-task executor, which admits every task, and a fixed pool, whose default {@code + * LinkedBlockingQueue} is unbounded — so an overload became latency and memory instead of a + * refusal, and the request deadline was the only thing that ever noticed. + */ +class GraphQlBlockingBridgeTest { + + private final GraphQlRequestContext context = GraphQlRequestContexts.testContext("tenant-a"); + + @Test + void aFullBridgeRefusesInsteadOfQueueing() throws Exception { + CountDownLatch occupied = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + + try (GraphQlBlockingBridge bridge = GraphQlBlockingBridge.bounded(1, 1)) { + bridge + .executorFor(context) + .execute( + () -> { + occupied.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + }); + assertThat(occupied.await(5, TimeUnit.SECONDS)).isTrue(); + + // One running, one queued: the bridge is now full. + bridge.executorFor(context).execute(() -> {}); + + assertThatThrownBy(() -> bridge.executorFor(context).execute(() -> {})) + .isInstanceOf(GraphQlBlockingBridgeFullException.class) + .hasMessageContaining(GraphQlBlockingBridgeFullException.CODE); + release.countDown(); + } + } + + @Test + void theRequestContextTravelsWithTheWork() throws Exception { + AtomicReference seen = new AtomicReference<>(); + CountDownLatch done = new CountDownLatch(1); + + try (GraphQlBlockingBridge bridge = GraphQlBlockingBridge.bounded(1, 1)) { + bridge + .executorFor(context) + .execute( + () -> { + seen.set(GraphQlContextPropagator.current().orElse(null)); + done.countDown(); + }); + assertThat(done.await(5, TimeUnit.SECONDS)).isTrue(); + } + + assertThat(seen.get()) + .as("a batch load that hopped threads without its context has no tenant") + .isNotNull(); + assertThat(seen.get().tenant().value()).isEqualTo("tenant-a"); + } + + @Test + void theContextDoesNotLeakIntoAPooledThreadAfterwards() throws Exception { + AtomicReference boundAfterwards = new AtomicReference<>(); + CountDownLatch first = new CountDownLatch(1); + CountDownLatch second = new CountDownLatch(1); + + try (GraphQlBlockingBridge bridge = GraphQlBlockingBridge.bounded(1, 2)) { + bridge.executorFor(context).execute(first::countDown); + assertThat(first.await(5, TimeUnit.SECONDS)).isTrue(); + + // A raw task on the same pool: nothing should still be bound to that thread. + bridge + .executorFor(context) + .execute( + () -> { + boundAfterwards.set(GraphQlContextPropagator.current().isPresent()); + second.countDown(); + }); + assertThat(second.await(5, TimeUnit.SECONDS)).isTrue(); + } + + assertThat(boundAfterwards.get()) + .as("the propagator binds for the task and restores afterwards") + .isTrue(); + } + + @Test + void aBridgeMustBeGivenPositiveBounds() { + assertThatThrownBy(() -> GraphQlBlockingBridge.bounded(0, 1)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> GraphQlBlockingBridge.bounded(1, 0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void theBoundsAreReadableForOperations() { + try (GraphQlBlockingBridge bridge = GraphQlBlockingBridge.bounded(4, 16)) { + assertThat(bridge.threads()).isEqualTo(4); + assertThat(bridge.queueDepth()).isEqualTo(16); + } + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlPlatformExecutionPathTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlPlatformExecutionPathTest.java new file mode 100644 index 00000000..8a05dcc6 --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/runtime/GraphQlPlatformExecutionPathTest.java @@ -0,0 +1,407 @@ +package dev.caskeleton.adapter.inbound.graphql.runtime; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.graphql.HealthGraphqlController; +import dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile; +import dev.caskeleton.adapter.inbound.graphql.api.GraphQlSchemaCoordinate; +import dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformAutoConfiguration; +import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; +import dev.caskeleton.adapter.inbound.graphql.execution.BoundedPreparsedDocumentProvider; +import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy; +import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationPolicy; +import java.net.URI; +import java.time.Instant; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.graphql.autoconfigure.GraphQlSourceBuilderCustomizer; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; +import org.springframework.stereotype.Controller; + +/** + * Proves the platform's policies run on the real {@code /graphql} endpoint. + * + *

Every other GraphQL test in this leaf exercises a policy object directly, which shows the rule + * is correct and says nothing about whether a request ever meets it. This one boots a random-port + * servlet, posts documents to Spring's own endpoint, and asserts two things per rejection: the + * client got the platform's stable error code, and the resolver was invoked zero times. + * + *

The resolver counter is the load-bearing assertion. A policy that rejects after the resolver + * has run has already performed the work — and, for a mutation, the side effect — that it was + * supposed to prevent. + */ +@SpringBootTest( + classes = GraphQlPlatformExecutionPathTest.TestApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "spring.graphql.graphiql.enabled=false", + // The schema permits introspection at the deployment level, and the two flags agree so the + // contradiction check stays quiet. What refuses the query is the anonymous profile's client + // policy below — which is the decision the platform exists to make, and the one Spring's + // deployment-wide flag cannot express. + "spring.graphql.schema.introspection.enabled=true", + "spring.graphql.schema.locations=classpath:graphql-platform-no-discovery/", + "spring.graphql.schema.additional-files=" + + "classpath:graphql/skeleton.graphqls," + + "classpath:graphql-platform/platform.graphqls", + "backend.graphql.environment=LOCAL", + "backend.graphql.console.introspection-enabled=true", + "backend.graphql.limits.maximum-page-size=100", + // Small enough that one nested selection exceeds it, large enough that the allowed queries do + // not. The unregistered-coordinate default weight is what both sides are measured against. + "backend.graphql.limits.maximum-complexity=100" + }) +@AutoConfigureTestRestTemplate +class GraphQlPlatformExecutionPathTest { + + private static final AtomicInteger RESOLVER_INVOCATIONS = new AtomicInteger(); + + @Autowired + private BoundedPreparsedDocumentProvider + preparsedCache; + + private static final AtomicReference OBSERVED_CONTEXT = + new AtomicReference<>(); + + @LocalServerPort int port; + + @Autowired TestRestTemplate http; + + @BeforeEach + void resetCounters() { + RESOLVER_INVOCATIONS.set(0); + OBSERVED_CONTEXT.set(null); + } + + @Test + void anAllowedQueryReachesTheResolverCarryingTheRequestContext() { + ResponseEntity response = + post("query AllowedQuery { platformNode(id: \"n-1\") { id name } }"); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).contains("\"id\":\"n-1\""); + assertThat(RESOLVER_INVOCATIONS).hasValue(1); + + GraphQlRequestContext context = OBSERVED_CONTEXT.get(); + assertThat(context).as("the resolver must see the platform context").isNotNull(); + assertThat(context.tenant().value()) + .isEqualTo(GraphQlPlatformAutoConfiguration.DEFAULT_ANONYMOUS_TENANT); + assertThat(context.clientProfile().value()) + .isEqualTo(GraphQlPlatformAutoConfiguration.DEFAULT_ANONYMOUS_PROFILE); + assertThat(context.operationId().value()) + .as("the context is rebound to the operation the pipeline selected") + .isEqualTo("allowedquery"); + assertThat(context.deadline().value()).isAfter(Instant.EPOCH); + } + + @Test + void aCustomScalarIsWiredAndCoercedOnARealRequest() { + ResponseEntity response = post("{ platformStamp }"); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).contains("\"platformStamp\":\"2026-08-14T00:00:00Z\""); + } + + @Test + void aDocumentDeeperThanTheStructuralLimitIsRejectedBeforeAnyResolverRuns() { + ResponseEntity response = post(nestedQuery(14)); + + assertThatRejected(response, "GRAPHQL_DOCUMENT_SHAPE_REJECTED"); + } + + @Test + void anAliasBombIsRejectedBeforeAnyResolverRuns() { + StringBuilder document = new StringBuilder("{ "); + for (int alias = 0; alias < 60; alias++) { + document.append("a").append(alias).append(": platformStamp "); + } + document.append("}"); + + ResponseEntity response = post(document.toString()); + + assertThatRejected(response, "GRAPHQL_DOCUMENT_SHAPE_REJECTED"); + } + + @Test + void introspectionIsRejectedWhenTheClientProfileForbidsIt() { + ResponseEntity response = post("{ __schema { types { name } } }"); + + assertThatRejected(response, "GRAPHQL_DOCUMENT_SHAPE_REJECTED"); + assertThat(response.getBody()).contains("INTROSPECTION"); + } + + @Test + void introspectionHiddenBehindANamedFragmentIsStillRejected() { + ResponseEntity response = + post("query Q { ...Sneak } fragment Sneak on Query { __schema { types { name } } }"); + + assertThatRejected(response, "GRAPHQL_DOCUMENT_SHAPE_REJECTED"); + assertThat(response.getBody()).contains("INTROSPECTION"); + } + + @Test + void aDocumentOverTheComplexityBudgetIsRejectedBeforeAnyResolverRuns() { + ResponseEntity response = + post("{ platformNode(id: \"n-1\") { id name child { id name child { id name } } } }"); + + assertThatRejected(response, "GRAPHQL_COMPLEXITY_EXCEEDED"); + } + + @Test + void anUnauthorizedCoordinateIsRejectedBeforeAnyResolverRuns() { + ResponseEntity response = post("{ platformSecret }"); + + assertThatRejected(response, "AUTHORIZATION_DENIED"); + } + + @Test + void anOversizeDocumentIsRejectedBeforeItIsParsed() { + ResponseEntity response = post("{ platformStamp } # " + "x".repeat(20_000)); + + assertThatRejected(response, "REQUEST_ERROR"); + } + + @Test + void anOmittedVariableFallsBackToTheArgumentDefault() { + ResponseEntity response = postWithVariables("{}"); + + assertThat(response.getBody()).contains("\"platformEcho\":\"default-applied\""); + } + + @Test + void anExplicitNullVariableIsNotAnOmittedOne() { + ResponseEntity response = postWithVariables("{\"value\":null}"); + + assertThat(response.getStatusCode().value()) + .as("an explicit null is legal input; it must not fail the request") + .isEqualTo(200); + assertThat(response.getBody()) + .as("an explicit null must reach coercion as null, not as the argument default") + .contains("\"platformEcho\":\"explicit-null\""); + } + + @Test + void aSuppliedVariableReachesCoercionUnchanged() { + ResponseEntity response = postWithVariables("{\"value\":\"supplied\"}"); + + assertThat(response.getBody()).contains("\"platformEcho\":\"supplied\""); + } + + @Test + void aVariablesObjectDeeperThanTheInputBudgetIsRejected() { + String nested = "{\"value\":" + "[".repeat(20) + "]".repeat(20) + "}"; + + ResponseEntity response = postWithVariables(nested); + + assertThatRejected(response, "REQUEST_ERROR"); + assertThat(response.getBody()).contains("GRAPHQL_INPUT_SHAPE_REJECTED"); + } + + @Test + void aBodyOverTheRawCapIsRefusedBeforeAnythingDecodesIt() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + // Larger than maxDocumentBytes + 2 x maxVariablesBytes + framing, so the filter refuses it + // without the JSON decoder ever seeing a complete value. + String body = "{\"query\":\"{ platformStamp }\",\"padding\":\"" + "x".repeat(200_000) + "\"}"; + + ResponseEntity response = + http.exchange( + new RequestEntity<>( + body, + headers, + HttpMethod.POST, + URI.create("http://localhost:" + port + "/graphql")), + String.class); + + assertThat(response.getStatusCode().value()).isEqualTo(413); + assertThat(RESOLVER_INVOCATIONS).as("an oversize body must never reach a resolver").hasValue(0); + } + + @Test + void theSecondIdenticalDocumentIsServedFromThePlatformParseCache() { + long hitsBefore = preparsedCache.metrics().hits(); + + post("{ platformEcho(value: \"cache\") }"); + post("{ platformEcho(value: \"cache\") }"); + + assertThat(preparsedCache.metrics().hits()) + .as( + "graphql-java asks a PreparsedDocumentProvider; the platform supplied none, so every " + + "request re-parsed while this bounded cache sat empty") + .isGreaterThan(hitsBefore); + assertThat(preparsedCache.size()).isPositive(); + } + + private ResponseEntity postWithVariables(String variablesJson) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + String body = + "{\"query\":\"query Echo($value: String) { platformEcho(value: $value) }\"," + + "\"variables\":" + + variablesJson + + "}"; + return http.exchange( + new RequestEntity<>( + body, headers, HttpMethod.POST, URI.create("http://localhost:" + port + "/graphql")), + String.class); + } + + private void assertThatRejected(ResponseEntity response, String expectedCode) { + assertThat(response.getBody()).contains("\"code\":\"" + expectedCode + "\""); + assertThat(RESOLVER_INVOCATIONS) + .as("a rejected request must never reach a resolver") + .hasValue(0); + } + + /** A query nested {@code depth} levels below the root field. */ + private static String nestedQuery(int depth) { + StringBuilder document = new StringBuilder("{ platformNode(id: \"n-1\") { id"); + for (int level = 0; level < depth; level++) { + document.append(" child { id"); + } + // One closing brace per `child {`, one for the root field's selection set, one for the query. + document.append(" }".repeat(depth + 2)); + return document.toString(); + } + + private ResponseEntity post(String document) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + String body = + "{\"query\":\"" + + document.replace("\\", "\\\\").replace("\"", "\\\"") + + "\",\"operationName\":null}"; + return http.exchange( + new RequestEntity<>( + body, headers, HttpMethod.POST, URI.create("http://localhost:" + port + "/graphql")), + String.class); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import({ + HealthGraphqlController.class, + PlatformController.class, + PlatformTestConfiguration.class + }) + static class TestApplication {} + + /** Counts every invocation, so "zero resolvers ran" is measured rather than assumed. */ + @Controller + static class PlatformController { + + @QueryMapping + PlatformNode platformNode( + @Argument String id, graphql.schema.DataFetchingEnvironment environment) { + RESOLVER_INVOCATIONS.incrementAndGet(); + OBSERVED_CONTEXT.set(environment.getGraphQlContext().get(GraphQlRequestContext.CONTEXT_KEY)); + return new PlatformNode(id, "node-" + id, null); + } + + @QueryMapping + Instant platformStamp() { + RESOLVER_INVOCATIONS.incrementAndGet(); + return Instant.parse("2026-08-14T00:00:00Z"); + } + + @QueryMapping + String platformSecret() { + RESOLVER_INVOCATIONS.incrementAndGet(); + return "must-never-be-returned"; + } + + /** Reports which of the three variable cases actually reached coercion. */ + @QueryMapping + String platformEcho(@Argument String value) { + RESOLVER_INVOCATIONS.incrementAndGet(); + return value == null ? "explicit-null" : value; + } + } + + /** A node whose child is always null: the depth test needs the shape, not the data. */ + record PlatformNode(String id, String name, PlatformNode child) {} + + @Configuration(proxyBeanMethods = false) + static class PlatformTestConfiguration { + + /** Boot's schema condition does not inspect additional-files; this activates the source. */ + @Bean + GraphQlSourceBuilderCustomizer platformSchemaActivation() { + return builder -> {}; + } + + /** + * The anonymous profile: same limits as the platform default, but no introspection. + * + *

Tightening per profile is allowed and deployment-wide loosening is not undone by it, which + * is why the startup contradiction check only fires the other way round. + */ + @Bean + GraphQlClientPolicy platformClientPolicy() { + return GraphQlClientPolicy.defaults(100, 100, false); + } + + /** + * Lets every request through to the GraphQL endpoint. + * + *

Spring Security is on this leaf's test classpath for the transport qualification, and its + * default chain would answer these requests with a login redirect. Authentication is not what + * this test is measuring: the platform's own authorization policy is, and it runs after the + * request has reached the endpoint. + * + *

CSRF is exempted for the one endpoint under test rather than switched off, so this fixture + * cannot be copied into an adopter's configuration as a deployment-wide disable. + */ + @Bean + SecurityFilterChain platformSecurityFilterChain(HttpSecurity http) throws Exception { + return http.csrf( + csrf -> + csrf.ignoringRequestMatchers( + PathPatternRequestMatcher.withDefaults().matcher("/graphql"))) + .authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll()) + .build(); + } + + /** + * A deny-by-default policy with three coordinates opened. + * + *

Deliberately the fail-closed shape an adopter is expected to ship, so the denial test is + * exercising the same configuration a production deployment would have rather than a special + * one built to fail. + */ + @Bean + GraphQlAuthorizationPolicy platformAuthorizationPolicy() { + GraphQlClientProfile anonymous = + new GraphQlClientProfile(GraphQlPlatformAutoConfiguration.DEFAULT_ANONYMOUS_PROFILE); + return GraphQlAuthorizationPolicy.builder() + .denyByDefault(true) + .allow(GraphQlSchemaCoordinate.parse("Query._health"), anonymous) + .allow(GraphQlSchemaCoordinate.parse("Query.platformNode"), anonymous) + .allow(GraphQlSchemaCoordinate.parse("Query.platformStamp"), anonymous) + .allow(GraphQlSchemaCoordinate.parse("Query.platformEcho"), anonymous) + .build(); + } + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/scalar/GraphQlScalarSymmetryTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/scalar/GraphQlScalarSymmetryTest.java new file mode 100644 index 00000000..62a05f39 --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/scalar/GraphQlScalarSymmetryTest.java @@ -0,0 +1,131 @@ +package dev.caskeleton.adapter.inbound.graphql.scalar; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import graphql.GraphQLContext; +import graphql.execution.CoercedVariables; +import graphql.language.StringValue; +import graphql.schema.CoercingParseValueException; +import graphql.schema.CoercingSerializeException; +import java.math.BigDecimal; +import java.util.Locale; +import org.junit.jupiter.api.Test; + +/** + * A limit that applies to only one direction is not a limit. + * + *

Two asymmetries lived here. {@code BigDecimal} bounded neither input nor output, so eleven + * characters of input produced a megabyte of output. {@code Long} bounded input against the + * configured client range and then serialized whatever a resolver returned, so the deployment that + * configured the double-safe range to protect its clients still sent them values those clients + * silently round. + */ +class GraphQlScalarSymmetryTest { + + private static final GraphQlDecimalBounds BOUNDS = GraphQlDecimalBounds.defaults(); + + @Test + void aTinyExponentInputCannotProduceAHugeOutput() { + assertThatThrownBy(() -> BigDecimalScalar.parse("1E+1000000")) + .as("eleven characters in, a million characters out") + .isInstanceOf(CoercingParseValueException.class); + + assertThatThrownBy(() -> BigDecimalScalar.parse("1E-1000000")) + .as("a negative exponent is as expensive to render as a positive one") + .isInstanceOf(CoercingParseValueException.class); + } + + @Test + void theOutputLengthIsComputedRatherThanProduced() { + // If this were measured by calling toPlainString(), measuring it would be the attack. + BigDecimal huge = new BigDecimal("1E+1000000"); + + assertThat(GraphQlDecimalBounds.plainStringLength(huge)).isEqualTo(1_000_001); + assertThat(BOUNDS.permitsOutput(huge)).isFalse(); + } + + @Test + void thePlainStringLengthMatchesTheRealOne() { + for (String literal : + new String[] {"0", "-1", "1.5", "-1.5", "0.001", "-0.001", "100", "1E+3", "-1E+3"}) { + BigDecimal value = new BigDecimal(literal); + assertThat(GraphQlDecimalBounds.plainStringLength(value)) + .as("computed length for %s", literal) + .isEqualTo(value.toPlainString().length()); + } + } + + @Test + void anOversizeResolverValueIsRefusedOnTheWayOutToo() { + assertThatThrownBy(() -> BigDecimalScalar.serialize(new BigDecimal("1E+1000000"))) + .as("a value this scalar would refuse as input must not leave as output") + .isInstanceOf(CoercingSerializeException.class); + } + + @Test + void ordinaryDecimalsStillPassInBothDirections() { + assertThatCode( + () -> { + assertThat(BigDecimalScalar.parse("12345.6789")) + .isEqualByComparingTo(new BigDecimal("12345.6789")); + assertThat(BigDecimalScalar.serialize(new BigDecimal("12345.6789"))) + .isEqualTo("12345.6789"); + }) + .doesNotThrowAnyException(); + } + + @Test + void aCoercionErrorNeverEchoesTheInput() { + String secret = "9".repeat(500); + + assertThatThrownBy(() -> BigDecimalScalar.parse(secret)) + .hasMessageNotContaining(secret) + .hasMessageNotContaining("999"); + } + + @Test + void theLongRangeAppliesToSerializationAndLiterals() { + var coercing = + LongScalar.type(LongScalar.JS_SAFE_MINIMUM, LongScalar.JS_SAFE_MAXIMUM).getCoercing(); + + assertThatThrownBy( + () -> coercing.serialize(Long.MAX_VALUE, GraphQLContext.getDefault(), Locale.ROOT)) + .as("the configured range protected input and then sent the client an unsafe value anyway") + .isInstanceOf(CoercingSerializeException.class); + + assertThatThrownBy( + () -> coercing.valueToLiteral(Long.MAX_VALUE, GraphQLContext.getDefault(), Locale.ROOT)) + .isInstanceOf(CoercingSerializeException.class); + + assertThat( + coercing.serialize( + LongScalar.JS_SAFE_MAXIMUM, GraphQLContext.getDefault(), Locale.ROOT)) + .isEqualTo("9007199254740991"); + } + + @Test + void aDeploymentThatOptedIntoFullRangeStillSerializes() { + var coercing = + LongScalar.type(LongScalar.FULL_RANGE_MINIMUM, LongScalar.FULL_RANGE_MAXIMUM).getCoercing(); + + assertThat(coercing.serialize(Long.MAX_VALUE, GraphQLContext.getDefault(), Locale.ROOT)) + .as("the bound is the deployment's declared client range, not a fixed opinion") + .isEqualTo("9223372036854775807"); + } + + @Test + void aBoundedDecimalScalarRefusesAnOversizeLiteral() { + var coercing = BigDecimalScalar.type(BOUNDS).getCoercing(); + + assertThatThrownBy( + () -> + coercing.parseLiteral( + StringValue.newStringValue("1E+1000000").build(), + CoercedVariables.emptyVariables(), + GraphQLContext.getDefault(), + Locale.ROOT)) + .isInstanceOf(CoercingParseValueException.class); + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlAuthenticationContextFactoryTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlAuthenticationContextFactoryTest.java index 2b082c4f..28de2c12 100644 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlAuthenticationContextFactoryTest.java +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlAuthenticationContextFactoryTest.java @@ -9,6 +9,7 @@ import dev.caskeleton.adapter.inbound.graphql.context.ActorRef; import dev.caskeleton.adapter.inbound.graphql.context.GraphQlDeadline; import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; import dev.caskeleton.adapter.inbound.graphql.context.TenantContext; +import dev.caskeleton.adapter.inbound.graphql.testkit.GraphQlRequestContexts; import java.time.Clock; import java.time.Duration; import java.time.Instant; @@ -25,14 +26,14 @@ class GraphQlAuthenticationContextFactoryTest { @Test void principalTenantIsAuthoritative() { - GraphQlRequestContext context = GraphQlAuthenticationContextFactory.testContext("tenant-a"); + GraphQlRequestContext context = GraphQlRequestContexts.testContext("tenant-a"); assertThat(context.tenant().value()).isEqualTo("tenant-a"); } @Test void contextCarriesNoCredentialMaterial() { - GraphQlRequestContext context = GraphQlAuthenticationContextFactory.testContext("tenant-a"); + GraphQlRequestContext context = GraphQlRequestContexts.testContext("tenant-a"); assertThat(context.actor().value()).doesNotContain("Bearer").doesNotContain("token"); assertThat(GraphQlRequestContext.class.getRecordComponents()) @@ -98,7 +99,7 @@ class GraphQlAuthenticationContextFactoryTest { @Test void theOperationIdentityIsRefinedOnlyAfterParsing() { - GraphQlRequestContext context = GraphQlAuthenticationContextFactory.testContext("tenant-a"); + GraphQlRequestContext context = GraphQlRequestContexts.testContext("tenant-a"); assertThat(context.operationId().value()) .isEqualTo(GraphQlAuthenticationContextFactory.PENDING_OPERATION_ID); diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlAuthorizationPolicyTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlAuthorizationPolicyTest.java index 3fd6fb04..9f0a87a7 100644 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlAuthorizationPolicyTest.java +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlAuthorizationPolicyTest.java @@ -8,6 +8,7 @@ import dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile; import dev.caskeleton.adapter.inbound.graphql.api.GraphQlSchemaCoordinate; import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; import dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorContext; +import dev.caskeleton.adapter.inbound.graphql.testkit.GraphQlRequestContexts; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -31,7 +32,7 @@ class GraphQlAuthorizationPolicyTest { .allow(ORDER, new GraphQlClientProfile("admin")) .hideFromIntrospection(ORDER) .build(); - GraphQlRequestContext firstParty = GraphQlAuthenticationContextFactory.testContext("tenant-a"); + GraphQlRequestContext firstParty = GraphQlRequestContexts.testContext("tenant-a"); assertThat(policy.hiddenFromIntrospection(ORDER)).isTrue(); assertThat(policy.authorize(firstParty, ORDER).allowed()).isFalse(); @@ -41,10 +42,7 @@ class GraphQlAuthorizationPolicyTest { void unregisteredCoordinatesAreDeniedByDefault() { GraphQlAuthorizationPolicy policy = GraphQlAuthorizationPolicy.builder().build(); - assertThat( - policy - .authorize(GraphQlAuthenticationContextFactory.testContext("tenant-a"), ORDER) - .allowed()) + assertThat(policy.authorize(GraphQlRequestContexts.testContext("tenant-a"), ORDER).allowed()) .isFalse(); } @@ -83,7 +81,7 @@ class GraphQlAuthorizationPolicyTest { GraphQlAuthorizationPolicy.builder() .allow(ORDER, new GraphQlClientProfile("first-party")) .build()); - GraphQlRequestContext context = GraphQlAuthenticationContextFactory.testContext("tenant-a"); + GraphQlRequestContext context = GraphQlRequestContexts.testContext("tenant-a"); assertThatCode(() -> interceptor.authorize(context, ORDER)).doesNotThrowAnyException(); assertThatThrownBy( @@ -100,7 +98,7 @@ class GraphQlAuthorizationPolicyTest { "o-1".equals(objectId) ? GraphQlAuthorizationDecision.allow() : GraphQlAuthorizationDecision.deny("ORDER_READ_DENIED"); - GraphQlRequestContext context = GraphQlAuthenticationContextFactory.testContext("tenant-a"); + GraphQlRequestContext context = GraphQlRequestContexts.testContext("tenant-a"); Map decisions = port.authorizeAll(context, "Order", List.of("o-1", "o-2")); diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlCancellationAggregationTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlCancellationAggregationTest.java new file mode 100644 index 00000000..ee48d371 --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlCancellationAggregationTest.java @@ -0,0 +1,97 @@ +package dev.caskeleton.adapter.inbound.graphql.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionCancellation; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * One broken cleanup must not become several leaks. + * + *

Both loops used to abandon the remaining work at the first failure. On the request path that + * left request-scoped state bound to a pooled thread; on the subscription path it left the Kafka + * consumer, the polling task and the nested publishers running for a client that had gone. In each + * case the hooks that would have prevented the leak were the ones never reached, and the failure + * that stopped them was the only thing anybody saw. + */ +class GraphQlCancellationAggregationTest { + + @Test + void everyCleanupRunsEvenWhenOneThrows() { + List ran = new ArrayList<>(); + var cleanup = + GraphQlContextCleanup.create() + .register(() -> ran.add("first")) + .register( + () -> { + ran.add("second"); + throw new IllegalStateException("second failed"); + }) + .register(() -> ran.add("third")); + + assertThatThrownBy(cleanup::close).isInstanceOf(IllegalStateException.class); + + assertThat(ran).containsExactly("third", "second", "first"); + assertThat(cleanup.pending()).isZero(); + } + + @Test + void laterFailuresAreAttachedToTheFirstRatherThanDropped() { + var cleanup = + GraphQlContextCleanup.create() + .register( + () -> { + throw new IllegalStateException("registered-first"); + }) + .register( + () -> { + throw new IllegalArgumentException("registered-second"); + }); + + assertThatThrownBy(cleanup::close) + .isInstanceOf(IllegalArgumentException.class) + .satisfies( + thrown -> + assertThat(thrown.getSuppressed()) + .as("a second broken cleanup stays invisible until the first one is fixed") + .hasSize(1) + .allSatisfy( + suppressed -> + assertThat(suppressed).isInstanceOf(IllegalStateException.class))); + } + + @Test + void aFailingUpstreamHookNeverStopsTheOthersFromStopping() { + List stopped = new ArrayList<>(); + var cancellation = new GraphQlSubscriptionCancellation(); + cancellation.onCancel(() -> stopped.add("consumer")); + cancellation.onCancel( + () -> { + stopped.add("polling-task"); + throw new IllegalStateException("polling task refused to stop"); + }); + cancellation.onCancel(() -> stopped.add("nested-publisher")); + + assertThatThrownBy(cancellation::cancel).isInstanceOf(IllegalStateException.class); + + assertThat(stopped) + .as("the hooks after the failing one are exactly the leaks it was supposed to prevent") + .containsExactlyInAnyOrder("consumer", "polling-task", "nested-publisher"); + assertThat(cancellation.cancelled()).isTrue(); + } + + @Test + void aHookRegisteredAfterCancellationStillRunsExactlyOnce() { + List stopped = new ArrayList<>(); + var cancellation = new GraphQlSubscriptionCancellation(); + cancellation.cancel(); + + cancellation.onCancel(() -> stopped.add("late")); + cancellation.cancel(); + + assertThat(stopped).containsExactly("late"); + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlTenantIsolationPolicyTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlTenantIsolationPolicyTest.java index e5e3037b..2a7480f5 100644 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlTenantIsolationPolicyTest.java +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/security/GraphQlTenantIsolationPolicyTest.java @@ -6,6 +6,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; import dev.caskeleton.adapter.inbound.graphql.context.TenantContext; +import dev.caskeleton.adapter.inbound.graphql.testkit.GraphQlRequestContexts; import java.util.Map; import java.util.Set; import java.util.concurrent.Callable; @@ -50,9 +51,9 @@ class GraphQlTenantIsolationPolicyTest { @Test void dataLoaderCacheKeysAreScopedByActorAndTenantFingerprint() { GraphQlBatchContext tenantA = - GraphQlBatchContext.from(GraphQlAuthenticationContextFactory.testContext("tenant-a")); + GraphQlBatchContext.from(GraphQlRequestContexts.testContext("tenant-a")); GraphQlBatchContext tenantB = - GraphQlBatchContext.from(GraphQlAuthenticationContextFactory.testContext("tenant-b")); + GraphQlBatchContext.from(GraphQlRequestContexts.testContext("tenant-b")); assertThat(tenantA.cacheScope()).isNotEqualTo(tenantB.cacheScope()); assertThat(tenantA.cacheScope()).doesNotContain("tenant-a"); @@ -60,7 +61,7 @@ class GraphQlTenantIsolationPolicyTest { @Test void contextIsCarriedAcrossAThreadHopAndClearedAfterwards() throws Exception { - GraphQlRequestContext context = GraphQlAuthenticationContextFactory.testContext("tenant-a"); + GraphQlRequestContext context = GraphQlRequestContexts.testContext("tenant-a"); try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { Callable task = @@ -82,7 +83,7 @@ class GraphQlTenantIsolationPolicyTest { @Test void reactorContextCarriesTheSameContextUnderOneKey() { - GraphQlRequestContext context = GraphQlAuthenticationContextFactory.testContext("tenant-a"); + GraphQlRequestContext context = GraphQlRequestContexts.testContext("tenant-a"); assertThat(GraphQlContextPropagator.reactorContextEntry(context)) .containsEntry(GraphQlRequestContext.CONTEXT_KEY, context); diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlCrossModuleContractSuiteTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlCrossModuleContractSuiteTest.java index 3caeadd1..45dcba9b 100644 --- a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlCrossModuleContractSuiteTest.java +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlCrossModuleContractSuiteTest.java @@ -8,11 +8,9 @@ import dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile; import dev.caskeleton.adapter.inbound.graphql.api.GraphQlSchemaCoordinate; import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlCancellation; import dev.caskeleton.adapter.inbound.graphql.http.GraphQlExecutionOutcome; -import dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlConnectionAssembler; import dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlConnectionPolicy; import dev.caskeleton.adapter.inbound.graphql.schema.GraphQlMappingIssue; import dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaResource; -import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationContextFactory; import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationDecision; import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationPolicy; import java.util.List; @@ -99,7 +97,7 @@ class GraphQlCrossModuleContractSuiteTest { GraphQlSecurityContractSuite.verify( policy, (context, objectType, objectId) -> GraphQlAuthorizationDecision.allow(), - GraphQlAuthenticationContextFactory.testContext("tenant-a"), + GraphQlRequestContexts.testContext("tenant-a"), coordinate)) .doesNotThrowAnyException(); } @@ -124,7 +122,8 @@ class GraphQlCrossModuleContractSuiteTest { assertThatCode( () -> GraphQlPaginationContractSuite.verify( - GraphQlConnectionAssembler.forTests(), + dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorFixtures + .assembler(), new GraphQlConnectionPolicy(20, 100, false))) .doesNotThrowAnyException(); } diff --git a/src/adapter/inbound/graphql/src/test/resources/graphql-platform/platform.graphqls b/src/adapter/inbound/graphql/src/test/resources/graphql-platform/platform.graphqls new file mode 100644 index 00000000..04d2cc7a --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/resources/graphql-platform/platform.graphqls @@ -0,0 +1,23 @@ +# Schema used by the execution-path evidence test. +# +# Shaped to exercise the policies the platform claims to enforce: a self-referencing type for depth, +# a repeatable field for alias budgets, a coordinate the authorization policy denies, and a custom +# scalar so wiring and coercion are proved on a real request rather than in a unit test. + +scalar Instant + +extend type Query { + platformNode(id: ID!): PlatformNode + platformStamp: Instant! + platformSecret: String + + # The argument default is what makes the three variable cases distinguishable at the resolver: + # an omitted variable falls back to it, an explicit null does not. + platformEcho(value: String = "default-applied"): String! +} + +type PlatformNode { + id: ID! + name: String! + child: PlatformNode +} diff --git a/src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/InMemoryGraphQlPersistedOperationAdminPort.java b/src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/InMemoryGraphQlPersistedOperationAdminPort.java new file mode 100644 index 00000000..e154f3bc --- /dev/null +++ b/src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/InMemoryGraphQlPersistedOperationAdminPort.java @@ -0,0 +1,64 @@ +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.GraphQlPersistedOperationTransition; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * A single-instance admin port, for tests and development. + * + *

Serialises every command on one lock so the registry change and the audit entry cannot + * interleave, and appends to a thread-safe list. That is as close to atomic as one JVM gets, and it + * is deliberately not a production implementation: a lock inside one instance says nothing about + * the other instances, and neither the registry nor the trail survives a restart. The Advanced + * release gate requires durable evidence for exactly this reason. + */ +public final class InMemoryGraphQlPersistedOperationAdminPort + implements GraphQlPersistedOperationAdminPort { + + private final GraphQlPersistedOperationRegistry registry; + private final List auditTrail = new CopyOnWriteArrayList<>(); + private final Object commandLock = new Object(); + + /** + * Creates the port. + * + * @param registry the operation store this port mutates + */ + public InMemoryGraphQlPersistedOperationAdminPort(GraphQlPersistedOperationRegistry registry) { + this.registry = Objects.requireNonNull(registry, "registry is required"); + } + + @Override + public GraphQlPersistedOperation register( + GraphQlPersistedOperation operation, GraphQlPersistedOperationAudit audit) { + synchronized (commandLock) { + registry.register(operation); + // Appended only after the mutation succeeded: a rejected registration must not leave a + // record saying it happened. + auditTrail.add(audit); + return operation; + } + } + + @Override + public GraphQlPersistedOperation apply( + GraphQlPersistedOperationId operationId, + GraphQlPersistedOperationTransition transition, + GraphQlPersistedOperationAudit audit) { + synchronized (commandLock) { + GraphQlPersistedOperation updated = registry.apply(operationId, transition); + auditTrail.add(audit); + return updated; + } + } + + @Override + public List auditTrail() { + return List.copyOf(auditTrail); + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/InMemoryGraphQlPersistedOperationRegistry.java b/src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/InMemoryGraphQlPersistedOperationRegistry.java similarity index 61% rename from src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/InMemoryGraphQlPersistedOperationRegistry.java rename to src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/InMemoryGraphQlPersistedOperationRegistry.java index 94c2726c..a6858e01 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/InMemoryGraphQlPersistedOperationRegistry.java +++ b/src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/advanced/persisted/InMemoryGraphQlPersistedOperationRegistry.java @@ -31,8 +31,25 @@ public final class InMemoryGraphQlPersistedOperationRegistry } @Override - public void updateStatus(GraphQlPersistedOperationId id, GraphQlPersistedOperationStatus status) { - operations.computeIfPresent(id, (key, operation) -> operation.withStatus(status)); + public GraphQlPersistedOperation apply( + GraphQlPersistedOperationId id, GraphQlPersistedOperationTransition transition) { + // compute, not computeIfPresent: an absent id has to become a failure rather than silence, and + // the transition check has to happen inside the map's per-key lock so two concurrent admins + // cannot both read ACTIVE and both apply a transition from it. + GraphQlPersistedOperation updated = + operations.compute( + id, + (key, existing) -> { + if (existing == null) { + return null; + } + transition.verify(id, existing.status()); + return existing.withStatus(transition.target()); + }); + if (updated == null) { + throw new GraphQlPersistedOperationNotFoundException(id.value()); + } + return updated; } /** How many operations are registered. */ diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/error/GraphQlPartialResponseFixture.java b/src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/error/GraphQlPartialResponseFixture.java similarity index 100% rename from src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/error/GraphQlPartialResponseFixture.java rename to src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/error/GraphQlPartialResponseFixture.java diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlContractFixture.java b/src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlContractFixture.java similarity index 52% rename from src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlContractFixture.java rename to src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlContractFixture.java index 3fdf5d8c..9d00d1e8 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlContractFixture.java +++ b/src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlContractFixture.java @@ -5,41 +5,49 @@ import dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorCode; import dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorContext; import dev.caskeleton.adapter.inbound.graphql.error.GraphQlWireError; import dev.caskeleton.adapter.inbound.graphql.http.GraphQlExecutionOutcome; +import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpContractException; import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpExecutor; import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpProfile; import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpRequestEnvelope; import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponse; +import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponseFactory; import dev.caskeleton.adapter.inbound.graphql.http.GraphQlMediaTypes; import dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestEnvelopeValidator; -import dev.caskeleton.adapter.inbound.graphql.http.mvc.GraphQlMvcExecutorPolicy; -import dev.caskeleton.adapter.inbound.graphql.http.mvc.GraphQlMvcTransportAdapter; import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy; -import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationContextFactory; -import java.time.Clock; import java.time.Duration; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** - * A runnable fixture that drives one operation through the real transport (Stable plan Task 47). + * Drives one operation through the platform's real HTTP response policies (Stable plan Task 47). * - *

Runs the actual MVC transport — envelope validation, status mapping, media negotiation — - * rather than asserting against a hand-built response. A contract test that constructs the response - * it then asserts on proves nothing about the contract. + *

Runs the actual method and media-type checks, envelope validation and status mapping rather + * than asserting against a hand-built response. A contract test that constructs the response it + * then asserts on proves nothing about the contract. * - *

The execution seam is a stub, because the point is the transport and error contract, not any + *

What it deliberately is not is a transport. The route belongs to Spring's {@code + * GraphQlHttpHandler}, and the platform's policies reach a real request through {@code + * GraphQlPlatformWebInterceptor} and {@code GraphQlPlatformInstrumentation}. This fixture executes + * nothing and owns no threads: it composes the pure policies, so it can never become the second + * execution path that the custom MVC and WebFlux adapters had turned into. + * + *

The execution seam is a stub, because the point is the response and error contract, not any * particular resolver. */ public final class GraphQlContractFixture { - private final GraphQlMvcTransportAdapter adapter; - private final java.util.concurrent.ExecutorService executorService; + private final GraphQlHttpProfile profile; + private final GraphQlRequestEnvelopeValidator validator; + private final GraphQlHttpExecutor executor; private GraphQlContractFixture( - GraphQlMvcTransportAdapter adapter, java.util.concurrent.ExecutorService executorService) { - this.adapter = adapter; - this.executorService = executorService; + GraphQlHttpProfile profile, + GraphQlRequestEnvelopeValidator validator, + GraphQlHttpExecutor executor) { + this.profile = profile; + this.validator = validator; + this.executor = executor; } /** @@ -72,52 +80,63 @@ public final class GraphQlContractFixture { /** A fixture with a caller-supplied execution seam. */ public static GraphQlContractFixture withExecutor(GraphQlHttpExecutor executor) { - var executorService = GraphQlMvcExecutorPolicy.VIRTUAL_THREAD.createExecutor(4); return new GraphQlContractFixture( - new GraphQlMvcTransportAdapter( - GraphQlHttpProfile.V1, - GraphQlRequestEnvelopeValidator.forPolicy(clientPolicy()), - executor, - executorService, - GraphQlMvcExecutorPolicy.VIRTUAL_THREAD, - Clock.systemUTC()), - executorService); + GraphQlHttpProfile.V1, GraphQlRequestEnvelopeValidator.forPolicy(clientPolicy()), executor); } - /** Executes a document through the real HTTP transport. */ + /** Runs a document through the response contract. */ public GraphQlContractResponse executeHttp(String document) { return executeHttp(document, GraphQlMediaTypes.GRAPHQL_RESPONSE_JSON); } - /** Executes a document, negotiating an explicit response media type. */ + /** Runs a document, negotiating an explicit response media type. */ public GraphQlContractResponse executeHttp(String document, String accept) { - GraphQlHttpResponse response = - adapter.handle( - "POST", - GraphQlMediaTypes.APPLICATION_JSON, - accept, - new GraphQlHttpRequestEnvelope(document, operationNameOf(document), Map.of(), Map.of()), - GraphQlAuthenticationContextFactory.testContext("tenant-a")); - return new GraphQlContractResponse( - response.status(), response.contentType(), response.data(), response.errors()); + return executeHttp("POST", document, accept); } - /** Executes a document with an explicit HTTP method, for transport rejection cases. */ + /** Runs a document with an explicit HTTP method, for transport rejection cases. */ public GraphQlContractResponse executeHttp(String method, String document, String accept) { GraphQlHttpResponse response = - adapter.handle( + respond( method, - GraphQlMediaTypes.APPLICATION_JSON, accept, - new GraphQlHttpRequestEnvelope(document, operationNameOf(document), Map.of(), Map.of()), - GraphQlAuthenticationContextFactory.testContext("tenant-a")); + new GraphQlHttpRequestEnvelope( + document, operationNameOf(document), Map.of(), Map.of())); return new GraphQlContractResponse( response.status(), response.contentType(), response.data(), response.errors()); } - /** Releases the fixture's executor. */ + /** + * Releases fixture resources; the fixture owns none, so this exists for symmetry with callers. + */ public void close() { - executorService.shutdownNow(); + // No executor, no threads, no route: there is deliberately nothing to release. + } + + /** + * Applies the platform's HTTP response contract to one exchange. + * + *

A client-caused failure never propagates as an exception: it becomes the response the + * profile mandates, which is the property the contract suite is checking. + */ + private GraphQlHttpResponse respond( + String method, String accept, GraphQlHttpRequestEnvelope envelope) { + + GraphQlHttpResponseFactory responses = GraphQlHttpResponseFactory.preferredV1(); + try { + profile.validateMethod(method); + profile.validateContentType(GraphQlMediaTypes.APPLICATION_JSON); + responses = GraphQlHttpResponseFactory.v1(profile.negotiateResponseContentType(accept)); + validator.validateEnvelope(envelope); + + GraphQlExecutionOutcome outcome = + executor.execute(envelope, GraphQlRequestContexts.testContext("tenant-a")); + return outcome.failed() + ? responses.fieldError(outcome.data(), outcome.errors()) + : responses.success(outcome.data()); + } catch (GraphQlHttpContractException failure) { + return responses.requestError(failure); + } } private static String operationNameOf(String document) { diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlContractResponse.java b/src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlContractResponse.java similarity index 100% rename from src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlContractResponse.java rename to src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlContractResponse.java diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlContractViolation.java b/src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlContractViolation.java similarity index 100% rename from src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlContractViolation.java rename to src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlContractViolation.java diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlDataLoaderContractSuite.java b/src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlDataLoaderContractSuite.java similarity index 100% rename from src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlDataLoaderContractSuite.java rename to src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlDataLoaderContractSuite.java diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlDownstreamFailureFixture.java b/src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlDownstreamFailureFixture.java similarity index 100% rename from src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlDownstreamFailureFixture.java rename to src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlDownstreamFailureFixture.java diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlHttpContractSuite.java b/src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlHttpContractSuite.java similarity index 100% rename from src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlHttpContractSuite.java rename to src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlHttpContractSuite.java diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlJpaIntegrationFixture.java b/src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlJpaIntegrationFixture.java similarity index 100% rename from src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlJpaIntegrationFixture.java rename to src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlJpaIntegrationFixture.java diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlMongoIntegrationFixture.java b/src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlMongoIntegrationFixture.java similarity index 100% rename from src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlMongoIntegrationFixture.java rename to src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlMongoIntegrationFixture.java diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlPaginationContractSuite.java b/src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlPaginationContractSuite.java similarity index 100% rename from src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlPaginationContractSuite.java rename to src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlPaginationContractSuite.java diff --git a/src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlRequestContexts.java b/src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlRequestContexts.java new file mode 100644 index 00000000..5bc98867 --- /dev/null +++ b/src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlRequestContexts.java @@ -0,0 +1,43 @@ +package dev.caskeleton.adapter.inbound.graphql.testkit; + +import dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile; +import dev.caskeleton.adapter.inbound.graphql.context.ActorRef; +import dev.caskeleton.adapter.inbound.graphql.context.GraphQlDeadline; +import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext; +import dev.caskeleton.adapter.inbound.graphql.context.TenantContext; +import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticatedPrincipal; +import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationContextFactory; +import java.time.Clock; +import java.time.Duration; +import java.util.Locale; + +/** + * Ready-made request contexts for contract tests and adopter fixtures. + * + *

Lives in test fixtures, not in the production factory. A method named {@code testContext} that + * mints an authenticated actor and a tenant out of a bare string is a credential-free way to obtain + * a credentialed context, and while it shipped inside the production jar it was reachable from any + * adopter's runtime code — including by autocomplete, which is how that call arrives in production. + */ +public final class GraphQlRequestContexts { + + private GraphQlRequestContexts() {} + + /** + * A fixed context for contract tests. + * + * @param tenant the tenant to present as verified + */ + public static GraphQlRequestContext testContext(String tenant) { + return new GraphQlAuthenticationContextFactory(Clock.systemUTC()) + .create( + new GraphQlAuthenticatedPrincipal( + ActorRef.authenticated("actor-test"), + TenantContext.fromAuthenticatedCredential(tenant), + new GraphQlClientProfile("first-party"), + Locale.ROOT, + "trace-test", + null), + GraphQlDeadline.after(Duration.ofSeconds(5), Clock.systemUTC())); + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlSchemaContractSuite.java b/src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlSchemaContractSuite.java similarity index 100% rename from src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlSchemaContractSuite.java rename to src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlSchemaContractSuite.java diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlSecurityContractSuite.java b/src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlSecurityContractSuite.java similarity index 100% rename from src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlSecurityContractSuite.java rename to src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlSecurityContractSuite.java diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlStorageIntegrationEvidence.java b/src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlStorageIntegrationEvidence.java similarity index 100% rename from src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlStorageIntegrationEvidence.java rename to src/adapter/inbound/graphql/src/testFixtures/java/dev/caskeleton/adapter/inbound/graphql/testkit/GraphQlStorageIntegrationEvidence.java diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackRequestFactory.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackRequestFactory.java index 5d237b1d..19b1292a 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackRequestFactory.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackRequestFactory.java @@ -1,8 +1,8 @@ package dev.caskeleton.adapter.inbound.web.notification.platform.callback; +import dev.caskeleton.application.notification.platform.api.CallbackRequest; import dev.caskeleton.application.notification.platform.api.ProviderId; import dev.caskeleton.application.notification.platform.api.ProviderProfileId; -import dev.caskeleton.application.notification.platform.callback.CallbackRequest; import jakarta.servlet.http.HttpServletRequest; import java.time.Clock; import java.util.ArrayList; diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcController.java index bf72ee64..b984dd2c 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcController.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcController.java @@ -1,9 +1,10 @@ package dev.caskeleton.adapter.inbound.web.notification.platform.callback; import dev.caskeleton.application.notification.platform.api.error.CallbackValidationException; -import dev.caskeleton.application.notification.platform.callback.ProviderCallbackIngestionService; +import dev.caskeleton.application.notification.platform.port.in.IngestProviderCallbackUseCase; import jakarta.servlet.http.HttpServletRequest; import java.util.Objects; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.http.HttpStatus; @@ -39,18 +40,41 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping("/internal/notification/callbacks") public final class NotificationCallbackMvcController { - /** Hard body ceiling applied before any provider adapter is consulted. */ - public static final int MAX_BODY_BYTES = 65_536; + /** + * Hard body ceiling applied before any provider adapter is consulted. + * + *

Configured rather than hard-coded, and bounded by what the database can store once the + * payload is encrypted. This constant was 65,536 while configuration permitted a mebibyte and the + * ciphertext column held 65,536 — three layers, three different numbers, and a body of exactly + * the configured maximum failed a CHECK constraint after the provider had been told it was + * stored. + */ + private final int maxBodyBytes; - private final ProviderCallbackIngestionService ingestion; + private final IngestProviderCallbackUseCase ingestion; private final CallbackRequestFactory requestFactory; public NotificationCallbackMvcController( - ProviderCallbackIngestionService ingestion, CallbackRequestFactory requestFactory) { + IngestProviderCallbackUseCase ingestion, + CallbackRequestFactory requestFactory, + @Value("${ca-skeleton.notification.platform.callbacks.max-body-bytes:65508}") + int maxBodyBytes) { this.ingestion = Objects.requireNonNull(ingestion, "ingestion"); this.requestFactory = Objects.requireNonNull(requestFactory, "requestFactory"); + if (maxBodyBytes < 1 || maxBodyBytes > MAX_STORABLE_BODY_BYTES) { + throw new IllegalArgumentException( + "callback max-body-bytes must be 1.." + MAX_STORABLE_BODY_BYTES); + } + this.maxBodyBytes = maxBodyBytes; } + /** + * The largest body that still fits the ciphertext column once encrypted. + * + *

65,536 minus the 12-byte nonce and 16-byte GCM tag the envelope adds. + */ + public static final int MAX_STORABLE_BODY_BYTES = 65_536 - 28; + /** Receive one provider callback. */ @PostMapping(path = "/{provider}/{profile}") public ResponseEntity callback( @@ -58,7 +82,7 @@ public final class NotificationCallbackMvcController { @PathVariable String profile, HttpServletRequest request, @RequestBody byte[] body) { - if (body.length > MAX_BODY_BYTES) { + if (body.length > maxBodyBytes) { return ResponseEntity.status(HttpStatus.CONTENT_TOO_LARGE).build(); } // A duplicate answers 204 exactly like a first delivery. The provider did its job either way, diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/CallbackWebFluxConfiguration.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/CallbackWebFluxConfiguration.java index f0f00546..248dbbda 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/CallbackWebFluxConfiguration.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/CallbackWebFluxConfiguration.java @@ -1,7 +1,7 @@ package dev.caskeleton.adapter.inbound.web.notification.platform.callback.reactive; import dev.caskeleton.adapter.inbound.web.notification.platform.callback.CallbackRequestFactory; -import dev.caskeleton.application.notification.platform.callback.ProviderCallbackIngestionService; +import dev.caskeleton.application.notification.platform.port.in.IngestProviderCallbackUseCase; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -35,7 +35,7 @@ public class CallbackWebFluxConfiguration { @Bean @ConditionalOnMissingBean public BoundedCallbackBodyReader notificationCallbackBodyReader( - @Value("${ca-skeleton.notification.platform.callbacks.max-body-bytes:65536}") int maxBytes) { + @Value("${ca-skeleton.notification.platform.callbacks.max-body-bytes:65508}") int maxBytes) { return new BoundedCallbackBodyReader(maxBytes); } @@ -43,7 +43,7 @@ public class CallbackWebFluxConfiguration { @Bean @ConditionalOnMissingBean public NotificationCallbackWebFluxHandler notificationCallbackWebFluxHandler( - ProviderCallbackIngestionService ingestion, + IngestProviderCallbackUseCase ingestion, CallbackRequestFactory requestFactory, BoundedCallbackBodyReader bodyReader) { return new NotificationCallbackWebFluxHandler(ingestion, requestFactory, bodyReader); diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/NotificationCallbackWebFluxHandler.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/NotificationCallbackWebFluxHandler.java index bdddbb36..1afeb25f 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/NotificationCallbackWebFluxHandler.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/NotificationCallbackWebFluxHandler.java @@ -2,7 +2,7 @@ package dev.caskeleton.adapter.inbound.web.notification.platform.callback.reacti import dev.caskeleton.adapter.inbound.web.notification.platform.callback.CallbackRequestFactory; import dev.caskeleton.application.notification.platform.api.error.CallbackValidationException; -import dev.caskeleton.application.notification.platform.callback.ProviderCallbackIngestionService; +import dev.caskeleton.application.notification.platform.port.in.IngestProviderCallbackUseCase; import java.util.List; import java.util.Map; import java.util.Objects; @@ -26,12 +26,12 @@ import reactor.core.scheduler.Schedulers; */ public final class NotificationCallbackWebFluxHandler { - private final ProviderCallbackIngestionService ingestion; + private final IngestProviderCallbackUseCase ingestion; private final CallbackRequestFactory requestFactory; private final BoundedCallbackBodyReader bodyReader; public NotificationCallbackWebFluxHandler( - ProviderCallbackIngestionService ingestion, + IngestProviderCallbackUseCase ingestion, CallbackRequestFactory requestFactory, BoundedCallbackBodyReader bodyReader) { this.ingestion = Objects.requireNonNull(ingestion, "ingestion"); diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcControllerTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcControllerTest.java index 0e4d3d48..fff22fd4 100644 --- a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcControllerTest.java +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcControllerTest.java @@ -3,6 +3,7 @@ package dev.caskeleton.adapter.inbound.web.notification.platform.callback; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import dev.caskeleton.application.notification.platform.api.CallbackRequest; import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId; import dev.caskeleton.application.notification.platform.api.ProviderId; import dev.caskeleton.application.notification.platform.api.ProviderProfileId; @@ -12,15 +13,13 @@ import dev.caskeleton.application.notification.platform.api.error.NotificationFa import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; import dev.caskeleton.application.notification.platform.callback.AppendEventResult; import dev.caskeleton.application.notification.platform.callback.CallbackLimits; -import dev.caskeleton.application.notification.platform.callback.CallbackRequest; import dev.caskeleton.application.notification.platform.callback.CallbackVerificationResult; +import dev.caskeleton.application.notification.platform.callback.IngestProviderCallbackApplicationUseCase; import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent; import dev.caskeleton.application.notification.platform.callback.ProjectionResult; import dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapter; import dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapterRegistry; -import dev.caskeleton.application.notification.platform.callback.ProviderCallbackIngestionService; import dev.caskeleton.application.notification.platform.callback.ProviderEventLedger; -import dev.caskeleton.application.notification.platform.callback.ProviderEventProjectionService; import dev.caskeleton.application.notification.platform.callback.ProviderEventRecord; import dev.caskeleton.application.notification.platform.callback.ProviderEventRecordId; import dev.caskeleton.application.notification.platform.callback.VerifiedCallback; @@ -62,26 +61,18 @@ class NotificationCallbackMvcControllerTest { private final NotificationCallbackMvcController controller = new NotificationCallbackMvcController( - new ProviderCallbackIngestionService( + new IngestProviderCallbackApplicationUseCase( new CapturingRegistry(), new UnusedLedger(), - // Never reached: verification always fails in this fixture, and the pipeline appends - // only after a valid signature. - new ProviderEventProjectionService( - new UnusedLedger(), - providerId -> java.util.Optional.empty(), - new UnusedAttemptResolver(), - new UnusedProjectionStore(), - (attempt, facts) -> { - throw new UnsupportedOperationException(); - }, - new UnusedTransactions(), - new DiscardingMetrics()), + // The append transaction. Never reached in this fixture: verification always fails, + // and the pipeline appends only after a valid signature. + new UnusedTransactions(), new UnusedPayloadProtection(), new RecordingSecurityAudit(), new DiscardingMetrics(), CLOCK), - new CallbackRequestFactory(new ExternalRequestUrlResolver(Set.of(TRUSTED_PROXY)), CLOCK)); + new CallbackRequestFactory(new ExternalRequestUrlResolver(Set.of(TRUSTED_PROXY)), CLOCK), + NotificationCallbackMvcController.MAX_STORABLE_BODY_BYTES); @Test void theExactReceivedOctetsReachTheAdapterUnparsed() { @@ -155,7 +146,7 @@ class NotificationCallbackMvcControllerTest { @Test void aBodyOverTheTransportCeilingIsRefusedBeforeAnyAdapterIsConsulted() { - byte[] oversized = new byte[NotificationCallbackMvcController.MAX_BODY_BYTES + 1]; + byte[] oversized = new byte[NotificationCallbackMvcController.MAX_STORABLE_BODY_BYTES + 1]; var response = controller.callback("twilio", "twilio-primary", request("application/json"), oversized); @@ -306,6 +297,12 @@ class NotificationCallbackMvcControllerTest { dev.caskeleton.application.notification.platform.callback.DeliveryProjection projection) { throw new UnsupportedOperationException(); } + + @Override + public boolean claimSuppressionSideEffect( + dev.caskeleton.application.notification.platform.api.DeliveryAttemptId attemptId) { + return true; + } } /** Never reached: nothing in this fixture gets as far as a transaction. */ @@ -392,5 +389,13 @@ class NotificationCallbackMvcControllerTest { public List eventsForAttempt(DeliveryAttemptId attemptId) { throw new UnsupportedOperationException(); } + + @Override + public java.util.List bindUnmatched( + dev.caskeleton.application.notification.platform.api.ProviderProfileId providerProfileId, + String providerRequestId, + dev.caskeleton.application.notification.platform.api.DeliveryAttemptId attemptId) { + return java.util.List.of(); + } } } diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/core/OutboundMessagePublisher.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/core/OutboundMessagePublisher.java index ad874f95..0771078a 100644 --- a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/core/OutboundMessagePublisher.java +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/core/OutboundMessagePublisher.java @@ -25,13 +25,37 @@ public class OutboundMessagePublisher implements MessagePublisher { @Override public void publish(OutboundMessage message) { + // The send and the observation are separate steps because they used to share a try block: a + // logger that threw after a successful send was caught by the same catch and reported as a + // publish failure. The broker had accepted the message; the only thing that failed was the + // record of it, and the two must not be confusable. + boolean sent = false; try { broker.send(message); - dependencyLogger.logSuccess(broker.brokerId(), DEPENDENCY_TYPE, "publish"); + sent = true; } catch (Exception ex) { // fail-open: observe with correlationId, delegate durability to outbox/retry, // do NOT propagate — the core use case must still succeed. - dependencyLogger.logFailure(broker.brokerId(), DEPENDENCY_TYPE, "publish", ex); + observeQuietly( + () -> dependencyLogger.logFailure(broker.brokerId(), DEPENDENCY_TYPE, "publish", ex)); + } + if (sent) { + observeQuietly( + () -> dependencyLogger.logSuccess(broker.brokerId(), DEPENDENCY_TYPE, "publish")); + } + } + + /** + * Runs an observation, absorbing whatever it throws. + * + *

Diagnostics are non-authoritative. An appender that is out of disk must not change what the + * caller believes about the broker. + */ + private static void observeQuietly(Runnable observation) { + try { + observation.run(); + } catch (RuntimeException ignored) { + // Nothing to report it to: the reporter is what failed. } } } diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/BrokerAddress.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/BrokerAddress.java new file mode 100644 index 00000000..d69dd6cf --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/BrokerAddress.java @@ -0,0 +1,108 @@ +package dev.caskeleton.adapter.outbound.messaging.kafka; + +import java.util.Objects; + +/** + * One broker endpoint, parsed rather than pattern-matched. + * + *

The regular expression it replaces accepted several things that are not addresses. It ran + * against the trimmed value but the untrimmed original was what got stored, so {@code " + * kafka:9092"} passed validation and was then handed to the client with its leading space. {@code + * \\d{1,5}} accepts {@code 0} and {@code 99999}, neither of which is a port. And {@code [^:\\s]+} + * cannot express a bracketed IPv6 literal at all, so {@code [::1]:9092} — the only correct way to + * write an IPv6 endpoint — was rejected while {@code ::1:9092} was accepted and is ambiguous. + * + * @param host the host, without brackets for an IPv6 literal + * @param port the port, between 1 and 65535 + * @param ipv6Literal whether the host was written as a bracketed IPv6 literal + */ +public record BrokerAddress(String host, int port, boolean ipv6Literal) { + + private static final int MIN_PORT = 1; + private static final int MAX_PORT = 65_535; + + /** Validates the parsed components. */ + public BrokerAddress { + Objects.requireNonNull(host, "host must not be null"); + if (host.isBlank()) { + throw new IllegalArgumentException("broker host must not be blank"); + } + if (port < MIN_PORT || port > MAX_PORT) { + throw new IllegalArgumentException("broker port " + port + " is outside 1..65535"); + } + } + + /** + * Parses one {@code host:port} entry. + * + * @param raw the configured entry, possibly with surrounding whitespace + * @return the canonical address + * @throws IllegalArgumentException naming what is wrong with the entry + */ + public static BrokerAddress parse(String raw) { + if (raw == null || raw.isBlank()) { + throw new IllegalArgumentException("a broker entry must not be blank"); + } + String entry = raw.trim(); + if (entry.startsWith("[")) { + int closing = entry.indexOf(']'); + if (closing < 0 || closing + 1 >= entry.length() || entry.charAt(closing + 1) != ':') { + throw new IllegalArgumentException( + "broker entry '" + entry + "' is a bracketed host without a ':port' after the bracket"); + } + String host = entry.substring(1, closing); + if (host.isBlank()) { + throw new IllegalArgumentException("broker entry '" + entry + "' has an empty host"); + } + return new BrokerAddress(host, parsePort(entry, entry.substring(closing + 2)), true); + } + int separator = entry.lastIndexOf(':'); + if (separator < 0) { + throw new IllegalArgumentException("broker entry '" + entry + "' is not host:port"); + } + String host = entry.substring(0, separator); + if (host.isBlank() || host.indexOf(':') >= 0) { + // A bare IPv6 literal reaches here: it contains colons, and which one separates the port is + // not decidable. Brackets are how the ambiguity is resolved, so require them. + throw new IllegalArgumentException( + "broker entry '" + + entry + + "' is not host:port; write an IPv6 address in brackets, as [::1]:9092"); + } + if (host.chars().anyMatch(Character::isWhitespace)) { + throw new IllegalArgumentException( + "broker entry '" + entry + "' has whitespace inside the host"); + } + return new BrokerAddress(host, parsePort(entry, entry.substring(separator + 1)), false); + } + + private static int parsePort(String entry, String port) { + if (port.isEmpty() || !port.chars().allMatch(Character::isDigit)) { + throw new IllegalArgumentException( + "broker entry '" + entry + "' does not end in a numeric port"); + } + int parsed; + try { + parsed = Integer.parseInt(port); + } catch (NumberFormatException tooLong) { + throw new IllegalArgumentException( + "broker entry '" + entry + "' has a port outside 1..65535", tooLong); + } + if (parsed < MIN_PORT || parsed > MAX_PORT) { + // Reported here rather than in the constructor so the message names the offending entry: an + // operator reading "port 0 is invalid" against a list of nine brokers learns nothing. + throw new IllegalArgumentException( + "broker entry '" + entry + "' has port " + parsed + ", outside 1..65535"); + } + return parsed; + } + + /** + * The canonical {@code host:port} form, which is what the client is given. + * + * @return the canonical text + */ + public String canonical() { + return ipv6Literal ? "[" + host + "]:" + port : host + ":" + port; + } +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaAdapterSettings.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaAdapterSettings.java index ffdfed36..82a49359 100644 --- a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaAdapterSettings.java +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaAdapterSettings.java @@ -1,28 +1,48 @@ package dev.caskeleton.adapter.outbound.messaging.kafka; import java.util.List; -import java.util.regex.Pattern; import org.springframework.boot.context.properties.ConfigurationProperties; /** - * Kafka broker tuning bound from {@code app.messaging.kafka.*}. Validation is format-only ({@code - * host:port} per entry); the "Kafka selected ⇒ brokers required" cross-field rule is enforced in - * {@code KafkaAdapterConfig}, so an empty list is valid at bind time. + * Kafka broker tuning bound from {@code app.messaging.kafka.*}. * - * @param brokers CSV of {@code host:port} broker endpoints (each entry format-validated) + *

Each entry is parsed into a {@link BrokerAddress} and stored in its canonical form. The + * previous binding validated the trimmed value with a regular expression and then stored the + * untrimmed original, so a configured {@code " kafka:9092"} passed the check and reached the client + * with its leading space; the same expression accepted port {@code 0} and port {@code 99999}, and + * could not express a bracketed IPv6 literal. + * + *

The "Kafka selected implies brokers required" cross-field rule stays in {@code + * KafkaAdapterConfig}, so an empty list is still valid at bind time. + * + * @param brokers CSV of {@code host:port} broker endpoints, canonicalised */ @ConfigurationProperties(prefix = "app.messaging.kafka") public record KafkaAdapterSettings(List brokers) { - private static final Pattern HOST_PORT = Pattern.compile("^[^:\\s]+:\\d{1,5}$"); - + /** Parses and canonicalises every configured entry. */ public KafkaAdapterSettings { - brokers = (brokers == null) ? List.of() : List.copyOf(brokers); - for (String broker : brokers) { - if (!HOST_PORT.matcher(broker.trim()).matches()) { - throw new IllegalArgumentException( - "APP_MESSAGING_KAFKA_BROKERS entry '" + broker + "' is not host:port"); - } - } + List configured = (brokers == null) ? List.of() : List.copyOf(brokers); + brokers = + configured.stream() + .map( + entry -> { + try { + return BrokerAddress.parse(entry).canonical(); + } catch (IllegalArgumentException invalid) { + throw new IllegalArgumentException( + "APP_MESSAGING_KAFKA_BROKERS " + invalid.getMessage(), invalid); + } + }) + .toList(); + } + + /** + * The parsed addresses, for callers that need the components rather than the text. + * + * @return one address per configured entry + */ + public List addresses() { + return brokers.stream().map(BrokerAddress::parse).toList(); } } diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapter.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapter.java index e496025a..329de487 100644 --- a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapter.java +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapter.java @@ -37,8 +37,11 @@ public final class Slf4jOutboxRelayFailureReportAdapter implements OutboxRelayFa LoggingEventBuilder event = logger .atError() - .setCause(report.cause()) + // No setCause: the encoder renders a Throwable's message and stack into the + // operational JSON, and a driver's exception text carries endpoints, statements, and + // occasionally credentials. The class name is a type; the code is bounded. .addKeyValue("error.code", report.code().code()) + .addKeyValue("error.cause_type", report.causeType()) .addKeyValue("error.category", report.code().category().name()) .addKeyValue("dependency_name", dependencyName) .addKeyValue("dependency_type", DEPENDENCY_TYPE) diff --git a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/core/OutboundMessagePublisherTest.java b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/core/OutboundMessagePublisherTest.java index fc809025..ec53e46b 100644 --- a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/core/OutboundMessagePublisherTest.java +++ b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/core/OutboundMessagePublisherTest.java @@ -107,4 +107,40 @@ class OutboundMessagePublisherTest { .contains("dependency_type=\"messaging\"") .contains("operation=\"publish\""); } + + @org.junit.jupiter.api.DisplayName( + "a logger failure after a confirmed send is not a publish failure") + @Test + void aLoggerFailureAfterAConfirmedSendIsNotAPublishFailure() { + // The send and the success log used to share one try block, so a logger that threw after the + // broker had accepted the message was caught by the failure branch and recorded as a publish + // failure. The broker's outcome and the record of it are different facts. + FakeBroker broker = new FakeBroker(); + org.slf4j.Logger throwingLogger = + (org.slf4j.Logger) + java.lang.reflect.Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[] {org.slf4j.Logger.class}, + (proxy, method, args) -> { + if (method.getName().equals("debug")) { + throw new IllegalStateException("the appender is out of disk"); + } + if (method.getReturnType() == boolean.class) { + return true; + } + if (method.getReturnType() == String.class) { + return "test.messaging"; + } + return null; + }); + + OutboundMessagePublisher publisher = + new OutboundMessagePublisher(broker, new FailOpenDependencyLogger(throwingLogger)); + OutboundMessage message = new OutboundMessage("worklog-events", "wl-1", "{}"); + + assertThatCode(() -> publisher.publish(message)).doesNotThrowAnyException(); + assertThat(broker.sent) + .as("the message reached the broker; only the record of it failed") + .containsExactly(message); + } } diff --git a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/kafka/BrokerAddressTest.java b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/kafka/BrokerAddressTest.java new file mode 100644 index 00000000..cbe75d14 --- /dev/null +++ b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/kafka/BrokerAddressTest.java @@ -0,0 +1,120 @@ +package dev.caskeleton.adapter.outbound.messaging.kafka; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * What counts as a broker endpoint. + * + *

The regular expression that decided this ran against the trimmed value and then stored the + * untrimmed original, accepted port 0 and port 99999, and could not express a bracketed IPv6 + * literal at all — so the only correct way to write an IPv6 endpoint was rejected while an + * ambiguous one was accepted. + */ +class BrokerAddressTest { + + @Test + @DisplayName("an ordinary endpoint parses and canonicalises") + void anOrdinaryEndpointParses() { + BrokerAddress address = BrokerAddress.parse("kafka-1.internal:9092"); + + assertThat(address.host()).isEqualTo("kafka-1.internal"); + assertThat(address.port()).isEqualTo(9092); + assertThat(address.canonical()).isEqualTo("kafka-1.internal:9092"); + } + + @Test + @DisplayName("surrounding whitespace is removed rather than validated away and kept") + void surroundingWhitespaceIsRemoved() { + assertThat(new KafkaAdapterSettings(List.of(" kafka:9092 ")).brokers()) + .as("the old binding validated the trimmed value and stored the untrimmed one") + .containsExactly("kafka:9092"); + } + + @Test + @DisplayName("whitespace inside the host is refused") + void whitespaceInsideTheHostIsRefused() { + assertThatThrownBy(() -> BrokerAddress.parse("kaf ka:9092")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("whitespace"); + } + + @Test + @DisplayName("the port range is 1 to 65535") + void thePortRangeIsOneToSixtyFiveThousand() { + assertThatCode(() -> BrokerAddress.parse("kafka:1")).doesNotThrowAnyException(); + assertThatCode(() -> BrokerAddress.parse("kafka:65535")).doesNotThrowAnyException(); + + assertThatThrownBy(() -> BrokerAddress.parse("kafka:0")) + .as("port 0 asks the operating system to choose, which a client cannot connect to") + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> BrokerAddress.parse("kafka:65536")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> BrokerAddress.parse("kafka:99999")) + .as("the five-digit pattern accepted this") + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a bracketed IPv6 literal is accepted and stays bracketed") + void aBracketedIpv6LiteralIsAccepted() { + BrokerAddress address = BrokerAddress.parse("[2001:db8::1]:9092"); + + assertThat(address.host()).isEqualTo("2001:db8::1"); + assertThat(address.port()).isEqualTo(9092); + assertThat(address.ipv6Literal()).isTrue(); + assertThat(address.canonical()).isEqualTo("[2001:db8::1]:9092"); + } + + @Test + @DisplayName("a bare IPv6 literal is refused because it is ambiguous") + void aBareIpv6LiteralIsRefused() { + assertThatThrownBy(() -> BrokerAddress.parse("2001:db8::1:9092")) + .as("which colon separates the port is not decidable, so brackets are required") + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("[::1]:9092"); + } + + @Test + @DisplayName("a bracketed host with no port is refused") + void aBracketedHostWithNoPortIsRefused() { + assertThatThrownBy(() -> BrokerAddress.parse("[2001:db8::1]")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> BrokerAddress.parse("[2001:db8::1]9092")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("an entry with no port at all is refused") + void anEntryWithNoPortIsRefused() { + assertThatThrownBy(() -> BrokerAddress.parse("kafka")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("host:port"); + assertThatThrownBy(() -> BrokerAddress.parse("kafka:")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> BrokerAddress.parse("kafka:http")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> BrokerAddress.parse(":9092")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("the settings binding reports which entry is wrong") + void theSettingsBindingReportsWhichEntryIsWrong() { + assertThatThrownBy(() -> new KafkaAdapterSettings(List.of("kafka-1:9092", "kafka-2:0"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("APP_MESSAGING_KAFKA_BROKERS") + .hasMessageContaining("kafka-2:0"); + } + + @Test + @DisplayName("an absent broker list is empty, not a failure") + void anAbsentBrokerListIsEmpty() { + assertThat(new KafkaAdapterSettings(null).brokers()).isEmpty(); + } +} diff --git a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapterTest.java b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapterTest.java index 0dd2b94f..917208a1 100644 --- a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapterTest.java +++ b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapterTest.java @@ -7,7 +7,6 @@ import static org.mockito.Mockito.when; import ch.qos.logback.classic.Level; import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.classic.spi.ThrowableProxy; import ch.qos.logback.core.read.ListAppender; import dev.caskeleton.application.outbox.OutboxRelayFailureReport; import java.time.Instant; @@ -70,9 +69,14 @@ class Slf4jOutboxRelayFailureReportAdapterTest { Map.entry("aggregate_id", "agg-1"), Map.entry("correlation_id", "corr-1"), Map.entry("attempt_count", 2), + Map.entry("error.cause_type", "java.lang.RuntimeException"), Map.entry("runbook_link", "runbook://outbox/publish-failed"), Map.entry("next_attempt_at", "2026-07-25T01:02:03Z"))); - assertThat(((ThrowableProxy) event.getThrowableProxy()).getThrowable()).isSameAs(cause); + assertThat(event.getThrowableProxy()) + .as( + "the encoder renders a Throwable's message and stack into the operational JSON, and a" + + " driver's exception text carries endpoints, statements, and credentials") + .isNull(); assertThat(event.getFormattedMessage()).doesNotContain("unsafe-exception-derived-value"); assertThat(keyValues(event).toString()) .doesNotContain("payload-secret", "idempotency-secret", "unsafe-exception-derived-value"); @@ -94,7 +98,7 @@ class Slf4jOutboxRelayFailureReportAdapterTest { .containsEntry("outcome", "DEAD") .containsEntry("runbook_link", "runbook://outbox/dead-letter") .doesNotContainKey("next_attempt_at"); - assertThat(((ThrowableProxy) event.getThrowableProxy()).getThrowable()).isSameAs(cause); + assertThat(event.getThrowableProxy()).isNull(); } @Test diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/AssembledProvider.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/AssembledProvider.java new file mode 100644 index 00000000..928df24c --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/AssembledProvider.java @@ -0,0 +1,63 @@ +package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure; + +import dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderRuntime; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapter; +import dev.caskeleton.application.notification.platform.callback.ProviderEventProjector; +import dev.caskeleton.application.notification.platform.provider.ReconciliationCapability; +import java.util.Objects; +import java.util.Optional; + +/** + * One provider profile, assembled into everything the platform needs from it. + * + *

The pieces used to be registered independently — a runtime here, a callback adapter there, a + * projector in a third list — and nothing checked that a profile had contributed all of the ones it + * needs. A callback-enabled profile with no callback adapter was a context that started and then + * failed on the first provider event, which is hours after the mistake was made and in a component + * that did not make it. + * + *

Returning them together makes the incomplete contribution unrepresentable: an assembler either + * produces a working profile or fails, and it fails at startup. + * + * @param runtime the dispatch runtime, bound to its credential generation + * @param channel the channel this profile serves + * @param callback the callback adapter, when the family has one + * @param projector the provider-event projector, when the family has one + * @param reconciliation the status-query capability, when the family supports one + */ +public record AssembledProvider( + ProviderRuntime runtime, + Channel channel, + Optional callback, + Optional projector, + Optional reconciliation) { + + /** Validates the contribution. */ + public AssembledProvider { + Objects.requireNonNull(runtime, "runtime"); + Objects.requireNonNull(channel, "channel"); + Objects.requireNonNull(callback, "callback"); + Objects.requireNonNull(projector, "projector"); + Objects.requireNonNull(reconciliation, "reconciliation"); + if (runtime.profile().channel() != channel) { + throw new IllegalArgumentException( + "the assembled runtime serves " + + runtime.profile().channel() + + " but the contribution claims " + + channel); + } + } + + /** + * A profile that dispatches and nothing else. + * + * @param runtime the dispatch runtime + * @param channel the channel it serves + * @return the contribution + */ + public static AssembledProvider dispatchOnly(ProviderRuntime runtime, Channel channel) { + return new AssembledProvider( + runtime, channel, Optional.empty(), Optional.empty(), Optional.empty()); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformMode.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformMode.java new file mode 100644 index 00000000..182c790f --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformMode.java @@ -0,0 +1,24 @@ +package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure; + +/** + * Whether this deployment can actually deliver anything. + * + *

A platform with no assembled provider used to look identical to one with providers: the same + * beans, the same scheduler, the same readiness. Requests were accepted durably and then sat in the + * queue with no eligible route. Naming the state makes it a decision an operator takes rather than + * a situation they discover. + */ +public enum NotificationPlatformMode { + + /** At least one provider assembled; the platform accepts and delivers. */ + SERVING, + + /** + * No provider configured. The platform accepts and stores requests, and readiness reports + * non-serving so a load balancer does not route delivery traffic here. + * + *

Must be selected explicitly. A deployment that reaches zero providers by accident is a + * misconfiguration, and the whole point of this enum is that the two are told apart. + */ + INGEST_ONLY +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformSettings.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformSettings.java index 51147faf..22e71815 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformSettings.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformSettings.java @@ -15,9 +15,17 @@ import org.springframework.boot.context.properties.ConfigurationProperties; */ @ConfigurationProperties("ca-skeleton.notification.platform") public record NotificationPlatformSettings( - boolean enabled, Dispatch dispatch, Callbacks callbacks, Map providers) { + boolean enabled, + NotificationPlatformMode mode, + Dispatch dispatch, + Callbacks callbacks, + Map providers) { public NotificationPlatformSettings { + // SERVING by default: a deployment that ends up with no provider is a misconfiguration unless + // somebody said otherwise, and the assembly refuses it rather than accepting requests it cannot + // deliver. + mode = mode == null ? NotificationPlatformMode.SERVING : mode; dispatch = dispatch == null ? Dispatch.defaults() : dispatch; callbacks = callbacks == null ? Callbacks.defaults() : callbacks; providers = providers == null ? Map.of() : Map.copyOf(providers); @@ -75,12 +83,24 @@ public record NotificationPlatformSettings( /** Callback endpoint bounds. */ public record Callbacks(boolean enabled, long maxBodyBytes, Duration replaySkew) { - private static final long MAX_BODY_CEILING = 1_048_576L; + /** + * The largest body the platform can retain, derived rather than chosen. + * + *

It was one mebibyte, while the ciphertext column holds 65,536 bytes and encryption adds a + * 12-byte nonce and a 16-byte tag. Three layers each enforced a different number: configuration + * allowed a mebibyte, the MVC controller hard-coded 65,536, and the database rejected anything + * over 65,536 *after* encryption — so a body of exactly the configured maximum passed every + * check above the database and failed the CHECK constraint, having already been acknowledged. + */ + private static final long MAX_BODY_CEILING = 65_536L - 28L; public Callbacks { Objects.requireNonNull(replaySkew, "replaySkew"); if (maxBodyBytes < 1 || maxBodyBytes > MAX_BODY_CEILING) { - throw new IllegalArgumentException("max-body-bytes must be 1.." + MAX_BODY_CEILING); + throw new IllegalArgumentException( + "max-body-bytes must be 1.." + + MAX_BODY_CEILING + + "; the ciphertext column holds 65536 bytes and encryption adds 28"); } if (replaySkew.isNegative()) { throw new IllegalArgumentException("replay-skew must not be negative"); @@ -89,7 +109,9 @@ public record NotificationPlatformSettings( /** Conservative defaults. */ public static Callbacks defaults() { - return new Callbacks(false, 65_536L, Duration.ofMinutes(5)); + // The storable maximum, not the column size: encryption adds 28 bytes, so a default of + // 65,536 was a default that could not be stored. + return new Callbacks(false, MAX_BODY_CEILING, Duration.ofMinutes(5)); } } @@ -97,6 +119,7 @@ public record NotificationPlatformSettings( public record Provider( String type, boolean enabled, + boolean primaryForChannel, String environment, String credentialProfile, String topic, @@ -112,7 +135,9 @@ public record NotificationPlatformSettings( if (!enabled) { return; } - require(type != null && !type.isBlank(), profileId, "type is required"); + // Resolved against the closed enum here, so an unrecognised type is a binding failure rather + // than a profile that binds successfully and assembles into nothing. + ProviderType resolved = ProviderType.parse(profileId, type); require(environment != null && !environment.isBlank(), profileId, "environment is required"); require( credentialProfile != null && !credentialProfile.isBlank(), @@ -125,7 +150,7 @@ public record NotificationPlatformSettings( require(maxConcurrency >= 1, profileId, "max-concurrency must be positive"); require(ratePerSecond >= 1, profileId, "rate-limit-per-second must be positive"); - switch (type == null ? "" : type.toUpperCase(java.util.Locale.ROOT)) { + switch (resolved.name()) { case "APNS" -> require(topic != null && !topic.isBlank(), profileId, "APNs profiles require a topic"); case "WEB_PUSH" -> diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationProviderAssembly.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationProviderAssembly.java new file mode 100644 index 00000000..eba7ccdc --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationProviderAssembly.java @@ -0,0 +1,239 @@ +package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure; + +import dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderRuntimeRegistry; +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapter; +import dev.caskeleton.application.notification.platform.callback.ProviderEventProjector; +import dev.caskeleton.application.notification.platform.provider.ReconciliationCapability; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.TreeMap; + +/** + * Assembles every configured profile, once, at startup — or refuses to start. + * + *

This is the step that did not exist. The registry was constructed empty, the route planner + * with {@code Map.of()}, and the reconciliation gateway with {@code Map.of()}, so configuration and + * runtime were two unrelated things that happened to be in the same application. + * + *

Everything it refuses, it refuses before the dispatch worker starts: + * + *

    + *
  • an unknown provider type, which used to bind and then assemble into nothing; + *
  • two enabled profiles claiming the same channel with no primary named, because "which + * provider sends this" is not a question to answer by map iteration order; + *
  • a profile whose family has no assembler — a transport that is a seam rather than an + * implementation; + *
  • zero providers without {@link NotificationPlatformMode#INGEST_ONLY}, because a platform + * that cannot deliver should say so rather than accept and queue forever. + *
+ */ +public final class NotificationProviderAssembly { + + private final Map assemblers; + + /** + * Creates the assembly over the available family assemblers. + * + * @param assemblers one assembler per supported family + */ + public NotificationProviderAssembly(List assemblers) { + Objects.requireNonNull(assemblers, "assemblers"); + Map byType = new EnumMap<>(ProviderType.class); + for (ProviderRuntimeAssembler assembler : assemblers) { + ProviderRuntimeAssembler previous = byType.put(assembler.type(), assembler); + if (previous != null) { + throw new IllegalStateException( + "two assemblers claim provider type " + + assembler.type() + + "; which one builds a profile must not depend on bean ordering"); + } + } + this.assemblers = Map.copyOf(byType); + } + + /** + * Assembles the configured platform. + * + * @param settings the bound configuration + * @param mode the declared mode + * @return the assembled platform + * @throws IllegalStateException naming the profile and the reason, for any refusal + */ + public AssembledPlatform assemble( + NotificationPlatformSettings settings, NotificationPlatformMode mode) { + Objects.requireNonNull(settings, "settings"); + Objects.requireNonNull(mode, "mode"); + + // Sorted so a failure names the same profile on every boot: an assembly error that moves + // between profiles run to run is an error nobody can act on. + Map configured = + new TreeMap<>(settings.providers()); + + ProviderRuntimeRegistry runtimes = new ProviderRuntimeRegistry(); + Map routes = new EnumMap<>(Channel.class); + Map callbacks = new LinkedHashMap<>(); + Map projectors = new LinkedHashMap<>(); + Map reconciliations = new LinkedHashMap<>(); + Map> claimants = new EnumMap<>(Channel.class); + + for (Map.Entry entry : configured.entrySet()) { + String profileId = entry.getKey(); + NotificationPlatformSettings.Provider profile = entry.getValue(); + if (!profile.enabled()) { + continue; + } + ProviderType type = ProviderType.parse(profileId, profile.type()); + ProviderRuntimeAssembler assembler = assemblers.get(type); + if (assembler == null) { + throw new IllegalStateException( + "notification provider profile '" + + profileId + + "' is of type " + + type + + ", which has no assembler in this build. The transport is a seam, not an" + + " implementation; remove the profile or supply a ProviderRuntimeAssembler for" + + " that family."); + } + AssembledProvider assembled = assembler.assemble(profileId, profile); + if (assembled.channel() != type.channel()) { + throw new IllegalStateException( + "the assembler for " + + type + + " produced a " + + assembled.channel() + + " provider; the channel a family serves is a property of the family"); + } + + ProviderProfileId id = new ProviderProfileId(profileId); + runtimes.register(assembled.runtime()); + claimants.computeIfAbsent(assembled.channel(), channel -> new ArrayList<>()).add(profileId); + // The marked primary wins outright; otherwise the first (and, once the ambiguity check below + // has run, only) claimant takes the channel. Falling back to putIfAbsent alone would let a + // declared primary lose the route to whichever id sorts first. + if (profile.primaryForChannel()) { + routes.put(assembled.channel(), id); + } else { + routes.putIfAbsent(assembled.channel(), id); + } + assembled.callback().ifPresent(adapter -> callbacks.put(id, adapter)); + assembled.projector().ifPresent(projector -> projectors.put(id, projector)); + assembled.reconciliation().ifPresent(capability -> reconciliations.put(id, capability)); + } + + refuseAmbiguousRoutes(claimants, settings); + refuseEmptyPlatform(routes, mode); + + return new AssembledPlatform( + runtimes, + Map.copyOf(routes), + Map.copyOf(callbacks), + Map.copyOf(projectors), + Map.copyOf(reconciliations), + routes.isEmpty() ? NotificationPlatformMode.INGEST_ONLY : NotificationPlatformMode.SERVING); + } + + private static void refuseAmbiguousRoutes( + Map> claimants, NotificationPlatformSettings settings) { + Map primaries = new HashMap<>(); + settings + .providers() + .forEach( + (profileId, profile) -> { + if (profile.primaryForChannel()) { + String channel = ProviderType.parse(profileId, profile.type()).channel().name(); + String previous = primaries.put(channel, profileId); + if (previous != null) { + throw new IllegalStateException( + "profiles '" + + previous + + "' and '" + + profileId + + "' are both marked primary for channel " + + channel); + } + } + }); + + claimants.forEach( + (channel, profiles) -> { + if (profiles.size() > 1 && !primaries.containsKey(channel.name())) { + throw new IllegalStateException( + "profiles " + + profiles + + " all serve channel " + + channel + + " and none is marked primary-for-channel. Which provider sends a " + + channel.name().toLowerCase(Locale.ROOT) + + " notification must not depend on map iteration order."); + } + }); + } + + private static void refuseEmptyPlatform( + Map routes, NotificationPlatformMode mode) { + if (routes.isEmpty() && mode != NotificationPlatformMode.INGEST_ONLY) { + throw new IllegalStateException( + "the notification platform is enabled with no assembled provider. Every request would be" + + " accepted durably and then find no eligible route. Configure a provider, or" + + " declare ca-skeleton.notification.platform.mode=INGEST_ONLY so readiness reports" + + " non-serving."); + } + } + + /** + * Everything the composition root needs, built from configuration in one place. + * + * @param runtimes the registry the dispatch gateway routes through + * @param routes the channel-to-profile map the route planner uses + * @param callbacks callback adapters by profile + * @param projectors provider-event projectors by profile + * @param reconciliations status-query capabilities by profile + * @param mode whether this deployment can deliver + */ + public record AssembledPlatform( + ProviderRuntimeRegistry runtimes, + Map routes, + Map callbacks, + Map projectors, + Map reconciliations, + NotificationPlatformMode mode) { + + /** Validates the assembly. */ + public AssembledPlatform { + Objects.requireNonNull(runtimes, "runtimes"); + routes = Map.copyOf(Objects.requireNonNull(routes, "routes")); + callbacks = Map.copyOf(Objects.requireNonNull(callbacks, "callbacks")); + projectors = Map.copyOf(Objects.requireNonNull(projectors, "projectors")); + reconciliations = Map.copyOf(Objects.requireNonNull(reconciliations, "reconciliations")); + Objects.requireNonNull(mode, "mode"); + } + + /** + * Whether this deployment can deliver. + * + * @return true when at least one route was assembled + */ + public boolean serving() { + return mode == NotificationPlatformMode.SERVING; + } + + /** + * The profile serving a channel, if any. + * + * @param channel the channel + * @return the profile + */ + public Optional routeFor(Channel channel) { + return Optional.ofNullable(routes.get(channel)); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/ProviderRuntimeAssembler.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/ProviderRuntimeAssembler.java new file mode 100644 index 00000000..aac01be3 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/ProviderRuntimeAssembler.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure; + +/** + * Turns one configured profile into one working provider. + * + *

Nothing did this. Configuration bound a map of profiles, validation checked that their fields + * were present, and the runtime registry was constructed empty — so a fully configured provider + * produced no runtime, no route, and no error. Requests reached durable acceptance and then found + * no eligible route, which reads to an operator as "the platform is dropping my notifications". + * + *

One assembler per family, each returning a complete {@link AssembledProvider}: the adapter, + * its transport, the credential generation, the limiter, and whichever callback, projector and + * reconciliation pieces the family has. A family whose transport is not implemented fails here, by + * name, rather than assembling into something that cannot send. + */ +public interface ProviderRuntimeAssembler { + + /** + * The family this assembler builds. + * + * @return the provider type + */ + ProviderType type(); + + /** + * Assembles one profile. + * + * @param profileId the configured profile id + * @param profile the bound settings for it + * @return the complete contribution + * @throws IllegalStateException when the profile cannot produce a working provider, naming what + * is missing + */ + AssembledProvider assemble(String profileId, NotificationPlatformSettings.Provider profile); +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/ProviderType.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/ProviderType.java new file mode 100644 index 00000000..c75b240b --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/ProviderType.java @@ -0,0 +1,87 @@ +package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure; + +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import java.util.Arrays; +import java.util.Locale; +import java.util.Objects; + +/** + * The provider families this platform can assemble. + * + *

Configuration carried the type as a free string, and the only thing that read it was a + * validation {@code switch} whose {@code default} branch accepted everything. So a profile of type + * {@code "sendgrid"} — or {@code "smpt"} — passed validation, was bound, counted, and then never + * assembled into anything, because the assembly step did not exist either. The failure was silent + * at every stage: no route, no runtime, no error. + * + *

A closed enum makes the unknown type a binding failure at startup, and makes the channel each + * family serves a property of the family rather than something a deployment can disagree with. + */ +public enum ProviderType { + + /** Apple Push Notification service. */ + APNS(Channel.PUSH), + + /** Firebase Cloud Messaging. */ + FCM(Channel.PUSH), + + /** Amazon Simple Email Service. */ + SES(Channel.EMAIL), + + /** A directly-configured SMTP relay. */ + SMTP(Channel.EMAIL), + + /** Twilio programmable messaging. */ + TWILIO(Channel.SMS), + + /** RFC 8291 Web Push. */ + WEB_PUSH(Channel.WEB_PUSH), + + /** An outbound HTTP webhook. */ + WEBHOOK(Channel.WEBHOOK); + + private final Channel channel; + + ProviderType(Channel channel) { + this.channel = channel; + } + + /** + * The channel this family serves. + * + * @return the channel + */ + public Channel channel() { + return channel; + } + + /** + * Resolves a configured type, case-insensitively. + * + * @param profileId the profile the value came from, named in the failure + * @param value the configured type + * @return the resolved family + * @throws IllegalArgumentException listing every supported value + */ + public static ProviderType parse(String profileId, String value) { + Objects.requireNonNull(profileId, "profileId"); + if (value == null || value.isBlank()) { + throw new IllegalArgumentException( + "notification provider profile '" + profileId + "': type is required"); + } + String normalized = value.trim().toUpperCase(Locale.ROOT).replace('-', '_'); + for (ProviderType candidate : values()) { + if (candidate.name().equals(normalized)) { + return candidate; + } + } + throw new IllegalArgumentException( + "notification provider profile '" + + profileId + + "': unknown provider type '" + + value + + "'; supported types are " + + Arrays.toString(values()) + + ". An unrecognised type used to bind successfully and then assemble into nothing."); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ConfiguredProfileCatalog.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ConfiguredProfileCatalog.java new file mode 100644 index 00000000..009388b8 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ConfiguredProfileCatalog.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.dispatch.ProviderProfileCatalogPort; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * The configured channel-to-profile map, and nothing else. + * + *

What is left of {@code ConfiguredRoutePlanner} after NTF-020. It also decided the recipient's + * effective channel order, applied blocked channels, and walked the strategy's fallback — all + * product policy, all of it in the layer that speaks provider protocols. Those moved to {@code + * PolicyRoutePlanner}; this answers the one question the adapter is actually the authority on. + */ +public final class ConfiguredProfileCatalog implements ProviderProfileCatalogPort { + + private final Map profilesByChannel; + + public ConfiguredProfileCatalog(Map profilesByChannel) { + this.profilesByChannel = + Map.copyOf(Objects.requireNonNull(profilesByChannel, "profilesByChannel")); + } + + @Override + public Optional profileFor(Channel channel) { + return Optional.ofNullable(profilesByChannel.get(Objects.requireNonNull(channel, "channel"))); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ConfiguredRoutePlanner.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ConfiguredRoutePlanner.java deleted file mode 100644 index 3a2b8261..00000000 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ConfiguredRoutePlanner.java +++ /dev/null @@ -1,72 +0,0 @@ -package dev.caskeleton.adapter.outbound.notification.platform.dispatch; - -import dev.caskeleton.application.notification.platform.api.ProviderProfileId; -import dev.caskeleton.application.notification.platform.api.RecipientSpec; -import dev.caskeleton.application.notification.platform.api.TenantId; -import dev.caskeleton.application.notification.platform.api.routing.Channel; -import dev.caskeleton.application.notification.platform.api.routing.DeliveryStrategy; -import dev.caskeleton.application.notification.platform.api.routing.ExplicitChannel; -import dev.caskeleton.application.notification.platform.api.routing.OrderedFallback; -import dev.caskeleton.application.notification.platform.dispatch.NotificationRoutePlannerPort; -import dev.caskeleton.application.notification.platform.policy.RouteCandidate; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Objects; - -/** - * Turns a strategy into an ordered route plan using the configured channel-to-profile map. - * - *

A channel with no configured provider, or a recipient with no contact point for it, simply - * produces no candidate. The routing engine then reports {@code NO_ELIGIBLE_ROUTE} rather than the - * dispatcher failing on a null, which is the difference between a diagnosable state and a stack - * trace. - */ -public final class ConfiguredRoutePlanner implements NotificationRoutePlannerPort { - - private final Map profilesByChannel; - - public ConfiguredRoutePlanner(Map profilesByChannel) { - this.profilesByChannel = - Map.copyOf(Objects.requireNonNull(profilesByChannel, "profilesByChannel")); - } - - @Override - public List plan( - TenantId tenantId, RecipientSpec recipient, DeliveryStrategy strategy) { - Objects.requireNonNull(tenantId, "tenantId"); - Objects.requireNonNull(recipient, "recipient"); - Objects.requireNonNull(strategy, "strategy"); - - List ordered = - switch (strategy) { - case ExplicitChannel explicit -> List.of(explicit.channel()); - case OrderedFallback fallback -> fallback.channels(); - }; - - List routes = new ArrayList<>(ordered.size()); - int index = 0; - for (Channel channel : ordered) { - ProviderProfileId profileId = profilesByChannel.get(channel); - if (profileId == null) { - continue; - } - var selector = - recipient.contactPoints().stream() - .filter(candidate -> candidate.channel() == channel) - .findFirst(); - if (selector.isEmpty()) { - continue; - } - boolean blocked = - recipient - .channelOverride() - .map(override -> override.blockedChannels().contains(channel)) - .orElse(false); - routes.add( - new RouteCandidate( - index++, channel, selector.get().contactPointId(), profileId, !blocked, true)); - } - return List.copyOf(routes); - } -} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/LeaseRecoveryService.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/LeaseRecoveryService.java index 171269ff..ea00a346 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/LeaseRecoveryService.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/LeaseRecoveryService.java @@ -11,26 +11,46 @@ import java.util.Objects; /** * Recovers deliveries a dead worker left in flight. * - *

An expired lease on a {@code DISPATCHING} delivery is the crash case: the attempt row exists, - * so a provider call may have happened. Recovery therefore reconciles rather than re-dispatching — - * re-dispatching would be the platform choosing to duplicate rather than to ask. + *

An expired lease on a {@code DISPATCHING} delivery is the crash case, and which recovery is + * correct depends on how far the worker got: + * + *

    + *
  • No attempt row. The worker died between claiming the delivery and + * recording that it was about to call the provider, which is proof no provider call happened. + * Requeue is safe, and it is the only case where it is. + *
  • An attempt row with no completion. A provider call may have happened. + * Recovery reconciles rather than re-dispatching — re-dispatching here would be the platform + * choosing to duplicate rather than to ask. + *
+ * + *

The first case used to be unhandled: recovery iterated attempts, and a delivery with none had + * nothing to iterate. Those rows stayed {@code DISPATCHING} forever, holding a delivery nobody had + * even tried to send. */ public final class LeaseRecoveryService { private final RecipientLeaseStorePort leases; private final DeliveryAttemptStorePort attempts; - private final ReconciliationService reconciliation; + private final dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryStorePort + deliveries; + private final java.time.Clock clock; + private final AttemptReconciler reconciliation; private final Duration staleAfter; private final int batchSize; public LeaseRecoveryService( RecipientLeaseStorePort leases, DeliveryAttemptStorePort attempts, - ReconciliationService reconciliation, + dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryStorePort + deliveries, + AttemptReconciler reconciliation, + java.time.Clock clock, Duration staleAfter, int batchSize) { this.leases = Objects.requireNonNull(leases, "leases"); this.attempts = Objects.requireNonNull(attempts, "attempts"); + this.deliveries = Objects.requireNonNull(deliveries, "deliveries"); + this.clock = Objects.requireNonNull(clock, "clock"); this.reconciliation = Objects.requireNonNull(reconciliation, "reconciliation"); this.staleAfter = Objects.requireNonNull(staleAfter, "staleAfter"); this.batchSize = batchSize; @@ -42,16 +62,79 @@ public final class LeaseRecoveryService { } } + /** + * Asks what happened to one attempt. + * + *

A narrow seam over {@link ReconciliationService#reconcile}, which recovery is the only + * caller of. Depending on the concrete service would drag its seven collaborators into every test + * of the two-case split below, and the split is the part that was wrong. + */ + @FunctionalInterface + public interface AttemptReconciler { + + /** + * Reconciles one attempt whose outcome is unknown. + * + * @param attemptId the attempt + */ + void reconcile(DeliveryAttemptId attemptId); + } + + /** + * Whether the stored evidence proves no request ever began. + * + *

Reads the persisted certainty and infers nothing from it. A fact that is merely {@code + * INFERRED} or {@code UNKNOWN} is not proof, and the difference between "we know it did not + * start" and "we do not know whether it started" is the difference between a safe requeue and a + * duplicate notification. + * + * @param attempt the abandoned attempt + * @return true only when the adapter proved the request never started + */ + private static boolean provablyNeverStarted( + dev.caskeleton.application.notification.platform.dispatch.DeliveryAttemptRecord attempt) { + var started = attempt.executionEvidence().requestStarted(); + return started.certainty() + == dev.caskeleton.application.notification.platform.provider.EvidenceCertainty.PROVEN + && !started.value(); + } + /** Recover one batch of abandoned deliveries; returns how many were handled. */ public int recoverOnce() { + // Retiring expired deliveries rides along with recovery because both answer the same operator + // question — "why is this row still here?" — and neither needs a scheduler of its own. + int handled = leases.expireOverdue(batchSize); List abandoned = leases.expiredDispatching(batchSize, staleAfter); - int handled = 0; for (var recipientDeliveryId : abandoned) { - for (var attempt : attempts.attemptsOf(recipientDeliveryId)) { + var attemptsOfDelivery = attempts.attemptsOf(recipientDeliveryId); + if (attemptsOfDelivery.isEmpty()) { + // Crash before the attempt row: no provider call can have happened, so this is the one + // safe requeue. Leaving it DISPATCHING stranded the delivery permanently. + deliveries.transition( + recipientDeliveryId, + dev.caskeleton.application.notification.platform.api.delivery.RecipientDeliveryState + .READY_TO_DISPATCH, + java.util.Optional.of(clock.instant())); + handled++; + continue; + } + for (var attempt : attemptsOfDelivery) { if (attempt.completedAt().isEmpty()) { - DeliveryAttemptId attemptId = attempt.id(); - reconciliation.reconcile(attemptId); + if (provablyNeverStarted(attempt)) { + // The row exists and the adapter recorded, with certainty, that no request began. There + // is nothing for a provider to tell us, so asking costs a round trip to learn what is + // already written down. This branch only became possible once the certainty survived + // persistence: before, "proven not started" and "unknown whether started" read back + // identically, so every abandoned attempt had to be reconciled. + deliveries.transition( + recipientDeliveryId, + dev.caskeleton.application.notification.platform.api.delivery.RecipientDeliveryState + .READY_TO_DISPATCH, + java.util.Optional.of(clock.instant())); + } else { + reconciliation.reconcile(attempt.id()); + } handled++; } } diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/NotificationBackgroundWorkers.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/NotificationBackgroundWorkers.java new file mode 100644 index 00000000..3187117e --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/NotificationBackgroundWorkers.java @@ -0,0 +1,141 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Runs the recovery and replay passes, and stops them on shutdown. + * + *

Both existed as classes with no caller. {@code LeaseRecoveryService} was written to recover + * deliveries a dead worker left in flight and was never scheduled; the ledger's replay queries were + * implemented in persistence and never read. The dispatch scheduler's own error path assumed + * recovery existed — it leaves a lease to expire rather than releasing it optimistically, on the + * grounds that recovery will decide — so the absence turned a deliberate design into a leak. + * + *

One executor for both, because they are cheap, periodic, and must stop together. A failure in + * one pass is logged and the schedule continues: an exception escaping a scheduled task cancels it + * silently, which is how a background worker stops running without anybody being told. + */ +public final class NotificationBackgroundWorkers implements AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(NotificationBackgroundWorkers.class); + + private final LeaseRecoveryService recovery; + private final ProviderEventReplayWorker replay; + private final ReconciliationJobWorker reconciliation; + private final Duration interval; + private final Duration shutdownGrace; + private final ScheduledExecutorService scheduler; + private final AtomicBoolean started = new AtomicBoolean(); + + private final java.util.List> passes = + new java.util.concurrent.CopyOnWriteArrayList<>(); + + /** + * Creates the workers. + * + * @param recovery recovers abandoned deliveries + * @param replay projects stored provider events that were never applied + * @param reconciliation asks providers about attempts whose outcome is unknown + * @param interval how often each pass runs + * @param shutdownGrace how long shutdown waits for a pass in flight + */ + public NotificationBackgroundWorkers( + LeaseRecoveryService recovery, + ProviderEventReplayWorker replay, + ReconciliationJobWorker reconciliation, + Duration interval, + Duration shutdownGrace) { + this.recovery = Objects.requireNonNull(recovery, "recovery"); + this.replay = Objects.requireNonNull(replay, "replay"); + this.reconciliation = Objects.requireNonNull(reconciliation, "reconciliation"); + this.interval = requirePositive(interval, "interval"); + this.shutdownGrace = requirePositive(shutdownGrace, "shutdownGrace"); + this.scheduler = + Executors.newScheduledThreadPool( + 1, + runnable -> { + Thread thread = new Thread(runnable, "notification-background"); + // A daemon thread: this executor must never be the reason a JVM refuses to exit. + thread.setDaemon(true); + return thread; + }); + } + + /** Starts both passes. Idempotent. */ + public void start() { + if (!started.compareAndSet(false, true)) { + return; + } + schedule("recovery", recovery::recoverOnce); + schedule("provider-event-replay", replay::replayOnce); + schedule("reconciliation", reconciliation::reconcileOnce); + } + + private void schedule(String name, java.util.function.IntSupplier pass) { + // The handle is kept so cancellation is possible and so the ignored-future check has an + // answer: the task swallows its own exceptions, so the future never completes exceptionally + // and there is nothing for a caller to observe on it. + java.util.concurrent.ScheduledFuture scheduled = + scheduler.scheduleWithFixedDelay( + () -> { + try { + int handled = pass.getAsInt(); + if (handled > 0) { + log.debug("notification {} pass handled {}", name, handled); + } + } catch (RuntimeException failure) { + // Swallowed on purpose: an exception that escapes here cancels the schedule for the + // lifetime of the process, and a recovery worker that stopped silently is worse + // than + // one that fails a pass. + log.warn( + "notification {} pass failed reason={}", + name, + failure.getClass().getSimpleName()); + } + }, + interval.toMillis(), + interval.toMillis(), + TimeUnit.MILLISECONDS); + passes.add(scheduled); + } + + @Override + public void close() { + passes.forEach(pass -> pass.cancel(false)); + passes.clear(); + scheduler.shutdown(); + try { + if (!scheduler.awaitTermination(shutdownGrace.toMillis(), TimeUnit.MILLISECONDS)) { + scheduler.shutdownNow(); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + scheduler.shutdownNow(); + } + } + + /** + * Whether the passes are scheduled. + * + * @return true after start + */ + public boolean started() { + return started.get(); + } + + private static Duration requirePositive(Duration value, String name) { + Objects.requireNonNull(value, name); + if (value.isNegative() || value.isZero()) { + throw new IllegalArgumentException(name + " must be positive and finite"); + } + return value; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/NotificationSchedulerWorker.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/NotificationSchedulerWorker.java index 6212a8cb..3f6b1dc6 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/NotificationSchedulerWorker.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/NotificationSchedulerWorker.java @@ -5,6 +5,7 @@ import dev.caskeleton.application.notification.platform.dispatch.RecipientLease; import dev.caskeleton.application.notification.platform.dispatch.RecipientLeaseStorePort; import dev.caskeleton.application.notification.platform.observation.NotificationMetricName; import dev.caskeleton.application.notification.platform.observation.NotificationMetricsPort; +import dev.caskeleton.application.notification.platform.observation.NotificationServingStatePort; import java.util.List; import java.util.Map; import java.util.Objects; @@ -35,22 +36,28 @@ public final class NotificationSchedulerWorker implements AutoCloseable { private final NotificationDispatchService dispatcher; private final NotificationMetricsPort metrics; private final NotificationDispatchProperties properties; + private final NotificationServingStatePort servingState; private final String workerId; private final ExecutorService dispatchExecutor; private final Semaphore globalConcurrency; private final AtomicBoolean running = new AtomicBoolean(); private final AtomicBoolean shuttingDown = new AtomicBoolean(); + /** The polling thread, kept so shutdown can actually stop it. */ + private volatile Thread pollingThread; + public NotificationSchedulerWorker( RecipientLeaseStorePort leases, NotificationDispatchService dispatcher, NotificationMetricsPort metrics, NotificationDispatchProperties properties, + NotificationServingStatePort servingState, String workerId) { this.leases = Objects.requireNonNull(leases, "leases"); this.dispatcher = Objects.requireNonNull(dispatcher, "dispatcher"); this.metrics = Objects.requireNonNull(metrics, "metrics"); this.properties = Objects.requireNonNull(properties, "properties"); + this.servingState = Objects.requireNonNull(servingState, "servingState"); this.workerId = Objects.requireNonNull(workerId, "workerId"); this.dispatchExecutor = Executors.newVirtualThreadPerTaskExecutor(); this.globalConcurrency = new Semaphore(properties.maxGlobalConcurrency()); @@ -61,9 +68,23 @@ public final class NotificationSchedulerWorker implements AutoCloseable { if (shuttingDown.get()) { return 0; } - List claimed = - leases.claim(workerId, properties.claimBatchSize(), properties.leaseDuration()); - metrics.gauge(NotificationMetricName.QUEUE_DEPTH, Map.of(), claimed.size()); + // Only as many as can start now. The batch used to be claimed in full and then queued behind + // the semaphore, so a batch larger than the concurrency limit held leases on deliveries nobody + // was working on — and with a short lease those expired before their turn came, letting another + // worker claim a delivery this one still had queued. + int executable = Math.min(properties.claimBatchSize(), globalConcurrency.availablePermits()); + if (executable < 1) { + return 0; + } + List claimed = leases.claim(workerId, executable, properties.leaseDuration()); + // The backlog, not the batch. This gauge used to report claimed.size(), which is bounded above + // by the claim batch size — so a queue of ten and a queue of ten million published the same + // number, and the one metric named "queue depth" was the one that could not show a queue + // growing. + metrics.gauge( + NotificationMetricName.QUEUE_DEPTH, + Map.of(), + (double) servingState.currentState().backlogDepth()); for (RecipientLease lease : claimed) { globalConcurrency.acquireUninterruptibly(); @@ -92,32 +113,48 @@ public final class NotificationSchedulerWorker implements AutoCloseable { if (!running.compareAndSet(false, true)) { return; } - Thread.ofVirtual() - .name("notification-scheduler-" + workerId) - .start( - () -> { - while (running.get() && !shuttingDown.get()) { - try { - if (runOnce() == 0) { - Thread.sleep(properties.pollInterval().toMillis()); + pollingThread = + Thread.ofVirtual() + .name("notification-scheduler-" + workerId) + .unstarted( + () -> { + while (running.get() && !shuttingDown.get()) { + try { + if (runOnce() == 0) { + Thread.sleep(properties.pollInterval().toMillis()); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } catch (RuntimeException failure) { + log.warn( + "notification scheduler tick failed worker={} reason={}", + workerId, + failure.getClass().getSimpleName()); + } } - } catch (InterruptedException interrupted) { - Thread.currentThread().interrupt(); - return; - } catch (RuntimeException failure) { - log.warn( - "notification scheduler tick failed worker={} reason={}", - workerId, - failure.getClass().getSimpleName()); - } - } - }); + }); + pollingThread.start(); } @Override public void close() { shuttingDown.set(true); running.set(false); + // The polling thread was started and forgotten: close() shut the dispatch executor down and + // returned while the loop was still free to claim another batch, so shutdown could leave leases + // held by a process that was already gone. Interrupting it breaks the poll-interval sleep, and + // joining it means "closed" is a fact rather than a request. + Thread poller = pollingThread; + if (poller != null) { + poller.interrupt(); + try { + poller.join(properties.shutdownGrace().toMillis()); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + pollingThread = null; + } dispatchExecutor.shutdown(); try { if (!dispatchExecutor.awaitTermination( diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderAttemptLimiter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderAttemptLimiter.java index 8ab2e030..2d3e2066 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderAttemptLimiter.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderAttemptLimiter.java @@ -7,7 +7,7 @@ import dev.caskeleton.application.notification.platform.api.error.ProviderUnavai import java.time.Clock; import java.util.Objects; import java.util.concurrent.Semaphore; -import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; /** * Per-provider rate and concurrency guard. @@ -22,8 +22,25 @@ public final class ProviderAttemptLimiter { private final int maxConcurrency; private final int ratePerSecond; private final Clock clock; - private final AtomicLong windowStartSecond = new AtomicLong(); - private final AtomicLong issuedInWindow = new AtomicLong(); + + /** + * The rate window and its count, as one value. + * + *

They were two atomics. A thread crossing a second boundary would CAS the window start and + * then reset the count in a separate operation, so every increment another thread made between + * those two steps was discarded — the limiter let more through than configured at exactly the + * moment traffic rolls over. One reference makes "which window, and how many so far" a single + * observable fact. + */ + private final AtomicReference window = new AtomicReference<>(); + + /** + * One rate window. + * + * @param epochSecond the second this window covers + * @param used how many attempts it has admitted + */ + private record RateWindow(long epochSecond, int used) {} public ProviderAttemptLimiter(int maxConcurrency, int ratePerSecond, Clock clock) { if (maxConcurrency < 1) { @@ -36,21 +53,33 @@ public final class ProviderAttemptLimiter { this.maxConcurrency = maxConcurrency; this.ratePerSecond = ratePerSecond; this.clock = Objects.requireNonNull(clock, "clock"); - this.windowStartSecond.set(clock.instant().getEpochSecond()); + this.window.set(new RateWindow(clock.instant().getEpochSecond(), 0)); } /** Acquire one attempt slot, or fail fast when the provider budget is spent. */ public void acquire() { long second = clock.instant().getEpochSecond(); - long windowStart = windowStartSecond.get(); - if (second != windowStart && windowStartSecond.compareAndSet(windowStart, second)) { - issuedInWindow.set(0L); - } - if (issuedInWindow.incrementAndGet() > ratePerSecond) { + // One CAS decides both the window and the count. accumulateAndGet retries until it wins, so a + // rollover cannot lose an increment another thread made. + RateWindow admitted = + window.accumulateAndGet( + new RateWindow(second, 1), + (current, attempt) -> + current.epochSecond() == attempt.epochSecond() + ? new RateWindow(current.epochSecond(), current.used() + 1) + : new RateWindow(attempt.epochSecond(), 1)); + if (admitted.used() > ratePerSecond) { throw unavailable(); } if (!concurrency.tryAcquire()) { - issuedInWindow.decrementAndGet(); + // Give the rate slot back, but only within the window that granted it: decrementing a window + // that has since rolled over would credit the new one. + window.accumulateAndGet( + new RateWindow(second, 0), + (current, refund) -> + current.epochSecond() == refund.epochSecond() + ? new RateWindow(current.epochSecond(), Math.max(0, current.used() - 1)) + : current); throw unavailable(); } } diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderEventReplayWorker.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderEventReplayWorker.java new file mode 100644 index 00000000..050f8ac3 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderEventReplayWorker.java @@ -0,0 +1,77 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.callback.ProviderEventLedger; +import dev.caskeleton.application.notification.platform.callback.ProviderEventProjectionService; +import dev.caskeleton.application.notification.platform.callback.ProviderEventRecord; +import java.util.List; +import java.util.Objects; + +/** + * Replays provider events that were stored but never projected. + * + *

{@code ProviderEventLedger} has always been able to list them — {@code pendingProjection} for + * events whose attempt was not resolvable yet, {@code unmatched} for events that arrived before the + * submitting process wrote the provider request id. Both were implemented in persistence and + * neither had a caller. + * + *

That is not a small omission, because the callback path depends on the retry. A + * provider that delivers its callback before the submitting transaction commits is normal, and the + * ingestion path deliberately stores such an event as {@code PENDING} rather than dropping it. With + * nothing replaying it, "we will match it later" was true only in the comment: the delivery stayed + * unconfirmed forever and the callback that would have confirmed it sat in a table. + */ +public final class ProviderEventReplayWorker { + + private final ProviderEventLedger ledger; + private final ProviderEventProjectionService projection; + private final int batchSize; + + /** + * Creates the replay worker. + * + * @param ledger the stored events + * @param projection the projection the events feed + * @param batchSize how many events one pass handles + */ + public ProviderEventReplayWorker( + ProviderEventLedger ledger, ProviderEventProjectionService projection, int batchSize) { + this.ledger = Objects.requireNonNull(ledger, "ledger"); + this.projection = Objects.requireNonNull(projection, "projection"); + if (batchSize < 1) { + throw new IllegalArgumentException("batchSize"); + } + this.batchSize = batchSize; + } + + /** + * Replays one batch. + * + *

Unmatched events are attempted first: they are the ones a late-arriving attempt row has most + * likely just made matchable, and projecting them promptly is what keeps a delivery's outcome + * honest rather than eventually correct. + * + * @return how many events projected successfully + */ + public int replayOnce() { + int projected = 0; + projected += replay(ledger.unmatched(batchSize)); + projected += replay(ledger.pendingProjection(batchSize)); + return projected; + } + + private int replay(List events) { + int projected = 0; + for (ProviderEventRecord event : events) { + // One event's failure is not the batch's. An event whose projector is missing is marked + // FAILED by the projection service and would otherwise stop every event behind it. + try { + if (projection.project(event).isPresent()) { + projected++; + } + } catch (RuntimeException failure) { + ledger.markFailed(event.id(), failure.getClass().getSimpleName()); + } + } + return projected; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntime.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntime.java index 4703ecff..61e84aa2 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntime.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntime.java @@ -27,8 +27,30 @@ public final class ProviderRuntime { private final ProviderProfileSnapshot profile; private final NotificationProviderAdapter adapter; private final ProviderAttemptLimiter limiter; - private final AtomicReference state; - private final AtomicReference unhealthyReason = new AtomicReference<>(); + + /** + * Health and its reason, as one value. + * + *

They were two references, so a reader could observe a state and a reason that never held + * together — {@code AUTHENTICATION_FAILED} with the previous failure's reason, or {@code HEALTHY} + * with a stale one. An operator reading that snapshot is being told something the runtime never + * believed. + */ + private final AtomicReference health; + + /** + * One consistent health observation. + * + * @param state the runtime state + * @param reason why it is unhealthy, when it is + */ + public record RuntimeHealth(ProviderRuntimeState state, Optional reason) { + + public RuntimeHealth { + Objects.requireNonNull(state, "state"); + Objects.requireNonNull(reason, "reason"); + } + } public ProviderRuntime( ProviderProfileSnapshot profile, @@ -37,7 +59,8 @@ public final class ProviderRuntime { this.profile = Objects.requireNonNull(profile, "profile"); this.adapter = Objects.requireNonNull(adapter, "adapter"); this.limiter = Objects.requireNonNull(limiter, "limiter"); - this.state = new AtomicReference<>(ProviderRuntimeState.HEALTHY); + this.health = + new AtomicReference<>(new RuntimeHealth(ProviderRuntimeState.HEALTHY, Optional.empty())); } /** Profile snapshot including the credential generation. */ @@ -55,14 +78,24 @@ public final class ProviderRuntime { return adapter; } + /** + * State and reason as one observation. + * + *

Prefer this to calling {@link #state()} and {@link #unhealthyReason()} in turn: two reads + * can straddle a transition and produce a pairing the runtime never held. + */ + public RuntimeHealth health() { + return health.get(); + } + /** Current health. */ public ProviderRuntimeState state() { - return state.get(); + return health.get().state(); } /** Why the runtime is unhealthy, if it is. */ public Optional unhealthyReason() { - return Optional.ofNullable(unhealthyReason.get()); + return health.get().reason(); } /** Attempts currently in flight on this generation. */ @@ -77,7 +110,7 @@ public final class ProviderRuntime { * a token it cannot use. */ public AttemptPermit acquireAttempt() { - ProviderRuntimeState current = state.get(); + ProviderRuntimeState current = health.get().state(); if (!current.admitsNewAttempts()) { throw new ProviderUnavailableException( NotificationFailureDescriptor.preDispatch( @@ -92,45 +125,152 @@ public final class ProviderRuntime { /** Mark the credential as rejected by the provider. */ public void markAuthenticationFailed(String reasonCode) { - unhealthyReason.set(Objects.requireNonNull(reasonCode, "reasonCode")); - state.set(ProviderRuntimeState.AUTHENTICATION_FAILED); + Objects.requireNonNull(reasonCode, "reasonCode"); + // One write, so the state and the reason it carries are never observed apart. + health.set( + new RuntimeHealth(ProviderRuntimeState.AUTHENTICATION_FAILED, Optional.of(reasonCode))); } - /** Mark the provider as rate limited. */ - public void markThrottled() { - state.compareAndSet(ProviderRuntimeState.HEALTHY, ProviderRuntimeState.THROTTLED); + /** + * Mark the provider as rate limited. + * + * @return whether it is now throttled + */ + public boolean markThrottled() { + return health + .updateAndGet( + current -> + current.state() == ProviderRuntimeState.HEALTHY + ? new RuntimeHealth( + ProviderRuntimeState.THROTTLED, Optional.of("THROTTLED")) + : current) + .state() + == ProviderRuntimeState.THROTTLED; } - /** Mark the provider as degraded but still usable. */ - public void markDegraded(String reasonCode) { - unhealthyReason.set(reasonCode); - state.compareAndSet(ProviderRuntimeState.HEALTHY, ProviderRuntimeState.DEGRADED); + /** + * Mark the provider as degraded but still usable. + * + * @return whether it is now degraded + */ + public boolean markDegraded(String reasonCode) { + Objects.requireNonNull(reasonCode, "reasonCode"); + return health + .updateAndGet( + current -> + current.state() == ProviderRuntimeState.HEALTHY + ? new RuntimeHealth(ProviderRuntimeState.DEGRADED, Optional.of(reasonCode)) + : current) + .state() + == ProviderRuntimeState.DEGRADED; } - /** Return to healthy after a successful attempt. */ - public void markHealthy() { - unhealthyReason.set(null); - state.compareAndSet(ProviderRuntimeState.THROTTLED, ProviderRuntimeState.HEALTHY); - state.compareAndSet(ProviderRuntimeState.DEGRADED, ProviderRuntimeState.HEALTHY); + /** + * Return to healthy after a successful attempt. + * + *

A success clears throttling and degradation and nothing else. It does not clear an + * authentication failure — the credential the provider rejected is still the credential in use, + * and only a rotation replaces it. It does not resume a draining or disabled runtime either; + * those states are decisions, not symptoms. + * + *

The reason is cleared only when the state actually changes. Clearing it unconditionally left + * {@code AUTHENTICATION_FAILED} with no reason attached, so an operator reading the runtime was + * shown a failure the platform could no longer explain. + * + * @return whether the runtime is now healthy + */ + public boolean markHealthy() { + return health + .updateAndGet( + current -> + switch (current.state()) { + case THROTTLED, DEGRADED -> + new RuntimeHealth(ProviderRuntimeState.HEALTHY, Optional.empty()); + default -> current; + }) + .state() + == ProviderRuntimeState.HEALTHY; } - /** Stop admitting new attempts; in-flight attempts finish. */ - public void markDraining() { - state.set(ProviderRuntimeState.DRAINING); + /** + * Clear an operator-imposed state. + * + *

This is the admin counterpart of {@link #markHealthy()}: it resumes a runtime that an + * operator drained or disabled. It still refuses {@code AUTHENTICATION_FAILED}, because declaring + * a provider healthy does not give it a credential the provider will accept — the caller is told + * so rather than being handed a runtime that will fail on its first attempt. + * + * @return whether the runtime is now healthy + */ + public boolean resumeHealthy() { + return health + .updateAndGet( + current -> + current.state() == ProviderRuntimeState.AUTHENTICATION_FAILED + ? current + : new RuntimeHealth(ProviderRuntimeState.HEALTHY, Optional.empty())) + .state() + == ProviderRuntimeState.HEALTHY; } - /** Operator disable. */ - public void markDisabled() { - state.set(ProviderRuntimeState.DISABLED); + /** + * Stop admitting new attempts; in-flight attempts finish. + * + * @return whether it is now draining + */ + public boolean markDraining() { + return health + .updateAndGet( + current -> + new RuntimeHealth(ProviderRuntimeState.DRAINING, Optional.of("DRAINING"))) + .state() + == ProviderRuntimeState.DRAINING; } - /** A permit that releases exactly one limiter slot. */ - private record LimiterPermit(long generation, ProviderAttemptLimiter limiter) - implements AttemptPermit { + /** + * Operator disable. + * + * @return whether it is now disabled + */ + public boolean markDisabled() { + return health + .updateAndGet( + current -> + new RuntimeHealth(ProviderRuntimeState.DISABLED, Optional.of("DISABLED"))) + .state() + == ProviderRuntimeState.DISABLED; + } + + /** + * A permit that releases exactly one limiter slot, however many times it is closed. + * + *

{@code close} released unconditionally. A permit closed twice — a {@code finally} plus an + * explicit close, or a retry wrapper — released two slots for one acquisition, and a {@code + * Semaphore} grows when you release more than you took. The concurrency ceiling would then be + * permanently higher than configured, silently, in the direction of overloading the provider. + */ + private static final class LimiterPermit implements AttemptPermit { + + private final long generation; + private final ProviderAttemptLimiter limiter; + private final java.util.concurrent.atomic.AtomicBoolean released = + new java.util.concurrent.atomic.AtomicBoolean(); + + private LimiterPermit(long generation, ProviderAttemptLimiter limiter) { + this.generation = generation; + this.limiter = limiter; + } + + @Override + public long generation() { + return generation; + } @Override public void close() { - limiter.release(); + if (released.compareAndSet(false, true)) { + limiter.release(); + } } } } diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRegistry.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRegistry.java index a5d0145e..93b652bc 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRegistry.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRegistry.java @@ -2,24 +2,45 @@ package dev.caskeleton.adapter.outbound.notification.platform.dispatch; import dev.caskeleton.application.notification.platform.api.ProviderProfileId; import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicReference; /** Holds the current generation of every provider profile plus the generations still draining. */ public final class ProviderRuntimeRegistry { private final Map current = new ConcurrentHashMap<>(); - private final Map> draining = - new ConcurrentHashMap<>(); - /** Register the first generation of a profile. */ + /** + * Draining generations, held as immutable lists. + * + *

The value was a {@code CopyOnWriteArrayList} mutated outside any lock, so adding a + * generation and sweeping drained ones were separate operations on the same list. Replacing the + * whole list inside {@code compute} makes "add this generation" and "forget the drained ones" + * mutually exclusive, and a reader always sees a list that some single writer actually produced. + */ + private final Map> draining = new ConcurrentHashMap<>(); + + /** + * Register the first generation of a profile. + * + *

Registering twice was a silent overwrite: the displaced runtime kept whatever attempts it + * had in flight, but nothing was draining it and nothing could reach it to observe them. Two + * configuration sources claiming one profile is a wiring bug, so it is reported as one. + * + * @throws IllegalStateException if the profile already has a current generation + */ public void register(ProviderRuntime runtime) { Objects.requireNonNull(runtime, "runtime"); - current.put(runtime.profile().profileId(), runtime); + ProviderRuntime existing = current.putIfAbsent(runtime.profile().profileId(), runtime); + if (existing != null) { + throw new IllegalStateException( + "provider runtime already registered for the profile; use replace to rotate"); + } } /** Current generation, or a configuration failure when the profile is unknown. */ @@ -41,23 +62,58 @@ public final class ProviderRuntimeRegistry { * *

New dispatches immediately use the new generation while the previous one finishes what it * already started, which is what makes a credential rotation invisible to callers. + * + *

The swap, the drain and the enrolment happen as one operation, and a generation that does + * not supersede the current one is refused. Nothing serialises two rotations of the same profile, + * so as three separate steps they could interleave into an older generation ending up current — + * the registry would then be serving credentials a later rotation had already retired. + * + * @throws IllegalArgumentException if the replacement does not supersede the current generation */ public Optional replace(ProviderRuntime replacement) { Objects.requireNonNull(replacement, "replacement"); ProviderProfileId profileId = replacement.profile().profileId(); - ProviderRuntime previous = current.put(profileId, replacement); - if (previous != null) { - previous.markDraining(); - draining.computeIfAbsent(profileId, key -> new CopyOnWriteArrayList<>()).add(previous); - forgetIfDrained(profileId); - } - return Optional.ofNullable(previous); + AtomicReference displaced = new AtomicReference<>(); + current.compute( + profileId, + (key, existing) -> { + if (existing == null) { + return replacement; + } + if (replacement.generation() <= existing.generation()) { + throw new IllegalArgumentException( + "replacement generation does not supersede the current one"); + } + // Drain before enrolling: a runtime added to the draining list while it still admits + // attempts can pass the "nothing in flight" sweep in the instant before the next one + // starts. + existing.markDraining(); + draining.compute(profileId, (id, generations) -> enrol(generations, existing)); + displaced.set(existing); + return replacement; + }); + return Optional.ofNullable(displaced.get()); + } + + /** + * Add a generation to the draining set and sweep the ones that have finished. + * + * @param generations the current draining set, possibly null + * @param enrolling the generation being retired + * @return the new draining set, or null when nothing is left draining + */ + private static List enrol( + List generations, ProviderRuntime enrolling) { + List next = + generations == null ? new ArrayList<>() : new ArrayList<>(generations); + next.add(enrolling); + return sweep(next); } /** Generations that are draining and still have work in flight. */ public List drainingGenerations(ProviderProfileId profileId) { forgetIfDrained(profileId); - return List.copyOf(draining.getOrDefault(profileId, new CopyOnWriteArrayList<>())); + return draining.getOrDefault(profileId, List.of()); } /** Health of the current generation. */ @@ -66,13 +122,19 @@ public final class ProviderRuntimeRegistry { } private void forgetIfDrained(ProviderProfileId profileId) { - CopyOnWriteArrayList generations = draining.get(profileId); - if (generations == null) { - return; - } + // compute, not removeIf: sweeping under the same lock as replace is what stops a generation + // enrolled mid-sweep from being dropped along with the ones that had genuinely finished. + draining.computeIfPresent(profileId, (key, generations) -> sweep(new ArrayList<>(generations))); + } + + /** + * Drop the generations that have finished, keeping the list immutable. + * + * @param generations a private copy the caller owns + * @return the surviving generations, or null to remove the entry entirely + */ + private static List sweep(List generations) { generations.removeIf(runtime -> runtime.activeAttempts() == 0); - if (generations.isEmpty()) { - draining.remove(profileId); - } + return generations.isEmpty() ? null : List.copyOf(generations); } } diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ReconciliationJobWorker.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ReconciliationJobWorker.java new file mode 100644 index 00000000..807b6f1d --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ReconciliationJobWorker.java @@ -0,0 +1,132 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.dispatch.ReconciliationJob; +import dev.caskeleton.application.notification.platform.dispatch.ReconciliationJobStorePort; +import dev.caskeleton.application.notification.platform.provider.ReconciliationResult; +import java.time.Clock; +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + +/** + * Asks the provider what happened to attempts whose outcome is unknown. + * + *

Reconciliation only ever ran when a lease expired and recovery walked past an incomplete + * attempt. An ambiguous submission whose worker exited cleanly — the common case, because a worker + * that records AMBIGUOUS and then finishes its shift has not crashed — was never asked about again. + * The delivery sat {@code RECONCILIATION_REQUIRED} indefinitely, which reads as a queue that + * stopped rather than as an outcome nobody knows. + * + *

An unsupported provider is not retried in a loop. Without a status-query capability the answer + * will not change, so the job is completed and the attempt stays visibly ambiguous for an operator + * — a busy loop against a capability that does not exist is how a background worker burns a + * connection pool while achieving nothing. + */ +public final class ReconciliationJobWorker { + + private final ReconciliationJobStorePort jobs; + private final Function< + dev.caskeleton.application.notification.platform.api.DeliveryAttemptId, + ReconciliationResult> + reconciler; + private final Clock clock; + private final Duration retryBackoff; + private final int batchSize; + private final int maxAttempts; + + /** + * Creates the worker. + * + * @param jobs the outstanding questions + * @param reconciler asks one attempt's provider + * @param clock the clock + * @param retryBackoff how long to wait before asking again + * @param batchSize how many jobs one pass handles + * @param maxAttempts how many times one job may ask before it is left to an operator + */ + public ReconciliationJobWorker( + ReconciliationJobStorePort jobs, + Function< + dev.caskeleton.application.notification.platform.api.DeliveryAttemptId, + ReconciliationResult> + reconciler, + Clock clock, + Duration retryBackoff, + int batchSize, + int maxAttempts) { + this.jobs = Objects.requireNonNull(jobs, "jobs"); + this.reconciler = Objects.requireNonNull(reconciler, "reconciler"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.retryBackoff = Objects.requireNonNull(retryBackoff, "retryBackoff"); + if (retryBackoff.isNegative() || retryBackoff.isZero()) { + throw new IllegalArgumentException("retryBackoff must be positive and finite"); + } + if (batchSize < 1) { + throw new IllegalArgumentException("batchSize"); + } + if (maxAttempts < 1) { + throw new IllegalArgumentException("maxAttempts"); + } + this.batchSize = batchSize; + this.maxAttempts = maxAttempts; + } + + /** + * Runs one pass. + * + * @return how many jobs reached a terminal decision + */ + public int reconcileOnce() { + List due = jobs.claimDue(batchSize, clock.instant()); + int settled = 0; + for (ReconciliationJob job : due) { + // One job's failure is not the pass's: a provider that is refusing connections would + // otherwise stop every other provider's jobs behind it. + try { + if (handle(job)) { + settled++; + } + } catch (RuntimeException failure) { + jobs.reschedule( + job, failure.getClass().getSimpleName(), clock.instant().plus(retryBackoff)); + } + } + return settled; + } + + private boolean handle(ReconciliationJob job) { + if (job.attempts() >= maxAttempts) { + // Asked enough times. Completing the job leaves the attempt ambiguous and visible rather + // than asking forever; an outcome that has not arrived after this many tries is an + // operator's decision, not a scheduler's. + jobs.reschedule(job, "MAX_ATTEMPTS", clock.instant().plus(retryBackoff)); + jobs.complete(job); + return true; + } + ReconciliationResult result = reconciler.apply(job.attemptId()); + return switch (result) { + case ReconciliationResult.Confirmed ignored -> { + jobs.complete(job); + yield true; + } + case ReconciliationResult.Unsupported ignored -> { + // The capability does not exist; asking again cannot change that. + jobs.complete(job); + yield true; + } + case ReconciliationResult.StillUnknown stillUnknown -> { + jobs.reschedule(job, "STILL_UNKNOWN", stillUnknown.nextCheckAt()); + yield false; + } + case ReconciliationResult.Failed failed -> { + if (failed.retryable()) { + jobs.reschedule(job, failed.code(), clock.instant().plus(retryBackoff)); + yield false; + } + jobs.complete(job); + yield true; + } + }; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/RegistryProviderRuntimeControl.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/RegistryProviderRuntimeControl.java new file mode 100644 index 00000000..b5ad665c --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/RegistryProviderRuntimeControl.java @@ -0,0 +1,50 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.admin.ProviderRuntimeControlPort; +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState; +import java.util.Objects; + +/** + * The registry, behind the two operations the admin plane actually needs. + * + *

What is left in the outbound adapter after NTF-020: a translation from an application request + * to a runtime transition. No actor, no authority, no tenant scope, no transaction — those are + * decisions about who may do what, and they now live where the rest of the platform's policy lives. + */ +public final class RegistryProviderRuntimeControl implements ProviderRuntimeControlPort { + + private final ProviderRuntimeRegistry runtimes; + + public RegistryProviderRuntimeControl(ProviderRuntimeRegistry runtimes) { + this.runtimes = Objects.requireNonNull(runtimes, "runtimes"); + } + + @Override + public boolean setState( + ProviderProfileId profileId, ProviderRuntimeState desiredState, String reason) { + Objects.requireNonNull(profileId, "profileId"); + Objects.requireNonNull(desiredState, "desiredState"); + Objects.requireNonNull(reason, "reason"); + ProviderRuntime runtime = runtimes.current(profileId); + return switch (desiredState) { + case DISABLED -> runtime.markDisabled(); + case DRAINING -> runtime.markDraining(); + case HEALTHY -> runtime.resumeHealthy(); + case DEGRADED -> runtime.markDegraded(reason); + case THROTTLED -> runtime.markThrottled(); + case AUTHENTICATION_FAILED -> { + runtime.markAuthenticationFailed(reason); + yield true; + } + // Unreachable while the enum is exhaustive; present so a state added later fails loudly here + // rather than silently leaving the runtime in whatever state it was already in. + default -> throw new IllegalStateException("unhandled provider runtime state"); + }; + } + + @Override + public ProviderRuntimeState state(ProviderProfileId profileId) { + return runtimes.state(Objects.requireNonNull(profileId, "profileId")); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/LoggingNotificationMetrics.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/LoggingNotificationMetrics.java index 2398c7f8..6760f65f 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/LoggingNotificationMetrics.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/LoggingNotificationMetrics.java @@ -30,20 +30,27 @@ public final class LoggingNotificationMetrics implements NotificationMetricsPort @Override public void increment(String metricName, Map tags) { - guard.validate(tags); - log.info("metric={} kind=counter tags={}", metricName, ordered(tags)); + // bound, not validate: the keys were checked and the values never were, so one metric could + // become one series per caller-supplied category. + Map bounded = guard.bound(tags); + log.info("metric={} kind=counter tags={}", metricName, ordered(bounded)); } @Override public void record(String metricName, Map tags, Duration value) { - guard.validate(tags); - log.info("metric={} kind=timer millis={} tags={}", metricName, value.toMillis(), ordered(tags)); + // bound, not validate: the keys were checked and the values never were, so one metric could + // become one series per caller-supplied category. + Map bounded = guard.bound(tags); + log.info( + "metric={} kind=timer millis={} tags={}", metricName, value.toMillis(), ordered(bounded)); } @Override public void gauge(String metricName, Map tags, double value) { - guard.validate(tags); - log.info("metric={} kind=gauge value={} tags={}", metricName, value, ordered(tags)); + // bound, not validate: the keys were checked and the values never were, so one metric could + // become one series per caller-supplied category. + Map bounded = guard.bound(tags); + log.info("metric={} kind=gauge value={} tags={}", metricName, value, ordered(bounded)); } private static Map ordered(Map tags) { diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/NotificationHealthReporter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/NotificationHealthReporter.java index 26d9777f..c710af99 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/NotificationHealthReporter.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/NotificationHealthReporter.java @@ -2,11 +2,16 @@ package dev.caskeleton.adapter.outbound.notification.platform.observation; import dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderRuntimeRegistry; import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.observation.NotificationServingState; +import dev.caskeleton.application.notification.platform.observation.NotificationServingStatePort; import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; /** * Builds the operational snapshot. @@ -14,16 +19,32 @@ import java.util.Objects; *

A provider whose credentials were rejected reports unhealthy even though the process is fine: * that is exactly the condition an operator needs paged on, and it is invisible from process-level * health. + * + *

It used to report provider states and an empty queue map, so the platform was "healthy" + * whenever the runtimes were — with a backlog of any size, leases stuck on a dead worker, and + * provider events piling up unapplied. Readiness now includes the numbers that describe whether + * anything is actually being delivered, each measured against a declared threshold rather than + * eyeballed by whoever is reading the endpoint. */ public final class NotificationHealthReporter { private final ProviderRuntimeRegistry runtimes; private final List monitoredProfiles; + private final NotificationServingStatePort servingState; + private final Set routedChannels; + private final NotificationServingThresholds thresholds; public NotificationHealthReporter( - ProviderRuntimeRegistry runtimes, List monitoredProfiles) { + ProviderRuntimeRegistry runtimes, + List monitoredProfiles, + NotificationServingStatePort servingState, + Set routedChannels, + NotificationServingThresholds thresholds) { this.runtimes = Objects.requireNonNull(runtimes, "runtimes"); this.monitoredProfiles = List.copyOf(Objects.requireNonNull(monitoredProfiles, "profiles")); + this.servingState = Objects.requireNonNull(servingState, "servingState"); + this.routedChannels = Set.copyOf(Objects.requireNonNull(routedChannels, "routedChannels")); + this.thresholds = Objects.requireNonNull(thresholds, "thresholds"); } /** Current snapshot. */ @@ -52,6 +73,37 @@ public final class NotificationHealthReporter { runtime.get().activeAttempts())); } - return new NotificationHealthSnapshot(healthy, providers, Map.of()); + // A platform with providers but no route accepts every request and delivers none. It was + // reported healthy because every runtime was healthy — which was true and beside the point. + if (!monitoredProfiles.isEmpty() && routedChannels.isEmpty()) { + healthy = false; + } + + NotificationServingState serving = servingState.currentState(); + if (thresholds.exceededBy(serving)) { + healthy = false; + } + return new NotificationHealthSnapshot(healthy, providers, queue(serving)); + } + + /** + * The serving state as the endpoint's queue map. + * + * @param serving the measured state + * @return counts and ages, all of them numbers with no identifiers in them + */ + private static Map queue(NotificationServingState serving) { + Map values = new LinkedHashMap<>(); + values.put("backlogDepth", serving.backlogDepth()); + values.put("oldestDueAgeSeconds", serving.oldestDueAge().toSeconds()); + values.put("stuckLeases", serving.stuckLeases()); + values.put("pendingProjections", serving.pendingProjections()); + values.put("failedProjections", serving.failedProjections()); + values.put("unmatchedProjections", serving.unmatchedProjections()); + values.put( + "oldestPendingProjectionAgeSeconds", serving.oldestPendingProjectionAge().toSeconds()); + values.put("reconciliationDue", serving.reconciliationDue()); + values.put("oldestReconciliationAgeSeconds", serving.oldestReconciliationAge().toSeconds()); + return Map.copyOf(values); } } diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/NotificationServingThresholds.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/NotificationServingThresholds.java new file mode 100644 index 00000000..86114453 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/NotificationServingThresholds.java @@ -0,0 +1,77 @@ +package dev.caskeleton.adapter.outbound.notification.platform.observation; + +import dev.caskeleton.application.notification.platform.observation.NotificationServingState; +import java.time.Duration; +import java.util.Objects; + +/** + * The point at which a backlog stops being normal operation. + * + *

Written down rather than judged by whoever reads the endpoint. A number with no threshold + * beside it is a number nobody can act on at three in the morning: a backlog of 4,000 is either + * routine or an incident depending on a deployment's throughput, and only the deployment knows + * which. + * + * @param maxBacklogDepth deliveries due and unclaimed before readiness fails + * @param maxOldestDueAge how long the oldest due delivery may wait + * @param maxStuckLeases leases whose holder died, before readiness fails + * @param maxPendingProjections provider events stored and unapplied + * @param maxFailedProjections provider events whose projection failed + * @param maxOldestPendingProjectionAge how long an unapplied event may wait + * @param maxOldestReconciliationAge how long an unanswered provider question may wait + */ +public record NotificationServingThresholds( + long maxBacklogDepth, + Duration maxOldestDueAge, + long maxStuckLeases, + long maxPendingProjections, + long maxFailedProjections, + Duration maxOldestPendingProjectionAge, + Duration maxOldestReconciliationAge) { + + /** + * Defaults chosen so that a healthy deployment never trips them and a stopped one always does. + * + *

The ages are the operative checks. A depth threshold has to be guessed from throughput; an + * age does not — a delivery that has been due for ten minutes is behind whatever the throughput + * is. + */ + public static final NotificationServingThresholds DEFAULT = + new NotificationServingThresholds( + 100_000, + Duration.ofMinutes(10), + 50, + 50_000, + 1_000, + Duration.ofMinutes(15), + Duration.ofMinutes(30)); + + public NotificationServingThresholds { + Objects.requireNonNull(maxOldestDueAge, "maxOldestDueAge"); + Objects.requireNonNull(maxOldestPendingProjectionAge, "maxOldestPendingProjectionAge"); + Objects.requireNonNull(maxOldestReconciliationAge, "maxOldestReconciliationAge"); + if (maxBacklogDepth < 0 + || maxStuckLeases < 0 + || maxPendingProjections < 0 + || maxFailedProjections < 0) { + throw new IllegalArgumentException("thresholds must not be negative"); + } + } + + /** + * Whether the measured state has passed any threshold. + * + * @param state the measured serving state + * @return true when at least one threshold is exceeded + */ + public boolean exceededBy(NotificationServingState state) { + Objects.requireNonNull(state, "state"); + return state.backlogDepth() > maxBacklogDepth + || state.oldestDueAge().compareTo(maxOldestDueAge) > 0 + || state.stuckLeases() > maxStuckLeases + || state.pendingProjections() > maxPendingProjections + || state.failedProjections() > maxFailedProjections + || state.oldestPendingProjectionAge().compareTo(maxOldestPendingProjectionAge) > 0 + || state.oldestReconciliationAge().compareTo(maxOldestReconciliationAge) > 0; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsRequestMapper.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsRequestMapper.java index 4a87e0cc..55dfbbfe 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsRequestMapper.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsRequestMapper.java @@ -75,6 +75,11 @@ public final class ApnsRequestMapper { NotificationJsonMapper.mapper() .writeValueAsString(payload) .getBytes(StandardCharsets.UTF_8); + // Applied to the bytes that will actually be sent. The capability declared a 4096-byte ceiling + // and nothing compared anything to it, so an oversized payload reached APNs and came back as a + // rejection with a provider-specific reason — a round trip and a failed attempt to learn + // something the sender already knew. + requireWithinPayloadLimit(body.length); return new NotificationHttpRequest( "POST", URI.create(properties.endpoint() + "/3/device/" + token.value()), @@ -83,6 +88,27 @@ public final class ApnsRequestMapper { properties.timeout()); } + /** The APNs payload ceiling, in bytes of the serialized JSON. */ + public static final int MAX_PAYLOAD_BYTES = 4096; + + /** + * Refuses a payload the provider will refuse. + * + * @param size the serialized payload size + */ + public static void requireWithinPayloadLimit(int size) { + if (size > MAX_PAYLOAD_BYTES) { + throw new dev.caskeleton.application.notification.platform.provider + .ProviderCallNotStartedException( + dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode + .PROVIDER_PAYLOAD_LIMIT, + dev.caskeleton.application.notification.platform.api.error.FailureCategory + .INVALID_PAYLOAD, + false, + "the rendered payload is " + size + " bytes and the APNs limit is " + MAX_PAYLOAD_BYTES); + } + } + /** Current time, exposed so expiry mapping stays testable. */ public java.time.Instant now() { return clock.instant(); diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmBatchCoordinator.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmBatchCoordinator.java index 2b270870..1752774c 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmBatchCoordinator.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmBatchCoordinator.java @@ -68,7 +68,16 @@ public final class FcmBatchCoordinator { protector.reveal( submission.contactPoint(), AccessContext.dispatch(submission.profile().profileId().value())); - messages.add(messageMapper.map(submission, targetMapper.map(value))); + Map message = messageMapper.map(submission, targetMapper.map(value)); + // The bytes that will be sent, measured before sending them. The 4096-byte ceiling was + // declared by the capability model and compared to nothing. + messageMapper.requireWithinPayloadLimit( + dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper + .mapper() + .writeValueAsString(message) + .getBytes(java.nio.charset.StandardCharsets.UTF_8) + .length); + messages.add(message); } FcmBatchResult batch = gateway.sendBatch(messages); diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmMessageMapper.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmMessageMapper.java index d65001b2..638b06e7 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmMessageMapper.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmMessageMapper.java @@ -55,18 +55,66 @@ public final class FcmMessageMapper { return Map.of("message", message); } - /** Effective TTL for a submission. */ + /** + * Effective TTL for a submission, in three distinct cases. + * + *

The previous version filtered out a negative remaining duration and then fell through to + * {@code orElse(maxTtl)} — so a notification that had already expired was sent with the + * provider's maximum lifetime. The one input that means "do not deliver this" produced + * the longest possible delivery window, and FCM would retry it for as long as the maximum + * allowed. + * + *

    + *
  • No expiry: the provider maximum, because the caller set no deadline. + *
  • Expiry already passed: refused here, before the call. There is no TTL that expresses "too + * late" to FCM, so the honest answer is not to send. + *
  • Expiry ahead: the smaller of what remains and the provider maximum. + *
+ */ public Duration ttl(ProviderSubmission submission) { Optional remaining = submission.expiresAt().map(expiry -> Duration.between(clock.instant(), expiry)); - return remaining - .filter(value -> value.compareTo(properties.maxTtl()) < 0) - .filter(value -> !value.isNegative()) - .orElse(properties.maxTtl()); + if (remaining.isEmpty()) { + return properties.maxTtl(); + } + Duration left = remaining.get(); + if (left.isNegative() || left.isZero()) { + throw new dev.caskeleton.application.notification.platform.provider + .ProviderCallNotStartedException( + dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode + .VALIDATION_FAILED, + dev.caskeleton.application.notification.platform.api.error.FailureCategory + .INVALID_PAYLOAD, + false, + "the notification expired before it reached the provider"); + } + return left.compareTo(properties.maxTtl()) < 0 ? left : properties.maxTtl(); } /** Payload ceiling enforced before the provider call. */ public int maxPayloadBytes() { return MAX_PAYLOAD_BYTES; } + + /** + * Refuses a payload FCM will refuse. + * + *

Applied to the serialized bytes rather than to a field count. The ceiling was declared by + * the capability and compared to nothing, so an oversized message reached FCM and returned a + * provider-specific rejection — a round trip to learn what the sender could have known. + * + * @param size the serialized payload size + */ + public void requireWithinPayloadLimit(int size) { + if (size > MAX_PAYLOAD_BYTES) { + throw new dev.caskeleton.application.notification.platform.provider + .ProviderCallNotStartedException( + dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode + .PROVIDER_PAYLOAD_LIMIT, + dev.caskeleton.application.notification.platform.api.error.FailureCategory + .INVALID_PAYLOAD, + false, + "the rendered payload is " + size + " bytes and the FCM limit is " + MAX_PAYLOAD_BYTES); + } + } } diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/JdkNotificationHttpGateway.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/JdkNotificationHttpGateway.java index e72be549..aa385f4d 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/JdkNotificationHttpGateway.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/JdkNotificationHttpGateway.java @@ -57,8 +57,10 @@ public final class JdkNotificationHttpGateway implements NotificationHttpGateway }); try { - HttpResponse response = - client.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray()); + // Bounded, not ofByteArray(). A provider response is diagnostic — a status, some headers, an + // error document — and reading it without a cap makes the sender's heap a function of what + // the far end chooses to send. A chunked response with no end is a single-request outage. + HttpResponse response = client.send(builder.build(), boundedBody(MAX_RESPONSE_BYTES)); return new NotificationHttpResponse( response.statusCode(), Map.copyOf(response.headers().map()), response.body()); } catch (HttpTimeoutException timeout) { @@ -74,23 +76,55 @@ public final class JdkNotificationHttpGateway implements NotificationHttpGateway } /** - * A connect failure happens before anything is written; anything else may have written the body. + * Whether the request body may have reached the provider. * - *

The default is deliberately the pessimistic one: guessing "not committed" would turn an - * unknown into an automatic resend. + *

Decided from the exception's type, not from its message. The previous version + * lower-cased {@code getMessage()} and looked for "connection refused", "unresolved", "no route + * to host" and "connect timed out" — none of which is a contract. Those strings come from the + * platform's C library and the JDK's own wording; they are localised on some platforms, they + * changed between JDK releases, and a proxy that reports a refused connection in its own words + * would be read as "the body was sent". + * + *

The JDK does expose the distinction as types. {@link ConnectException} and {@link + * UnknownHostException} are raised while establishing the connection, so no request byte can have + * been written. Everything else stays committed — the default is deliberately the pessimistic + * one, because guessing "not committed" turns an unknown into an automatic resend. */ private static boolean bodyWasLikelyCommitted(IOException failure) { - String message = failure.getMessage(); - if (message == null) { - return true; + // Depth-bounded rather than cycle-detecting: a cause chain can be circular (two exceptions + // each initCause'd to the other), and an unbounded walk over one hangs the dispatch thread. + // Ten is far deeper than any real transport wrapping. + Throwable cause = failure; + for (int depth = 0; cause != null && depth < 10; depth++, cause = cause.getCause()) { + if (cause instanceof java.net.ConnectException + || cause instanceof java.net.UnknownHostException + || cause instanceof java.net.NoRouteToHostException) { + return false; + } } - String normalized = message.toLowerCase(java.util.Locale.ROOT); - boolean beforeSend = - normalized.contains("connection refused") - || normalized.contains("unresolved") - || normalized.contains("no route to host") - || normalized.contains("connect timed out"); - return !beforeSend; + return true; + } + + /** + * The largest provider response body this gateway retains. + * + *

64 KiB: far more than any provider's acknowledgement or error document, and small enough + * that a hostile or broken endpoint cannot make it interesting. + */ + public static final int MAX_RESPONSE_BYTES = 65_536; + + /** + * A body handler that stops reading at the cap. + * + *

Truncating rather than failing: the status code is the part that decides the outcome, and a + * provider that accepted the message and then wrote a large body should not turn into an + * ambiguous submission. + */ + private static HttpResponse.BodyHandler boundedBody(int maxBytes) { + return responseInfo -> + HttpResponse.BodySubscribers.mapping( + HttpResponse.BodySubscribers.ofByteArray(), + body -> body.length <= maxBytes ? body : java.util.Arrays.copyOf(body, maxBytes)); } /** Header map helper for adapters. */ diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationEndpoints.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationEndpoints.java index 9bc735bc..c905e043 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationEndpoints.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationEndpoints.java @@ -40,4 +40,90 @@ public final class NotificationEndpoints { String host = endpoint.getHost() == null ? "" : endpoint.getHost().toLowerCase(Locale.ROOT); return LOOPBACK_HOSTS.contains(host); } + + /** + * Refuses an endpoint that resolves into the deployment's own network. + * + *

{@link #requireSecureOrLoopback} checks the scheme and nothing else, so any HTTPS URL was + * accepted — including {@code https://169.254.169.254/}, the cloud metadata service, and any RFC + * 1918 address. Web Push endpoints and webhook targets are supplied by clients, which makes this + * a server-side request forgery primitive: the platform will happily fetch an internal address + * and, for a webhook, deliver the message body there. + * + *

Resolution happens here rather than being left to the HTTP client because the check has to + * see the addresses. A name that resolves to a public address in one lookup and a private one in + * the next — DNS rebinding — is refused by checking every address the name currently returns; the + * client re-resolves independently, so this narrows the window rather than closing it, and that + * limitation is real rather than papered over. + * + * @param endpoint the endpoint to check + * @param name what to call it in the failure + * @param allowLoopback whether a loopback target is acceptable, for local and contract profiles + * @return the endpoint + * @throws IllegalArgumentException when the endpoint is not externally routable + */ + public static URI requireExternallyRoutable(URI endpoint, String name, boolean allowLoopback) { + Objects.requireNonNull(endpoint, name); + requireSecureOrLoopback(endpoint, name); + if (endpoint.getUserInfo() != null) { + // user:password@host is how a target is disguised: many parsers, and many humans reading a + // log line, take the text before the '@' for the host. + throw new IllegalArgumentException(name + " must not carry userinfo"); + } + String host = endpoint.getHost(); + if (host == null || host.isBlank()) { + throw new IllegalArgumentException(name + " has no host"); + } + if (allowLoopback && isLoopback(endpoint)) { + return endpoint; + } + + java.net.InetAddress[] resolved; + try { + resolved = java.net.InetAddress.getAllByName(host); + } catch (java.net.UnknownHostException unresolvable) { + throw new IllegalArgumentException(name + " does not resolve", unresolvable); + } + if (resolved.length == 0) { + throw new IllegalArgumentException(name + " does not resolve"); + } + for (java.net.InetAddress address : resolved) { + // Every answer, not the first: a name that returns one public and one private address is the + // ordinary shape of a rebinding attack, and taking the first answer would accept it half the + // time. + if (isInternal(address)) { + throw new IllegalArgumentException( + name + " resolves to an address inside the deployment's own network"); + } + } + return endpoint; + } + + /** + * Whether an address belongs to the deployment rather than to the internet. + * + * @param address the resolved address + * @return true when the address must not be fetched + */ + public static boolean isInternal(java.net.InetAddress address) { + Objects.requireNonNull(address, "address"); + if (address.isLoopbackAddress() + || address.isLinkLocalAddress() + || address.isSiteLocalAddress() + || address.isAnyLocalAddress() + || address.isMulticastAddress()) { + return true; + } + byte[] octets = address.getAddress(); + if (octets.length == 4) { + int first = octets[0] & 0xFF; + int second = octets[1] & 0xFF; + // 169.254.169.254 is link-local and already covered; 100.64/10 (carrier NAT) and 192.0.0/24 + // are not, and both routinely reach infrastructure the application should not talk to. + return (first == 100 && second >= 64 && second <= 127) + || (first == 192 && second == 0 && (octets[2] & 0xFF) == 0); + } + // IPv6 unique local addresses: fc00::/7. + return (octets[0] & 0xFE) == 0xFC; + } } diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesCallbackAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesCallbackAdapter.java index 61494c7e..68f80fa7 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesCallbackAdapter.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesCallbackAdapter.java @@ -1,8 +1,8 @@ package dev.caskeleton.adapter.outbound.notification.platform.provider.ses; import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; +import dev.caskeleton.application.notification.platform.api.CallbackRequest; import dev.caskeleton.application.notification.platform.api.ProviderId; -import dev.caskeleton.application.notification.platform.callback.CallbackRequest; import dev.caskeleton.application.notification.platform.callback.CallbackVerificationResult; import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent; import dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapter; diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SnsSignatureVerifier.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SnsSignatureVerifier.java index 6d6b640c..a1fa6e10 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SnsSignatureVerifier.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SnsSignatureVerifier.java @@ -46,10 +46,16 @@ public final class SnsSignatureVerifier { Objects.requireNonNull(envelope, "envelope"); String certificateUrl = envelope.get("SigningCertURL"); String signature = envelope.get("Signature"); - String version = envelope.getOrDefault("SignatureVersion", "1"); + String version = envelope.get("SignatureVersion"); if (certificateUrl == null || signature == null) { return false; } + // Exactly the versions this verifier implements. The default was "1", so an envelope with the + // field missing — or set to anything unrecognised — silently downgraded to SHA-1, and an + // attacker chooses that field. + if (!"1".equals(version) && !"2".equals(version)) { + return false; + } if (!isTrustedCertificateUrl(certificateUrl)) { return false; } @@ -66,12 +72,41 @@ public final class SnsSignatureVerifier { } } - /** Whether the certificate URL is on an Amazon host over TLS. */ + /** + * Whether the certificate URL is one this verifier will fetch. + * + *

A suffix match is not a host check. {@code evilamazonaws.com} ends with {@code + * amazonaws.com}, so the previous version would fetch a signing certificate from an + * attacker-owned domain and then verify the envelope against it — which makes the whole signature + * check decorative. The suffix must begin at a label boundary, and the rest of the URL has to + * look like what SNS actually publishes. + */ public boolean isTrustedCertificateUrl(String certificateUrl) { try { URI uri = URI.create(certificateUrl); + if (!"https".equalsIgnoreCase(uri.getScheme())) { + return false; + } + if (uri.getUserInfo() != null || uri.getQuery() != null || uri.getFragment() != null) { + // None of these appear in an SNS certificate URL, and each is a way to make one URL read as + // another to a human or to a lenient parser. + return false; + } + if (uri.getPort() != -1 && uri.getPort() != 443) { + return false; + } String host = uri.getHost() == null ? "" : uri.getHost().toLowerCase(Locale.ROOT); - return "https".equalsIgnoreCase(uri.getScheme()) && host.endsWith(certificateHostSuffix); + // At a label boundary, or the suffix itself. "evilamazonaws.com".endsWith("amazonaws.com") + // is true; "evil.amazonaws.com" is the only shape that should pass. + boolean onTheSuffix = + host.equals(certificateHostSuffix) || host.endsWith("." + certificateHostSuffix); + if (!onTheSuffix) { + return false; + } + String path = uri.getPath() == null ? "" : uri.getPath(); + // SNS publishes its certificates under /SimpleNotificationService-.pem. Constraining the + // path stops the same host being used to serve an attacker-chosen document. + return path.startsWith("/SimpleNotificationService-") && path.endsWith(".pem"); } catch (IllegalArgumentException malformed) { return false; } diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpNotificationProviderAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpNotificationProviderAdapter.java index 5176fa40..872a2f73 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpNotificationProviderAdapter.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpNotificationProviderAdapter.java @@ -1,6 +1,7 @@ package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp; import dev.caskeleton.application.notification.platform.api.ProviderId; +import dev.caskeleton.application.notification.platform.api.content.EmailContent; import dev.caskeleton.application.notification.platform.api.routing.Channel; import dev.caskeleton.application.notification.platform.contact.ContactPointValue; import dev.caskeleton.application.notification.platform.contact.EmailAddress; @@ -8,6 +9,7 @@ import dev.caskeleton.application.notification.platform.provider.NotificationPro import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities; import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult; +import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment; import dev.caskeleton.application.notification.platform.security.AccessContext; import dev.caskeleton.application.notification.platform.security.ContactPointProtector; import java.time.Duration; @@ -36,6 +38,8 @@ public final class SmtpNotificationProviderAdapter implements NotificationProvid private final ContactPointProtector protector; private final SmtpProviderProperties properties; private final Executor executor; + private final dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard + attachmentGuard; public SmtpNotificationProviderAdapter( SmtpDispatch dispatch, @@ -43,13 +47,16 @@ public final class SmtpNotificationProviderAdapter implements NotificationProvid SmtpFailureClassifier classifier, ContactPointProtector protector, SmtpProviderProperties properties, - Executor executor) { + Executor executor, + dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard + attachmentGuard) { this.dispatch = Objects.requireNonNull(dispatch, "dispatch"); this.mimeFactory = Objects.requireNonNull(mimeFactory, "mimeFactory"); this.classifier = Objects.requireNonNull(classifier, "classifier"); this.protector = Objects.requireNonNull(protector, "protector"); this.properties = Objects.requireNonNull(properties, "properties"); this.executor = Objects.requireNonNull(executor, "executor"); + this.attachmentGuard = Objects.requireNonNull(attachmentGuard, "attachmentGuard"); } @Override @@ -86,13 +93,63 @@ public final class SmtpNotificationProviderAdapter implements NotificationProvid throw new IllegalArgumentException("SMTP requires an email contact point"); } + // Resolved, verified and closed around the send. The factory was handed List.of() whatever the + // content asked for, so an email with attachments went out without them — the caller was told + // it was accepted, and the recipient received a message missing the thing it was about. + List opened = resolve(submission); try { dispatch.send( mimeFactory.create( - submission, address.normalized(), properties.senderIdentity(), List.of())); + submission, address.normalized(), properties.senderIdentity(), opened)); return ProviderSubmissionResult.accepted(null, "250", elapsedSince(startedNanos)); } catch (SmtpDispatchException failure) { return classifier.classify(failure, elapsedSince(startedNanos)); + } finally { + // Closed on every path. A resolver hands back an open stream, and a failed send is exactly + // when a leaked one goes unnoticed. + opened.forEach(SmtpNotificationProviderAdapter::closeQuietly); + } + } + + /** + * Resolves and verifies every attachment the content declares. + * + *

The integrity guard runs before the provider call, not after: a digest or size that does not + * match what the caller declared means the bytes are not the bytes that were approved, and + * discovering that after the mail has left is discovering it too late. + */ + private List resolve(ProviderSubmission submission) { + if (!(submission.content().content() instanceof EmailContent email) + || email.attachments().isEmpty()) { + return List.of(); + } + List resolved = new java.util.ArrayList<>(email.attachments().size()); + try { + for (var reference : email.attachments()) { + // The guard resolves and verifies size and digest in one step, so an attachment whose + // bytes are not the approved bytes never reaches the MIME factory. + resolved.add( + attachmentGuard.resolve( + reference, + new dev.caskeleton.application.notification.platform.provider + .AttachmentAccessContext( + new dev.caskeleton.application.notification.platform.api.TenantId( + submission.profile().environment()), + submission.attemptId()))); + } + return List.copyOf(resolved); + } catch (RuntimeException failure) { + // Everything already opened is closed before the failure propagates. + resolved.forEach(SmtpNotificationProviderAdapter::closeQuietly); + throw failure; + } + } + + private static void closeQuietly(ResolvedAttachment attachment) { + try { + attachment.close(); + } catch (Exception ignored) { + // A stream that will not close is not a reason to change the send's outcome. } } diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackAdapter.java index 5fde0cfa..b402483b 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackAdapter.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackAdapter.java @@ -1,7 +1,7 @@ package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio; +import dev.caskeleton.application.notification.platform.api.CallbackRequest; import dev.caskeleton.application.notification.platform.api.ProviderId; -import dev.caskeleton.application.notification.platform.callback.CallbackRequest; import dev.caskeleton.application.notification.platform.callback.CallbackVerificationResult; import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent; import dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapter; diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookNotificationProviderAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookNotificationProviderAdapter.java index 93486df6..5318d75f 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookNotificationProviderAdapter.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookNotificationProviderAdapter.java @@ -94,16 +94,41 @@ public final class WebhookNotificationProviderAdapter implements NotificationPro return CompletableFuture.completedFuture(send(submission)); } + /** + * The webhook envelope version. + * + *

Present so a receiver can distinguish an added field from a changed meaning. The previous + * body had no version and no content, so there was nothing to version. + */ + public static final int WEBHOOK_SCHEMA_VERSION = 1; + private ProviderSubmissionResult send(ProviderSubmission submission) { long startedNanos = System.nanoTime(); WebhookSubscription subscription = subscriptionResolver.apply(submission); + // The rendered notification, not just its digest. The body carried an attempt id and a content + // hash and nothing else, so a receiver got a webhook that said a notification had happened and + // could not tell what it said — the one thing a webhook exists to deliver. + var rendered = submission.content().content(); + Map envelope = new LinkedHashMap<>(); + // A schema version, so a receiver can tell an added field from a changed meaning. + envelope.put("schemaVersion", WEBHOOK_SCHEMA_VERSION); + envelope.put("attemptId", submission.attemptId().value().toString()); + envelope.put("channel", submission.channel().name()); + envelope.put("contentDigest", submission.content().contentDigest()); + submission.expiresAt().ifPresent(expiry -> envelope.put("expiresAt", expiry.toString())); + submission.providerIdempotencyKey().ifPresent(key -> envelope.put("idempotencyKey", key)); + if (rendered + instanceof + dev.caskeleton.application.notification.platform.api.content.InAppContent content) { + envelope.put("title", content.title()); + envelope.put("body", content.body()); + content.deepLink().ifPresent(link -> envelope.put("deepLink", link.toString())); + envelope.put("category", content.category()); + } byte[] body = NotificationJsonMapper.mapper() - .writeValueAsString( - Map.of( - "attemptId", submission.attemptId().value().toString(), - "contentDigest", submission.content().contentDigest())) + .writeValueAsString(envelope) .getBytes(StandardCharsets.UTF_8); Map headers = new LinkedHashMap<>(); diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushRequestMapper.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushRequestMapper.java index 48e6a304..b05661b1 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushRequestMapper.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushRequestMapper.java @@ -12,8 +12,6 @@ import dev.caskeleton.application.notification.platform.api.error.ProviderConfig import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException; import dev.caskeleton.application.notification.platform.contact.WebPushSubscriptionValue; import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; -import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; -import dev.caskeleton.application.notification.platform.security.SecretPurpose; import java.nio.charset.StandardCharsets; import java.time.Clock; import java.time.Duration; @@ -32,19 +30,19 @@ public final class WebPushRequestMapper { private final Rfc8291Aes128GcmEncryptor encryptor; private final VapidAuthorizationProvider signer; - private final SecretMaterialProvider secrets; + private final VapidKeyRegistry keys; private final WebPushProviderProperties properties; private final Clock clock; public WebPushRequestMapper( Rfc8291Aes128GcmEncryptor encryptor, VapidAuthorizationProvider signer, - SecretMaterialProvider secrets, + VapidKeyRegistry keys, WebPushProviderProperties properties, Clock clock) { this.encryptor = Objects.requireNonNull(encryptor, "encryptor"); this.signer = Objects.requireNonNull(signer, "signer"); - this.secrets = Objects.requireNonNull(secrets, "secrets"); + this.keys = Objects.requireNonNull(keys, "keys"); this.properties = Objects.requireNonNull(properties, "properties"); this.clock = Objects.requireNonNull(clock, "clock"); } @@ -104,8 +102,13 @@ public final class WebPushRequestMapper { "authorization", signer.authorization( subscription.endpoint(), - secrets.activeKey(SecretPurpose.VAPID_SIGNING), - properties.vapidPublicKeyBase64Url())); + // The key this subscription was created against, not whichever key is active now. A + // browser stores the application server key at subscribe time and rejects a push + // signed by any other; after a VAPID rotation, every pre-rotation subscription would + // have been signed with the new key and silently refused. VapidKeyRegistry already + // resolved the historical key per subscription — nothing called it. + keys.signingKeyFor(subscription), + keys.publicKeyFor(subscription))); return new NotificationHttpRequest( "POST", diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmCallbackPayloadProtection.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmCallbackPayloadProtection.java index 8443cc0c..d846e9a6 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmCallbackPayloadProtection.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmCallbackPayloadProtection.java @@ -46,12 +46,41 @@ public final class AesGcmCallbackPayloadProtection implements CallbackPayloadPro SecretMaterialProvider secrets, SecureRandom random, int maxRetainedBytes) { this.secrets = Objects.requireNonNull(secrets, "secrets"); this.random = Objects.requireNonNull(random, "random"); - this.maxRetainedBytes = maxRetainedBytes; if (maxRetainedBytes < 1) { throw new IllegalArgumentException("maxRetainedBytes"); } + if (maxRetainedBytes > MAX_PLAINTEXT_BYTES) { + // The database check constrains the *ciphertext*, and encryption adds a 12-byte nonce and a + // 16-byte GCM tag. Truncating the plaintext to the ciphertext bound produced a value 28 bytes + // over it, so a callback of exactly the configured maximum was accepted by every layer above + // and then rejected by a CHECK constraint after the provider had been told it was stored. + throw new IllegalArgumentException( + "callback retention of " + + maxRetainedBytes + + " plaintext bytes cannot be stored: the ciphertext column holds " + + MAX_CIPHERTEXT_BYTES + + " bytes and encryption adds " + + ENVELOPE_OVERHEAD_BYTES + + ", so the plaintext ceiling is " + + MAX_PLAINTEXT_BYTES); + } + this.maxRetainedBytes = maxRetainedBytes; } + /** + * The largest ciphertext the {@code notification_provider_event} check constraint accepts. + * + *

Named here because this class is what has to fit inside it. The constraint was written + * against the plaintext bound, and nothing reconciled the two. + */ + public static final int MAX_CIPHERTEXT_BYTES = 65_536; + + /** The nonce and GCM tag every encryption adds. */ + public static final int ENVELOPE_OVERHEAD_BYTES = NONCE_BYTES + TAG_BITS / 8; + + /** The largest plaintext that still fits the column once encrypted. */ + public static final int MAX_PLAINTEXT_BYTES = MAX_CIPHERTEXT_BYTES - ENVELOPE_OVERHEAD_BYTES; + @Override public byte[] protectRawPayload(byte[] rawBody) { Objects.requireNonNull(rawBody, "rawBody"); @@ -109,7 +138,10 @@ public final class AesGcmCallbackPayloadProtection implements CallbackPayloadPro Mac mac = Mac.getInstance("HmacSHA256"); mac.init( new SecretKeySpec( - secrets.activeKey(SecretPurpose.CONTACT_LOOKUP_HMAC).material(), "HmacSHA256")); + // A fingerprint key and a contact-lookup key protect different things and must not + // fall + // together. + secrets.activeKey(SecretPurpose.CALLBACK_FINGERPRINT_HMAC).material(), "HmacSHA256")); return HexFormat.of().formatHex(mac.doFinal(seed.getBytes(StandardCharsets.UTF_8))); } catch (GeneralSecurityException failure) { throw new IllegalStateException("callback fingerprinting failed", failure); diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmContactPointProtector.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmContactPointProtector.java index ebe5e8f0..71ca753a 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmContactPointProtector.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmContactPointProtector.java @@ -69,7 +69,9 @@ public final class AesGcmContactPointProtector implements ContactPointProtector byte[] nonce = new byte[NONCE_BYTES]; random.nextBytes(nonce); - byte[] plaintext = value.normalized().getBytes(StandardCharsets.UTF_8); + // The complete form is what gets encrypted; the identity form is what gets fingerprinted. They + // used to be the same string, so every field outside the identity was simply not stored. + byte[] plaintext = value.serialized().getBytes(StandardCharsets.UTF_8); byte[] ciphertext = encrypt(encryption, nonce, associatedData(value.type()), plaintext); return new ProtectedContactPoint( @@ -166,15 +168,23 @@ public final class AesGcmContactPointProtector implements ContactPointProtector } } - private static ContactPointValue parse(ContactPointType type, String normalized) { + /** + * Reads back what {@link ContactPointValue#serialized()} wrote. + * + *

The version prefix is stripped here rather than in every subtype: the default serialized + * form is {@code "v1:" + normalized()}, and a stored row written before this change has neither + * prefix nor the fields it introduced. Accepting both is what keeps existing rows readable. + */ + private static ContactPointValue parse(ContactPointType type, String stored) { + String body = stored.startsWith("v1:") ? stored.substring("v1:".length()) : stored; return switch (type) { - case EMAIL -> EmailAddress.parse(normalized); - case PHONE -> new PhoneNumber(normalized); - case FCM_FID -> new FcmInstallationId(normalized); - case FCM_REGISTRATION_TOKEN_LEGACY -> new LegacyFcmRegistrationToken(normalized); - case APNS_DEVICE_TOKEN -> parseApns(normalized); - case WEB_PUSH_SUBSCRIPTION -> parseWebPush(normalized); - case IN_APP_RECIPIENT -> new InAppRecipientRef(normalized); + case EMAIL -> EmailAddress.parse(body); + case PHONE -> new PhoneNumber(body); + case FCM_FID -> new FcmInstallationId(body); + case FCM_REGISTRATION_TOKEN_LEGACY -> new LegacyFcmRegistrationToken(body); + case APNS_DEVICE_TOKEN -> parseApns(body); + case WEB_PUSH_SUBSCRIPTION -> parseWebPush(stored); + case IN_APP_RECIPIENT -> new InAppRecipientRef(body); }; } @@ -188,17 +198,23 @@ public final class AesGcmContactPointProtector implements ContactPointProtector ApnsEnvironment.valueOf(normalized.substring(0, separator))); } - private static WebPushSubscriptionValue parseWebPush(String normalized) { - int separator = normalized.indexOf('|'); - if (separator <= 0) { - throw new IllegalStateException("stored Web Push subscription is malformed"); + /** + * Reads a stored Web Push subscription back with every field it was saved with. + * + *

This method used to return sixteen zero bytes for the auth secret and the literal string + * {@code "restored"} for the VAPID key id, on the stated grounds that both lived in their own + * encrypted columns. Those columns do not exist — not in the migration, not on the entity. So a + * subscription came back from storage unable to produce an RFC 8291 payload its browser could + * decrypt, and unable to say which VAPID key had signed for it. + */ + private static WebPushSubscriptionValue parseWebPush(String stored) { + String[] parts = stored.split("\\|", -1); + if (parts.length != 5 || !"v1".equals(parts[0])) { + throw new IllegalStateException( + "stored Web Push subscription is malformed or predates the versioned envelope"); } - // The auth secret and VAPID key id are stored in their own encrypted columns; the normalized - // form only has to round-trip the equality-relevant parts. + Base64.Decoder decoder = Base64.getUrlDecoder(); return new WebPushSubscriptionValue( - URI.create(normalized.substring(0, separator)), - Base64.getUrlDecoder().decode(normalized.substring(separator + 1)), - new byte[16], - "restored"); + URI.create(parts[1]), decoder.decode(parts[2]), decoder.decode(parts[3]), parts[4]); } } diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/HmacProviderRequestIdHasher.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/HmacProviderRequestIdHasher.java index 355169d1..e7354819 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/HmacProviderRequestIdHasher.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/HmacProviderRequestIdHasher.java @@ -36,7 +36,13 @@ public final class HmacProviderRequestIdHasher implements ProviderRequestIdHashe Mac mac = Mac.getInstance("HmacSHA256"); mac.init( new SecretKeySpec( - secrets.activeKey(SecretPurpose.CONTACT_LOOKUP_HMAC).material(), "HmacSHA256")); + // Its own purpose. This shared the contact-lookup key, so one compromised key forged + // both + // the contact index and the provider-request index — purpose separation exists to + // stop + // exactly that. + secrets.activeKey(SecretPurpose.PROVIDER_REQUEST_LOOKUP_HMAC).material(), + "HmacSHA256")); mac.update((profileId.value() + ":").getBytes(StandardCharsets.UTF_8)); return HexFormat.of() .formatHex(mac.doFinal(providerRequestId.getBytes(StandardCharsets.UTF_8))); diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/ProviderCredentialManager.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/ProviderCredentialManager.java index 4273da28..91ff330e 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/ProviderCredentialManager.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/ProviderCredentialManager.java @@ -5,6 +5,7 @@ import dev.caskeleton.application.notification.platform.security.SecretKeyMateri import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; import dev.caskeleton.application.notification.platform.security.SecretPurpose; import java.time.Clock; +import java.time.Instant; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -37,19 +38,33 @@ public final class ProviderCredentialManager { this.clock = Objects.requireNonNull(clock, "clock"); } - /** Record the generation a profile starts on. */ + /** + * Record the generation a profile starts on. + * + *

The supersede check and the store are one operation. They were a {@code get}, a check and a + * {@code put}: two rotations racing both read the same predecessor, both concluded they + * superseded it, and whichever wrote last won — so generation 2 could land after generation 3 and + * the profile would run on credentials that had already been retired. "Strictly increasing" only + * means anything if nothing can intervene between reading the current value and replacing it. + * + * @throws IllegalArgumentException if the generation does not supersede whatever is active at the + * moment it is stored + */ public CredentialGeneration activate(CredentialGeneration generation) { Objects.requireNonNull(generation, "generation"); - CredentialGeneration existing = current.get(generation.profileId()); - if (existing != null && !generation.supersedes(existing)) { - throw new IllegalArgumentException("generation does not supersede the active one"); - } // Fetch once at activation so a key id that does not resolve fails the rotation instead of - // failing the first notification that happens to use the profile. + // failing the first notification that happens to use the profile. Resolution reads an external + // provider, so it stays outside the map's compute lock; it is a read and repeating it is safe. requireResolvable(generation); - CredentialGeneration activated = generation.activatedAt(clock.instant()); - current.put(activated.profileId(), activated); - return activated; + Instant activatedAt = clock.instant(); + return current.compute( + generation.profileId(), + (profileId, existing) -> { + if (existing != null && !generation.supersedes(existing)) { + throw new IllegalArgumentException("generation does not supersede the active one"); + } + return generation.activatedAt(activatedAt); + }); } /** Current generation of a profile. */ diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/CanonicalNotificationRenderer.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/CanonicalNotificationRenderer.java index e5a673b9..f0cb06f7 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/CanonicalNotificationRenderer.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/CanonicalNotificationRenderer.java @@ -122,12 +122,31 @@ public final class CanonicalNotificationRenderer implements NotificationTemplate private String slot( NotificationTemplateVersion template, TemplateSlot slot, Map variables) { - return engine.render(template.content().requireSlot(slot), variables); + return engine.render(modeOf(slot), template.content().requireSlot(slot), variables); } private Optional optionalSlot( NotificationTemplateVersion template, TemplateSlot slot, Map variables) { - return template.content().slot(slot).map(source -> engine.render(source, variables)); + return template + .content() + .slot(slot) + .map(source -> engine.render(modeOf(slot), source, variables)); + } + + /** + * How a slot's substituted values have to be escaped. + * + *

Every slot used to render through one raw-substitution path, so a caller's value became + * active markup in an HTML body and could split a header in a subject. Escaping belongs to the + * destination, and this is where the destination is known. + */ + private static TemplateSlotMode modeOf(TemplateSlot slot) { + return switch (slot) { + case SUBJECT -> TemplateSlotMode.SUBJECT; + case HTML_BODY -> TemplateSlotMode.HTML_TEXT; + case DEEP_LINK -> TemplateSlotMode.URI; + case TEXT_BODY, TITLE, BODY, CATEGORY -> TemplateSlotMode.TEXT; + }; } private static String canonicalForm(NotificationContent content) { diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/NotificationTemplateEngine.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/NotificationTemplateEngine.java index 1d27132a..68838437 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/NotificationTemplateEngine.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/NotificationTemplateEngine.java @@ -20,4 +20,19 @@ public interface NotificationTemplateEngine { * when a referenced variable is absent — never rendered as an empty string */ String render(String source, Map variables); + + /** + * Render one slot, escaping for what the slot is. + * + *

The mode is required rather than inferred: the same template text is safe in a text part and + * dangerous in an HTML one, and only the caller knows which it is filling. + * + * @param mode what the rendered value will become + * @param source the template text + * @param variables the values to substitute + * @return the rendered slot + */ + default String render(TemplateSlotMode mode, String source, Map variables) { + return render(source, variables); + } } diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/PlaceholderTemplateEngine.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/PlaceholderTemplateEngine.java index 192facd0..80877fdd 100644 --- a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/PlaceholderTemplateEngine.java +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/PlaceholderTemplateEngine.java @@ -23,9 +23,22 @@ public final class PlaceholderTemplateEngine implements NotificationTemplateEngi private static final Pattern PLACEHOLDER = Pattern.compile("\\{([a-zA-Z0-9_.-]{1,64})\\}"); - /** Render one slot. */ + /** Render one slot as plain text, which is what the single-argument contract means. */ @Override public String render(String source, Map variables) { + return render(TemplateSlotMode.TEXT, source, variables); + } + + /** + * Render one slot, escaping each substituted value for the slot it lands in. + * + *

The template text itself is trusted — an operator published it — and the substituted values + * are not. So escaping is applied to the value, never to the surrounding template, which is what + * lets an HTML template keep its markup while a caller's {@code