diff --git a/.github/scripts/verify-gradle-wrapper.sh b/.github/scripts/verify-gradle-wrapper.sh index 8dd8fe7e..6581c124 100755 --- a/.github/scripts/verify-gradle-wrapper.sh +++ b/.github/scripts/verify-gradle-wrapper.sh @@ -24,8 +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' + '04851f44ba94533bfbc8fabe2b3a2b408726a9996e86ed3864986d1499d16b50 .github/workflows/jpa-pr.yml' '59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml' + 'ea7f8214a3cc9ec3e7ba3183a2201fd26a05a61f0b0fdcb1f041b71efca3e81c .github/workflows/jpa-release.yml' '5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml' + '3d5afcef6bf1c65dcd8cad3d1687f07c2cfbb15d360f41251e46f9eb8950baac .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 new file mode 100644 index 00000000..eb81e6c7 --- /dev/null +++ b/.github/workflows/jpa-next-hibernate8.yml @@ -0,0 +1,43 @@ +name: jpa-next-hibernate8 + +# Hibernate ORM 8 compatibility lane (experimental plan Task 8). +# +# Re-runs the contracts most likely to move between provider majors: collection fetch pagination, +# StatementInspector, Statistics, JSONB, batch, and StatelessSession. Differences are recorded, not +# accommodated — weakening the 7.x gate to make this lane green would delete the evidence that 7.x +# behaves as documented. + +on: + workflow_dispatch: + schedule: + - cron: '0 5 * * 1' + +permissions: + contents: read + +jobs: + hibernate8-compatibility: + runs-on: ubuntu-latest + timeout-minutes: 45 + continue-on-error: true + 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: Report Hibernate ORM 8 compatibility + working-directory: src + run: >- + ./gradlew + :adapter:outbound:persistence-jpa:test --tests '*HibernateCompatibilityPolicyTest' + --no-daemon + --stacktrace diff --git a/.github/workflows/jpa-next-jpa4.yml b/.github/workflows/jpa-next-jpa4.yml new file mode 100644 index 00000000..79cd2eef --- /dev/null +++ b/.github/workflows/jpa-next-jpa4.yml @@ -0,0 +1,43 @@ +name: jpa-next-jpa4 + +# Jakarta Persistence 4.0 compatibility lane (experimental plan Task 7). +# +# Non-blocking by design: it reports whether the Stable public API still compiles and whether the +# selected mapping contracts still hold on JPA 4. It publishes nothing, and a red result here never +# changes a Stable contract — the 3.2 gate keeps asserting what 3.2 must do, because that is what +# deployments run. + +on: + workflow_dispatch: + schedule: + - cron: '0 4 * * 1' + +permissions: + contents: read + +jobs: + jpa4-compatibility: + runs-on: ubuntu-latest + timeout-minutes: 45 + continue-on-error: true + 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: Report Jakarta Persistence 4.0 compatibility + working-directory: src + run: >- + ./gradlew + :adapter:outbound:persistence-jpa:test --tests '*CompatibilityLaneDefinitionTest' + --no-daemon + --stacktrace diff --git a/.github/workflows/jpa-next-postgresql19.yml b/.github/workflows/jpa-next-postgresql19.yml new file mode 100644 index 00000000..34fc6364 --- /dev/null +++ b/.github/workflows/jpa-next-postgresql19.yml @@ -0,0 +1,42 @@ +name: jpa-next-postgresql19 + +# PostgreSQL 19 compatibility lane (experimental plan Task 9). +# +# 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. + +on: + workflow_dispatch: + schedule: + - cron: '0 6 * * 1' + +permissions: + contents: read + +jobs: + postgresql19-compatibility: + runs-on: ubuntu-latest + timeout-minutes: 60 + continue-on-error: true + 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: Report PostgreSQL 19 compatibility + working-directory: src + run: >- + ./gradlew + :adapter:outbound:persistence-jpa:test --tests '*ExperimentalPromotionGateTest' + --no-daemon + --stacktrace diff --git a/.github/workflows/jpa-nightly.yml b/.github/workflows/jpa-nightly.yml new file mode 100644 index 00000000..a609bd6a --- /dev/null +++ b/.github/workflows/jpa-nightly.yml @@ -0,0 +1,131 @@ +name: jpa-nightly + +# The suites that are too slow, too Docker-heavy, or too machine-dependent for a PR, and the middle +# of the PostgreSQL matrix. +# +# The failure-injection lane is the one that matters most and is easiest to lose: it is the only +# place the commit-ambiguity scenarios run, and they are the only evidence that a lost commit +# acknowledgement produces completion-unknown rather than a retry. + +on: + workflow_dispatch: + schedule: + # 02:30 UTC daily. + - cron: '30 2 * * *' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + jpa-full-matrix: + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + postgresql: ["16", "17", "18"] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Certify the platform against PostgreSQL ${{ matrix.postgresql }} + working-directory: src + run: >- + ./gradlew + :adapter:outbound:persistence-jpa:jpaPlatformContractTest + -Pjpa.matrix.versions=${{ matrix.postgresql }} + --no-daemon + --stacktrace + + jpa-failure-injection: + runs-on: ubuntu-latest + timeout-minutes: 45 + 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: Reproduce deadlock, serialization, and commit-ambiguity scenarios + working-directory: src + run: >- + ./gradlew + :adapter:outbound:persistence-jpa:jpaPlatformFailureTest + --no-daemon + --stacktrace + + jpa-query-plan-and-security: + runs-on: ubuntu-latest + timeout-minutes: 45 + 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: Run the query plan and database security suites + working-directory: src + run: >- + ./gradlew + :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest + :adapter:outbound:persistence-jpa:jpaPlatformSecurityTest + --no-daemon + --stacktrace + + jpa-pool-pressure: + runs-on: ubuntu-latest + timeout-minutes: 45 + 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: Measure pool saturation and REQUIRES_NEW pressure + working-directory: src + # Machine-dependent bounds are reported rather than asserted unless explicitly enabled, so a + # noisy shared runner does not produce a red build that means nothing. + run: >- + ./gradlew + :adapter:outbound:persistence-jpa:jpaPlatformPerformanceTest + -Pperformance.assertions.enabled=false + --no-daemon + --stacktrace diff --git a/.github/workflows/jpa-pr.yml b/.github/workflows/jpa-pr.yml new file mode 100644 index 00000000..8cf45400 --- /dev/null +++ b/.github/workflows/jpa-pr.yml @@ -0,0 +1,114 @@ +name: jpa-pr + +# Every "Stable" row in docs/jpa/support-matrix.md is backed by a job here or in jpa-nightly / +# jpa-release. A support level with no job behind it is a marketing claim. +# +# The PR lane runs the oldest and the newest Stable PostgreSQL rather than all three: a behaviour +# that differs across the matrix almost always differs at its ends, and the middle version is +# covered nightly. What it does not do is skip the container lane on a runner without Docker — +# PostgreSqlContainerFactory throws, because a skipped contract reports success for a database +# nobody tested. + +on: + workflow_dispatch: + pull_request: + paths: + - 'src/adapter/outbound/persistence-jpa/**' + - 'src/app-bootstrap/src/**/jpa/**' + - 'src/config/architecture/modules.json' + - 'docs/jpa/**' + - 'docs/adr/ADR-JPA-*' + - 'infra/jpa/**' + - '.github/workflows/jpa-pr.yml' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + jpa-unit-and-architecture: + runs-on: ubuntu-latest + timeout-minutes: 30 + 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: Run the JPA unit and architecture suites + working-directory: src + run: >- + ./gradlew + :adapter:outbound:persistence-jpa:test + :app-bootstrap:test --tests '*CleanArchitectureTest' + verifyCleanArchitectureDependencies + verifyOneTypePerFile + --no-daemon + --stacktrace + + jpa-postgresql-contract: + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + # 16 and 18 — the ends of the Stable matrix. 17 runs nightly. + postgresql: ["16", "18"] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Certify the platform against PostgreSQL ${{ matrix.postgresql }} + working-directory: src + run: >- + ./gradlew + :adapter:outbound:persistence-jpa:jpaPlatformContractTest + -Pjpa.matrix.versions=${{ matrix.postgresql }} + --no-daemon + --stacktrace + + jpa-migration-smoke: + runs-on: ubuntu-latest + timeout-minutes: 30 + 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: Run the migration upgrade smoke scenarios + working-directory: src + run: >- + ./gradlew + :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest + --no-daemon + --stacktrace diff --git a/.github/workflows/jpa-release.yml b/.github/workflows/jpa-release.yml new file mode 100644 index 00000000..9e78dedd --- /dev/null +++ b/.github/workflows/jpa-release.yml @@ -0,0 +1,73 @@ +name: jpa-release + +# The release gate. Every item in docs/jpa/support-matrix.md's gate table has a job or an assertion +# here, and JpaReleaseManifest parses that document so a gate removed from the docs fails the build +# rather than quietly ceasing to be checked. + +on: + workflow_dispatch: + push: + tags: + - 'v*' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + jpa-release-gate: + runs-on: ubuntu-latest + timeout-minutes: 90 + 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: Run the full JPA release gate + working-directory: src + run: >- + ./gradlew + jpaReleaseGate + -Pjpa.matrix.versions=16,17,18 + --no-daemon + --stacktrace + + jpa-architecture-and-docs: + runs-on: ubuntu-latest + timeout-minutes: 30 + 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: Verify architecture boundaries and the support matrix + working-directory: src + run: >- + ./gradlew + verifyCleanArchitectureDependencies + verifyOneTypePerFile + :app-bootstrap:test --tests '*CleanArchitectureTest' + :adapter:outbound:persistence-jpa:test --tests '*JpaReleaseManifestTest' + --no-daemon + --stacktrace diff --git a/.github/workflows/notification-platform.yml b/.github/workflows/notification-platform.yml new file mode 100644 index 00000000..ca40fc3f --- /dev/null +++ b/.github/workflows/notification-platform.yml @@ -0,0 +1,121 @@ +name: notification-platform + +# Verification tiers for the Notification Delivery Platform. +# +# The PR tier is deliberately free of any external provider. A gate that depends on a third-party +# sandbox fails for reasons that have nothing to do with the change under review, and a gate people +# learn to re-run is not a gate. Real provider smoke tests live in the secret-protected tier, where +# a failure is an environment signal rather than a merge blocker. +# +# Every job that invokes Gradle validates the wrapper first with the repository's pinned action; +# the wrapper JAR is executable code fetched at build time, so validating it is what keeps a +# compromised wrapper from turning any workflow run into arbitrary code execution. + +on: + pull_request: + paths: + - 'src/application-core/src/**/notification/platform/**' + - 'src/adapter/outbound/notification/**' + - 'src/adapter/outbound/persistence-jpa/src/**/notification/**' + - 'src/adapter/inbound/web/src/**/notification/**' + - 'docs/notification/**' + - 'infra/notification/**' + - '.github/workflows/notification-platform.yml' + push: + branches: [ main ] + schedule: + # Nightly: the chaos tier, which is slower and inherently less deterministic than the PR tier. + - cron: '0 17 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: notification-platform-${{ github.ref }} + cancel-in-progress: true + +jobs: + pr: + name: contract (Java 21, no external provider) + runs-on: ubuntu-latest + timeout-minutes: 30 + 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: Compile and format check + working-directory: src + run: ./gradlew :application-core:compileJava :adapter:outbound:notification:compileJava --console=plain + - name: Application contracts + working-directory: src + run: ./gradlew :application-core:test --console=plain + - name: Provider contract suite + working-directory: src + run: ./gradlew :adapter:outbound:notification:test --console=plain + - name: Persistence and web + working-directory: src + run: ./gradlew :adapter:outbound:persistence-jpa:test :adapter:inbound:web:test --console=plain + - name: Architecture gates + working-directory: src + run: | + ./gradlew verifyCleanArchitectureDependencies --console=plain + ./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --tests '*NotificationArchitectureTest' --console=plain + - name: Configuration surface + working-directory: src + run: ./gradlew verifyEnvKeys verifyPublicPathSnapshot --console=plain + - name: Static analysis + working-directory: src + run: ./gradlew :adapter:outbound:notification:check -x test --console=plain + + nightly-chaos: + name: chaos (ambiguity, restart recovery, callback burst) + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 60 + 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: Ambiguity and fault harness + working-directory: src + run: ./gradlew :adapter:outbound:notification:test --tests '*ChaosSecurity*' --tests '*CrossProviderContractSuite*' --console=plain + - name: Full suite + working-directory: src + run: ./gradlew test --console=plain + + provider-sandbox: + name: provider sandbox smoke (secret-protected, non-blocking) + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: notification-provider-sandbox + continue-on-error: true + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Smoke test against real provider sandboxes + 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." diff --git a/docs/adr/ADR-JPA-001-domain-owns-persistence-model.md b/docs/adr/ADR-JPA-001-domain-owns-persistence-model.md new file mode 100644 index 00000000..835275e5 --- /dev/null +++ b/docs/adr/ADR-JPA-001-domain-owns-persistence-model.md @@ -0,0 +1,35 @@ +# ADR-JPA-001 — The domain owns the persistence model + +- Status: Accepted +- Date: 2026-08-11 +- Design: §10.1, §23.3 + +## Context + +A persistence platform can either own the repository abstraction — a `GenericRepository` +every aggregate inherits — or provide only the pieces domains assemble themselves. + +## Decision + +The domain owns entities, embeddables, repositories, queries, index requirements, and lock, +soft-delete, and audit policy. The platform provides no generic CRUD repository and no base +repository. `JpaRepositoryFragmentSupport` exists, has no `save`, `findById`, `findAll`, or +`delete`, and is enforced not to acquire them. + +## Consequences + +A generic base repository has one property that looks like a benefit and is not: every aggregate +gets the same operations. That means each aggregate is offered operations that may be wrong for it — +a `delete` on an append-only ledger, a `findAll` on a table that will never be small — and, worse, +one aggregate's later requirement changes the shared base and therefore changes behaviour for +aggregates nobody reviewed. + +Spring Data already implements CRUD. Re-implementing it adds a layer whose only function is to be +harder to opt out of. + +The cost is a small amount of repetition: each domain declares the repository interface it needs. +That repetition is the thing that makes each aggregate's persistence surface reviewable. + +## Enforcement + +`JpaArchitectureRules.noGenericRepository()`; `JpaRepositoryFragmentSupportTest`. diff --git a/docs/adr/ADR-JPA-002-full-transaction-retry.md b/docs/adr/ADR-JPA-002-full-transaction-retry.md new file mode 100644 index 00000000..76eb2cb6 --- /dev/null +++ b/docs/adr/ADR-JPA-002-full-transaction-retry.md @@ -0,0 +1,37 @@ +# ADR-JPA-002 — Retry re-runs the whole use case + +- Status: Accepted +- Date: 2026-08-11 +- Design: §19.2 + +## Context + +Optimistic conflicts, deadlocks, and serialization failures are recoverable. The question is what +unit gets retried: the failed statement, the transaction, or the use case. + +## Decision + +The whole use case, in a new transaction with a new Persistence Context. +`FullTransactionRetryCoordinator` re-enters `JpaTransactionExecutor` for every attempt, and the +retry advice is ordered outside Spring's transaction advice so each attempt begins a new +transaction. + +## Consequences + +Statement-level retry is wrong for exactly the failures being retried. An optimistic conflict means +the state the attempt computed against is no longer the committed state; re-issuing the same +statement computes the same wrong answer against a version that has moved on. The domain rules have +to run again over reloaded data, which means the whole use case. + +Reusing the Persistence Context would be equally wrong: the second attempt would read the first +attempt's stale entities out of the first-level cache. And with the advice ordering inverted, the +retry loop would run inside one transaction that has already been marked rollback-only, so the +second attempt fails immediately without executing anything. + +The cost is that a retryable use case must be safe to run from scratch — no irreversible external +effect before the commit. `IrreversibleSideEffectContext` lets a use case declare when that does not +hold, and the policy then refuses to retry it whatever budget remains. + +## Enforcement + +`FullTransactionRetryCoordinatorTest`; `RetryableJpaTransactionInterceptor.DEFAULT_ORDER`. diff --git a/docs/adr/ADR-JPA-003-completion-unknown.md b/docs/adr/ADR-JPA-003-completion-unknown.md new file mode 100644 index 00000000..82e4caf9 --- /dev/null +++ b/docs/adr/ADR-JPA-003-completion-unknown.md @@ -0,0 +1,41 @@ +# ADR-JPA-003 — Completion unknown is never retried + +- Status: Accepted +- Date: 2026-08-11 +- Design: §17 + +## Context + +A connection can break while a commit is in flight. The server may have committed; the +acknowledgement may simply have been lost. The driver cannot tell the two apart. + +## Decision + +`TransactionCompletionUnknownException` is never retried, automatically or otherwise. It is +produced only by a failure observed while the transaction phase is `COMMITTING`, and only for +SQLSTATE `40003`, a connection-class (`08*`) state, or a transport break. Recovery is +domain-specific reconciliation through `TransactionCompletionResolver`. + +## Consequences + +Retrying a possibly-committed write is the most damaging thing this platform could do: a duplicate +payment, a duplicate order, a double decrement. There is no budget or backoff that makes it safe, +because the failure is epistemic rather than transient. + +The invariant is enforced at the type level rather than by policy alone. `JpaFailureContext` refuses +to construct a retryable completion-unknown context, and the exception rebuilds its context through +the safe factory whatever it is handed. A future policy bug therefore cannot produce an unsafe +retry — the value it would need does not exist. + +The rule is deliberately narrow in the other direction too. Classifying every connection failure as +completion-unknown would push ordinary pool exhaustion and server restarts into the reconciliation +queue, which trains operators to clear that queue without reading it — and then the one entry that +mattered gets cleared with the rest. + +The cost is that the domain must supply the resolver. The platform cannot: only the domain knows +which idempotency record, business row, or outbox entry proves the write happened. + +## Enforcement + +`JpaFailureContextTest`; `DefaultJpaRetryPolicyTest`; `CommitFailureClassifierTest`; release gate +`completion-unknown-no-retry`. diff --git a/docs/adr/ADR-JPA-004-flyway-schema-source-of-truth.md b/docs/adr/ADR-JPA-004-flyway-schema-source-of-truth.md new file mode 100644 index 00000000..584424be --- /dev/null +++ b/docs/adr/ADR-JPA-004-flyway-schema-source-of-truth.md @@ -0,0 +1,37 @@ +# ADR-JPA-004 — Flyway is the schema source of truth + +- Status: Accepted +- Date: 2026-08-11 +- Design: §31 + +## Context + +Hibernate can create and alter schema from the entity mapping. Flyway can apply versioned scripts. +Both cannot own the schema. + +## Decision + +Flyway owns every schema change. Hibernate validates and never mutates: `ddl-auto` is `validate` or +`none`, enforced at startup. The runtime database credential holds no DDL privilege, so the rule is +enforced by the server as well as by configuration. + +## Consequences + +`ddl-auto=update` fails in a specific and expensive way: it adds but never drops or narrows, so the +result is a schema that is neither the previous one nor the one the mappings describe — produced +silently, by whichever instance started first, with no record of what it did. + +Two credentials rather than one is what makes this more than a convention. A configuration rule can +be overridden by a property; a role without `CREATE` cannot be overridden by anything the +application does. + +Validation fails closed and never repairs. `repair` rewrites the schema history to match the scripts +on disk, which resolves a checksum mismatch by deleting the evidence of which change is missing. + +The cost is that a schema change requires a migration script and a deployment step. That is the +intended cost: it makes schema change reviewable and reversible. + +## Enforcement + +`JpaDangerousConfigurationGuard`; `FlywaySchemaPolicy`; `FlywayValidationGate`; +`PostgreSqlRuntimeRoleVerifier`; release gates `flyway-validate` and `runtime-role-no-ddl`. diff --git a/docs/adr/ADR-JPA-005-postgresql-real-contract.md b/docs/adr/ADR-JPA-005-postgresql-real-contract.md new file mode 100644 index 00000000..20d1806c --- /dev/null +++ b/docs/adr/ADR-JPA-005-postgresql-real-contract.md @@ -0,0 +1,38 @@ +# ADR-JPA-005 — Contracts run against real PostgreSQL + +- Status: Accepted +- Date: 2026-08-11 +- Design: §40 + +## Context + +An in-memory database makes tests fast and hermetic. A container makes them slow and requires +Docker. + +## Decision + +Every persistence contract runs against real PostgreSQL 16, 17, and 18 in containers. H2 remains a +local-development convenience and never satisfies a contract. The lanes fail closed when Docker is +absent rather than skipping. + +## Consequences + +The behaviours these contracts verify either do not exist in H2 or differ there: SQLSTATE values for +the same violation, `FOR UPDATE SKIP LOCKED` semantics, JSONB operators, range types, concurrent +index builds, `search_path` privileges, and the generated SQL for a paged collection fetch. A green +H2 run is evidence that the code compiles and runs — not that any of the above holds. + +Three versions rather than one because the platform claims three. A contract suite that ran only on +16 would make "Stable on 17 and 18" an assumption. + +Skipping on missing Docker is the failure mode this decision most wants to avoid: a skipped contract +reports success, and CI eventually inherits that silence. `PostgreSqlContainerFactory.assertDockerAvailable()` +throws instead. + +The cost is that the contract lanes need Docker and take minutes. The unit lane stays hermetic and +fast, and is where most tests live; the container lanes verify the things only a real server can +answer. + +## Enforcement + +`PostgreSqlVersion.stable()`; `PostgreSqlContainerFactory`; release gate `postgresql-contract`. diff --git a/docs/adr/ADR-MONGO-001-platform-boundary.md b/docs/adr/ADR-MONGO-001-platform-boundary.md new file mode 100644 index 00000000..1a4b496e --- /dev/null +++ b/docs/adr/ADR-MONGO-001-platform-boundary.md @@ -0,0 +1,63 @@ +# ADR-MONGO-001 — MongoDB platform boundary + +- **Status:** Accepted +- **Date:** 2026-08-13 +- **Design source:** `mongodb-superpowers-package/.../2026-08-11-mongodb-document-persistence-platform-design.md` §1, §2 (D-01, D-04, D-05), §5, §6 + +## Context + +Two failure modes are common when a team wraps MongoDB. + +The first is flattening: a shared `CommonMongoRepository` and a generic CRUD facade, which +forces every collection to share an id strategy, a consistency profile and a query surface. MongoDB's +single-document atomicity, aggregation model and change streams stop being reachable, and the first +collection that needs something different gets a cast or a leaky generic. + +The second is unrestricted exposure: the driver and `runCommand` available everywhere. Then any +service can drop a collection, run an unbounded pipeline, or issue an admin command from a request +thread, and no review catches it because there is nothing structural to catch. + +## Decision + +The domain owns its documents; the platform owns the cross-cutting decisions. Four exposure planes: + +| Plane | Contents | Client | +|---|---|---| +| D1 Standard document persistence | Spring Data repositories, typed queries, mapping manifest, atomic update primitives, optimistic revision | Stable API V1, `apiStrict=true` | +| D2 Advanced document operations | `MongoTemplate`, transactions/sessions, bulk, aggregation, keyset cursors, change streams | Stable API V1, `apiStrict=true` | +| D3 Explicit Mongo capability | Native BSON, time series, search/vector, CSFLE/QE, shard-aware operations | Separate capability client | +| D4 Admin plane | Collection, validator, index, migration, shard, repair | Separate admin client and credential | + +Specifically: + +1. **No `CommonMongoRepository`.** Each aggregate declares its own repository. +2. **D1/D2 run on Stable API V1 with `apiStrict=true`,** so a command outside the versioned API fails + at development time instead of on the next server upgrade. +3. **D3 is not a raw-client escape.** Every call passes a fixed admission order: capability registered + → database profile → collection allowlist → operation name → timeout → consistency profile → + result limit → trace → redaction → command category → admin-command refusal → execute. +4. **D4 is a separate client with a separate credential.** No application-plane path reaches it; + `PolicyAwareMongoNativeGateway` refuses admin-category commands regardless of capability. +5. **Advanced and Experimental capabilities are opt-in modules**, never transitive dependencies of the + Stable surface. + +## Consequences + +**Positive.** MongoDB's semantics stay reachable. Misuse is refused structurally rather than reviewed +for. A server upgrade cannot silently change D1/D2 behaviour. Admin operations have their own audit +trail and credential. + +**Negative.** Every operation needs a registered name and profile, so a new query is a small amount of +configuration rather than zero. A genuinely new capability requires a registration before it can be +used. Both are deliberate: the cost is paid once per operation, at review time. + +**Rejected alternative — "expose the driver, rely on code review."** Review does not scale to every +query in every service, and the operations that matter (unbounded pipeline, `dropCollection`, +unanchored regex on user input) look unremarkable in a diff. + +## Repository adaptation + +The design assumes 19 Gradle modules under `modules/mongodb/`. This repository's fail-closed registry +declares exactly 19 leaf identities, so the modules became package boundaries inside +`:adapter:outbound:persistence-mongo`, enforced by ArchUnit. See +[docs/mongodb/repository-adaptation.md](../mongodb/repository-adaptation.md). diff --git a/docs/adr/ADR-MONGO-002-bson-representation.md b/docs/adr/ADR-MONGO-002-bson-representation.md new file mode 100644 index 00000000..40f9f443 --- /dev/null +++ b/docs/adr/ADR-MONGO-002-bson-representation.md @@ -0,0 +1,58 @@ +# ADR-MONGO-002 — BSON representation is a pinned manifest + +- **Status:** Accepted +- **Date:** 2026-08-13 +- **Design source:** design §10, decision D-06 + +## Context + +How a Java value is represented in BSON is a data contract, but nothing in the default toolchain +treats it as one. Spring Data and the MongoDB driver both have defaults, and those defaults have +changed across versions. A `BigDecimal` can land as a `Double`, a `String` or a `Decimal128`; a `UUID` +can land as `Binary` subtype 3 or subtype 4; an `Instant` can land as a `Date` or a `String`. + +The consequences are asymmetric. A representation change is invisible in a value-equality test — +`12.30` looks like `12.30` whether it is a double or a `Decimal128` — but once a collection holds +production data, changing it is a full migration. And the UUID case is worse than a migration: legacy +Java representation byte-swaps two halves of the UUID, so a document written under one representation +and read under the other yields a *different, valid-looking* UUID. Nothing errors. You get the wrong +record. + +## Decision + +`MongoTypeRepresentationManifest` pins the representation for every type the platform maps, and +`MongoMappingConfiguration` builds the Spring Data converters from it. Nothing relies on a library +default. + +| Java | BSON | Rationale | +|---|---|---| +| `UUID` | `Binary` subtype 4 (`STANDARD`) | Subtype 3 byte-swaps; cross-representation reads are silently wrong. | +| `BigDecimal` | `Decimal128` | A double cannot represent `12.30`; money compared as a double is eventually wrong by a cent. | +| `BigInteger` | `Decimal128`, or declared `String` when out of range | 34 significant digits; out of range fails on write instead of rounding. | +| `Instant` / `OffsetDateTime` / `ZonedDateTime` | UTC `Date` | One instant, one representation. | +| `LocalDate` | declared per field | A calendar day is not an instant. | +| `LocalDateTime` | **refused** | No offset: the stored value depends on the writing JVM's default zone. | +| `enum` | `String` name | Ordinals renumber when someone inserts a constant. | + +Type metadata follows `MongoTypeMetadataPolicy` — `NONE`, `ALIAS` or `CLASS_NAME`. A +`@LongLivedMongoDocument` type may not use `CLASS_NAME`: writing a FQCN into a million documents makes +a package rename a data migration. + +The manifest is enforced by a golden gate. `MongoBsonSnapshot` canonicalises a stored document, +preserving BSON types and keeping missing distinct from null, and +`MongoBsonSnapshotAssert.hasTypeSignature(...)` fails on any representation change. The registry +pins `UuidCodec(STANDARD)` explicitly rather than inheriting a default, since inheriting the default +is the exact drift the gate exists to catch. + +## Consequences + +**Positive.** A library upgrade cannot move a representation without failing a test. Money is exact. +UUIDs read back as themselves. Class moves stay refactors. + +**Negative.** Every representation-affecting change requires updating a snapshot *and* writing a +migration. A new mapped type needs a manifest entry before it can be used. This is the intended +friction: the alternative is discovering the change in production. + +**Rejected alternative — "snapshot the JSON."** JSON destroys exactly the distinctions the gate +protects: `Decimal128` and `String` both render as text, `Binary` UUID and `ObjectId` both render as +hex, and missing and null both disappear. diff --git a/docs/adr/ADR-MONGO-003-transaction-retry.md b/docs/adr/ADR-MONGO-003-transaction-retry.md new file mode 100644 index 00000000..d5501285 --- /dev/null +++ b/docs/adr/ADR-MONGO-003-transaction-retry.md @@ -0,0 +1,68 @@ +# ADR-MONGO-003 — Transaction body retry and commit retry are separate loops + +- **Status:** Accepted +- **Date:** 2026-08-13 +- **Design source:** design §14–§16, decisions D-07 through D-10 + +## Context + +MongoDB reports two transaction failures that look similar and must be handled in opposite ways. + +`TransientTransactionError` means the transaction definitively did not commit. The correct response is +to run the whole thing again. + +`UnknownTransactionCommitResult` means the commit **may already have applied** — typically because the +primary changed while the commit was in flight. The correct response is to retry *the commit*, which +is a no-op if it already succeeded. + +The common implementation wraps everything in one retry loop. That loop replays the body after an +unknown commit, and if the commit did apply, the body applies twice. In a payment or notification path +that is a duplicate charge or a duplicate message, produced by the error handler. + +The related trap is session reuse: retrying on the same session after an abort carries the aborted +transaction's state into the retry. + +## Decision + +`MongoTransactionRetryCoordinator` implements two loops with different scopes. + +``` +for each body attempt within the budget: + open a NEW session + run the body + TransientTransactionError -> abort, continue to next body attempt + commitWithRetry(session): + UnknownTransactionCommitResult -> retry the COMMIT ONLY, same session +``` + +Rules that follow, all of them load-bearing: + +1. **A new session per body attempt.** No aborted state leaks into a retry. +2. **The body is never replayed after a commit ambiguity.** `MongoRetryScope.COMMIT_ONLY` is a + distinct value from `BODY` precisely so this cannot be collapsed by accident. +3. **One budget bounds both loops.** `MongoRetryBudget` limits attempts *and* elapsed time, with + jittered backoff, so a struggling primary is not retried into the ground by every instance at once. +4. **An exhausted commit retry surfaces `TRANSACTION_COMMIT_UNKNOWN`,** never a generic failure. An + ambiguous outcome reported as a failure invites the caller to retry — the one thing that must not + happen. See [docs/mongodb/runbooks/unknown-commit.md](../mongodb/runbooks/unknown-commit.md). +5. **Transaction bodies write a deterministic marker** so `MongoCommitReconciler` can establish what + actually happened. A transaction that cannot be reconciled has no recovery path. +6. **Classification reads labels before codes.** Server error labels are the authoritative statement + about retryability; error codes vary by version. + +Surrounding decisions that reduce how often this path is reached at all: single-document atomic +operations are preferred over transactions (D-09), partial changes use update operators rather than +`save()` (D-07), and whole-document replacement requires an optimistic revision (D-08). + +## Consequences + +**Positive.** A commit ambiguity cannot become a duplicate effect. The ambiguity reaches the caller as +an ambiguity. The retry budget is bounded in both attempts and time. + +**Negative.** Callers must handle a third outcome beyond success and failure. Transaction bodies must +write a marker they would not otherwise need. Both costs are small compared with reconciling +duplicated financial effects after the fact. + +**Rejected alternative — "one retry loop, at-least-once everywhere."** It requires every transaction +body to be fully idempotent, which is a much stronger and much less checkable property than writing +one marker, and it is silently violated the first time someone adds a non-idempotent step. diff --git a/docs/adr/ADR-MONGO-004-index-schema-admin-plane.md b/docs/adr/ADR-MONGO-004-index-schema-admin-plane.md new file mode 100644 index 00000000..f39fffa8 --- /dev/null +++ b/docs/adr/ADR-MONGO-004-index-schema-admin-plane.md @@ -0,0 +1,62 @@ +# ADR-MONGO-004 — Index and schema changes belong to the admin plane + +- **Status:** Accepted +- **Date:** 2026-08-13 +- **Design source:** design §21–§25, decisions D-11, D-13 + +## Context + +Spring Data can create indexes automatically from annotations. On a laptop this is convenient. On a +collection with a hundred million documents, an index build is a capacity event: it consumes CPU, IO +and memory on the primary for minutes to hours, and it starts because a pod restarted. + +Worse, it starts N times when N pods restart, and there is no approval step, no ordering relative to +the code that needs the index, and no record afterwards of what was created. + +Schema validators have the same shape with a sharper edge: tightening a validator on a collection with +existing data rejects writes to documents that were legal when they were written. + +TTL has a third shape. It looks like a scheduler and is not one: the TTL monitor runs about once a +minute and deletes in batches, so an expired document routinely remains readable for minutes or hours. + +## Decision + +**Indexes and validators are declared in a manifest and applied by the admin plane (D4).** Automatic +index creation in production is disabled. + +1. `MongoManifestRegistry` holds the declared indexes (`MongoIndexManifest`) and validator + (`MongoSchemaManifest`) per collection. The manifest is the source of truth, reviewed in a pull + request. +2. `MongoIndexDiffEngine` compares manifest against observed state and reports missing, extra and + *changed* indexes. Changed ones are reported rather than re-issued: MongoDB will not silently + rebuild an index whose definition moved. +3. `MongoIndexApplyPolicy` sets what an environment may do — `APPLY` (local), `APPLY_WITH_DIFF` + (staging), `DIFF_WITH_APPROVED_APPLY` (production), `REPORT_ONLY` (audit). +4. **Ownership gates every drop.** `MongoMetadataOwnership` distinguishes `APPLICATION_MANAGED` from + `SEARCH_MANAGED`, `ENCRYPTION_MANAGED` and `EXTERNAL`. Only application-managed objects are + droppable on drift. A diff engine without ownership eventually proposes dropping + `enxcol_.customers.esc`, and "the drift tool cleaned it up" is a very bad incident summary. +5. **Retirement is staged.** `MongoIndexRetirementState` moves an index declared → hidden → + observed-unused → droppable, one deployment per transition. Hiding is instantly reversible; + dropping is a rebuild. +6. **Stable validation actions are `error` and `warn` only.** `errorAndLog` is not part of the Stable + contract on 7.0 or 8.0 and is refused. Tightening goes `warn`+`MODERATE` → confirm zero warnings → + `error`+`STRICT`, in two deployments. +7. **TTL is physical cleanup only** (D-13). `MongoExpirationAccessPolicy` states the rule: a + document's presence is not authorization and its absence is not a deadline. Access control checks + the expiry field; scheduling uses a scheduler. +8. **Migrations are checksummed, locked, precondition-checked and resumable.** + `MongoMigrationRunner` fails hard when an applied id's checksum changed — two environments running + different code under one id is worse than a failed deploy. + +## Consequences + +**Positive.** Index builds are scheduled by people who know the capacity. Rollback is possible at +every step. Drift is visible without being dangerous. Nothing drops what it does not own. + +**Negative.** Adding an index is a manifest change plus an apply, not an annotation. Local development +uses `APPLY` so the friction is confined to environments where it is warranted. + +**Rejected alternative — "auto-create with a feature flag."** The flag is either on in production, +which is the problem, or off, in which case the manifest is the real mechanism and the annotation is a +second, divergent source of truth. diff --git a/docs/adr/ADR-MONGO-ADV-001-capability-promotion.md b/docs/adr/ADR-MONGO-ADV-001-capability-promotion.md new file mode 100644 index 00000000..c8de0a0f --- /dev/null +++ b/docs/adr/ADR-MONGO-ADV-001-capability-promotion.md @@ -0,0 +1,79 @@ +# ADR-MONGO-ADV-001 — Advanced capability promotion + +- **Status:** Accepted +- **Date:** 2026-08-13 +- **Design source:** design §2 (D-15), §3.2–§3.3; Advanced expansion plan Task 15 + +## Context + +Sharding, time series, CSFLE, Queryable Encryption, search, vector search and multi-tenancy each work +in a demo within an afternoon. What they do not do is behave the same way in production, and the +differences are not discovered by functional tests: + +- Sharding changes which queries are efficient. A query that misses the shard key becomes + scatter-gather, which passes every test on a one-shard cluster. +- Encryption's failure modes are KMS failure modes — wrong key, revoked permission, mid-rotation — + none of which occur against a local key provider. +- Search and vector search can be functionally correct and useless: the index returns results, and + the results are not relevant. Recall is not visible in a pass/fail assertion. +- Database-per-tenant works until the tenant count crosses what the connection and file-handle + budget supports, which is an operational property, not a code property. + +The failure mode this ADR prevents is a capability marked "done" on the strength of a green test that +never touched the environment where it will run. + +## Decision + +Every Advanced and Experimental capability is an **opt-in module behind its own flag**, and promotion +requires evidence, not confidence. + +### Enablement + +`MongoAdvancedCapabilityFlags` gates construction of every Advanced entry point. A disabled capability +does not produce a runtime warning — the type refuses to be constructed, naming the property that +enables it (`MongoAdvancedCapabilityFlags.propertyFor(capability)`). Being on the classpath is not +being enabled, and `stableNeverDependsOnAdvanced` (ArchUnit) keeps the Stable surface free of them. + +### Promotion evidence + +`MongoAdvancedPromotionGate.verify(evidence)` requires every category: + +| Category | Means | +|---|---| +| `stable-platform` | The Stable release gate passed on the same revision. | +| `actual-topology` | The capability ran on the real topology — a real sharded cluster, the real KMS, the actual target deployment. Atlas Local is a pull-request convenience and explicitly not release evidence (`MongoAtlasCapabilityContractSuite.Environment.ATLAS_LOCAL`). | +| `security` | Privileges reviewed; the capability's admin role is separate from the application role. | +| `migration` | A documented path in and, where the capability is irreversible, an explicit statement that there is no path back. | +| `failure` | Negative cases fail closed: wrong key, missing permission, rotation, non-ready index, unrouted query. | +| `runbook` | A runbook exists for the capability's characteristic incident. | + +### Additional per-capability requirements + +- **Search / vector search:** relevance and performance evidence, not functional success alone. + `MongoVectorSearchBenchmarkGate` requires recall alongside latency and index size; a gate that + measures only latency certifies a fast wrong answer. +- **Database-per-tenant and reshard orchestration remain Experimental** until operational scale + evidence exists. Both are correct in the small and unbounded in the large. +- **Reshard requires an explicit `ReshardApproval`** — a named approver and a stated window. It + rewrites the collection. + +### Promotion does not change the dependency boundary + +A capability promoted to Stable **remains an opt-in module** unless a later starter ADR changes the +dependency boundary. Promotion is a statement about evidence, not an invitation to add a transitive +dependency to every service. + +## Consequences + +**Positive.** No capability reaches production on the strength of a container-only test. The evidence +list is the same for every capability, so promotion is reviewable rather than negotiated. + +**Negative.** Promotion requires access to real infrastructure — a sharded cluster, a real KMS, the +target deployment. That is the cost of the guarantee: the alternative is finding out in production, +where encryption and sharding are both expensive to reverse. + +## Verification + +```bash +bash scripts/verify-mongodb-advanced.sh +``` diff --git a/docs/jpa/entity-mapping-guide.md b/docs/jpa/entity-mapping-guide.md new file mode 100644 index 00000000..61a00665 --- /dev/null +++ b/docs/jpa/entity-mapping-guide.md @@ -0,0 +1,64 @@ +# Entity Mapping Guide + +Design §10-§13. The rules here exist because each one has a failure mode that is invisible in review +and expensive in production. + +## The domain owns the model + +The platform defines no business entity. Table names, column semantics, keys, unique and check +requirements, associations, cascade rules, lock policy, and soft-delete policy all belong to the +domain module. There is no `GenericRepository` and no platform base repository, because a +single generic API forces every aggregate through the same operations — and one aggregate's later +requirement then changes behaviour for all of them. + +## Entities must be proxyable + +- Not `final`. Hibernate creates a lazy proxy by generating a subclass; a final entity cannot be + subclassed, so *every* association to it loads eagerly whatever the mapping says. Nothing errors. +- A non-private no-arg constructor. The provider instantiates entities reflectively before + populating fields. + +`EntityMappingCondition` in the testkit enforces both. + +## Identifiers + +Default to a sequence with an `allocationSize` that matches the migration's `INCREMENT BY`. When +they disagree, the provider hands out identifiers the sequence has not reserved and the collision +surfaces later as a primary-key violation under load. + +`GenerationType.IDENTITY` is supported and limited: the key is assigned on insert, so the provider +must execute each insert immediately to learn it, which disables JDBC insert batching entirely. +`HibernateBatchConfigurationGuard` fails a batch profile that targets an IDENTITY entity rather than +letting the import silently run an order of magnitude slower. + +UUIDv7 (`UuidV7Generator`) is the application-side option. It is preferred over UUIDv4 for a primary +key because v4 is uniformly random: every insert lands on a random leaf of the B-tree, so the index +never stays in cache and write amplification grows with the table. + +## Values + +- Enums are `EnumType.STRING` or an explicit converter. **Never** `ORDINAL` — it stores the + constant's position, so inserting a new constant anywhere but the end silently reinterprets every + existing row. +- Money is `BigDecimal` with explicit precision and scale. `double` cannot represent `0.1`, so sums + drift and reconciliation disagrees with the ledger. +- `Duration` goes through a converter that stores milliseconds. The ISO-8601 text form sorts and + compares wrongly in SQL. +- `Instant` and `OffsetDateTime` map differently; a column typed for one cannot faithfully store the + other. + +## Associations + +- To-one associations are `LAZY`. JPA's default is `EAGER`, which means every query that loads a + child also queries for its parent — the most common accidental N+1 in a JPA application. +- The owning side holds the foreign key. Adding to the inverse collection alone leaves the row + unlinked, so aggregates expose an association helper that sets both sides. +- `CascadeType.ALL` with `orphanRemoval` is correct only for a child the aggregate genuinely owns. + Between independent aggregates it deletes rows another part of the system still owns. + +## Entities never leave the transaction + +A controller must not return an entity, or a collection or `Optional` of one. Response serialisation +happens after the transaction closes, so a lazy association touched by the serialiser either throws +or — with OSIV on, which this platform forbids — issues a query from the view layer, one per element. +`EntityExposureCondition` checks generic type arguments, not just the erased return type. diff --git a/docs/jpa/experimental-promotion-checklist.md b/docs/jpa/experimental-promotion-checklist.md new file mode 100644 index 00000000..80d3814f --- /dev/null +++ b/docs/jpa/experimental-promotion-checklist.md @@ -0,0 +1,43 @@ +# Experimental Promotion Checklist + +`ExperimentalPromotionGate` evaluates this checklist. Every technical item, then the ADR. + +## Technical evidence + +- [ ] **Compatibility** — the Stable contract suite passes on the experimental target, twice, on two + supported patch releases. One passing run is a coincidence. +- [ ] **Security** — for tenancy features, cross-tenant read *and* write are both proven impossible, + including through native SQL, bulk DML, `getReference`, and the second-level cache. A filter + that covers only entity queries covers none of those. +- [ ] **Failure** — connection reuse does not leak tenant context; a failover does not silently route + a read-after-write to a stale replica; the commit-ambiguity scenarios still behave. +- [ ] **Migration** — per-tenant migration is resumable after a partial failure, and rate-limited. + With one schema per tenant, a run is N independent migrations and "it failed" is not an answer. +- [ ] **Performance** — pool capacity, replica lag under load, and per-tenant memory are measured, + not estimated. Database-per-tenant fails as a sum, not as an individual pool. + +## Decision + +- [ ] **Reviewed ADR** — recording what is being promised, the operational burden it carries, and + what would cause it to be withdrawn. + +The ADR is not a formality. The technical suites establish that something works; the ADR records +that the platform should promise it, which is a different question with a different cost. + +## What does not count as evidence + +- The version being generally available. +- The feature working in one environment. +- A passing suite that skipped because Docker was unavailable. +- A green lane whose assertions were relaxed to make it pass. + +## Outcomes + +| Decision | Meaning | +|---|---| +| `BLOCKED_TECHNICAL` | at least one suite has not passed | +| `BLOCKED_MISSING_ADR` | evidence is complete; no reviewed decision exists | +| `ELIGIBLE_FOR_STABLE_REVIEW` | both; Stable review may begin | + +The two blocked states are distinct because they need different work: one needs evidence, the other +needs a decision. diff --git a/docs/jpa/experimental-support-matrix.md b/docs/jpa/experimental-support-matrix.md new file mode 100644 index 00000000..f7293fa6 --- /dev/null +++ b/docs/jpa/experimental-support-matrix.md @@ -0,0 +1,43 @@ +# Experimental Support Matrix + +Everything here is off unless its `backend.jpa.experimental.*` flag is explicitly true, and none of +it is part of the Stable composition. + +| Feature | Flag | State | +|---|---|---| +| Shared-schema multi-tenancy (column) | `backend.jpa.experimental.multitenancy-column` | Experimental | +| PostgreSQL RLS multi-tenancy | `backend.jpa.experimental.multitenancy-rls` | Experimental | +| Schema-per-tenant | `backend.jpa.experimental.multitenancy-schema` | Experimental | +| Database-per-tenant | `backend.jpa.experimental.multitenancy-database` | Experimental | +| Consistency-aware read replica | `backend.jpa.experimental.read-replica` | Experimental | +| Jakarta Persistence 4.0 lane | `backend.jpa.experimental.jakarta-persistence-4` | Experimental | +| Hibernate ORM 8 lane | `backend.jpa.experimental.hibernate-8` | Experimental | +| PostgreSQL 19 lane | `backend.jpa.experimental.postgresql-19` | Experimental | + +Presence on the classpath is not consent. `ExperimentalFeatureGate` fails startup when a module is +present and its flag is not set, because an experimental module can arrive transitively and a +tenant-isolation feature that switched itself on would be the worst possible default. + +## Known constraints + +- Tenant context is fail-closed. An unbound tenant in a shared-schema deployment means a query with + no tenant predicate, which returns every tenant's rows. +- A Hibernate filter is not the security boundary. It does not apply to native SQL, bulk DML, + `getReference`, or the second-level cache. +- RLS requires all three of: `ENABLE ROW LEVEL SECURITY`, `FORCE ROW LEVEL SECURITY` (the owner is + otherwise exempt from its own policies), and a runtime role without `BYPASSRLS`. +- Tenant bindings are transaction-local. A session-local setting survives the connection's return to + the pool. +- `readOnly=true` never routes to a replica on its own. Read-after-write uses a consistency token or + the primary. +- Unavailable replica lag evidence means the primary. Absence of evidence is not evidence of + freshness. +- Per-tenant pools are bounded globally. Fifty tenants with a modest pool each is five hundred + connections against a server that permits a hundred. +- Tenant ids never become metric tags. Tenant cardinality is unbounded by definition. + +## Lanes never change Stable + +A compatibility lane publishes nothing and changes no Stable contract. If Hibernate 8 generates +different SQL for the fetch-pagination gate, that is a finding about Hibernate 8 — the 7.x gate keeps +asserting what 7.x must do, because that is what deployments run. diff --git a/docs/jpa/migration-guide.md b/docs/jpa/migration-guide.md new file mode 100644 index 00000000..f5cef523 --- /dev/null +++ b/docs/jpa/migration-guide.md @@ -0,0 +1,64 @@ +# Migration Guide + +Design §31-§32. Flyway owns the schema; Hibernate only validates. + +## Who may change the schema + +| Environment | Mode | +|---|---| +| local, test, dev | migrate at startup with the migration credential | +| staging, prod | deployment-owned migration; the application validates only | + +Migrating from inside the application in production means every instance of a rolling deploy races +to apply the same script, and the loser's failure is indistinguishable from a real one. + +`ddl-auto` is `validate` or `none`. Never `update`: it never drops or narrows anything, so it +produces a schema that is neither the old one nor the one the migrations describe — silently, on +whichever instance started first. + +## Validation fails closed and never repairs + +`FlywayValidationGate` throws `SchemaMismatchException` on a checksum mismatch, a missing migration, +or a schema Hibernate disagrees with. It never calls `repair`. + +Repair rewrites the schema history table to match whatever scripts are on disk. That resolves the +symptom by deleting the evidence: a checksum mismatch means the deployed script differs from the +applied one, and the interesting question is which change is missing from this database. Repair +makes that question unaskable. It exists only as an explicit admin operation with an operator, a +reason, and an approval (design §8.4). + +Only Flyway's structured error codes reach the exception. Its messages embed the script path and +part of the failing statement. + +## Concurrent index builds + +`CREATE INDEX CONCURRENTLY` cannot run inside a transaction block, and Flyway wraps migrations in +one by default. The migration therefore needs a companion configuration: + +```conf +# V42__order_index.sql.conf +executeInTransaction=false +``` + +`ConcurrentIndexMigrationInspector` fails validation without it, and additionally requires the +migration to contain nothing else. A failed concurrent build leaves an invalid index behind; +recovering is a single `DROP INDEX` when the migration did nothing else, and a manual reconstruction +of partial state when it did. + +An invalid index is not merely useless — the planner ignores it while every write still maintains +it. `FailedConcurrentIndexRecovery` reports them with the statement to run, and deliberately does +not drop them: an invalid index can also mean a build is still running, and the two are +indistinguishable from the catalog alone. + +## Upgrade scenarios + +Three, each catching something the others do not: + +| Scenario | Catches | +|---|---| +| `empty` | an early migration edited to match a later one, no longer applying to a fresh database | +| `previous-release` | the actual deployment path; the only one exercising this release's migrations | +| `oldest-supported` | a migration that silently assumes state only recent databases have | + +Each asserts a data invariant, not just the schema version. A migration that renames a column and +loses its contents leaves the version correct and the data gone. diff --git a/docs/jpa/observability.md b/docs/jpa/observability.md new file mode 100644 index 00000000..3e242447 --- /dev/null +++ b/docs/jpa/observability.md @@ -0,0 +1,61 @@ +# Observability + +Design §37. What is measured, and what must never appear in a measurement. + +## Bounded tags, always + +Every JPA metric carries exactly five tags: persistence unit, operation, query, outcome, failure +category. All five are registered identifiers, validated by `LowCardinality` at construction rather +than at the registry — so an unbounded value fails where it was introduced instead of surviving +until a dashboard stops loading. + +Never a tag: entity id, tenant id, SQL parameter, exception message, JDBC URL. Each is unbounded, so +each creates a time series per row or per failure; several are also the data the platform keeps out +of logs, which a metrics backend would store just as durably and export just as widely. + +## Transaction metrics + +| Meter | Why it exists | +|---|---| +| `jpa.transaction.duration` | the baseline | +| `jpa.transaction.rollback` | rollback rate by failure category | +| `jpa.transaction.timeout` | timeouts, distinct from other rollbacks | +| `jpa.transaction.completion.unknown` | its own counter, deliberately | + +Completion-unknown gets a separate counter rather than being folded into failures. It is the one +outcome that means a human has to look: every other failure is a transaction that definitely did not +happen, while this one is a transaction that may have. + +## Query metrics + +`jpa.query.duration` and `jpa.query.rows`. Rows are measured as well as duration because a query +that issues one statement and hydrates twenty thousand rows is fast per statement and catastrophic +per request — a duration metric alone reports it as merely slow. + +## Retry metrics + +Attempts are metrics, not warnings. Optimistic conflicts and serialization failures are the expected +cost of concurrency; logging each at WARN pages someone for a system working as designed, after +which the retry log gets filtered out and takes the genuinely interesting entries with it. + +`jpa.retry.attempt`, `jpa.retry.attempts` (distribution per operation), `jpa.retry.exhausted`. + +## Query names in SQL + +`NamedStatementInspector` prefixes each statement with its registered query name as a SQL comment, +which travels into `pg_stat_activity`, `auto_explain`, and the slow-query log. Without it, "which +endpoint issues this query" is answered by grepping the codebase for fragments of SQL. + +## Diagnostics + +`SqlDiagnosticRedactor` removes string literals, numbers, and anything email-shaped before SQL +reaches a log. Redaction is blunt on purpose: preserving "harmless" values would require knowing +which columns hold personal data. + +## The actuator endpoint + +`jpaplatform` reports database major version, provider version, schema version, OSIV state, runtime +role verification, and capability levels. It reports no JDBC URL, no username, no SQL, and no entity +catalog — an actuator endpoint is reachable by anyone who reaches the management port, and each of +those would be a free reconnaissance answer. It is read-only: an endpoint that could trigger a +migration or a repair would be an admin capability exposed over HTTP. diff --git a/docs/jpa/postgresql-extensions.md b/docs/jpa/postgresql-extensions.md new file mode 100644 index 00000000..7b2a2640 --- /dev/null +++ b/docs/jpa/postgresql-extensions.md @@ -0,0 +1,72 @@ +# PostgreSQL Extensions + +Design §8.3, §21, §30. What the platform uses beyond portable JPA, and what each is guarded by. + +Everything here is core PostgreSQL. No server extension is required. + +## Locking + +`SELECT ... FOR UPDATE` with a finite bound, always. `PostgreSqlLockOptions` refuses an unbounded +lock request because it waits as long as the holder holds it, turning one slow transaction into a +pile-up of blocked connections. + +`NOWAIT` and a wait timeout are separate requests, not two spellings of one — modelling them as a +single field with a magic zero is how "no wait" becomes "wait forever". + +`55P03` (lock not available) and `40P01` (deadlock) drive opposite recovery and are never collapsed: +the first leaves the transaction alive and the caller in control; the second has already been rolled +back by the server. + +## Work claims + +`FOR UPDATE SKIP LOCKED` is reachable only through a registered `WorkQueueName`, never as a +repository flag. It deliberately returns an incomplete view of the table: correct for handing +disjoint work to competing workers, silently wrong for anything that needs to see every matching +row. A registered claim statement must skip locked rows and impose a deterministic `ORDER BY`. + +## Upserts + +`INSERT ... ON CONFLICT ... RETURNING` under a registered `NativeWriteName` with a fixed conflict +target and update column set. The conflict target cannot be a bound parameter, so accepting one from +a caller would mean building SQL from input. + +An upsert is the correct answer to a create race precisely because the database decides. +Read-then-write cannot be made correct: another transaction can commit between the read and the +write. `(xmax = 0) AS inserted` in the `RETURNING` list is what lets the platform report +insert-versus-update without a second query. + +The executor flushes before and clears after: a native write is invisible to the Persistence +Context, so a pending managed change would otherwise overwrite it, and a managed entity loaded +beforehand would keep serving pre-upsert values. + +## JSONB + +`JsonDocument` carries a schema name and version alongside the payload. A JSONB column is schemaless +at the database level, so without an envelope the only record of what a stored document means is the +code that wrote it — and a document written two releases ago is indistinguishable from a current one. + +The payload never carries a Java class name. Type metadata in a JSONB column is a deserialization +gadget: whoever can write a row chooses the class the reader instantiates. + +Query paths are registered. A JSON path is part of the SQL text and cannot be bound, so forwarding a +request field into one is concatenating untrusted input into a statement. Values are always bound. + +## Arrays and ranges + +Arrays are built with `Connection.createArrayOf`, never by formatting a literal — hand-formatting is +where quoting bugs live, and a tag containing a comma changes the array's shape rather than its +content. + +`PgRange` models both endpoints as independently optional and independently inclusive, because that +is what a PostgreSQL range is. Whether `[09:00, 10:00)` and `[10:00, 11:00)` overlap depends on the +bracket, not the values, and a pair of `timestamptz` columns cannot express it. + +## COPY (J4 admin) + +`COPY` bypasses the Persistence Context, entity callbacks, version checks, and Envers entirely. That +is why it is fast and why it is an admin capability with a registered statement, a bounded stream, a +row and byte cap, a finite server-side `statement_timeout`, and a named operator. + +The registry accepts only `COPY ... FROM STDIN`. `COPY ... FROM '/path'` reads a file on the +*database server* as the server's OS user; it is superuser-only for exactly that reason and does not +belong behind an application API. diff --git a/docs/jpa/query-fetch-guide.md b/docs/jpa/query-fetch-guide.md new file mode 100644 index 00000000..ac73f945 --- /dev/null +++ b/docs/jpa/query-fetch-guide.md @@ -0,0 +1,74 @@ +# Query and Fetch Guide + +Design §23-§28. How queries are chosen, bounded, and proven. + +## Named queries + +Every registered query carries a `QueryName`. It becomes the metric tag, the trace attribute, and +the SQL comment that appears in `pg_stat_activity` and the slow-query log — which is the only thing +that connects a statement on the server back to the use case that issued it. The format rejects raw +SQL for a reason: a metric tag built from a query string is unbounded by construction, and one built +from a parameterised value leaks row data into telemetry. + +## Fetch plans, not eager mappings + +N+1 is solved per use case with a registered entity graph, not by making an association `EAGER` in +the mapping. The eager fix repairs the one query that needed it and imposes the extra join on every +other query against that entity, including the ones that only wanted the id. + +`fetchgraph` and `loadgraph` are different: a fetch graph is exhaustive (attributes outside it are +lazy whatever the mapping says), a load graph is additive. Choosing the wrong one produces either +missing data or the amplification the graph was meant to avoid. + +## Measuring, not guessing + +`QueryMeasurement` records statements, hydrated entities, rows, fetches, and elapsed time. Statement +count alone cannot distinguish the two failures that matter: + +- **N+1** — many statements, few rows. +- **Cartesian fetch** — one statement, an enormous number of rows. + +A suite asserting only on statement count passes the second one every time. + +## Pagination + +Offset pagination makes the database walk and discard `n` rows before returning any. Keyset +pagination replaces it: + +- The predicate is lexicographic. For an ordering of `(createdAt, id)`, "after `(t, x)`" is + `createdAt < t OR (createdAt = t AND id < x)` — **not** `createdAt <= t AND id < x`, which reads + plausibly and silently drops rows from the middle of the result set. +- The ordering must end in a unique column. Without one, a page boundary inside a run of equal + values duplicates and skips rows. +- `size + 1` rows are fetched and `size` returned. That extra row answers `hasNext` without a count + query, which would be a second full scan whose answer is stale on arrival. + +Cursors are signed. An unsigned cursor is client-controlled ordering state: rewriting it lets a +caller seek to arbitrary keys. + +## Sorting + +Client sort parameters are mapped through `SafeSortRegistry`, never passed through. A sort field +reaches the query as part of the ORDER BY clause rather than as a bound value, so forwarding the +client's string means the client writes part of the statement. `JpaSort.unsafe` has no call site in +this platform. + +The registry's tie-breaker is always appended, because a sort that does not end in a unique column +has no total order and paging over a non-total order duplicates and skips rows. + +## Streaming + +A JPA `Stream` is a live cursor holding a `ResultSet`, a statement, and a connection. `JpaStreamExecutor` +consumes it inside a try-with-resources and never returns it, because a stream returned past the +transaction boundary is a connection leak that presents as unrelated timeouts elsewhere. A read-only +transaction is required: streaming inside a write transaction pins a write connection for the whole +traversal. + +## Batching + +Configuring `hibernate.jdbc.batch_size` proves nothing. `BatchExecutionResult.jdbcBatches` comes from +counting real `executeBatch()` calls at the JDBC layer, because an IDENTITY generator, an interleaved +select, or a mid-loop flush disables batching while the configuration still says it is on. + +Flush and clear are separate boundaries. Flushing alone sends the statements and keeps every entity +in the Persistence Context — the classic bulk-import out-of-memory. diff --git a/docs/jpa/repository-adaptation.md b/docs/jpa/repository-adaptation.md new file mode 100644 index 00000000..3aa7d544 --- /dev/null +++ b/docs/jpa/repository-adaptation.md @@ -0,0 +1,156 @@ +# JPA Relational Persistence Platform — Repository Adaptation Contract + +**Design source:** `jpa-superpowers-package/docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md` +(copied to `docs/superpowers/specs/`) +**Stable plan source:** `jpa-superpowers-package/docs/superpowers/plans/2026-08-11-jpa-persistence-platform-implementation-plan.md` +(copied to `docs/superpowers/plans/`) +**Experimental plan source:** `jpa-superpowers-package/docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md` +(copied to `docs/superpowers/plans/`) + +The design package states its own adaptation rule (§3.2): the assumed package paths and Gradle +structure are explicit implementation *assumptions* made because the real Backend Skeleton +repository was not supplied. Before implementing, paths are adjusted to the repository's existing +conventions and root package while the public contracts and policy semantics are preserved. + +This file is the single record of *how* that mapping was performed. Only paths, build DSL, and +composition-root ownership changed. Public contracts, policy order, retry semantics, and error +semantics are implemented as specified. + +## 1. Why the module layout differs + +The plan assumes a greenfield library with 18 Stable Gradle projects under `modules/jpa/` plus 7 +Experimental projects under `modules/jpa-experimental/`. This repository is a Clean Architecture +template whose **fail-closed registry** (`src/config/architecture/modules.json`, enforced by +`src/settings.gradle` and `verifyCleanArchitectureDependencies`) declares **exactly 19 leaf +identities**, and `src/settings.gradle` throws when the registry does not contain exactly 19 +modules. Creating 25 more Gradle projects would violate HARD-STOP #5 in `AGENTS.md`. + +Therefore the plan's library modules become **package boundaries inside the registered leaf** +`:adapter:outbound:persistence-jpa`, with two exceptions driven by this repository's own rules. +This is the same adaptation already applied to the HTTP client platform +(`docs/httpclient/repository-adaptation.md`). + +| Plan module | Repository home | Reason | +|---|---|---| +| `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. + +## 2. Package mapping + +Root package: `io.backend.skeleton.jpa` → `dev.caskeleton.adapter.outbound.persistence`. + +| Plan module | Plan package | Repository package | +|---|---|---| +| `jpa-core-api` | `…jpa.api` (+ `.capability`, `.error`, `.query`, `.transaction`) | `dev.caskeleton.adapter.outbound.persistence.api` (+ same subpackages) | +| `jpa-transaction` | `…jpa.transaction` | `…persistence.transaction` | +| `jpa-spring-data` | `…jpa.springdata` | `…persistence.springdata` | +| `jpa-querydsl` | `…jpa.querydsl` | `…persistence.querydsl` | +| `jpa-hibernate` | `…jpa.hibernate` (+ `.batch`, `.bulk`, `.stateless`) | `…persistence.hibernate` (+ same subpackages) | +| `jpa-postgresql` | `…jpa.postgresql` (+ `.error`, `.lock`, `.constraint`, `.json`, `.array`, `.range`, `.write`) | `…persistence.postgresql` (+ same subpackages) | +| `jpa-postgresql-copy` | `…jpa.postgresql.copy` | `…persistence.postgresql.copy` | +| `jpa-migration-flyway` | `…jpa.migration` | `…persistence.migration` | +| `jpa-auditing` | `…jpa.auditing` | `…persistence.auditing` | +| `jpa-envers` | `…jpa.envers` | `…persistence.envers` | +| `jpa-cache-hibernate` | `…jpa.cache` | `…persistence.cache` | +| `jpa-observability` | `…jpa.observation` | `…persistence.observation` | +| `jpa-security` | `…jpa.security` | `…persistence.security` | +| `jpa-spring-boot-starter` | `…jpa.autoconfigure` | `dev.caskeleton.bootstrap.autoconfigure.jpa` | +| `jpa-testkit*` | `…jpa.testkit` (+ `.id`, `.mapping`, `.lifecycle`, `.query`, `.fetch`, `.postgresql`, `.migration`, `.queryplan`, `.failure`, `.pool`, `.release`) | `…persistence.testkit` (+ same subpackages), `testkit` source set | +| `jpa-experimental/*` | `…jpa.experimental` (+ `.tenant`, `.rls`, `.schema`, `.database`, `.replica`, `.next`) | `…persistence.experimental` (+ same subpackages) | + +The existing `…persistence.transaction` and `…persistence.postgresql` packages already hold this +leaf's `TransactionPort` implementation and PostgreSQL vendor composition. The platform types are +**additive**: no existing type was renamed, moved, or replaced, and no plan type collides with an +existing name. + +## 3. Test-suite mapping + +The plan declares seven JVM test suites (`test`, `integrationTest`, `contractTest`, +`migrationTest`, `failureTest`, `performanceTest`, `compatibilityTest`). This leaf already owns a +Docker-backed `postgresqlIntegrationTest` source set and its readiness Gradle tasks are registered +in a fail-closed contract (`verifyJpaReadinessRegistry` in `src/build.gradle`). + +| Plan suite | Repository lane | +|---|---| +| `test` | `src/test` — hermetic unit lane, `./gradlew :adapter:outbound:persistence-jpa:test` | +| `contractTest`, `integrationTest`, `migrationTest`, `failureTest`, `compatibilityTest` | `src/postgresqlIntegrationTest` — real PostgreSQL containers; selected by the `jpaPlatform*` Gradle tasks | +| `performanceTest` | `src/jpaPlatformPerformanceTest` — machine-dependent bounds, never part of `check` | + +Docker-dependent lanes fail closed rather than skipping, matching the existing +`PostgreSqlReadinessSupport.assertDockerAvailable()` convention in this leaf. + +## 4. Other deliberate substitutions + +| Plan assumption | Repository reality | Adaptation | +|---|---|---| +| Gradle Kotlin DSL, `build-logic` convention plugin, `jpa-library-conventions.gradle.kts` | Groovy DSL, root `src/build.gradle` conventions (spotless google-java-format, checkstyle, SpotBugs + FindSecBugs, ErrorProne, `-Werror`, one-type-per-file), `LockMode.STRICT` dependency locking | Source sets and dependencies declared in `src/adapter/outbound/persistence-jpa/build.gradle`; `gradle.lockfile` regenerated with `resolveAndLockAll --write-locks`. | +| Spring Boot 4.1 dependency management, Spring Data JPA 4.1 | Repository baseline is Spring Boot 4.0.0 | Versions are inherited from the repository BOM and never pinned per module, exactly as the plan requires ("do not override Hibernate/Flyway/Hikari versions outside the Boot BOM"). | +| Hibernate ORM 7.4 is the Stable provider | Boot 4.0.0 resolves `org.hibernate.orm:hibernate-core:7.1.8.Final` | The *declared* Stable provider baseline of the design stays 7.4 in `HibernateProviderPolicy`; the runtime provider version is read from Hibernate itself and reported. The collection-fetch-pagination gate runs against whatever provider the BOM resolves, and `HibernateProviderPolicy.driftsFromDeclaredBaseline()` makes the difference visible instead of hiding it behind a green check. | +| PostgreSQL 16·17·18 Stable matrix | This leaf's existing evidence image is `postgres:16-alpine` | `PostgreSqlVersion` declares exactly PG 16, 17, 18. The default lane runs the repository's existing 16 image; 17 and 18 are selected by `-Pjpa.matrix.versions=16,17,18`, and an unknown or empty selection is an error rather than a skip. | +| `settings.gradle.kts` module registration | Fail-closed 19-leaf registry | No registry change: leaf identity, Gradle path, allowed dependencies, and runtime memberships are unchanged. | +| `infra/jpa/{postgres,roles,toxiproxy}` | Repository already owns `infra/` | Created at the same repository-relative paths. | +| `docs/jpa/**`, `docs/adr/ADR-JPA-*`, `.github/workflows/jpa-*.yml` | Repository already owns `docs/` and `.github/workflows/` | Created at the same repository-relative paths. | +| `build.gradle.kts` release aggregate `jpaReleaseGate` | Root is `src/build.gradle` | Registered there against the repository lane names in §3. | +| Per-task `git add` + `git commit` | `AGENTS.md`: commit policy is `human-only`; agents do not stage, commit, amend, or push | Implementation is delivered unstaged. This is the only plan step intentionally not executed, and it is recorded here. | +| Querydsl as an optional module dependency | Querydsl is not part of this repository's dependency set | `querydsl` is implemented against the plan's contracts with the Querydsl types kept behind `compileOnly`, so the Stable runtime classpath never carries Querydsl and a deployment opting in adds the artifact itself. | +| Hibernate Envers as a module dependency | Envers is not part of this repository's dependency set | Same treatment as Querydsl: `compileOnly` + explicit opt-in, matching the plan's "Envers is opt-in and never enabled by a global base class". | +| `build-logic/src/test/kotlin/JpaModuleBoundaryTest.kt` | There is no `build-logic` project and no Kotlin source set; module boundaries are enforced by the registry itself | `verifyCleanArchitectureDependencies` plus `:app-bootstrap:test --tests '*CleanArchitectureTest'` assert the same property against `src/config/architecture/modules.json`, which is the authority the plan's test would have had to duplicate. | +| `PostgreSqlRuntimeRoleVerifierIntegrationTest` (Task 45) | The security lane is one suite in this leaf rather than a per-module `integrationTest` | `PostgreSqlSecurityContractTest` (tag `jpa-security`) exercises `PostgreSqlRuntimeRoleVerifier.verify` and `.requireSafe` against a real restricted role on a real server. | +| `JpaSafetyProperties`, `JpaDataSourceProperties` | `NamingConventionTest` requires every `@ConfigurationProperties` type to end in `Settings` or `Policy` | Renamed to `JpaSafetySettings` and `JpaDataSourceSettings`. The bound property prefixes and every field are unchanged; only the class names move to this repository's convention. | + +### Types relocated to keep the dependency direction legal + +The plan's module map forbids `jpa-core-api` from depending on any other platform module. Three +value-only types the design places in a downstream module are consumed by a core contract, so they +live in the core here instead. Each is a pure value with no framework dependency, so the relocation +costs nothing and the alternative — a core contract importing an adapter package — would break the +boundary the module map exists to hold. + +| Type | Plan module | Repository package | Consumed by | +|---|---|---|---| +| `TransactionCompletionEvidence` | `jpa-transaction` | `…persistence.api.transaction` | `TransactionCompletionUnknownException` (design §17.3 types the field) | +| `ConstraintCode` | `jpa-postgresql` | `…persistence.api.error` | `ConstraintViolationDetails` (design §22.4) | +| `SqlStateResolver`, `SqlExceptionSqlStateResolver` | `jpa-transaction` | `…persistence.api.error` | both the transaction module's commit classifier and the PostgreSQL translator | + +The ArchUnit rule pack (`JpaArchitectureRules`, `EntityMappingCondition`, `EntityExposureCondition`) +is placed in the `testkit` source set rather than in `…persistence.security` production code. ArchUnit +is a test library; putting the rule pack in `main` would drag it onto every deployment's runtime +classpath to serve code that only ever runs in a test. + + +### Findings the contracts produced against a real server + +Two of the design's rules turned out to be stated slightly wrong, and the container lanes are what +showed it. Both are recorded here because the design text still reads the old way. + +- **§17.2 commit ambiguity is not only SQLSTATE class `08`.** `pg_terminate_backend` on a backend + with a commit in flight reports `57P01` (`admin_shutdown`), not a connection-class state — and the + commit record may already be in the WAL when it arrives. `CommitFailureClassifier` now treats + `57P01`/`57P02`/`57P03` as completion-unknown alongside `40003`, class `08`, and transport breaks. + `CommitAmbiguityContractTest` asserts the SQLSTATE directly so the rule cannot silently narrow + again. +- **Schema-per-tenant status must be read back, not inferred from the run.** `MigrateResult`'s + target version is empty for a tenant that was already current, so recording it reported migrated + tenants as unmigrated during a partial rollout. `SchemaTenantMigrationOrchestrator` now reads the + applied version from the tenant's schema history. + +## 5. What is unchanged from the design + +- Domain owns Entity, Embeddable, Repository, Query, index requirements, lock/soft-delete/audit + policy. No `GenericRepository` and no Spring Data CRUD re-implementation exists. +- Application Service owns the transaction boundary; OSIV is false in every runtime profile. +- `TransactionCompletionUnknownException` always reports `completionUnknown=true`, + `retryable=false`, and is never automatically retried — reconciliation handles it. +- Retry re-executes the whole use case in a new transaction and a new Persistence Context. +- SQLSTATE classification is structural (`40001`, `40003`, `40P01`, `23505`, `23503`, `23514`, + `55P03`) and never parses localized message text. +- Flyway is the source of truth for production schema change; Hibernate only validates; + `ddl-auto` never mutates a deployed schema. +- `CREATE INDEX CONCURRENTLY` requires an explicit non-transactional migration marker. +- Metric labels and ordinary logs never carry SQL parameters, entity IDs, tenant IDs, or PII. +- Experimental features (multi-tenancy, RLS, schema/database tenancy, read replica, JPA 4, + Hibernate 8, PostgreSQL 19) stay behind `backend.jpa.experimental.*` flags and never enter the + Stable composition. diff --git a/docs/jpa/runbooks.md b/docs/jpa/runbooks.md new file mode 100644 index 00000000..1e585db6 --- /dev/null +++ b/docs/jpa/runbooks.md @@ -0,0 +1,85 @@ +# JPA Platform Runbooks + +Operator procedures for the failures this platform is designed to surface rather than hide. + +## A transaction reported completion unknown + +**Signal:** `jpa.transaction.completion.unknown` incremented; a `CompletionUnknownRecord` in the +reconciliation channel. + +**What it means:** the commit may or may not have happened. It is not a rollback. + +**Do not** re-run the use case. That is what the platform refused to do automatically, for the same +reason. + +**Procedure:** + +1. Take the `transactionKey` from the record. +2. Check the idempotency record for that key. +3. Check the business row the use case would have written. +4. Check the outbox for a corresponding event. +5. If all three agree the write happened, mark the record `COMMITTED` and stop. +6. If all three agree it did not, the use case may be re-run. +7. If they disagree or are inconclusive, leave it `STILL_UNKNOWN` and escalate. An inconclusive + answer is a legitimate outcome; guessing is not. + +A record with no `transactionKey` cannot be resolved automatically — use the operation name and +timestamp. + +## Deadlock or serialization rate rising + +**Signal:** `jpa.retry.attempt` rising; `jpa.retry.exhausted` non-zero. + +Retries are expected. Exhaustion is not. + +1. Group `jpa.retry.attempt` by operation. A single operation dominating means a hot row or an + inconsistent lock order. +2. For deadlocks, check whether two operations take the same rows in opposite orders — that is a + code fix, not a tuning one. +3. For serialization failures under `SERIALIZABLE`, confirm the isolation is actually required. +4. Only then consider raising `maxAttempts`. A larger budget on a hot row converts a fast failure + into a slow one. + +## Pool exhaustion + +**Signal:** connection acquisition timeouts; `PoolMeasurement.pending` non-zero. + +1. Check `REQUIRES_NEW` usage. It takes a second connection while pinning the first, so the pool + must satisfy `(threads x (1 + depth)) + 1`. +2. Check for streaming outside a bounded scope — a `Stream` returned past the transaction holds its + connection until the pool notices. +3. Check for external calls inside a DB transaction. The design forbids them precisely because an + HTTP timeout then holds a connection for its whole duration. + +## Flyway validation failed at startup + +The deployment is running against a schema it was not built for. It failed closed, which is correct. + +1. Read the reported error codes (the messages are deliberately not propagated). +2. `CHECKSUM_MISMATCH` — an applied migration was edited afterwards. Find which change is missing + from this database. **Do not run `repair`**: it rewrites history to match the scripts, which + resolves the symptom by deleting the evidence. +3. `MISSING_SCRIPT` — a migration applied here is not in this build. Usually a rollback to an older + artifact. + +## An invalid index exists + +**Signal:** `FailedConcurrentIndexRecovery.invalidIndexes()` is non-empty. + +A concurrent build failed. The index is ignored by the planner and maintained by every write. + +1. Confirm no build is currently running. An in-progress build looks identical in the catalog. +2. Run the reported `DROP INDEX CONCURRENTLY` outside a migration. +3. Re-apply the index migration. + +The platform does not drop these automatically: on a rolling deploy every instance would race to +drop an index another instance was about to finish building. + +## The runtime role failed verification + +Startup refused because the runtime credential holds `CREATE`, or `search_path` contains an +unapproved schema. + +This is not a false positive to be worked around. Re-provision from +`infra/jpa/roles/runtime-roles.sql`; the application's credential having DDL is the condition that +makes every other schema guarantee unenforceable. diff --git a/docs/jpa/security.md b/docs/jpa/security.md new file mode 100644 index 00000000..a466e37e --- /dev/null +++ b/docs/jpa/security.md @@ -0,0 +1,66 @@ +# Security + +Design §36. Credential separation, privilege verification, and what never leaves the process. + +## Three credentials + +| Role | May | +|---|---| +| `app_migration` | own the schema, apply migrations (DDL) | +| `app_runtime` | select, insert, update, delete (DML only) | +| `app_admin` | J4 operations — COPY, backfill, maintenance | + +The separation is what makes "Flyway owns schema change" enforceable rather than aspirational. If +the application's own credential cannot execute DDL, then no code path, no library, and no injected +statement can alter the schema at runtime, regardless of what the application intended. + +`infra/jpa/roles/runtime-roles.sql` provisions them. + +## Startup verification + +`PostgreSqlRuntimeRoleVerifier` asks the *server* what the connection can do: + +```sql +select current_user, + current_setting('search_path'), + has_schema_privilege(current_user, current_schema(), 'CREATE'), + has_database_privilege(current_user, current_database(), 'CREATE') +``` + +Configuration cannot answer this. Effective privileges come from direct grants, inherited role +memberships, `PUBLIC` grants, and schema ownership, and no reading of a deployment manifest +reconstructs that combination reliably. + +Startup fails when the runtime role is not on the allowlist, or holds `CREATE` on the schema or the +database. + +## search_path + +`SearchPathPolicy` is an allowlist. `search_path` decides which schema an unqualified name resolves +to, so a writable untrusted schema on it — classically `public`, where `CREATE` was granted broadly +before PostgreSQL 15 — lets a planted table, function, or operator shadow the real one, and the +application executes it without noticing. `$user` is exempt: only the connected role owns it. + +Refusing the runtime role `CREATE` closes the same route from the other side. + +## What never leaves the process + +- SQL parameter values, entity ids, tenant ids, and PII: not in exception messages, not in metric + tags, not in logs. `JpaFailureContext` composes messages from bounded values only. +- Constraint names reach the application as registered `ConstraintCode`s; an unregistered physical + name maps to a bounded unknown code rather than being passed through. +- Cursors are HMAC-signed. An unsigned cursor is client-controlled ordering state. +- The actuator report carries no JDBC URL, username, or SQL. + +## Injection surfaces, and how each is closed + +| Surface | Why it cannot be a parameter | Closed by | +|---|---|---| +| sort field | part of ORDER BY | `SafeSortRegistry` allowlist | +| JSON path | part of the statement | registered `JsonPathName` | +| schema name | an identifier | registered `SchemaTenantRegistry` | +| upsert conflict target | an identifier list | registered `UpsertConflictTarget` | +| COPY table | an identifier | registered `RegisteredCopyStatement` | +| queue claim SQL | a whole statement | registered `WorkQueueDefinition` | + +Values are always bound. Identifiers are always registered. diff --git a/docs/jpa/support-matrix.md b/docs/jpa/support-matrix.md new file mode 100644 index 00000000..8daf6383 --- /dev/null +++ b/docs/jpa/support-matrix.md @@ -0,0 +1,79 @@ +# 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. + +## 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 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. + +## Specification and provider + +| Component | Stable | Experimental | +|---|---|---| +| Jakarta Persistence | 3.2 | 4.0 (lane) | +| Hibernate ORM | 7.4 declared baseline | 8 (lane) | +| Spring Boot | repository BOM | — | + +The Hibernate row needs a note. The design declares 7.4 as the Stable provider; this repository's +Spring Boot BOM resolves 7.1.x. `HibernateProviderPolicy` holds both — the declared baseline as a +constant, the resolved version read from Hibernate itself — and `driftsFromDeclaredBaseline()` makes +the difference visible instead of asserting a constant against itself. See +[repository-adaptation.md](repository-adaptation.md) §4. + +## Capability support levels + +| Capability | Level | +|---|---| +| Full-transaction retry | Stable | +| Commit completion evidence | Stable | +| Keyset pagination | Stable | +| JDBC batch | Stable | +| Flyway schema gate | Stable | +| Runtime role verification | Stable | +| Observability | Stable | +| PostgreSQL native write (`ON CONFLICT`/`RETURNING`) | Advanced | +| PostgreSQL work claim (`SKIP LOCKED`) | Advanced | +| PostgreSQL JSONB | Advanced | +| PostgreSQL array and range | Advanced | +| Bulk DML | Advanced | +| Hibernate `StatelessSession` | Advanced | +| PostgreSQL `COPY` | Admin (J4) | +| Hibernate second-level cache | Advanced | +| Hibernate Envers | Advanced | +| Multi-tenancy (column, RLS, schema, database) | Experimental | +| Consistency-aware read replica | Experimental | + +## Release gates + +Each row is a way the platform could pass its tests and still be wrong in production. + +| Gate | Kind | What it prevents | +|---|---|---| +| `postgresql-contract` | gate | a release whose only database evidence came from H2 | +| `completion-unknown-no-retry` | gate | automatically re-running a write that may already have committed | +| `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 | + +## Explicitly unsupported + +- Reactive JPA. JPA is a blocking specification; a reactive facade over it moves the blocking call + onto an event loop rather than removing it. +- Hibernate as the production schema writer. `ddl-auto` never mutates a deployed schema. +- A platform-owned generic CRUD repository. Domains own their repositories (design §10.1). +- Automatic reconciliation of a completion-unknown transaction. The platform records; the domain + resolves. diff --git a/docs/jpa/transaction-guide.md b/docs/jpa/transaction-guide.md new file mode 100644 index 00000000..ac5e8df8 --- /dev/null +++ b/docs/jpa/transaction-guide.md @@ -0,0 +1,66 @@ +# Transaction Guide + +Design §15-§20. What owns a transaction, what may be retried, and what must never be. + +## The application service owns the boundary + +Repository adapters do not open transactions. The use case does, through `TransactionPort` or +`JpaTransactionExecutor`, because the unit of work is a business decision and only the use case +knows where it starts and ends. + +Open Session In View is off in every runtime profile. It is on by default in Spring Boot, which is +why `JpaDangerousConfigurationGuard` fails startup rather than trusting configuration review. + +## Profiles + +A `TransactionProfile` fixes propagation, isolation, timeout, read-only, and the retry budget. A +write profile must carry a positive finite timeout — the type refuses to represent one without — +because an unbounded write transaction holds a connection, its locks, and its row versions for as +long as one stuck statement takes. + +`REQUIRES_NEW` is opt-in. It acquires a second physical connection while pinning the first, so a +profile using it must be paired with the pool-pressure evidence in design §38: + +```text +maximumPoolSize >= (concurrent_threads x (1 + max_requires_new_depth)) + 1 +``` + +## Retry is per use case, never per statement + +`FullTransactionRetryCoordinator` re-enters the executor, which produces a new transaction and a new +Persistence Context for every attempt. That granularity is the whole point: an optimistic conflict +means the state the attempt computed against is no longer the committed state, so re-issuing the +same statement would compute the same wrong answer. The domain rules have to run again against +reloaded data. + +Retryable: serialization failure (`40001`), deadlock (`40P01`), optimistic conflict. +Not retryable: constraint violations, schema mismatch, query timeout, and anything unclassified. + +Two additional refusals, independent of budget: + +- An attempt that declared an irreversible external effect through `IrreversibleSideEffectContext`. + Rollback reverses database work only; an email or a card charge has already changed the world. +- Anything completion-unknown. + +## Completion unknown + +`TransactionCompletionUnknownException` is never retried, and the type system enforces it twice: +`JpaFailureContext` refuses to represent a retryable completion-unknown failure, and the exception +rebuilds its context through the safe factory whatever it is handed. + +`EvidenceAwareJpaTransactionManager` marks the phase `COMMITTING` immediately before delegating to +the provider commit and never after. If the network, the JVM, or the server dies inside that call, +the last thing written is "we asked, we do not know" — which is exactly the state that must not be +mistaken for a rollback. + +Recovery is reconciliation, not retry: + +```text +record the transaction key -> check the idempotency record + -> check the business row + -> check the outbox + -> still undetermined? reconciliation queue +``` + +`CompletionUnknownRecorder` writes that record through a channel outside the unknown transaction. +Writing it through the same connection would make the audit trail share the failure it documents. diff --git a/docs/messaging/configuration-reference.md b/docs/messaging/configuration-reference.md new file mode 100644 index 00000000..06f345e9 --- /dev/null +++ b/docs/messaging/configuration-reference.md @@ -0,0 +1,156 @@ +# 설정 레퍼런스 + +## Destination profile + +```yaml +messaging: + destinations: + order-events: + broker: kafka-primary + kind: EVENT_STREAM # ASYNC_COMMAND | DOMAIN_EVENT | INTEGRATION_EVENT + # | WORK_QUEUE | PUBLISH_SUBSCRIBE | EVENT_STREAM | REQUEST_REPLY + tier: M1 # M1 | M2 | M3 + physical: + topic: order.events.v1 + schema: + codec: application/json + compatibility: BACKWARD_TRANSITIVE + message-types: [order.created] + guarantees: + delivery: AT_LEAST_ONCE # AT_MOST_ONCE | AT_LEAST_ONCE + ordering: KEY # NONE | DESTINATION | PARTITION | KEY + external-side-effect: INBOX_TRANSACTIONAL + producer: + confirmation: REPLICATION_OR_PERSISTENCE_ACK + timeout: 5s + mandatory-routing: true + idempotent: true + consumer: + group: order-projection + concurrency: 6 + max-in-flight-per-ordering-unit: 1 + prefetch: 16 + handler-timeout: 30s + manual-settlement: false + retry: + mode: PAUSE_PARTITION # NONE | INLINE | BLOCKING | PAUSE_PARTITION + # | RETRY_DESTINATION | BROKER_DELAYED + max-attempts: 3 + initial-delay: 200ms + max-delay: 2s + multiplier: 2.0 + jitter: true + ordering-impact: PRESERVE # PRESERVE | ALLOW_REORDER + dlq: + destination: order-events-dlq + max-redrive-count: 1 + payload: + max-bytes: 1048576 + claim-check-threshold-bytes: 1048576 + key-resolver-configured: true + production: true + topology-auto-create: false +``` + +## 기본값 + +| 설정 | 기본값 | 근거 | +|---|---:|---| +| logical payload 최대 | 1,048,576 bytes | portability. 초과는 Claim Check | +| global hard 최대 | 8,388,608 bytes | 어떤 destination도 넘을 수 없는 상한 | +| header 총 크기 | 32,768 bytes | | +| header 개수 | 64 | | +| header key | 128 bytes | metric tag 안전 | +| header value | 4,096 bytes | | +| publish timeout | 5s | | +| handler timeout | 30s | | +| graceful shutdown drain | 30s | | +| 일반 destination retry | 0회 | 자동 retry는 opt-in | +| DLQ redrive batch | 100 | 한 번의 작업이 source를 덮치지 않게 | +| Outbox relay batch | 100 | | +| Outbox lease | 30s | | +| Outbox polling | 500ms | | +| metric dimension 상한 | 200 | cardinality 폭발 방지 | + +## Broker profile + +### Kafka + +```yaml +messaging: + brokers: + kafka-primary: + type: kafka + stable: true + production: true + bootstrap-servers: [broker-1:9093, broker-2:9093] + enable-idempotence: true # stable에서 필수 + acks: all # stable에서 필수 + max-in-flight-requests-per-connection: 5 # 최대 5 + delivery-timeout: 30s + enable-auto-commit: false # 항상 금지 + tls-enabled: true # production 필수 + authentication-enabled: true # production 필수 +``` + +### RabbitMQ + +```yaml +messaging: + brokers: + rabbit-primary: + type: rabbitmq + stable: true + production: true + addresses: [rabbit-1:5671] + publisher-confirms: true # stable에서 필수 + publisher-returns: true # stable에서 필수 + mandatory: true # stable에서 필수 + confirm-timeout: 5s + auto-ack: false # 항상 금지 + prefetch: 16 + quorum-queues: true # durable work queue 필수 + tls-enabled: true + authentication-enabled: true +``` + +## 보안 + +```yaml +messaging: + security: + kafka-primary: + producer: { type: SASL_SCRAM, credential-id: kafka-producer } + consumer: { type: SASL_SCRAM, credential-id: kafka-consumer } + # admin은 application runtime에 설정하지 않는다 + hostname-verification: true + access: + publishable: [order-events] + consumable: [] + administrable: [] +``` + +## Experimental / Optional + +기본값은 전부 `false`다. + +```yaml +messaging: + experimental: + kafka-share: false + pulsar: false + nats: false + bridge: + spring-cloud-stream: false +``` + +## Backpressure + +```yaml +messaging: + backpressure: + global-limit: 512 + per-destination-limit: 64 # global-limit 이하여야 한다 +``` + +`per-destination-limit > global-limit`이면 global limit이 limit이 아니게 되므로 부팅에 실패한다. diff --git a/docs/messaging/delivery-guarantees.md b/docs/messaging/delivery-guarantees.md new file mode 100644 index 00000000..518aa3e2 --- /dev/null +++ b/docs/messaging/delivery-guarantees.md @@ -0,0 +1,75 @@ +# 전달 보장 + +## 왜 `EXACTLY_ONCE`가 없는가 + +어떤 브로커도 **외부 side effect를 포함한** exactly-once를 제공하지 않는다. +실제로 존재하는 것은 at-least-once 전달 + 멱등하거나 transactional한 consumer의 조합이다. + +플랫폼이 지킬 수 없는 이름을 enum에 두면 그 책임이 눈에 보이지 않는 곳으로 밀려난다. +그래서 `DeliveryGuarantee`는 증거가 끝나는 지점에서 멈춘다. + +```java +public enum DeliveryGuarantee { AT_MOST_ONCE, AT_LEAST_ONCE } +``` + +## Publish 결과는 boolean이 아니다 + +```java +public enum PublishCompletion { CONFIRMED, REJECTED, AMBIGUOUS } +``` + +`REJECTED`와 `AMBIGUOUS`를 하나의 "실패"로 합치면 중복 주문이 만들어진다. +전자는 broker가 저장하지 않았음이 **확정**되어 포기해도 안전하고, 후자는 그렇지 않다. + +| 상황 | 결과 | +|---|---| +| 로컬 validation 실패 | `REJECTED`, `NOT_TRANSMITTED` | +| broker 명시적 reject / nack | `REJECTED` | +| confirm 수신 | `CONFIRMED` | +| Rabbit confirm + unroutable return | `REJECTED`, `UNROUTABLE` | +| bytes 전송 후 connection loss | `AMBIGUOUS` | +| confirm timeout | `AMBIGUOUS` | +| adapter가 판정 불가 | 보수적으로 `AMBIGUOUS` | + +`PublishResult` 생성자가 이 규칙을 강제한다. `CONFIRMED`인데 broker acceptance가 없거나, +`AMBIGUOUS`인데 confirmation level을 주장하면 **객체 생성 자체가 실패**한다. + +## Ordering + +```java +public enum OrderingScope { NONE, DESTINATION, PARTITION, KEY } +``` + +순서는 partition·key·단일 consumer의 성질이지 destination 전체의 성질이 아니다. +`GLOBAL`이 없는 이유가 이것이다. + +`DestinationProfileValidator`가 다음을 거부한다. + +- `ordering=KEY`인데 key resolver 없음 +- ordered destination인데 `ALLOW_REORDER` retry +- `orderingImpact=PRESERVE`인데 재발행형 retry(`RETRY_DESTINATION`, `BROKER_DELAYED`) +- `ordering=DESTINATION`인데 concurrency > 1 +- ordered destination인데 ordering unit당 in-flight > 1 + +## External side effect + +```java +public enum ExternalSideEffectGuarantee { NONE, IDEMPOTENCY_REQUIRED, INBOX_TRANSACTIONAL } +``` + +`INBOX_TRANSACTIONAL`만이 "DB side effect와 중복 차단이 같은 transaction에서 commit된다"를 의미한다. +Kafka transaction은 **Kafka 안에서만** 원자적이므로 이 값과 함께 설정하면 +`KafkaTransactionProfileValidator`가 거부한다. 두 개의 독립적인 commit을 하나로 착각하게 두지 않기 위해서다. + +## Consumer settlement 순서 + +```text +RECEIVED → DECODING → PROCESSING → HANDLER_SUCCEEDED → SETTLEMENT_SENDING + ├→ SETTLED + └→ SETTLEMENT_UNKNOWN +``` + +- handler는 broker ACK API를 호출하지 않는다. +- `Success` 이후에만 source settlement한다. +- `SETTLEMENT_UNKNOWN`은 성공이 아니다. redelivery 가능성을 의미한다. +- `SettlementResult` 생성자가 `SETTLED`인데 `redeliveryPossible=true`인 조합을 거부한다. diff --git a/docs/messaging/experimental-policy.md b/docs/messaging/experimental-policy.md new file mode 100644 index 00000000..e2052c4b --- /dev/null +++ b/docs/messaging/experimental-policy.md @@ -0,0 +1,97 @@ +# Experimental 정책 + +## Stable과 Experimental의 차이 + +**Stable**은 공통 Contract Suite(`MessagingAdapterContract`)를 변경 없이 통과한 어댑터다. +컴파일되는 어댑터가 아니라, 아래 7가지를 실제로 증명한 어댑터다. + +```text +publishesAndConfirms +returnsAmbiguousWhenConfirmIsLost +redeliversWhenSettlementIsLost +preservesMessageIdAcrossRetryAndDlq +keepsSourceUnsettledWhenDlqPublishFails +rejectsOversizedPayloadBeforeTransport +stopsAcceptingNewWorkDuringShutdown +``` + +**Experimental**은 아직 그 증명이 끝나지 않은 어댑터다. + +## 규칙 + +### 1. 기본 비활성 + +```yaml +messaging.experimental.kafka-share: false +messaging.experimental.pulsar: false +messaging.experimental.nats: false +``` + +활성화하지 않으면 validator가 `MessagingCapabilityUnavailableException`을 던진다. +Contract Suite가 아직 증명 중인 어댑터가 누군가의 기본 설정 때문에 load-bearing이 되어서는 안 된다. + +### 2. Stable 모듈이 Experimental 모듈에 의존하지 않는다 + +Gradle 의존 그래프로 강제된다. `messaging-spring-boot-starter`의 `allowed_dependencies`에 +`messaging-kafka-share-experimental`, `messaging-pulsar-experimental`, +`messaging-nats-experimental`, `messaging-spring-cloud-stream-bridge`가 **없다**. + +`verifyCleanArchitectureDependencies`가 위반을 빌드 실패로 만든다. + +### 3. Core 계약을 바꾸지 않는다 + +Experimental 어댑터는 브로커의 차이를 `MessagingCapabilities`로 표현할 뿐, +`messaging-core-api`의 타입을 바꾸지 않는다. + +### 4. 없는 기능을 광고하지 않는다 + +| 어댑터 | 광고하지 않는 것 | 이유 | +|---|---|---| +| Kafka Share Group | orderedStream, keyedOrdering, replay, brokerTransaction | 경쟁 소비자 + 개별 ack는 partition 순서를 유지할 수 없다 | +| Pulsar | brokerTransaction | Pulsar에 있지만 플랫폼 Contract Suite로 증명되지 않았다 | +| Pulsar (Shared) | keyedOrdering | round-robin 분배 | +| NATS JetStream | nativeDeadLetter | delivery limit 초과 시 terminate할 뿐 라우팅하지 않는다 | +| NATS JetStream | keyedOrdering | subject 기반 모델에 per-key 순서가 없다 | + +`false`인 capability를 요구하는 profile은 startup에서 실패한다. +조용히 약화되지 않는다. + +### 5. 명시적 거부 + +| 조합 | 결과 | +|---|---| +| Kafka Share Group + ordering != NONE | 거부 | +| Kafka Share Group + pause/resume | `MessagingCapabilityUnavailableException` | +| Pulsar Shared + ordering=KEY | 거부 (Key_Shared 필요) | +| Pulsar + ordering=DESTINATION | 거부 | +| NATS Core + AT_LEAST_ONCE | 거부 (JetStream 필요) | +| NATS ordered consumer + 경쟁 워커 > 1 | 거부 | +| NATS + ordering=KEY | 거부 | + +## Spring Cloud Stream bridge + +Experimental이 아니라 **Optional**이다. 위험이 다르다. + +Stream은 자체 binder 설정을 소유하므로, binding이 destination profile이 모르는 +serializer·error handling·acknowledgement mode를 조용히 획득할 수 있다. + +따라서 브리지는 **플랫폼 보장에 의존하지 않는 destination에만** 허용한다. + +```text +ordering scope 선언 → 거부 +retry policy 선언 → 거부 +dead letter 선언 → 거부 +``` + +이 셋 중 하나라도 필요하면 native adapter를 쓴다. 거기서만 실제로 강제되기 때문이다. + +## 승격 조건 + +Experimental → Stable로 올리려면 전부 필요하다. + +1. `MessagingAdapterContract` 7개 테스트를 변경 없이 통과 +2. 장애 주입(연결 끊김, confirm 유실, settlement 유실) 하에서 통과 +3. 지원 브로커 버전 범위 명시 및 CI 검증 +4. `support-matrix.md`의 capability 표 갱신 +5. ADR 작성 +6. 기본 활성화 여부에 대한 별도 결정 diff --git a/docs/messaging/migration-guide.md b/docs/messaging/migration-guide.md new file mode 100644 index 00000000..1b849a35 --- /dev/null +++ b/docs/messaging/migration-guide.md @@ -0,0 +1,113 @@ +# 마이그레이션 가이드 + +## 기존 Spring Kafka / Spring AMQP 코드에서 + +### 1. topic 이름을 코드에서 제거한다 + +```java +// before +kafkaTemplate.send("order.events.v1", key, payload); + +// after +publisher.publish(orderEvents, envelope, PublishOptions.defaults()); +``` + +`MessageDestination`은 logical name만 가진다. 물리 매핑은 destination profile이 소유한다. +`DestinationName`의 패턴이 `topic://orders` 같은 값을 거부하므로 우회할 수 없다. + +### 2. boolean 성공 판정을 없앤다 + +```java +// before +try { template.send(...).get(); success(); } +catch (Exception e) { fail(); } // REJECTED와 AMBIGUOUS를 구분하지 못한다 + +// after +PublishResult result = ...; +switch (result.completion()) { + case CONFIRMED -> success(); + case REJECTED -> abandon(); // broker가 저장하지 않음이 확정 + case AMBIGUOUS -> retrySameMessageId(result); // broker가 가지고 있을 수 있음 +} +``` + +이 구분이 없으면 confirm 유실 한 번이 중복 주문 하나가 된다. + +### 3. auto-commit / auto-ack를 끈다 + +```yaml +# Kafka +enable.auto.commit: false +# RabbitMQ +auto-ack: false +``` + +둘 다 validator가 강제로 거부한다. 타이머 기반 commit은 handler가 실행되기도 전에 +메시지를 처리 완료로 표시한다. + +### 4. handler에서 ack 호출을 제거한다 + +```java +// before +@KafkaListener(...) +void handle(ConsumerRecord record, Acknowledgment ack) { + process(record); + ack.acknowledge(); // 실패 시 순서가 애매해진다 +} + +// after +CompletionStage handle(MessageDelivery delivery) { + process(delivery.message().payload()); + return completedFuture(HandleResult.success()); +} +``` + +settlement는 플랫폼이 수행한다. "성공한 뒤에만 ack"가 각 handler의 기억이 아니라 +플랫폼 불변식이 된다. + +### 5. 중복을 정상 상황으로 다룬다 + +at-least-once는 중복을 전제한다. 세 가지 중 하나를 고른다. + +| 방식 | 언제 | +|---|---| +| handler 자체 멱등 | 자연 멱등 연산 (upsert 등) | +| Inbox | DB side effect가 있는 경우 | +| Kafka transaction | Kafka → Kafka 파이프라인만 | + +`ExternalSideEffectGuarantee`에 선언한다. `INBOX_TRANSACTIONAL`과 Kafka transaction을 +동시에 설정하면 거부된다. Kafka transaction은 DB를 포함하지 않는다. + +### 6. 큰 payload는 Claim Check로 + +broker frame 크기를 키우지 않는다. broker 메모리, replication latency, +consumer recovery가 동시에 나빠지고, 유계·검증 가능한 실패가 무계 실패로 바뀐다. + +1 MiB 초과는 외부 저장소로 offload하고 digest를 포함한 참조만 발행한다. + +## DB 마이그레이션 + +```text +V1__messaging_outbox.sql +V2__messaging_inbox.sql +``` + +Outbox row는 business transaction과 같은 transaction에서 쓴다. +Inbox reservation은 handler side effect와 같은 transaction에서 쓴다. +별도 transaction이면 각 패턴이 닫으려던 창이 그대로 열려 있다. + +## 단계적 전환 + +1. **publish만 전환** — 기존 consumer는 그대로. wire format은 reserved header가 추가될 뿐이다. +2. **Outbox 도입** — publish 유실 창을 닫는다. +3. **consume 전환** — handler를 `MessageHandler`로 옮기고 ack 호출을 제거한다. +4. **Inbox 도입** — 중복 side effect를 닫는다. +5. **retry·DLQ 정책 선언** — 이 시점까지 자동 retry는 0회다. + +각 단계는 독립적으로 배포 가능하고, 되돌릴 수 있다. + +## 되돌릴 수 없는 것 + +- 한 번 발행된 message type의 wire contract +- 이미 retention 안에 있는 메시지의 schema +- redrive된 메시지의 `messageId` (바뀌지 않는다 — 이것이 의도다) diff --git a/docs/messaging/operations.md b/docs/messaging/operations.md new file mode 100644 index 00000000..e5d5bf61 --- /dev/null +++ b/docs/messaging/operations.md @@ -0,0 +1,113 @@ +# 운영 Runbook + +## 배포 전 체크 + +```bash +./gradlew verifyCleanArchitectureDependencies --console=plain +./gradlew verifyRuntimeModuleMembership --console=plain +./gradlew verifyOneTypePerFile --console=plain +``` + +destination profile은 startup에서 검증된다. 아래는 **부팅 실패**다. + +- ordered destination + reorder 가능 retry +- `ordering=KEY` + key resolver 없음 +- payload 상한 > 8,388,608 bytes +- DLQ 자기 참조 / retry 자기 참조 +- retry·DLQ 그래프 cycle +- 미등록 retry·DLQ destination +- M1 destination + manual settlement +- `AT_LEAST_ONCE` + confirmation `NONE` +- production profile + topology auto-create +- broker topology가 manifest와 불일치 + +## 증상별 대응 + +### publish가 AMBIGUOUS로 쏟아진다 + +broker confirm 경로 문제다. 실패가 아니다. + +1. `PublishEvidence.transmission`이 `MAY_HAVE_BEEN_TRANSMITTED`인지 확인 +2. Kafka: `delivery.timeout.ms`, ISR 상태, leader election 확인 +3. Rabbit: confirm timeout, channel 상태 확인 +4. Outbox를 쓰고 있다면 `status='AMBIGUOUS'` row가 같은 messageId로 재시도 중이다. **정상이다.** +5. consumer 쪽 Inbox가 중복을 흡수하는지 확인 + +`AMBIGUOUS`를 실패로 취급해 새 messageId로 재발행하지 말 것. 중복이 복구 불가능해진다. + +### DLQ가 비어 있는데 메시지가 사라졌다 + +DLQ publish 실패 시 source는 settlement되지 않는다. 메시지는 source에 남아 재전달된다. + +1. `msg.failure-code`가 `DEAD_LETTER_*`인 로그 확인 +2. DLQ destination이 실제로 존재하는지 (topology validation) +3. DLQ credential에 publish 권한이 있는지 + +### consumer lag이 한 partition에서만 증가한다 + +`ContiguousPartitionOffsetTracker`가 gap에서 멈춘 것이다. 설계된 동작이다. + +commit은 **연속** 완료 offset까지만 전진한다. offset 11이 아직 실행 중이면 +10과 12가 끝나도 watermark는 10에 머문다. 12를 commit하면 consumer가 죽었을 때 11을 잃는다. + +1. 해당 partition의 in-flight를 확인 +2. 느린 handler를 찾는다 (`handlerTimeout` 초과 여부) +3. 필요하면 `PAUSE_PARTITION` retry가 걸려 있는지 확인 + +### 재시도 폭풍 + +`RetryPolicy.jitter=false`인지 확인한다. jitter 없이는 같은 초에 실패한 모든 consumer가 +같은 초에 재시도한다. + +### shutdown이 오래 걸린다 + +`GracefulShutdownCoordinator`가 in-flight를 기다리는 중이다. + +- `inFlight()`가 0이 되면 즉시 종료 +- drain deadline(기본 30초) 초과 시 남은 작업을 **unsettled로 포기**한다 → broker가 재전달 +- draining 시작 후 새 retry attempt는 만들지 않는다 + +## Destructive 작업 + +전부 `DestructiveOperationGuard`를 통과해야 한다. + +| 조건 | 요구 | +|---|---| +| admin credential | application runtime은 보유하지 않음 | +| `AdminApproval` | 유효기간 내 | +| dry-run | 항상 허용 | + +### Replay + +```text +기본: 격리된 consumer group (replay-) +기존 group 대상: 승인 티켓 필수 +``` + +기존 production group으로 replay하는 것은 "다시 읽기"가 아니라 **live consumer를 되감는 것**이다. +그 사이의 모든 것이 재처리된다. + +### Redrive + +```text +dry-run으로 후보 수 확인 +→ 승인 획득 +→ batch 100건 이하로 실행 +→ republish CONFIRMED 인 것만 DLQ에서 settlement +``` + +`redriveId`로 재구동 루프를 추적한다. 같은 메시지가 반복해서 redrive되면 +근본 원인이 해결되지 않은 것이다. + +### Offset reset + +`KafkaOffsetResetExecutor`는 승인 predicate를 **생성자 인자**로 받는다. +승인 소스 없이 조립된 runtime은 물리적으로 reset을 수행할 수 없다. + +## Topology + +production topology는 IaC가 만들고 애플리케이션은 **검증만** 한다. + +`TopologyValidationRuntime`은 모든 불일치를 한 번에 보고하고 startup을 실패시킨다. +partition 수가 다르면 destination이 광고하는 ordering 보장이 달라지고, +`min.insync.replicas`가 없으면 `acks=all`의 의미가 달라진다. diff --git a/docs/messaging/outbox-inbox.md b/docs/messaging/outbox-inbox.md new file mode 100644 index 00000000..a3813cab --- /dev/null +++ b/docs/messaging/outbox-inbox.md @@ -0,0 +1,95 @@ +# Outbox · Inbox + +## 두 패턴이 각각 무엇을 해결하는가 + +| 패턴 | 해결하는 문제 | 해결하지 않는 문제 | +|---|---|---| +| Transactional Outbox | DB commit과 publish 사이의 창(窓) | 중복 | +| Inbox | 중복 delivery의 side effect | 유실 | + +**둘 다 필요하다.** Outbox만으로는 exactly-once가 되지 않는다. + +## Outbox + +business transaction과 **같은 transaction**에서 row를 쓴다. 둘 다 commit되거나 둘 다 안 된다. + +```sql +BEGIN; + UPDATE orders SET status = 'PLACED' WHERE id = ?; + INSERT INTO messaging_outbox (message_id, destination, ...) VALUES (?, ?, ...); +COMMIT; +``` + +### relay + +```text +leaseBatch(100, 30s) -- lease로 다중 relay 인스턴스 안전 +→ publish (messageId 그대로) +→ CONFIRMED → markPublished +→ AMBIGUOUS → markAmbiguous (같은 messageId로 재시도 가능) +→ REJECTED → markFailed +``` + +### 핵심 규칙: ambiguous는 같은 messageId로 재시도 + +새 id를 발급하면 "전달됐을 수도 있는 메시지"가 "확실히 두 번째인 메시지"가 되어 +downstream의 어떤 중복 제거도 복구할 수 없다. +failed로 표시하면 broker가 이미 가지고 있을 수 있는 메시지를 잃는다. + +`message_id`를 primary key로 둔 것도 같은 이유다. 어떤 코드 경로도 실수로 새 id를 붙일 수 없다. + +### lease + +```text +status IN ('PENDING','AMBIGUOUS') AND (lease_expires_at IS NULL OR lease_expires_at <= now) +``` + +partial index `ix_messaging_outbox_claimable`이 이 쿼리를 backlog 크기에 비례하게 유지한다. +PUBLISHED row는 retention job이 지울 때까지 쌓이기 때문이다. + +## Inbox + +reservation과 side effect가 **같은 transaction**이어야 한다. + +```java +transactions.inTransaction(() -> { + if (!inbox.reserve(messageId, consumerId, now)) { + return InboxOutcome.duplicate(); // 이미 처리됨 + } + return InboxOutcome.processed(sideEffect.get()); +}); +``` + +별도 transaction으로 예약하면 Inbox가 닫으려던 바로 그 창이 다시 열린다. + +### 복합 키 + +`PRIMARY KEY (message_id, consumer_id)`. + +message_id만으로 중복 제거하면 같은 event를 소비하는 두 번째 consumer가 +첫 번째에 의해 억제된다. 각 consumer가 한 번씩 처리해야 한다. + +### retention + +broker의 최대 redelivery window보다 **길어야** 한다. +row를 먼저 지우면 늦게 도착한 redelivery가 두 번 처리된다. + +## Debezium CDC 대안 + +polling relay 대신 WAL을 읽는다. polling interval과 lease 경합이 사라지지만 +인프라와 그 자체의 실패 모드가 추가된다. + +wire contract는 동일하다. `DebeziumOutboxEventRouter`가 polling relay와 같은 reserved header를 +방출하므로 consumer는 어느 쪽이 발행했는지 구분할 수 없고, 전환은 배포 결정일 뿐 계약 변경이 아니다. + +## Claim Check + +1 MiB 초과 payload는 broker 프레임을 키우지 않고 외부 저장소로 offload한다. + +`ClaimCheckReference`는 digest를 **필수**로 가진다. claim check는 메시지를 서로 다른 retention과 +replication을 가진 두 시스템으로 쪼개므로, consumer는 producer가 저장한 바로 그 bytes를 받았음을 +증명할 수 있어야 한다. 그렇지 않으면 잘린 객체와 정상 객체를 구분할 수 없다. + +`ClaimCheckIntegrityGuard`는 fetch 전에 만료를, fetch 후에 크기와 digest를 검사한다. +digest 불일치는 `DESERIALIZATION`이 아니라 **validation** 실패로 분류한다. +bytes가 깨진 JSON인 게 아니라, 틀린 bytes이기 때문이다. diff --git a/docs/messaging/retry-dlq-redrive.md b/docs/messaging/retry-dlq-redrive.md new file mode 100644 index 00000000..39355e65 --- /dev/null +++ b/docs/messaging/retry-dlq-redrive.md @@ -0,0 +1,103 @@ +# Retry · DLQ · Redrive + +## 자동 retry는 opt-in이다 + +일반 destination의 기본값은 **retry 없음**이다. 순서를 깨거나, 멱등하지 않은 side effect를 +증폭시키거나, 이미 throttle된 downstream을 더 때리는 retry는 보이는 실패보다 나쁘다. + +## 결정 순서 + +`DefaultRetryDecisionEngine`은 아래 순서를 위에서 아래로 평가한다. + +```text +1. non-retryable category → parking(DeadLetter) 또는 Reject +2. attempt >= maxAttempts → DeadLetter +3. PRESERVE + ordered + orderedStream capability → PauseAndRetry +4. mode=PAUSE_PARTITION → PauseAndRetry +5. mode=RETRY_DESTINATION + ALLOW_REORDER → PublishToRetryDestination +6. mode=INLINE|BLOCKING → RetryInline +7. mode=BROKER_DELAYED + delayedDelivery capability → PublishToRetryDestination +8. 그 외 → DeadLetter +``` + +**retryability를 attempt 예산보다 먼저** 검사한다. deserialization 실패는 payload가 바뀌지 않으므로 +재시도가 3번 더 실패할 뿐이다. 첫 delivery에서 바로 park한다. + +**순서 보존 전략을 재발행 전략보다 먼저** 검사한다. 둘 다 설정되어 있어도 ordered destination이 +reorder 경로로 흘러내리지 않는다. + +## 기본 non-retryable + +`DESERIALIZATION`, `AUTHENTICATION`, `AUTHORIZATION`, `CONFIGURATION`은 자동 retry하지 않는다. +매 redelivery마다 동일하게 실패하므로 부하만 늘어난다. +destination profile의 `retryableCategories`로 명시적으로 뒤집을 수는 있다. + +## Backoff + +`min(maxDelay, initialDelay * multiplier^(attempt-1))`, 이후 full jitter. + +full jitter는 `[0, delay]` 균등 분포다. jitter가 없으면 같은 초에 실패한 모든 consumer가 +같은 초에 재시도하고, downstream의 회복이 재시도 폭풍으로 즉시 무효화된다. + +## Kafka: pause-and-seek vs retry topic + +| 전략 | 순서 | 언제 | +|---|---|---| +| `PAUSE_PARTITION` | 유지 | ordered destination | +| `RETRY_DESTINATION` | 깨짐 | work queue, `ALLOW_REORDER` 명시 | + +pause-and-seek는 메시지가 로그의 자기 자리를 떠나지 않는다. partition을 멈추고, 기다리고, +같은 offset으로 seek해 재전달한다. 뒤의 메시지도 함께 기다리며 이것이 의도된 동작이다. + +## RabbitMQ: delayed retry queue + +core broker에 per-message delay가 없으므로 **TTL + DLX**로 구현한다. +retry queue의 `x-message-ttl`이 만료되면 `x-dead-letter-exchange`를 통해 work queue로 되돌아간다. + +주의: TTL 만료는 큐 **head**에서 평가된다. 하나의 retry queue에 서로 다른 delay를 섞으면 +독립적으로 만료되지 않는다. + +`basic.nack(requeue=true)`는 사용하지 않는다. delay 없이 큐 head로 되돌리므로 hot loop가 된다. + +## DLQ: publish 확인 후 settlement + +이것이 dead lettering이 데이터 손실이 되지 않게 하는 **유일한** 불변식이다. + +```text +DLQ envelope 생성 (원래 messageId 유지) +→ DLQ publish +→ CONFIRMED 이면 source settlement +→ REJECTED / AMBIGUOUS 이면 source를 settlement하지 않음 +``` + +source를 먼저 ACK하면, DLQ publish가 실패했을 때 메시지의 사본이 **어디에도 남지 않는다**. +broker는 이미 해제했고 DLQ는 받지 못했다. + +AMBIGUOUS DLQ publish는 중복을 만든다. 이것이 의도된 trade다. DLQ는 사람이 읽는 곳이고 +중복은 알아볼 수 있지만, 손실은 복구할 수 없다. + +## DLQ envelope 내용 + +reserved header에만 기록한다. payload에 넣지 않는다. + +```text +msg.failure-category, msg.failure-code, msg.origin-destination, +msg.retry-attempt, msg.first-failure-at, msg.last-failure-at +``` + +stack trace, exception message, secret header, 실제 key는 **넣지 않는다**. +DLQ는 원본 topic보다 오래 보관되고 더 많은 사람이 읽는다. + +## Redrive + +M4 Admin 전용이다. `DestructiveOperationGuard`를 통과해야 한다. + +- admin credential 필요 (application runtime은 보유하지 않는다) +- 유효기간 내 `AdminApproval` 필요 +- dry-run은 항상 허용 (계획이 공짜여야 사람이 계획한다) +- batch 상한 100건 +- source == target 금지 +- `redriveId`는 `messageId`와 별개다. 재구동 루프를 식별하기 위해서다. + +redrive도 **publish → settlement** 순서다. republish가 confirm되지 않은 메시지는 +DLQ에 남는다. diff --git a/docs/messaging/security.md b/docs/messaging/security.md new file mode 100644 index 00000000..c6dc3923 --- /dev/null +++ b/docs/messaging/security.md @@ -0,0 +1,92 @@ +# Messaging 보안 + +## Credential 분리 + +producer / consumer / admin은 **서로 다른 credential**이다. +`MessageSecurityValidator`가 startup에서 강제한다. + +```text +producer credential == consumer credential → 실패 +admin credential == producer|consumer → 실패 +production 프로필에 admin credential 존재 → 실패 +``` + +마지막 규칙이 "애플리케이션은 topic을 purge할 수 없다"를 **구조적으로** 만든다. +runtime이 admin 자격 증명을 아예 보유하지 않으므로, 침해된 handler가 상승시킬 대상이 없다. + +## Production 필수 조건 + +- TLS 활성 +- TLS hostname verification 활성 +- broker authentication 활성 +- topology auto-create 비활성 + +Kafka는 추가로 `enable.idempotence=true`, `acks=all`, +`max.in.flight.requests.per.connection <= 5`, consumer auto-commit 금지. + +RabbitMQ는 추가로 publisher confirm, publisher return, `mandatory=true`, +durable work queue의 quorum queue, consumer auto-ack 금지. + +## Credential은 값이 아니라 참조다 + +`BrokerCredentialProfile`의 어떤 variant도 secret을 담지 않는다. +식별자만 보관하고 connect 시점에 `CredentialProvider`로 해석한다. +heap dump나 설정 출력에서 사용 가능한 credential이 나오지 않는다. + +`CredentialIds`는 `bearer `, `sk-`, `-----begin`, `eyJ` 같은 접두사를 거부한다. +참조가 들어갈 자리에 secret 자체를 붙여넣는 가장 흔한 사고를 막는다. + +## Rotation + +`CredentialRotationPlan.isDue()`는 만료 **전에** 참이 된다. +broker가 연결을 거부하기 시작한 시점에는 이미 publish가 실패하고 consumer가 멈춰 있다. + +rotation은 세대 교체다. `DefaultMessagingRuntimeRegistry.install()`이 새 세대를 원자적으로 +게시하고, 이전 세대는 마지막 lease가 닫힐 때까지 열려 있다가 닫힌다. +진행 중인 publish는 시작한 연결에서 confirm을 받는다. + +drain deadline이 이 대기를 제한한다. 없으면 lease 하나가 새면 폐기된 credential이 +무기한 열려 있고, rotation이 보안상 무의미해진다. + +## Header + +금지 header는 application·platform 양쪽에서 거부한다. + +```text +Authorization, Proxy-Authorization, Cookie, Set-Cookie, +access_token, refresh_token, api_key, password, client_secret +``` + +credential이 header에 들어가면 broker storage, DLQ dump, 운영 도구에 남는다. +downstream redaction으로는 되돌릴 수 없다. + +예약 header(`msg.*`, `traceparent`, `tracestate`, `baggage`)는 platform만 쓴다. +application이 `msg.id`를 설정할 수 있으면 Inbox 중복 제거와 DLQ 상관관계가 의존하는 +logical identity가 호출자 제어가 된다. + +## ACL + +`DestinationAccessValidator`가 broker ACL **이전에** 검사한다. +broker ACL 거부는 애플리케이션 컨텍스트가 없는 연결 수준 오류로 도착하므로 +"어느 모듈이 어디에 publish하려 했는가"가 조사 대상이 된다. + +## 관측성 누출 + +`MessagingRedactor`는 denylist다. + +- secret: authorization, cookie, token, password, secret, credential +- per-message identity: messageId, correlationId, causationId, partitionKey, key, offset, deliveryTag, sequence +- payload: payload, body, data +- 예외 상세: exceptionMessage, stackTrace + +identity를 지우는 이유는 두 가지다. bounded metric을 message당 하나의 series로 만들고, +support log를 재식별 표면으로 만들기 때문이다. + +`CardinalityGuard`는 dimension당 값 개수를 상한한다. +cardinality 사고는 점진적이지 않다. 테스트 10건에서는 멀쩡하고 운영에서 백엔드를 죽인다. + +## 감사 + +`MessagingAuditEvent`는 replay, redrive, offset reset, purge, delete를 기록한다. +subject(운영자 identity), approval ticket, 그리고 redactor를 통과한 details만 담는다. +누가 무엇을 했는지 증명하되 payload의 두 번째 사본이 되지 않는다. diff --git a/docs/messaging/support-matrix.md b/docs/messaging/support-matrix.md new file mode 100644 index 00000000..dc9356c3 --- /dev/null +++ b/docs/messaging/support-matrix.md @@ -0,0 +1,116 @@ +# Messaging 지원 매트릭스 + +플랫폼이 **무엇을 보장하는지**와 **무엇을 보장하지 않는지**를 브로커별로 고정한다. +여기 없는 조합은 지원되지 않는다. + +## 브로커 등급 + +| 브로커 | 등급 | 인증 기준 | Stable 기능 | 제한 | +|---|---|---|---|---| +| Kafka | Stable | 4.2+ / 4.3.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 없음(플랫폼이 대행), 기본 비활성 | +| Artemis/JMS | Extension | 범위 밖 | adapter SPI만 | 별도 ADR + Contract Suite 통과 필요 | + +## Capability 매트릭스 + +`MessagingCapabilities`가 런타임에 선언하는 값이다. `false`인 기능을 요구하는 destination profile은 +**startup에서 실패**하며, 조용히 약화되지 않는다. + +| Capability | Kafka | Kafka Share | RabbitMQ | Pulsar | NATS JS | +|---|---|---|---|---|---| +| brokerAcknowledgement | O | O | O | O | O | +| replicationOrPersistenceEvidence | O | O | O | O | O | +| perMessageSettlement | O | O | O | O | O | +| batchSettlement | O | X | X | O | O | +| orderedStream | O | **X** | X | X | O | +| keyedOrdering | O | **X** | X | Key_Shared만 | X | +| replay | O | X | X | O | O | +| delayedDelivery | X | X | retry queue로 대행 | O | X | +| brokerTransaction | O | X | X | 미승격 | X | +| deduplicatedPublish | O | X | X | X | O | +| nativeDeadLetter | X | X | O | O | **X** | +| topologyManagement | O | X | O | O | O | + +Kafka Share Group이 ordering 전부 `X`인 것은 설계 결정이다. share group은 개별 record를 +경쟁 소비자에게 나눠주고 개별 ack하므로 partition 순서를 유지할 수 없다. ordered destination을 +share group에 설정하면 `KafkaShareProfileValidator`가 거부한다. + +NATS JetStream의 `nativeDeadLetter=X`도 마찬가지다. JetStream은 delivery limit 초과 시 메시지를 +**terminate**할 뿐 어디로도 라우팅하지 않으므로, 플랫폼이 DLQ publish를 직접 수행한다. + +## 기능 등급 + +| 기능 | 등급 | +|---|---| +| Typed Publish·Consume | Stable M1 | +| At-least-once contract | Stable | +| Ambiguous publish 결과 | Stable | +| handler 성공 후 자동 settlement | Stable M1 | +| Batch / Manual settlement / Pause·Resume / Delayed / Replay 요청 | M2 | +| Broker transaction / partition / routing / subscription | M3 | +| Replay 실행 / Redrive / offset reset / purge / delete | M4 Admin | +| Kafka Share Group, Pulsar, NATS | Experimental | +| Spring Cloud Stream bridge | Optional | + +## 무엇이 "Stable"을 증명하는가 + +Stable 등급은 두 가지를 **모두** 통과해야 한다. `CompatibilityMatrixTest`가 이 규칙을 강제한다. + +### 1. 공유 Contract Suite (`MessagingAdapterContract`, 7개) + +Kafka와 RabbitMQ가 동일한 7개 테스트를 변경 없이 통과한다. 결정적 하네스를 쓰므로 +확인 유실·settlement 유실 같은 장애를 요청 시점에 재현할 수 있다. + +### 2. 실 브로커 인증 (Testcontainers) + +| 스위트 | 무엇을 증명하는가 | +|---|---| +| `KafkaBrokerIT` | `acks=all`이 실제 replication 증거를 만든다 / 잘못된 토픽은 `REJECTED` / 발행-소비 왕복에서 identity 보존 및 contiguous commit | +| `KafkaAmbiguityChaosIT` | 브로커를 `docker pause`로 멈춘 상태의 publish가 **`AMBIGUOUS`** 로 보고된다 (broker acceptance 없음, confirmation level `NONE`, 비-retryable) | +| `RabbitBrokerIT` | exchange가 confirm했는데 어떤 큐에도 바인딩되지 않은 publish가 **`REJECTED` + `UNROUTABLE`** 로 보고된다 | +| `OutboxPostgresIT` | 롤백된 트랜잭션은 발행 가능한 행을 남기지 않는다 / `SKIP LOCKED` lease가 두 relay를 분리한다 / ambiguous 행이 같은 `messageId`로 재클레임된다 | +| `InboxPostgresIT` | 재전달이 side effect를 두 번 적용하지 않는다 / 롤백은 예약도 되돌린다 | + +Docker가 없으면 `DockerAvailability` 가드로 skip되며, 이 표의 항목은 그때 **검증되지 않은 것**으로 취급한다. + +### 3. 장애 시나리오 커버리지 (`BrokerFailureMatrix`) + +`NetworkFaultScenario`가 5개 시나리오와 **각각의 기대 결과**를 코드로 고정한다. 기대 결과를 어댑터별로 +두지 않는 것이 핵심이다 — 어댑터마다 다른 답을 허용하면 공유 계약이 존재할 이유가 없다. + +| 시나리오 | 시점 | 기대 결과 | 이유 | +|---|---|---|---| +| `connection-refused` | 전송 전 | `REJECTED` | 바이트가 나가지 않았으므로 broker가 가질 수 없다 | +| `connection-cut-after-write` | 전송 후 | `AMBIGUOUS` | broker가 저장했고 confirm만 유실됐을 수 있다 | +| `confirm-timeout` | 전송 후 | `AMBIGUOUS` | timeout은 부재의 증거가 아니라 증거의 부재다 | +| `settlement-lost` | settlement 중 | `REDELIVERED` | 미settlement 메시지는 재전달이 설계다 | +| `high-latency` | 전송 후 | `AMBIGUOUS` | 판단 시점에는 confirm 유실과 구별할 수 없다 | + +`CrossBrokerContractSuite`가 릴리스 게이트로 이를 강제한다. Stable 어댑터는 5개 전부를 **실 브로커에서** +커버해야 하고, Experimental 어댑터는 `LIVE_BROKER` 커버리지를 주장할 수 없다. 커버리지는 *능력*이 아니라 +*무엇을 실제로 돌렸는지*의 기록이다. + +### 실 브로커가 실제로 잡아낸 결함 + +이 스위트들은 장식이 아니다. 작성 과정에서 결정적 테스트가 통과하는데 실 인프라에서 실패한 +결함을 두 건 잡았다. + +1. **Outbox `IN_FLIGHT` 고아 행** — lease 쿼리가 `PENDING`/`AMBIGUOUS`만 클레임 대상으로 봐서, + publish 도중 죽은 relay가 남긴 행이 lease 만료 후에도 영영 회수되지 않았다. +2. **Rabbit confirm 경합** — transport가 publish *후에* confirm을 등록해서, 연결 스레드에서 + confirm이 먼저 도착하면 유실되고 호출자가 무한 대기했다. + +둘 다 인메모리 double이 실제보다 관대해서 통과하고 있었다. + +## 명시적 비지원 + +- 공통 `EXACTLY_ONCE` 설정 — `DeliveryGuarantee`에 상수가 존재하지 않는다. +- 전역 순서 — `OrderingScope`에 `GLOBAL`이 존재하지 않는다. +- DB와 broker의 자동 원자 transaction, 기본 XA +- Java native serialization +- 무제한 payload·header, 무한 retry +- 운영 application에서의 topology 파괴 작업 +- 일반 애플리케이션에 raw broker client 반환 +- DLQ publish 확인 전 source ACK diff --git a/docs/mongodb/advanced/encryption.md b/docs/mongodb/advanced/encryption.md new file mode 100644 index 00000000..a1885890 --- /dev/null +++ b/docs/mongodb/advanced/encryption.md @@ -0,0 +1,91 @@ +# Advanced — CSFLE and Queryable Encryption + +**Capabilities:** `MongoCapability.CSFLE`, `MongoCapability.QUERYABLE_ENCRYPTION` +**Properties:** `ca-skeleton.persistence-mongo.advanced.csfle.enabled`, +`ca-skeleton.persistence-mongo.advanced.queryable-encryption.enabled` +**Status:** Advanced. + +## Requirements + +| | | +|---|---| +| Topology | Replica set or sharded cluster. | +| Server | MongoDB 7.0 or 8.0 (see §4 for the 8.0 query-type limits). | +| Privilege | `MongoPrincipalRole.ENCRYPTION_ADMIN` for the key vault; the application role never holds it. | +| Environment | A real KMS and key vault. A local key provider does not exercise any of the failure modes that matter. | + +## 1. CSFLE + +`MongoCsfleProfile` binds a collection to its `MongoCsfleFieldPolicy` list, a key vault +`MongoCredentialReference` and the key vault namespace. `MongoCsfleClientFactory` builds the encrypted +client; `MongoDataKeyResolver` resolves data keys. + +`MongoCsfleMode`: + +| Mode | Queryable | Trade-off | +|---|---|---| +| `RANDOMIZED` | no | Same plaintext encrypts differently each time. The safe default. | +| `DETERMINISTIC` | equality only | Same plaintext always yields the same ciphertext, so equality works — and so does frequency analysis. | +| `UNINDEXED` | no | Stored encrypted, excluded from any index. | + +`MongoCsfleFieldPolicy.forPii(field, queryable)` defaults to `RANDOMIZED` when the field is not +queried. Deterministic encryption requires a written `equalityQueryJustification`; the constructor +refuses a blank one, naming frequency analysis. A low-cardinality deterministic field (a status, a +country, a boolean) leaks its distribution to anyone who can read the collection, which is the party +encryption was protecting against. + +## 2. Queryable Encryption + +`MongoQueryableEncryptionProfile` binds a collection to `MongoEncryptedFieldDescriptor` entries. +`MongoQueryableEncryptionQueryType` has exactly two values: + +- `EQUALITY` +- `RANGE` — must declare its domain (`min`, `max`). The constructor refuses a range field without one, + because changing the domain later means re-encrypting the field. + +`MongoQueryableEncryptionCollectionManager` owns the collection's lifecycle, because a QE collection +is not just a collection: it carries metadata collections. + +## 3. Metadata ownership + +`MongoEncryptionMetadataOwnership` maps `customers` to `enxcol_.customers.esc` and +`enxcol_.customers.ecoc`, and reports `__safeContent__`-prefixed indexes as +`MongoMetadataOwnership.ENCRYPTION_MANAGED`. + +These are never application-owned and never droppable by drift reconciliation. A drift tool that +drops `enxcol_.customers.ecoc` corrupts the collection's queryability. This is the single most +important integration point between encryption and +[ADR-MONGO-004](../../adr/ADR-MONGO-004-index-schema-admin-plane.md). + +## 4. Unsupported combinations + +Refused at declaration, not discovered at runtime: + +| Combination | Why | +|---|---| +| CSFLE **and** QE on the same collection | Two incompatible encryption schemes over one namespace. Both profile constructors refuse it. | +| CSFLE on a time series collection | `requireNotTimeSeries(true)` raises `MongoOperationRejectedException`. | +| QE `prefix` / `suffix` / `substring` | Not available on the platform's 8.0 baseline. The factory methods throw `UnsupportedOperationException` rather than returning a profile that fails later. | +| Deterministic CSFLE without a justification | `IllegalArgumentException` naming frequency analysis. | +| Range QE without a declared domain | `IllegalArgumentException` naming re-encryption. | + +## 5. Failure recovery + +| Symptom | Cause | Action | +|---|---|---| +| `MongoEncryptionException` on read | Wrong data key, or the key vault is unreachable | Check KMS reachability and the key vault credential. Data is intact; the client cannot decrypt it. | +| `MongoEncryptionException` on write | KMS permission revoked mid-operation | Restore the grant. Writes fail closed — nothing was written in plaintext. | +| Queries return nothing on a deterministic field | The field was re-keyed | Equality matching is over ciphertext; a new key produces different ciphertext. Re-encrypt the field. | +| QE queries fail after a drift reconciliation | A metadata collection was dropped | Restore from backup. This is why ownership gates drops. | + +**Key rotation.** Rotating the customer master key re-wraps the data keys and does not require +re-encrypting documents. Rotating a *data* key does require re-encrypting every document that used +it. These are different operations with different costs, and confusing them is how a rotation becomes +an outage. + +## 6. Promotion evidence + +Per [ADR-MONGO-ADV-001](../../adr/ADR-MONGO-ADV-001-capability-promotion.md), promotion requires the +real KMS and key vault, plus negative cases that fail closed: wrong key, missing permission, rotation +mid-operation (`MongoAtlasCapabilityContractSuite.kmsFailureModes`). A local key provider certifies +none of these — it never rejects anything. diff --git a/docs/mongodb/advanced/gridfs-migration.md b/docs/mongodb/advanced/gridfs-migration.md new file mode 100644 index 00000000..6cbf32ec --- /dev/null +++ b/docs/mongodb/advanced/gridfs-migration.md @@ -0,0 +1,63 @@ +# Advanced — GridFS compatibility and migration + +**Capability:** `MongoCapability.GRIDFS_COMPATIBILITY` +**Property:** `ca-skeleton.persistence-mongo.advanced.gridfs-compatibility.enabled` +**Status:** Advanced, compatibility only. Decision D-14. + +## Position + +GridFS is a **compatibility adapter for files that already exist there**. New files use the existing +Fileserver / Object Storage adapter, which is the source of truth for binary content. + +The reason is not preference. GridFS stores file chunks in the same collections, on the same replica +set, competing for the same working set as your documents. A large file read evicts document pages +from cache, and file storage growth becomes replica-set growth — which means it becomes oplog +pressure, backup duration and failover time. Object storage was built for this and MongoDB was not. + +## Reading legacy files + +`MongoGridFsCompatibilityReader` reads existing GridFS content as +`GridFsLegacyContent(legacyId, filename, sizeBytes, checksum, stream)`. It reads; it does not write. + +## Migration + +`MongoGridFsMigrationJob` moves a file to object storage in a fixed order: + +``` +read legacy content +→ write to object storage +→ verify the target checksum matches the source +→ switch the reference +→ (later, separately) delete the source +``` + +Three properties, each of which exists because of a specific way this goes wrong: + +1. **Verify before switching.** `MongoGridFsObjectReference` requires a non-blank checksum, and the + job returns empty and writes no reference when the target checksum does not match the source. A + migration that switches the reference on a successful *write* rather than a verified *copy* + silently points at a truncated object. +2. **The source is never deleted here.** Deletion is a separate, later decision after the new + location has been serving reads long enough to be trusted. A migration that deletes as it goes has + no rollback. +3. **The checkpoint separates migrated from failed.** `MongoGridFsMigrationCheckpoint` tracks + `migratedCount()`, `failedCount()`, `clean()` and `lastMigratedLegacyId()`, so a restart continues + from the last completed file rather than starting over, and a partially failed run is visible as + partial rather than as "done". + +## Failure recovery + +| Symptom | Cause | Action | +|---|---|---| +| `migrate` returns empty | Checksum mismatch | The copy is bad. Investigate before retrying; do not force the reference. | +| `IllegalArgumentException` on the reference | Missing checksum | A reference without a checksum cannot be verified and is refused. | +| Checkpoint not `clean()` | Some files failed | Re-run for the failed ids only; the checkpoint names the last successful one. | +| Reference switched but content missing | Source deleted too early | Restore from backup. This is what rule 2 prevents. | + +## Promotion evidence + +Actual-topology evidence against the real object storage backend, a security review of the storage +credential, the migration path above, failure cases (checksum mismatch refused, missing checksum +refused, restart resumes), and this document as the runbook. + +New file storage does not go through here at all — see the fileserver adapter. diff --git a/docs/mongodb/advanced/multi-tenancy.md b/docs/mongodb/advanced/multi-tenancy.md new file mode 100644 index 00000000..5064a1f2 --- /dev/null +++ b/docs/mongodb/advanced/multi-tenancy.md @@ -0,0 +1,90 @@ +# Advanced — Multi-tenancy + +**Capabilities:** `MongoCapability.SHARED_COLLECTION_TENANCY` (Advanced), +`MongoCapability.DATABASE_PER_TENANT` (Experimental) +**Properties:** `ca-skeleton.persistence-mongo.advanced.shared-collection-tenancy.enabled`, +`ca-skeleton.persistence-mongo.advanced.database-per-tenant.enabled` + +## 1. Shared collection + +Every tenant's documents live in one collection, discriminated by a tenant field. + +`MongoTenantContext` carries the tenant. `MongoTenantPredicateInjector` adds the tenant predicate to +every query, every atomic filter and the **first** aggregation stage. `TenantScopedMongoOperations` +is the entry point, so a caller cannot construct an unscoped operation by forgetting. + +Three details are load-bearing: + +- **Injection, not convention.** A tenant predicate that each query is expected to add itself is a + cross-tenant leak waiting for one missed `where(...)`. The injector adds it structurally. +- **First aggregation stage.** `firstStageMatch(...)` places the tenant `$match` before anything else. + A `$lookup` or `$group` that runs before the tenant filter has already crossed the boundary, even if + a later stage filters the output. +- **An absent tenant is not "all tenants".** The injector takes an `Optional` so + the missing case is a decision the policy makes explicitly, not a predicate that quietly disappears. + +`MongoTenantManifestValidator.validate(manifest, tenantScopedUniqueIndexes)` checks that every unique +index that should be per-tenant actually includes the tenant field. A unique index on `email` alone in +a shared collection makes an email globally unique across tenants — tenant B cannot register an +address tenant A already used, which is both a bug and an information leak. + +`requireShardKeyAnalysed(...)` requires a shard-key readiness report before a shared-collection tenant +model is sharded: tenant id as a shard key prefix concentrates the largest tenant on one shard. + +### Observability + +`tenantId` and `rawTenantId` are on `MongoObservationConvention`'s forbidden tag list. Cardinality +grows with the customer list, and the tag ships tenant identity into the metrics backend. + +## 2. Database per tenant (Experimental) + +`MongoTenantDatabaseResolver` maps a tenant to its database; `MongoTenantClientRegistry` holds the +clients. + +Experimental for a specific reason: it is correct in the small and unbounded in the large. Each tenant +database costs connections, file handles and monitoring cardinality. It works beautifully at 20 +tenants and falls over at 2,000, and nothing in a functional test distinguishes the two. Promotion +requires operational scale evidence. + +`MongoTenantLifecyclePolicy`: + +- `requireActivationReady(tenantKey, schemaAndIndexesValidated)` — a tenant is not activated until its + schema and indexes are validated. Activating first means the first customer request is the migration + test. +- `requireDeleteAllowed(...)` — deletion requires an explicit retention decision. Dropping a tenant + database is irreversible and takes the backup surface with it. + +`MongoTenantMigrationCoordinator` runs a migration across tenant databases with per-tenant results. +Partial failure is normal and must be reported per tenant: "migration failed" across 500 databases is +not a report anyone can act on. + +## 3. Choosing + +| | Shared collection | Database per tenant | +|---|---|---| +| Isolation | Logical, enforced by injection | Physical | +| Tenant count | Unbounded | Bounded by connections and file handles | +| Per-tenant restore | Hard | Natural | +| Noisy neighbour | Shared resources | Isolated | +| Migration | One collection | N databases, partial failures | +| Cross-tenant query | Possible (and must be forbidden) | Structurally impossible | + +Shared collection is the default. Database-per-tenant is for a small number of tenants with a +contractual isolation or per-tenant-restore requirement. + +## 4. Failure recovery + +| Symptom | Cause | Action | +|---|---|---| +| Cross-tenant data visible | An operation bypassed `TenantScopedMongoOperations` | Treat as a security incident. Find the path, close it, audit access. | +| Unique constraint fires across tenants | Unique index missing the tenant field | Rebuild the index with the tenant field as prefix; the validator catches this before it ships. | +| One shard holds most data | Tenant id as shard-key prefix with a dominant tenant | Refine the shard key with a high-cardinality suffix. | +| Connection exhaustion | Database-per-tenant beyond the connection budget | The scale limit. Consolidate or move to shared collections. | +| Migration partially applied across tenants | Normal | `MongoTenantMigrationCoordinator` reports per tenant; re-run for the failures only. | + +## 5. Promotion evidence + +Shared-collection tenancy: actual-topology evidence, a security review covering cross-tenant access, +a migration path, failure cases (injection proven on query, atomic filter and first aggregation +stage), this runbook. Database-per-tenant additionally requires **operational scale evidence** and +stays Experimental until it exists. diff --git a/docs/mongodb/advanced/search-vector.md b/docs/mongodb/advanced/search-vector.md new file mode 100644 index 00000000..4f61294b --- /dev/null +++ b/docs/mongodb/advanced/search-vector.md @@ -0,0 +1,81 @@ +# Advanced — Search and Vector Search + +**Capabilities:** `MongoCapability.SEARCH`, `MongoCapability.VECTOR_SEARCH` +**Properties:** `ca-skeleton.persistence-mongo.advanced.search.enabled`, +`ca-skeleton.persistence-mongo.advanced.vector-search.enabled` +**Status:** Experimental (design §3.3). Hybrid search likewise. + +## Requirements + +| | | +|---|---| +| Topology | A deployment with the search service. Atlas Local in a container is a pull-request convenience and is **not** release evidence. | +| Privilege | `MongoPrincipalRole.SEARCH_ADMIN` for index management; the application role queries only. | +| Gate | `MongoAtlasCapabilityContractSuite` on the actual target deployment. | + +## 1. Created is not ready + +`MongoSearchIndexState`: `CREATED` → `BUILDING` → `READY`, plus `FAILED` and `DELETING`. + +`MongoSearchReadinessGate.requireReady(state)` refuses anything but `READY`. A search index is built +asynchronously: the create call returns immediately and the index answers queries with *partial* +results while building. Not an error, not empty — partial. A deployment that creates an index and +starts querying serves incomplete results for as long as the build takes, and nothing reports it. + +## 2. Search indexes are not application-owned + +`MongoSearchIndexDescriptor.metadataOwnership()` is `MongoMetadataOwnership.SEARCH_MANAGED`, and +`droppableByApplicationDrift()` is false. The index reconciliation described in +[ADR-MONGO-004](../../adr/ADR-MONGO-004-index-schema-admin-plane.md) must not drop it. + +## 3. Query guardrails + +`MongoSearchQuery` binds an index, an allowlist of paths, the search text and a result limit. + +- `requireAllowedPaths(allowed)` raises `MongoOperationRejectedException` on a path outside the + allowlist. Without it, a caller can search any indexed field, including ones indexed for a + different purpose. +- Search text length and result count are bounded at construction. An unbounded search text is a + cost multiplier on someone else's service. + +## 4. Vector search + +`MongoVectorIndexDescriptor.cosine(path, dimensions)` declares the index. +`MongoEmbedding.forIndex(index, values)` binds an embedding to it and **rejects a dimension +mismatch** — a 1536-dimension embedding against a 768-dimension index is not a runtime degradation, +it is a category error, and catching it at construction beats catching it as a confusing server +message. + +`MongoEmbedding` copies its backing array in and out. A vector that shares an array with its caller +can be mutated after the query is built, which produces a query nobody wrote. + +`MongoVectorQuery` requires `numCandidates > limit` — searching 10 candidates to return 10 results is +an exhaustive scan wearing an ANN index's name. `MongoVectorQuery.nearest(embedding, 10)` uses the +standard 20× ratio (200 candidates for 10 results). + +## 5. Relevance is the gate, not functionality + +`MongoVectorSearchBenchmarkGate.standard()` requires **recall** alongside latency and index size. +`requiredEvidence()` names recall explicitly. + +This is the difference between search and everything else in the platform. A vector index can be +functionally perfect — it accepts the index, accepts the query, returns k results, within the latency +budget — and return the wrong k. A gate that measures only latency certifies a fast wrong answer. +`failures(recall, latencyMs, indexMb)` reports which dimension failed so the finding is actionable. + +## 6. Failure recovery + +| Symptom | Cause | Action | +|---|---|---| +| Incomplete results after a deploy | Queried a `BUILDING` index | Wait for `READY`. The gate prevents this; if it fired, something bypassed it. | +| `MongoOperationRejectedException` on a path | Path not in the allowlist | Add it deliberately, or fix the caller. | +| Dimension mismatch | Model changed | A new model means a new index. Build alongside, cut over, then retire. | +| Recall dropped without a code change | The index was rebuilt with different parameters, or the data distribution shifted | Re-run the benchmark gate; treat a recall regression like a failing test. | +| Index `FAILED` | Build error on the search service | Search-side diagnosis; the application must not fall back to a scan silently. | + +## 7. Promotion evidence + +Per [ADR-MONGO-ADV-001](../../adr/ADR-MONGO-ADV-001-capability-promotion.md): the actual target +deployment (not Atlas Local), security review of `SEARCH_ADMIN`, a rebuild path, failure cases +(non-ready index refused, disallowed path refused, dimension mismatch refused), this document as the +runbook, **and** relevance evidence. Search and vector search do not promote on functional success. diff --git a/docs/mongodb/advanced/sharding.md b/docs/mongodb/advanced/sharding.md new file mode 100644 index 00000000..4e2f8f1a --- /dev/null +++ b/docs/mongodb/advanced/sharding.md @@ -0,0 +1,82 @@ +# Advanced — Sharding + +**Capability:** `MongoCapability.SHARDING` +**Property:** `ca-skeleton.persistence-mongo.advanced.sharding.enabled` +**Status:** Advanced. Reshard orchestration remains Experimental. + +## Requirements + +| | | +|---|---| +| Topology | A real sharded cluster. A replica set cannot exercise routing. | +| Server | MongoDB 7.0 or 8.0. | +| Privilege | `MongoPrincipalRole.SHARD_ADMIN` for the admin plane; the application role is unchanged. | +| Gate | `mongoShardedTest` lane with `MongoShardingContractSuite`. | + +## Shard key + +`ShardKeyDescriptor` declares the key as an ordered list of `ShardKeyPart` plus a `ShardStrategy`: + +| Strategy | Distributes | Cost | +|---|---|---| +| `RANGE` | by value ranges | Range queries stay targeted; a monotonic key (a timestamp, an `ObjectId`) sends every insert to one shard. | +| `HASHED` | by hash of the key | Inserts spread evenly; every range query becomes scatter-gather. | + +There is no strategy that is good at both, which is why the choice is a declaration rather than a +default. + +## Routing classification + +`ShardAwareQueryValidator` classifies each query before execution: + +| `MongoRoutingClassification` | Meaning | +|---|---| +| `TARGETED` | The full shard key is present. One shard answers. | +| `PREFIX_TARGETED` | A prefix of a compound key is present. A subset of shards answers. | +| `SCATTER_GATHER` | No shard-key predicate. Every shard answers. | +| `REJECTED` | Scatter-gather where the profile forbids it. | + +A scatter-gather query is not an error — some queries legitimately need every shard — but it must be +declared. Undeclared scatter-gather raises `MongoShardRoutingException`. The reason is that +scatter-gather passes every test on a single-shard development cluster and only degrades once the +cluster grows, at which point the query is already in production and the fix is a schema change. + +## Unsupported combinations + +- Unique index on a field that is not a prefix of the shard key. MongoDB cannot enforce it across + shards, and it fails at index creation, not at query time. +- Transactions that touch documents on multiple shards remain supported but cost a cross-shard + two-phase commit. Prefer a shard key that keeps a transaction's documents co-located. +- CSFLE on a sharded collection: see [encryption.md](encryption.md) for the combinations that are + refused. + +## Admin plane + +`MongoShardingAdminGateway` (D4, `SHARD_ADMIN` credential) covers shard-collection, refine-shard-key +and reshard. + +`ShardKeyAnalyzer` produces a `ShardKeyReadinessReport` before sharding a collection: cardinality, +frequency skew and monotonicity. A key with low cardinality creates jumbo chunks that cannot be split; +a monotonic key creates a hot shard. Both are visible in the report and invisible in a functional +test. + +`ReshardApproval` is required for a reshard — a named approver and a stated window. Resharding +rewrites the collection: it duplicates the data during the operation and saturates IO. It is not a +runtime operation and the type refuses to pretend otherwise. + +## Failure recovery + +| Symptom | Cause | Action | +|---|---|---| +| `MongoShardRoutingException` | Undeclared scatter-gather | Add the shard key to the predicate, or declare the query as scatter-gather in its profile after review. | +| Jumbo chunks | Low-cardinality shard key | Refine the shard key (adds a suffix, non-destructive) before considering a reshard. | +| One hot shard | Monotonic range key | Refine with a high-cardinality prefix, or reshard to hashed if range queries are not needed. | +| Balancer never converges | Chunk migration blocked by long-running operations | Check for long transactions and cursors; the balancer waits on them. | + +## Promotion evidence + +Per [ADR-MONGO-ADV-001](../../adr/ADR-MONGO-ADV-001-capability-promotion.md): actual sharded-cluster +evidence, security review of the `SHARD_ADMIN` role, a migration path for an existing unsharded +collection, failure cases (undeclared scatter-gather refused, jumbo chunk detected), and this +document as the runbook. Reshard orchestration stays Experimental until operational scale evidence +exists. diff --git a/docs/mongodb/advanced/time-series.md b/docs/mongodb/advanced/time-series.md new file mode 100644 index 00000000..7228da3f --- /dev/null +++ b/docs/mongodb/advanced/time-series.md @@ -0,0 +1,73 @@ +# Advanced — Time Series + +**Capability:** `MongoCapability.TIME_SERIES` +**Property:** `ca-skeleton.persistence-mongo.advanced.time-series.enabled` +**Status:** Advanced. + +## Requirements + +| | | +|---|---| +| Topology | Replica set or sharded cluster. | +| Server | MongoDB 7.0 or 8.0. | +| Privilege | Standard application role; collection creation goes through the admin plane. | + +## Descriptor + +`MongoTimeSeriesDescriptor` declares: + +- **timeField** — required, a BSON date. This is the bucketing axis. +- **metaField** — optional but nearly always wanted: the series identity (device id, tenant, sensor). + Documents sharing a `metaField` value bucket together, which is where the compression comes from. +- **granularity** — `MongoTimeSeriesGranularity`: + +| Granularity | Bucket span | Use for | +|---|---|---| +| `SECONDS` | 1 hour | Sub-second to per-second ingest. | +| `MINUTES` | 24 hours | Per-minute metrics. | +| `HOURS` | 30 days | Hourly rollups. | + +Granularity that is too fine produces many small buckets and loses the compression; too coarse +produces oversized buckets that must be read whole to answer a narrow query. + +## What a time series collection is not + +`MongoTimeSeriesCapabilityValidator` refuses the operations the collection type does not support, at +declaration time rather than at first use: + +- **No arbitrary updates.** Time series data is append-mostly. Delete and limited update support + exists on recent servers but is not part of this platform's contract. +- **No unique index on the measurement.** There is no `_id` to be unique on in the usual sense. +- **No CSFLE.** Refused — see [encryption.md](encryption.md). +- **No change stream on the raw buckets** as a business event source. The bucket documents are a + storage representation, not your measurements. + +Converting an existing regular collection to a time series collection is a copy, not an alter. Plan +it as a migration with a dual-write window. + +## TTL + +Time series collections use `expireAfterSeconds` on the collection rather than a TTL index on a +field. The [TTL rules](../schema-index-migration-guide.md#4-ttl) still apply: expiry is physical +cleanup on a bucket boundary, so a measurement can outlive its expiry by up to a bucket span plus the +monitor interval. Do not treat absence as a deadline. + +## Operations + +`MongoTimeSeriesOperations` is the port for insert and windowed read. Reads are bounded by the same +`MongoOperationBudget` as everything else: an unbounded time-range query on a time series collection +is the fastest way to read a year of data into heap. + +## Failure recovery + +| Symptom | Cause | Action | +|---|---|---| +| Writes rejected with an unsupported-operation error | An update or unique-index expectation | The collection type does not support it; change the access pattern. | +| Poor compression / large storage | Missing `metaField`, or granularity too fine | Both require a rebuild; measure on a copy before committing. | +| Slow range queries | Granularity too coarse for the query window | Same: rebuild with the granularity matched to the dominant query. | + +## Promotion evidence + +Actual-topology evidence on the target deployment, a migration path from the existing collection, +failure cases (unsupported update refused, CSFLE combination refused), and this document as the +runbook. diff --git a/docs/mongodb/bson-mapping-guide.md b/docs/mongodb/bson-mapping-guide.md new file mode 100644 index 00000000..ffccb583 --- /dev/null +++ b/docs/mongodb/bson-mapping-guide.md @@ -0,0 +1,92 @@ +# BSON Mapping Guide + +Design §10 and decision D-06. The representation of a value in BSON is a data contract, not an +implementation detail: once a collection holds a million documents, changing how a `BigDecimal` is +stored is a migration with downtime, not a code change. `MongoTypeRepresentationManifest` pins the +representation so a library upgrade or a different default cannot move it. + +## 1. The manifest + +`MongoTypeRepresentationManifest.standard()` fixes: + +| Java type | BSON | Representation type | +|---|---|---| +| `UUID` | `Binary` subtype 4 | `MongoUuidRepresentation.STANDARD` | +| `BigDecimal` | `Decimal128` | `MongoDecimalRepresentation.DECIMAL_128` | +| `BigInteger` | `Decimal128` (or `String` when out of range, declared) | `MongoBigIntegerRepresentation` | +| `Instant` / `OffsetDateTime` / `ZonedDateTime` | UTC `Date` | `MongoTemporalRepresentation.UTC_DATE` | +| `LocalDate` | `String` (ISO-8601) or UTC `Date`, declared per field | `MongoTemporalRepresentation` | +| `enum` | `String` name | `MongoEnumRepresentation.NAME` | + +`MongoMappingConfiguration` and `MongoCustomConversionsFactory` build the Spring Data converters from +the manifest, so there is one place to read and one place to change. + +## 2. UUID + +`UuidRepresentation.STANDARD` (subtype 4), always. The driver's legacy Java representation +(subtype 3) byte-swaps two halves of the UUID, so a document written by one representation and read +by the other yields a different — and valid-looking — UUID. Nothing errors; you just get the wrong +row. The golden snapshot kit pins the codec explicitly for this reason +(`MongoBsonSnapshot.defaultRegistry()`). + +## 3. Decimal + +`BigDecimal` → `Decimal128`, never `Double`. `12.30` stored as a double is `12.299999999999999`, and +a monetary comparison written against it will one day be wrong by a cent for a customer who notices. +`BigDecimalToDecimal128Converter` / `Decimal128ToBigDecimalConverter` are registered from the +manifest. + +`Decimal128` has 34 significant digits; a `BigDecimal` beyond that range fails on write rather than +rounding silently. + +## 4. Time + +Store instants, not local times. `LocalDateTimeMappingGuard` refuses `LocalDateTime` fields on a +mapped document: a `LocalDateTime` has no offset, so the value that goes in depends on the JVM +default zone of whichever instance wrote it, and the two instances in a rolling deploy can disagree. +Use `Instant` when the moment matters and `LocalDate` when the calendar day matters. + +## 5. Type metadata + +`MongoTypeMetadataPolicy` decides what goes in `_class`: + +| Policy | Stored | Use when | +|---|---|---| +| `NONE` | nothing | The collection holds exactly one type and never will hold a subtype. | +| `ALIAS` | a registered short alias | A polymorphic hierarchy in a long-lived collection. | +| `CLASS_NAME` | the FQCN | Short-lived or internal collections only. | + +`PolicyAwareMongoTypeMapper` enforces it, and `MongoTypeMetadataRegistry` holds alias → class. +A `@LongLivedMongoDocument` type with `CLASS_NAME` is refused: writing `com.example.OrderV2` into a +million documents means that renaming the package is a data migration. + +## 6. Missing versus null + +The golden kit keeps these apart deliberately. `MongoBsonSnapshotAssert.hasNoField(...)` and +`hasExplicitNull(...)` are different assertions, because in MongoDB they are different documents: +`{"a": null}` matches `{a: null}` and `{a: {$exists: true}}`, while `{}` matches only the first. +A mapper change that starts writing explicit nulls silently changes what your queries return. + +## 7. Golden representation tests + +Every collection with a fixed representation should have a snapshot test: + +```java +MongoBsonSnapshot snapshot = MongoBsonSnapshot.of(storedDocument); +MongoBsonSnapshotAssert.assertThat(snapshot) + .hasBsonType("amount", "DECIMAL128") + .hasBsonType("externalId", "BINARY") + .hasNoJavaClassName("dev.caskeleton") + .hasTypeSignature("_id:OBJECT_ID,amount:DECIMAL128,createdAt:DATE_TIME,externalId:BINARY"); +``` + +`hasTypeSignature` is the regression gate: it fails on *any* representation change, including ones a +value-equality assertion would pass. When it fails, the question is whether the change was intended +and has a migration — not whether to update the string. + +## 8. Round trips + +`MongoRoundTripContract` asserts that `write → read` returns an equal domain object *and* that +`write → read → write` produces an identical BSON document. The second half is what catches an +asymmetric converter: a value that reads back equal but re-serialises differently makes every +subsequent `save()` a spurious update, and turns change streams into a noise generator. diff --git a/docs/mongodb/change-stream-guide.md b/docs/mongodb/change-stream-guide.md new file mode 100644 index 00000000..f92ba7c1 --- /dev/null +++ b/docs/mongodb/change-stream-guide.md @@ -0,0 +1,105 @@ +# Change Stream Guide + +Design §20, decision D-12. A change stream is an **at-least-once projector**, not an event bus. + +## 1. What a change stream is not + +D-12 is explicit: a physical change event is not a business integration event. The two differ in +ways that matter to every consumer: + +| Change event | Integration event | +|---|---| +| Emitted per document write | Emitted per business fact | +| Shape follows the storage schema | Shape is a published contract | +| A refactor of the document changes it | A refactor of the document does not change it | +| Replayed on resume, duplicated on retry | Versioned and deliberately evolved | + +Publishing raw change events externally makes your storage schema a public API, and the first time +someone renames a field the downstream consumers break. If you need to bridge to messaging, use the +Advanced bridge, which maps to an owned envelope +([advanced/multi-tenancy.md](advanced/multi-tenancy.md) is separate; +the bridge is described in §7 below). + +## 2. Subscription and resume + +`MongoChangeStreamSubscription` declares the collection, pipeline and consistency. `MongoResumePosition` +is either a resume token or a cluster time; `MongoResumeCheckpoint` is what gets persisted and +`MongoResumeCheckpointStore` persists it. + +The checkpoint stores the token as a Base64 `encodedToken` string rather than a byte array — a record +with an array component has broken equality, and a checkpoint that does not compare correctly is a +checkpoint that silently fails its own dedup test. + +## 3. Checkpoint after processing, not after receiving + +The ordering rule that makes at-least-once actually hold: + +``` +receive event +→ process it (idempotently) +→ persist the checkpoint +``` + +Checkpointing on receipt turns the delivery guarantee into at-most-once, and the events lost are +exactly the ones the process died while handling. + +## 4. Idempotency + +`MongoChangeEventIdentity` is the dedup key: `(resumeToken, documentKey, clusterTime, operationType)`. +`MongoChangeDeduplicationStore` records what has been applied. Duplicates are not an edge case — every +resume after any interruption replays at least one event, so a projector that is not idempotent is +wrong on its first restart, not on some rare day. + +`MongoChangeProjector` returns a `MongoChangeProjectionResult` so the runner can distinguish applied +from skipped-as-duplicate, and the skip count is worth a metric: a sudden rise means something is +looping. + +## 5. States and recovery + +`MongoChangeStreamState`: `STARTING`, `RUNNING`, `RESUMING`, `STOPPED`, `HISTORY_LOST`. + +`MongoChangeStreamRecoveryPolicy` returns a `MongoChangeStreamRecoveryDecision`, which is either +`resume()` (auto-resume from the checkpoint) or `halt(state, runbook)`. A halting decision **must** +name a runbook — a decision that only says "stopped" leaves the on-call engineer to work out from +scratch whether the projection can be rebuilt and from what. + +| Situation | Decision | +|---|---| +| Transient network error, token still valid | `resume()` | +| Primary failover | `resume()` — the token survives an election | +| `invalidate` (collection dropped/renamed) | `halt(STOPPED, …)` → `MongoInvalidateRecovery` | +| Token no longer in the oplog | `halt(HISTORY_LOST, "history-lost")` → `MongoChangeHistoryLostException` | + +## 6. History lost + +`MongoChangeHistoryLostException` is raised when the resume token predates the oldest oplog entry. +The stream **cannot** be resumed: the events between the checkpoint and now are gone from the server, +and no amount of retrying brings them back. + +What the platform will not do is silently restart from "now". That looks like a recovery and is +actually a silent gap in the projection — the worst possible outcome, because nothing reports it. The +runner halts and requires an operator decision. See +[runbooks/history-lost.md](runbooks/history-lost.md). + +## 7. Bridging to messaging (Advanced) + +`MongoChangeMessagingBridge` is opt-in behind `MongoCapability.CHANGE_STREAM` plus the bridge's own +flag. It maps a change event to a platform-owned `MongoIntegrationEventEnvelope` through +`MongoChangeToIntegrationEventMapper` and hands it to a `MongoIntegrationEventPublisher` port. + +The port is defined in the bridge package rather than imported from the messaging adapter because the +architecture registry forbids adapter-to-adapter dependencies; the composition root supplies the +implementation. + +`MongoBridgeOutboxPolicy` and `MongoBridgeCheckpointPolicy` state the delivery contract: publish then +checkpoint, at-least-once, consumers must dedup on the envelope's event id. + +## 8. Operating notes + +- Change streams require a replica set. `MongoStartupValidator` refuses a change-stream profile on + `STANDALONE`. +- The change-stream principal is its own role (`MongoPrincipalRole.CHANGE_STREAM`) with + `changeStream` and `find` — not the application write credential. +- Oplog window is the recovery budget. If the oplog holds four hours, a consumer that is down for five + hours needs a rebuild, not a resume. Alert on consumer lag against the oplog window, not against + wall-clock. diff --git a/docs/mongodb/consistency-transaction-guide.md b/docs/mongodb/consistency-transaction-guide.md new file mode 100644 index 00000000..06ff6c9e --- /dev/null +++ b/docs/mongodb/consistency-transaction-guide.md @@ -0,0 +1,126 @@ +# Consistency and Transaction Guide + +Design §12–§16, decisions D-07 through D-10. This is the part of the platform where the wrong +default is most expensive and the least visible in testing, because every failure mode here needs a +primary change to reproduce. + +## 1. Prefer a single-document atomic operation + +D-09: a transaction is for a **multi-document invariant**, nothing else. A single document is already +atomic in MongoDB, so wrapping a one-document update in a transaction buys nothing and costs a +session, a two-phase commit and a new ambiguous outcome. + +D-07: partial change uses update operators, not `save()`. `MongoAtomicOperations` / +`MongoAtomicOperationsTemplate` expose the operator set through `MongoUpdateOperator` +(`$set`, `$inc`, `$push`, `$pull`, `$addToSet`, `$min`, `$max`, `$currentDate`, …) with an +`AtomicFilter` precondition and a `ReturnDocumentMode`. Read-modify-write through `save()` replaces +the whole document and silently discards any field another writer changed in between — a lost update +with no error. + +## 2. Whole-document replacement needs a revision + +D-08. `VersionedMongoUpdater` requires a `MongoRevision`: either a Spring Data `@Version` field or an +explicit expected-revision predicate in `VersionedUpdateCommand`. A replacement whose filter matched +zero documents is not "nothing to do" — `MongoOptimisticConflictTranslator` distinguishes: + +- filter matched nothing and the id does not exist → `MongoDocumentNotFoundException` +- filter matched nothing and the id exists → `MongoOptimisticConflictException` + +Collapsing these two into one is how a concurrent overwrite becomes a 404. + +## 3. Consistency profiles + +`MongoConsistencyProfile` names the read/write concern pair; `MongoConsistencyRegistry` binds a +profile to an operation or collection, and `MongoConsistencyBinder` / +`ReactiveMongoConsistencyBinder` apply it at execution. + +| Profile | Meaning | Use for | +|---|---|---| +| `PRIMARY_LOCAL` | primary read, local concern | Throughput-sensitive reads that tolerate a rollback window. | +| `PRIMARY_MAJORITY` | primary read, majority write | The default for anything a user will see again immediately. | +| `CAUSAL_MAJORITY` | majority inside a causal session | Read-your-writes across separate operations. | +| `STALE_READ_ALLOWED` | secondary reads permitted | Reporting and analytics that state their staleness. | +| `SNAPSHOT_TRANSACTION` | snapshot isolation | Multi-document reads inside a transaction. | + +A profile is a declaration, not a hint: the registry is consulted per operation and an operation +without a registered profile is rejected rather than defaulting. + +## 4. Causal sessions + +`MongoCausalSessionContext` plus `SpringMongoCausalSessionExecutor` / +`ReactiveMongoCausalSessionExecutor` carry the cluster time and operation time between operations, so +"write then read" returns the write even when the read lands on a different node. Without a causal +session, `PRIMARY_MAJORITY` gives you durability but not read-your-writes across two calls. + +In the reactive path the session travels in the Reactor context (`ReactiveMongoContextKeys`), not in +a thread local — a thread local is empty on the next operator in the chain. + +## 5. Transactions + +`MongoTransactionExecutor` / `ReactiveMongoTransactionExecutor` open a session through the session +factory, run the body, and commit. `MongoTransactionProfile` carries the consistency profile, the +`maxCommitTime` and the retry budget. Topology matters: a transaction requires a replica set, and +`MongoStartupValidator` refuses a transaction-declaring profile on `STANDALONE` at startup rather +than at the first call. + +## 6. Retry: body and commit are different loops + +D-10, and the single most consequential rule in the design. + +``` +for each body attempt: + open a NEW session + run the body + TransientTransactionError -> abort, next body attempt + commit + UnknownTransactionCommitResult -> retry COMMIT ONLY, same session +``` + +`MongoTransactionRetryCoordinator` implements exactly this: + +- **A new session per body attempt.** Reusing the session after an abort carries the aborted + transaction's state into the retry. +- **The body is never replayed after a commit ambiguity.** An unknown commit means the commit may + already have applied. Re-running the body would apply it a second time. Only the commit is retried, + and a commit retry on an already-committed transaction is a no-op by design. +- **A budget bounds both loops.** `MongoRetryBudget` limits attempts *and* elapsed time, with jittered + backoff (`delayBefore(attempt, random)`), so a struggling primary is not retried into the ground. + +`MongoRetryDecision` and `MongoRetryScope` (in `…api.error`) say what may be retried: +`MongoRetryScope.BODY`, `COMMIT_ONLY`, or `NONE`. + +## 7. Ambiguous outcomes + +`MongoExecutionOutcome` has six values, two of which are ambiguous and must not be collapsed: + +| Outcome | Did the write happen? | +|---|---| +| `NOT_SENT` | No. Safe to retry. | +| `NO_WRITE_PERFORMED` | No — the server answered and did nothing. | +| `WRITE_CONFIRMED` | Yes. | +| `PARTIAL_BULK_WRITE` | Some of it. See `MongoBulkResult`. | +| `WRITE_RESULT_UNKNOWN` | **Unknown.** | +| `TRANSACTION_COMMIT_UNKNOWN` | **Unknown.** | + +An unknown outcome is not a failure and must not be reported to a caller as one. The caller either +reconciles (`MongoCommitReconciler` re-reads a deterministic marker the body wrote) or surfaces the +ambiguity. See [runbooks/unknown-commit.md](runbooks/unknown-commit.md). + +`MongoFailureContext` records only the design-permitted fields — outcome, category, operation name, +collection profile, retry scope, attempt — never the query, the document, or the values. + +## 8. Failure translation + +`DefaultMongoFailureClassifier` classifies **labels before codes**. The server's error labels +(`TransientTransactionError`, `UnknownTransactionCommitResult`, `RetryableWriteError`) are the +authoritative statement about retryability; an error code is a secondary signal whose meaning varies +by server version. `DefaultMongoFailureTranslator` maps a classification onto the stable exception +hierarchy, and anything unmatched becomes `MongoUnclassifiedFailureException` rather than leaking a +driver type. + +## 9. Bulk writes + +`MongoBulkExecutor` returns a `MongoBulkResult` with per-item `MongoBulkItemFailure` entries. An +unordered bulk write that partially fails is `PARTIAL_BULK_WRITE`, not a failure: some documents were +written. `MongoBulkPartialFailureException` carries the succeeded and failed indexes so a caller can +resume rather than replay. diff --git a/docs/mongodb/document-modeling-guide.md b/docs/mongodb/document-modeling-guide.md new file mode 100644 index 00000000..492744f4 --- /dev/null +++ b/docs/mongodb/document-modeling-guide.md @@ -0,0 +1,85 @@ +# Document Modeling Guide + +Design §7–§9. The platform does not own your documents — D-01 is explicit that the domain owns +`@Document`, repositories, queries, index requirements and schema version. What the platform owns is +the set of modeling decisions that are expensive to reverse once a collection holds production data. + +## 1. There is no `CommonMongoRepository` + +A generic `CommonMongoRepository` is listed under explicitly unsupported (§3.4), and the +reason is not purity. A shared supertype forces every collection to share an id strategy, a +consistency profile and a query surface, and the first collection that needs a different one either +gets a cast or a leaky generic parameter. Declare a Spring Data repository per aggregate. + +## 2. Embed or reference + +`MongoDocumentModelManifest` records the decision per collection so it is reviewable, and +`MongoDocumentModelValidator` refuses the combinations that do not survive growth. + +| Descriptor | Use when | +|---|---| +| `EmbeddedCollectionDescriptor` | The child is read with the parent, is bounded, and has no independent lifecycle. Declare `maxElements`; an unbounded array is the single most common way a document reaches the size limit. | +| `MongoReferenceDescriptor` | The child is queried independently, is unbounded, or outlives the parent. Declare `MongoReferenceLifecycle` so the deletion story is written down rather than discovered. | + +The validator rejects an embedded collection without a bound, and a reference whose lifecycle says +the child is owned by the parent but which is also referenced from elsewhere. + +## 3. Size budget + +`MongoDocumentSizeBudget`: + +| Constant | Bytes | Meaning | +|---|---|---| +| `MONGODB_HARD_LIMIT_BYTES` | 16 MiB | MongoDB's own limit. | +| `PLATFORM_CEILING_BYTES` | 4 MiB | The largest budget the platform will accept. | +| `DEFAULT_BYTES` | 2 MiB | `MongoDocumentSizeBudget.standard()`. | + +A budget above the ceiling is refused at construction. Budgeting to 16 MiB means the failing write +is the first symptom, and by then the collection is already full of near-limit documents. + +## 4. Identity + +`DomainDocumentId` and `MongoIdRepresentation` fix how a domain identifier becomes `_id`. Pick the +representation once per collection and record it in the manifest: + +- `OBJECT_ID` — server-generated, monotonic, 12 bytes. Good default when the domain has no natural id. +- `UUID_BINARY` — a domain UUID stored as `Binary` subtype 4 (`STANDARD`). Never store a UUID as a + string "because it is easier to read"; it doubles the index size and loses the type. +- `STRING` — a natural key that is genuinely a string (a slug, an external system's id). + +An `_id` choice is effectively permanent: it is the shard key candidate, the resume-token join key +and the pagination tie-breaker. + +## 5. Schema version + +Every long-lived collection carries `DocumentSchemaVersion`. `MongoSchemaVersionPolicy` and +`MongoSchemaVersionRange` say which versions the running code can read; a document outside the range +raises `MongoDataSchemaUnsupportedException` rather than being silently mapped with missing fields. + +Write the range down before the migration, not after: the range is what lets old and new instances +run at once during a rolling deploy. + +## 6. Type metadata + +`@LongLivedMongoDocument` marks a document whose stored type alias must not be a Java class name. +`MongoTypeMetadataRegistry` maps alias → class. Storing the FQCN means moving or renaming the class +becomes a data migration; storing an alias keeps it a refactor. See +[bson-mapping-guide.md](bson-mapping-guide.md) §4. + +## 7. Collection profiles + +`MongoCollectionProfileRegistry` binds a `CollectionProfileName` to its consistency profile, budget +and allowlist. A collection that is not registered cannot be reached through +`MongoImperativeExecutor` or `ReactiveMongoExecutor` — the allowlist is the mechanism that keeps an +unreviewed collection from appearing in production by accident. + +## 8. What to write down before the first insert + +1. Embed/reference decision per child collection, with bounds. +2. Size budget. +3. `_id` representation. +4. Schema version range. +5. Index manifest (see [schema-index-migration-guide.md](schema-index-migration-guide.md)). +6. Consistency profile (see [consistency-transaction-guide.md](consistency-transaction-guide.md)). + +Each of these is cheap now and a migration later. diff --git a/docs/mongodb/query-aggregation-guide.md b/docs/mongodb/query-aggregation-guide.md new file mode 100644 index 00000000..812e0309 --- /dev/null +++ b/docs/mongodb/query-aggregation-guide.md @@ -0,0 +1,140 @@ +# Query and Aggregation Guide + +Design §17–§19, decision D-11. Every query and every pipeline is a registered, bounded thing. Free-form +JSON queries and unbounded pipelines are explicitly unsupported (§3.4). + +## 1. Registered operations + +Every execution carries a `MongoOperationContext`: a `MongoOperationName`, a `DatabaseProfileName`, a +`CollectionProfileName`, a `MongoOperationType` and a `MongoOperationScope`. + +`MongoOperationName` matches `[a-z][a-z0-9.-]{2,95}`. It is the join key for the budget registry, the +consistency registry, the metric tag and the log line — a free-form or interpolated name breaks all +four at once, which is why the pattern is enforced at construction. + +`MongoOperationScope` uses an `UNSPECIFIED` sentinel rather than `null`, so "the caller did not say" +is a value the policy layer can reject rather than an NPE further down. + +## 2. Query guardrails + +`PolicyAwareMongoQueryBuilder` builds a query from `MongoFieldDescriptor` + `MongoOperator` pairs +against a `MongoQueryPolicy`. The policy refuses: + +- a field not in the collection's allowlist +- an operator not allowed for that field +- a sort on an unindexed field +- `$where`, `$expr` with arbitrary JavaScript, and server-side evaluation generally +- an unbounded `$regex` + +`MongoRegexPolicy` requires an anchored prefix pattern and bounds the pattern length. An unanchored +regex is a collection scan wearing an index's clothes, and a user-supplied one is a denial-of-service +primitive. + +`MongoSortDescriptor` pairs a field with a direction and is validated against the index manifest, so +a sort that would spill to disk fails review rather than production. + +## 3. Operation budgets + +`MongoOperationBudget` bounds four things at once: + +| Bound | Why | +|---|---| +| `maxTimeMS` | The server stops working on a query nobody is waiting for. | +| result limit | An unbounded result set is an OOM with extra steps. | +| batch size | Bounds the per-round-trip memory. | +| examined-document ceiling | Catches an index regression that a time limit alone would hide on a fast day. | + +`MongoBudgetPolicyRegistry` binds a budget to an operation name; `MongoBudgetEnforcer` applies it and +raises `MongoOperationRejectedException` before execution when a request exceeds it, and +`MongoTimeoutException` when the server enforces it. + +## 4. Keyset pagination + +Unbounded `skip` is unsupported: `skip(1_000_000)` makes the server walk a million documents to throw +them away, so page 1000 costs a thousand times page 1. + +`MongoKeysetQueryBuilder` builds the resume predicate lexicographically. For a sort on `(a DESC, _id +DESC)` resuming after `(A, I)`: + +``` +(a < A) OR (a = A AND _id < I) +``` + +`validate()` rejects a `MongoKeysetSort` without a unique tie-breaker. Without one, two documents with +the same sort value straddle the page boundary and one of them is skipped or repeated — invisibly, +and only under concurrency. + +`MongoNullSortOrdering` makes null placement explicit, because MongoDB's own ordering of missing +versus null versus present is not what most people assume. + +### Cursors are authenticated + +`MongoKeysetCursorCodec` signs the cursor with HMAC-SHA256 and compares with +`MessageDigest.isEqual` (constant time). An unsigned cursor is a client-controlled query predicate: a +caller can edit it to read a range they were never offered. A tampered or truncated cursor yields +`MongoCursorException`, never a partially-decoded resume position. + +## 5. Aggregation guardrails + +`MongoAggregationPlan` is a registered pipeline: an ordered list of `MongoAggregationStageDescriptor` +validated against a `MongoAggregationProfile`. `PolicyAwareMongoAggregationExecutor` runs only a +registered plan. + +`MongoAggregationRisk` grades each stage, and the profile sets the ceiling: + +| Risk | Stages | Policy | +|---|---|---| +| low | `$match` on an indexed prefix, `$limit`, `$project` | Always allowed. | +| moderate | `$group`, `$sort` with an index, `$unwind` with a bound | Allowed within budget. | +| high | `$lookup`, `$graphLookup`, `$facet`, unindexed `$sort` | Requires explicit approval in the profile. | +| forbidden | `$out`, `$merge` outside the admin plane, `$function`, `$accumulator` | Refused. | + +`allowDiskUse` is a declared property of the plan, not a runtime flag. A pipeline that needs disk is a +pipeline whose shape should be reviewed. + +## 6. Reactive execution and cursors + +`ReactiveMongoExecutor` / `DefaultReactiveMongoExecutor` carry the operation context in the Reactor +context. `MongoCursorGuard` and `MongoCursorLease` bound cursor lifetime: + +- a cursor has a lease with a deadline +- cancellation closes the server-side cursor (`MongoCursorTermination`) +- an abandoned cursor is a server-side resource, so the lease is released on cancel, error *and* + completion — `MongoReactiveCursorPublisher` uses `Flux.using` so all three paths run the same + release + +A leaked cursor does not fail anything locally; it consumes a connection and a snapshot on the server +until the server's own timeout, which is why the guard is not optional. + +## 7. Geospatial + +`MongoGeoQuery` + `MongoGeoPoint` + `MongoGeoDistance` over a `2dsphere` index. Distances are metres +on a sphere (`nearSphere` with `maxDistance`), never degrees — a degree of longitude is a different +distance in Oslo than in Nairobi, and a radius expressed in degrees is a bug that only shows up away +from the equator. `SpringMongoGeospatialOperations` is the Spring Data binding; +`MongoGeospatialOperations` is the port. + +## 8. Native capability gateway + +When a registered operation genuinely needs something outside the Stable API, it goes through +`MongoNativeCapabilityGateway` (`PolicyAwareMongoNativeGateway`), never through the driver directly. +The admission order is fixed: + +``` +capability registered +→ database profile +→ collection allowlist +→ operation name present +→ timeout / maxTimeMS +→ consistency profile +→ result / batch limit +→ trace +→ log redaction +→ command category (MongoNativeCommandCategory) +→ D4 admin command refused +→ execute +``` + +`ApprovedMongoNativeOperation` is the registration record; `MongoNativeOperationPolicy` is the policy. +An admin-plane command reaching this gateway is refused regardless of capability — the admin plane has +its own credential and its own client (see [security-observability.md](security-observability.md)). diff --git a/docs/mongodb/repository-adaptation.md b/docs/mongodb/repository-adaptation.md new file mode 100644 index 00000000..2d8b1609 --- /dev/null +++ b/docs/mongodb/repository-adaptation.md @@ -0,0 +1,124 @@ +# MongoDB Document Persistence Platform — Repository Adaptation Contract + +**Design source:** `mongodb-superpowers-package/docs/superpowers/specs/2026-08-11-mongodb-document-persistence-platform-design.md` +**Stable plan:** `mongodb-superpowers-package/docs/superpowers/plans/2026-08-11-mongodb-document-persistence-platform-implementation-plan.md` +**Advanced plan:** `mongodb-superpowers-package/docs/superpowers/plans/2026-08-11-mongodb-advanced-capabilities-expansion-plan.md` + +The design package declares its own module root (`modules/mongodb`) and root package +(`io.backend.skeleton.mongodb`) as *implementation assumptions*, not as contract. This file is the +single record of how that assumed layout was mapped onto this repository. Only paths, build DSL, +and composition-root ownership changed. Public contracts, policy order, and error semantics are +implemented exactly as specified. + +## 1. Why the module layout differs + +The design assumes 19 Stable Gradle projects under `modules/mongodb/` and 12 Advanced projects +under `modules/mongodb-advanced/`. This repository is a Clean Architecture template whose +**fail-closed registry** (`src/config/architecture/modules.json`, enforced by `src/settings.gradle` +and `verifyCleanArchitectureDependencies`) declares **exactly 19 leaf identities**. Creating 31 more +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. + +## 2. Package mapping + +Root package: `io.backend.skeleton.mongodb` → `dev.caskeleton.adapter.outbound.mongo`. + +### 2.1 Stable modules + +| Design module | Repository package | +|---|---| +| `mongodb-core-api` | `…outbound.mongo.api` (+ `.capability`, `.consistency`, `.error`, `.mapping`, `.observation`, `.profile`, `.schema`) | +| `mongodb-spring-data` | `…outbound.mongo.mapping` (+ `.type`), `…outbound.mongo.failure` | +| `mongodb-imperative` | `…outbound.mongo.imperative` (+ `.atomic`, `.bulk`, `.revision`) | +| `mongodb-reactive` | `…outbound.mongo.reactive` (+ `.cursor`) | +| `mongodb-query` | `…outbound.mongo.query` (+ `.budget`, `.pagination`) | +| `mongodb-aggregation` | `…outbound.mongo.aggregation` | +| `mongodb-transaction` | `…outbound.mongo.transaction` (+ `.retry`, `.session`) | +| `mongodb-index-schema` | `…outbound.mongo.schema` (+ `.index`, `.manifest`, `.model`, `.ttl`, `.validation`) | +| `mongodb-change-stream` | `…outbound.mongo.changestream` (+ `.projector`, `.recovery`) | +| `mongodb-geospatial` | `…outbound.mongo.geo` | +| `mongodb-migration-core` | `…outbound.mongo.migration` | +| `mongodb-migration-flamingock` | `…outbound.mongo.migration.flamingock` | +| `mongodb-observability` | `…outbound.mongo.observation` | +| `mongodb-security` | `…outbound.mongo.security` (+ `.admin`), `…outbound.mongo.nativecap` | +| `mongodb-spring-boot-starter` | `…outbound.mongo.autoconfigure` | +| `mongodb-testkit-core` | `…outbound.mongo.testkit.mapping`, `.compat`, `.performance` (`testkit` source set) | +| `mongodb-testkit-replicaset` | `…outbound.mongo.testkit.rs` (`testkit` source set) | +| `mongodb-testkit-failover` | `…outbound.mongo.testkit.failover` (`testkit` source set) | +| `mongodb-testkit-migration` | `…outbound.mongo.testkit.migration` (`testkit` source set) | + +`…outbound.mongo.architecture` has no design counterpart: it holds the `@MongoOperation` marker and +the reusable ArchUnit rule set a fork applies to its own document/repository code. + +### 2.2 Advanced modules + +| Design module | Repository package | +|---|---| +| `mongodb-sharding` | `…outbound.mongo.advanced.sharding` (+ `.admin` for the D4 shard plane) | +| `mongodb-timeseries` | `…outbound.mongo.advanced.timeseries` | +| `mongodb-csfle` | `…outbound.mongo.advanced.encryption.csfle` | +| `mongodb-queryable-encryption` | `…outbound.mongo.advanced.encryption.qe` | +| `mongodb-search` | `…outbound.mongo.advanced.search` | +| `mongodb-vector-search` | `…outbound.mongo.advanced.vector` | +| `mongodb-tenancy-shared` | `…outbound.mongo.advanced.tenancy.shared` | +| `mongodb-tenancy-database` | `…outbound.mongo.advanced.tenancy.database` | +| `mongodb-change-stream-messaging-bridge` | `…outbound.mongo.advanced.bridge` | +| `mongodb-gridfs-compat` | `…outbound.mongo.advanced.gridfs` | +| `mongodb-testkit-sharded` | `…outbound.mongo.testkit.sharded` (`testkit` source set) | +| `mongodb-testkit-atlas` | `…outbound.mongo.testkit.atlas` (`testkit` source set) | + +The design's rule that a Stable module never depends on an Advanced one survives as an ArchUnit rule +(`stableNeverDependsOnAdvanced`) plus the opt-in flag: every Advanced entry point requires +`MongoAdvancedCapabilityFlags` to have the matching capability enabled and refuses construction +otherwise. Being on the classpath is not being enabled. + +## 3. Other deliberate substitutions + +| Design assumption | Repository reality | Adaptation | +|---|---|---| +| Gradle Kotlin DSL under `modules/mongodb*` | Groovy DSL, root `build.gradle` conventions, `LockMode.STRICT` locking | Dependencies declared in `src/adapter/outbound/persistence-mongo/build.gradle`; `gradle.lockfile` regenerated. | +| `mongodb-spring-boot-starter` is a separate module the app depends on | `modules.json` gives `adapter-outbound-persistence-mongo` `runtime_memberships: []` and does **not** list it among `app-bootstrap`'s allowed dependencies | The `autoconfigure` package stays inside the leaf and registers through the leaf's own `META-INF/spring/…AutoConfiguration.imports`. This differs from the httpclient precedent, where the starter moved to `:app-bootstrap`; here the registry forbids that edge. | +| Spring Boot 4.1 / Spring Data MongoDB 5.1 baseline | Repository baseline is Spring Boot 4.0.0 / Spring Data MongoDB 5.0.0 | The platform targets the Spring Data MongoDB **API surface** common to both; no 5.1-only type is referenced. The support matrix records the actual pinned versions. | +| `MongoRetryScope` lives in `mongodb-transaction` | The `mongodb-spring-data` failure translator must classify retry scope, and it cannot depend on `mongodb-transaction` | `MongoRetryScope` lives in `…api.error` (core-api), which both packages already depend on. Same values, same meaning, one legal position in the DAG. | +| `mongodb-migration-flamingock` depends on Flamingock | Adding an unvetted external dependency is out of scope for this task, and the design itself requires the public contract not to depend on Flamingock types | The adapter is provider-neutral: it consumes a platform-owned `FlamingockChangeUnitView`. Wiring an actual Flamingock distribution is a one-file change behind that view. | +| Testkit as its own Gradle module | The design forbids production modules depending on the testkit | A dedicated `testkit` source set whose output is on the test compile/runtime classpaths only. ArchUnit rule `productionNeverDependsOnTestkit` enforces the direction. | +| Per-task `git commit` | `AGENTS.md`: commit policy is `human-only` | Implementation is delivered unstaged; commits are the human's action. This is the only plan step intentionally not executed, and it is recorded here. | +| `docs/mongodb/**`, `scripts/verify-mongodb-*.sh` | Repository already owns `docs/` and `scripts/` | Created at the same repository-relative paths. | + +## 4. What is unchanged from the design + +- D1 / D2 / D3 / D4 exposure planes and the ordered D3 admission sequence (§5). +- Stable API V1 with `apiStrict=true` on the D1/D2 client generation; D3/D4 on separate generations. +- `MongoExecutionOutcome`, including both ambiguous outcomes (`WRITE_RESULT_UNKNOWN`, + `TRANSACTION_COMMIT_UNKNOWN`), and `MongoFailureContext`'s permitted-field list. +- The complete stable exception hierarchy and the label-before-code classification order. +- The BSON representation manifest (UUID `STANDARD`, `Decimal128`, UTC instants, alias type metadata) + and the document-size budget. +- Update-operator-first writes, and optimistic revision as the precondition for whole-document + replacement. +- Transaction body retry and commit retry as separate loops: a new session per body attempt, and + commit-only retry on unknown commit. The body is never replayed after a commit ambiguity. +- Registered operation names and manifests for query, aggregation and index; no free-form JSON query + and no unbounded pipeline. +- Keyset pagination with an authenticated cursor and a unique tie-breaker requirement. +- Change stream as an at-least-once projector with resume-token checkpointing and explicit + history-lost handling. +- TTL as physical cleanup only, never the sole basis for access denial or business scheduling. +- Manifest-owned index/validator state with an apply policy that never drops what it does not own. +- Low-cardinality observation tags, command redaction, and the credential reference indirection. +- The Stable release gate's evidence categories, and the Advanced promotion gate's requirement for + actual-topology evidence. + +## 5. Verification + +```bash +bash scripts/verify-mongodb-platform.sh # Stable gate +bash scripts/verify-mongodb-advanced.sh # Advanced gate (opt-in lanes) +``` + +Both scripts run from the repository root and delegate to `src/gradlew`. diff --git a/docs/mongodb/runbooks/failover.md b/docs/mongodb/runbooks/failover.md new file mode 100644 index 00000000..3f9df62c --- /dev/null +++ b/docs/mongodb/runbooks/failover.md @@ -0,0 +1,98 @@ +--- +title: Runbook — MongoDB primary failover +category: mongodb +severity: P2 +owner: oncall +last_updated: 2026-08-13 +status: active +--- + +# Runbook: MongoDB primary failover + +Design §29, scenarios `PRIMARY_KILL`, `NETWORK_PARTITION`, `SERVER_SELECTION_TIMEOUT`, +`WRITE_RESPONSE_LOSS`. + +## Symptoms + +- `MongoServerSelectionException` / `MongoConnectionException` spike, then recovery within seconds. +- `MongoSdamObservationListener` reports a topology change (primary removed, new primary elected). +- `MongoPoolObservationListener` shows checkout wait times rising while server-side command duration + stays flat — the wait is topology, not query cost. +- Latency spike on writes with no corresponding rise in read latency. + +A failover that resolves in under ~15 s and produces no `WRITE_RESULT_UNKNOWN` is normal replica-set +behaviour and needs no action beyond confirming it self-healed. + +## Diagnosis + +1. Confirm an election actually happened. SDAM events distinguish an election from "the database got + slow"; without them the two are indistinguishable in application metrics. +2. Split the failure categories. Metric tag `failureCategory`: + - `SERVER_SELECTION` / `CONNECTION` → the driver could not reach a primary. `NOT_SENT`; safe. + - `TIMEOUT` with outcome `WRITE_RESULT_UNKNOWN` → a write may have applied. Not safe; see below. + - `TRANSACTION_COMMIT_UNKNOWN` → go to [unknown-commit.md](unknown-commit.md) instead. +3. Check the election duration against `MongoRetryBudget`. If the election outlasted the budget, the + retries were exhausted before a primary existed and callers saw errors that a longer budget would + have absorbed. +4. Check whether the new primary is in the expected region/AZ. A failover to a distant node changes + write latency permanently, not transiently. + +## Action + +**Self-healed (the common case).** +Confirm outcome distribution contains no `WRITE_RESULT_UNKNOWN`, record the election in the incident +log, and close. Nothing to replay. + +**Writes with `WRITE_RESULT_UNKNOWN`.** +These writes may or may not have applied. Do not blind-retry. +- Idempotent operation (registered `MongoUpdateOperator` with an `AtomicFilter` precondition): retry. + The precondition makes the second application a no-op. +- Non-idempotent operation: reconcile by reading the target document and comparing against the + intended post-state. Retry only if it does not reflect the write. + +**Server selection never recovers.** +The set has lost quorum — two of three nodes are down or partitioned. No client-side action fixes +this; escalate to the database owner to restore a majority. The application should be failing closed, +not queueing. + +**Elections are frequent (more than one a day, unprompted).** +This is an infrastructure symptom, not an application one: check node resource saturation, disk +latency on the primary, and network stability between members. Repeated elections cause repeated +unknown-outcome windows. + +## Escalation + +- P2 → P1 if server selection has failed for more than 2 minutes, or if any non-idempotent write + returned `WRITE_RESULT_UNKNOWN` and cannot be reconciled. +- Page the database owner for quorum loss, and the service owner for reconciliation of ambiguous + writes. + +## Verification + +The failover lane reproduces this deliberately: + +```bash +cd src +./gradlew :adapter:outbound:persistence-mongo:mongoFailoverTest --console=plain +``` + +It starts a real three-node set (`MongoThreeNodeReplicaSet`), stops the primary +(`MongoPrimaryController`), and injects network faults through Toxiproxy +(`ToxiproxyMongoNetworkFaultController`). A single-node set is not sufficient for the election: it +never holds one, so every guarantee that depends on a primary change goes untested. + +The network faults need their own fixture (`MongoProxiedReplicaSetNode`) because a stopped container +cannot produce them. Stopping a node tells the client the write did not happen; cutting the *path* +while the server keeps running produces a client that cannot tell. `MongoNetworkFaultLaneTest` +asserts the difference by reaching the same server twice — once through the proxy, once directly: + +- **Partition**: the proxied client fails, the direct client finds the server healthy and the earlier + write intact. The path was cut, not the server. +- **Response loss**: the proxied client fails, and the direct client then finds the document + *present*. The write applied and only the acknowledgement was lost — + `DefaultMongoFailureClassifier` returns `WRITE_RESULT_UNKNOWN`, and a retry would have inserted a + second document. + +One detail the lane depends on: the connection is warmed before the toxic is applied. On a cold +connection it is the driver's handshake whose response is dropped, so the write is never transmitted +— `NOT_SENT`, the opposite of the ambiguity being tested. diff --git a/docs/mongodb/runbooks/history-lost.md b/docs/mongodb/runbooks/history-lost.md new file mode 100644 index 00000000..b24cd70f --- /dev/null +++ b/docs/mongodb/runbooks/history-lost.md @@ -0,0 +1,90 @@ +--- +title: Runbook — MongoDB change stream history lost +category: mongodb +severity: P1 +owner: oncall +last_updated: 2026-08-13 +status: active +--- + +# Runbook: change stream history lost + +Design §20.3, scenarios `OPLOG_HISTORY_LOSS`, `RESUME_TOKEN_LOSS`. + +The stored resume token predates the oldest entry in the oplog. The events between the checkpoint and +now are gone from the server; no retry recovers them. `MongoChangeStreamRecoveryPolicy` returns +`halt(HISTORY_LOST, "history-lost")` and the runner stops. + +**The platform will not silently restart from "now".** That looks like a recovery and is actually a +permanent, unreported gap in the projection. + +## Symptoms + +- `MongoChangeHistoryLostException`. +- `MongoChangeStreamState.HISTORY_LOST`; the consumer is stopped, not looping. +- Precedes it: consumer lag approaching the oplog window, or a consumer that was down for a long + period (a deploy that failed, a scaled-to-zero worker, a long outage). + +## Diagnosis + +1. **Determine the gap.** The checkpoint's cluster time is the start; the oldest oplog entry is the + end of what is unrecoverable. Everything in between was never processed. +2. **Determine the oplog window.** `rs.printReplicationInfo()` on the primary gives the first and last + oplog timestamps. If the window is materially smaller than it was, the write rate rose or the + oplog was resized — the consumer may be fine and the server changed. +3. **Determine what the projection is missing.** Which collections and which operations does this + projector consume? The gap is bounded by that, not by everything that happened. +4. **Check for a second consumer.** If another projector on the same collection is healthy, its + checkpoint tells you whether the problem is this consumer or the oplog. + +## Action + +Resuming is not an option. The choices are: + +**Rebuild from source.** If the projection is derivable from the current state of the source +collections, rebuild it: stop the consumer, rebuild the projection, then start the stream from the +cluster time at which the rebuild snapshot was taken. This is the correct answer whenever the +projection is a materialised view rather than an event log, and it is the reason a projection should +be derivable. + +**Backfill the gap.** If the source documents carry a timestamp covering the gap, run a bounded +backfill for that window through the migration runner (checkpointed, resumable — see +[schema-index-migration-guide.md](../schema-index-migration-guide.md) §5), then resume from the +current cluster time. + +**Accept the gap explicitly.** Only when the projection is advisory and the business owner says so. +Record the window in the incident log and reset the checkpoint. This is a decision someone signs, not +a default. + +Never: reset the checkpoint to "now" and restart quietly. That converts a visible P1 into an +invisible data-quality defect that surfaces months later as "the report has been wrong since March". + +## Prevention + +- **Alert on lag against the oplog window, not wall-clock.** "Consumer is 30 minutes behind" is fine + with a 24-hour oplog and an emergency with a 45-minute one. The threshold that matters is + `lag / oplogWindow`. +- **Size the oplog for the longest tolerable consumer outage**, including a failed deploy discovered + the next morning. +- **Checkpoint after processing, never on receipt** — see + [change-stream-guide.md](../change-stream-guide.md) §3. +- **Back up the checkpoint store.** `RESUME_TOKEN_LOSS` is the same incident reached from the other + direction: the oplog is fine, the checkpoint is gone. +- **Make the projection rebuildable.** A projection that can only be built by replaying every event + has no recovery path once the oplog rolls. + +## Escalation + +- P1 on detection. The consumer is stopped, so lag grows for as long as this is unresolved. +- Page the service owner for the rebuild decision, and the database owner if the oplog window shrank + unexpectedly. + +## Verification + +```bash +cd src +./gradlew :adapter:outbound:persistence-mongo:mongoFailoverTest --console=plain +``` + +`MongoFailoverScenario.OPLOG_HISTORY_LOSS` and `RESUME_TOKEN_LOSS` assert the runner halts and names +this runbook rather than restarting from the current position. diff --git a/docs/mongodb/runbooks/unknown-commit.md b/docs/mongodb/runbooks/unknown-commit.md new file mode 100644 index 00000000..164b1576 --- /dev/null +++ b/docs/mongodb/runbooks/unknown-commit.md @@ -0,0 +1,87 @@ +--- +title: Runbook — MongoDB unknown transaction commit result +category: mongodb +severity: P1 +owner: oncall +last_updated: 2026-08-13 +status: active +--- + +# Runbook: unknown transaction commit result + +Design §16, decision D-10, scenario `UNKNOWN_TRANSACTION_COMMIT_RESULT`. + +`MongoExecutionOutcome.TRANSACTION_COMMIT_UNKNOWN` means the commit **may have applied**. It is not a +failure and must never be reported to a caller as one. The single worst response is to re-run the +transaction body: if the commit did apply, the body applies a second time. + +## Symptoms + +- `MongoTransactionCommitUnknownException` in logs. +- Metric `failureCategory=TRANSACTION_COMMIT_UNKNOWN`. +- Usually accompanies a primary election — see [failover.md](failover.md). +- Downstream reports of duplicated effects (double charge, double increment) are the symptom of this + being handled wrongly, not of the condition itself. + +## Diagnosis + +1. **Confirm the platform did the right thing automatically.** + `MongoTransactionRetryCoordinator` retries the *commit only*, on the same session, within + `MongoRetryBudget`. A commit retry against an already-committed transaction is a no-op by design. + Most occurrences resolve here and never reach a human. + +2. **If the budget was exhausted, determine the actual state.** The commit either applied or it did + not; you must find out which, not guess. + - If the transaction body wrote a deterministic marker (an idempotency key, a business id, a + revision), read it back. That is exactly what `MongoCommitReconciler` does, and it is the + reason the design requires transactions to write one. + - If there is no marker: reconstruct from a downstream artefact — an outbox row, an audit record, + an external side effect. If nothing exists to compare against, the transaction was not + designed to be reconcilable and that is the finding to record. + +3. **Check whether the body was replayed.** Grep for a second execution with the same operation name + and correlation id. If the body ran twice, the effects need reversing, and the code path that + replayed it is a defect: an ambiguous commit is `COMMIT_ONLY` scope + (`MongoRetryScope.COMMIT_ONLY`), never `BODY`. + +## Action + +**Commit applied.** Nothing to do. Record the reconciliation. + +**Commit did not apply.** Re-run the whole operation from the top — a new session, a new body +attempt. This is safe precisely because you established the previous attempt left no trace. + +**Cannot determine.** Do not retry. Escalate. A blind retry here is a coin flip between "no effect" +and "duplicate effect", and duplicates in a financial or notification path are worse than a delay. +Freeze the affected entity if the domain supports it, and hand off with: operation name, correlation +id, document id, the time window, and what you checked. + +**Recurring.** More than one an hour means the commit path is racing something structural — a +`maxCommitTime` shorter than the observed election duration, an oversized transaction, or an +undersized retry budget. Fix the budget or the transaction shape; do not raise the retry count and +call it resolved. + +## Prevention + +- Every transaction body writes a deterministic marker that identifies its own commit. +- `maxCommitTime` exceeds the observed p99 election duration. +- Callers surface the ambiguity to their own callers rather than mapping it to a generic 500 — an + ambiguous outcome reported as a failure invites the caller to retry, which is the one thing that + must not happen. +- Prefer a single-document atomic operation (D-09). A transaction that exists only to wrap one + document write has invented this failure mode for nothing. + +## Escalation + +- Always P1 when the state cannot be determined and the operation has an external effect. +- Page the service owner immediately; the database owner only if elections are the trigger. + +## Verification + +```bash +cd src +./gradlew :adapter:outbound:persistence-mongo:mongoFailoverTest --console=plain +``` + +`MongoFailoverScenario.UNKNOWN_TRANSACTION_COMMIT_RESULT` runs this path against a real three-node +set, and the coordinator test asserts the body is never replayed after a commit ambiguity. diff --git a/docs/mongodb/schema-index-migration-guide.md b/docs/mongodb/schema-index-migration-guide.md new file mode 100644 index 00000000..bf6913d6 --- /dev/null +++ b/docs/mongodb/schema-index-migration-guide.md @@ -0,0 +1,137 @@ +# Schema, Index and Migration Guide + +Design §21–§25, decision D-13. Indexes and validators are **declared** in a manifest and **applied** +by an explicit plane. Automatic index creation in production is explicitly unsupported (§3.4): an +index build on a large collection is a capacity event, and discovering it because a deployment +started one is not an operating model. + +## 1. The manifest is the source of truth + +`MongoManifestRegistry` holds one `MongoCollectionManifest` per collection, containing: + +- `MongoIndexManifest` — the declared indexes (`MongoIndexKey`, `MongoIndexDirection`, uniqueness, + partial filter, collation) +- `MongoSchemaManifest` — the declared `$jsonSchema` validator +- `MongoMetadataOwnership` — who owns each observed object + +Ownership is the field that makes drift handling safe: + +| Ownership | Owner | Droppable on drift | +|---|---|---| +| `APPLICATION_MANAGED` | this manifest | yes | +| `SEARCH_MANAGED` | the search service | no | +| `ENCRYPTION_MANAGED` | Queryable Encryption | no | +| `EXTERNAL` | someone else (a DBA, another service) | no | + +A diff engine that does not know about ownership eventually proposes dropping +`enxcol_.customers.esc` or a search index, and "the drift tool cleaned it up" is a very bad incident +summary. + +## 2. Index diff and apply + +`MongoIndexDiffEngine` compares the manifest against `MongoIndexDescriptorView` observations and +produces a `MongoIndexDiff`: missing, extra, and *changed* (same name, different definition — +MongoDB will not silently rebuild these, so they must be reported rather than re-issued). + +`MongoIndexApplyPolicy` decides what happens with a diff: + +| Policy | Behaviour | Environment | +|---|---|---| +| `APPLY` | create what is missing | local / test | +| `APPLY_WITH_DIFF` | create what is missing and report the rest | staging | +| `DIFF_WITH_APPROVED_APPLY` | apply only what a human approved | production | +| `REPORT_ONLY` | never write | audit | + +Dropping is never implicit. `MongoIndexRetirementPlan` moves an index through +`MongoIndexRetirementState` — declared → hidden → observed-unused → droppable — and each transition +is a separate deployment. Hiding an index makes the planner ignore it while keeping it maintained, so +an unexpected regression is one command to undo. Dropping it is not. + +## 3. Validators + +`MongoValidatorDescriptor` carries the `$jsonSchema`, a `MongoValidationLevel` +(`OFF` / `MODERATE` / `STRICT`) and a `MongoValidationAction`. + +**Stable validation actions are `error` and `warn` only.** `errorAndLog` is not part of the Stable +contract on MongoDB 7.0 or 8.0 and the descriptor refuses it. + +`MongoValidatorDiffEngine` produces a `MongoValidatorDiff`; `MongoValidatorApplyPolicy` gates the +apply. Tightening a validator on a collection with existing data is the dangerous direction: introduce +it as `warn` + `MODERATE`, confirm the warning count is zero, then promote to `error` + `STRICT` in a +second deployment. + +## 4. TTL + +D-13: TTL is **physical cleanup**, nothing else. + +`MongoTtlIndexDescriptor` declares the field and `expireAfterSeconds`. `MongoTtlPolicyValidator` +enforces what `MongoTtlPolicy` allows, and `MongoExpirationAccessPolicy` states the rule that matters: + +> A document's presence is not authorization, and its absence is not a deadline. + +The TTL monitor runs about once a minute and deletes in batches, so a document can outlive its +expiry by minutes to hours under load. Consequences: + +- Access control must check the expiry field, not the document's existence. A still-present expired + session is a valid document and an invalid session. +- Business scheduling must not be built on TTL. If something must happen at a time, schedule it. +- A TTL field must be a BSON date. A TTL index on a string silently never deletes anything. + +## 5. Migrations + +`MongoMigrationRunner` executes `MongoMigration` units with: + +- `MongoMigrationId` — ordered, unique +- `MongoMigrationChecksum` — content hash; a changed checksum for an applied id is a hard failure, not + a re-run. Editing an applied migration means two environments ran different code under the same id. +- `MongoMigrationLedger` — what has been applied +- `MongoMigrationLock` — one runner at a time; a rolling deploy starts several instances at once +- `MongoMigrationPrecondition` / `MongoMigrationPostcondition` — checked before and after; a migration + that cannot verify its own result is a migration whose failure is discovered by a customer +- `MongoMigrationCheckpoint` — a resumable position for a backfill + +`MongoMigrationResult` reports applied / incomplete / dry-run with the reason. `INCOMPLETE` is not a +failure: a rate-limited backfill that ran out of its time budget has done real work and stored a +checkpoint, and reporting it as failed would send the next run back to the beginning. + +`MongoCollectionMigrationLedger` and `MongoCollectionMigrationLock` are the MongoDB-backed +implementations. Two details are load-bearing and only exist on a server: + +- The ledger's **unique index** on the migration id, created by `ensureIndexes()`. Without it, two + runners that both pass the "not applied yet" read both insert, and the ledger then reports one + migration applied twice with two checksums — indistinguishable from tampering. +- The lease is taken with **one conditional update**, not read-then-write. A filter matching only a + free or expired lease lets the server pick the winner; two runners that each read "free" and then + write would both believe they hold it. + +The lease expires so a runner killed mid-migration does not block every future deployment, and +`refresh` between batches is what proves the holder is still alive. + +### Backfills restart, they do not restart-from-zero + +A long backfill will be interrupted — a deploy, an OOM, a node replacement. The checkpoint records +the last completed key so the restart continues rather than re-processing from the beginning. +`MongoBackfillRestartFixture` in the testkit asserts exactly this: kill mid-run, restart, and the +result is identical to the uninterrupted run and does not re-apply completed work. + +### Flamingock + +`FlamingockMongoMigrationAdapter` bridges to Flamingock through the platform-owned +`FlamingockChangeUnitView`, with `FlamingockLedgerAdapter` and `FlamingockLockAdapter` mapping the +ledger and lock. The public contract does not reference Flamingock types, so the provider can be +replaced without touching a migration. + +## 6. Ordering with deployments + +``` +1. Add the index (hidden if it is large) -> deployment N +2. Unhide / verify usage -> deployment N+1 +3. Ship code that depends on the index -> deployment N+1 +4. Backfill data -> migration, resumable +5. Tighten the validator from warn to error -> deployment N+2 +6. Retire the old index through the retirement states -> deployments N+3… +``` + +Each step is independently reversible. A deployment that adds an index and the code that requires it +at the same time has no safe rollback: rolling back the code leaves the index build running, and +rolling back the index breaks the code that is still live on half the fleet. diff --git a/docs/mongodb/security-observability.md b/docs/mongodb/security-observability.md new file mode 100644 index 00000000..1adb4c63 --- /dev/null +++ b/docs/mongodb/security-observability.md @@ -0,0 +1,147 @@ +# Security and Observability + +Design §26–§28, decision D-05. The application plane and the admin plane are different credentials on +different clients, and telemetry never becomes an exfiltration path. + +## 1. Roles + +`MongoPrincipalRole` — one credential per role, least privilege: + +| Role | Grants | +|---|---| +| `APP_READ` | `find` on allowlisted collections | +| `APP_WRITE` | `insert`, `update`, `delete` on allowlisted collections | +| `CHANGE_STREAM` | `changeStream`, `find` | +| `MIGRATION` | index and validator management on the target collections | +| `SEARCH_ADMIN` | search index management | +| `SHARD_ADMIN` | shard key operations | +| `ENCRYPTION_ADMIN` | key vault access | +| `DBA` | the human plane; never used by an application | + +`MongoSecurityProfileValidator` checks the profile at startup. `forbiddenPrivilegesHeld()` names the +privileges the profile holds and must not — the validator reports *which* one, because "your +credential is over-privileged" without a name is an unactionable finding. + +The privileges that must never appear on an application credential: `dropDatabase`, +`dropCollection`, `shutdown`, `killop`, `root`, `__system`, `dbOwner`, `userAdminAnyDatabase`. + +## 2. Credentials are references, not values + +`MongoCredentialReference` holds a `secret://…` reference plus the role. The reference is resolved at +connection time by the secret provider; the password is never a property value, a log field, or a +constructor argument that could end up in a stack trace. + +`MongoCredentialRotationPolicy` states the rotation contract: overlapping validity, a drain window, +and a rotation that never requires a restart. `MongoClientGenerationRegistry` implements the swap — +a new `MongoClientGeneration` starts serving new operations while the previous generation is +`markDraining()` until its in-flight operations finish. Killing the old client immediately fails every +in-flight request, which is why rotation without generations is an outage. + +Rotation is a failover scenario in the release gate (`MongoFailoverScenario.CREDENTIAL_ROTATION`), +not a runbook step people hope works. + +## 3. TLS and connection policy + +`MongoSecurityProfile.production(...)` requires TLS and refuses `tlsAllowInvalidCertificates` / +`tlsAllowInvalidHostnames`. `MongoSecurityProfile.local(...)` exists so a developer does not have to +weaken the production factory to get a container to connect; the startup validator refuses a local +profile on a production runtime profile. + +## 4. Admin plane (D4) + +`MongoAdminGateway` is the only path to `MongoAdminOperation`, and it runs on the D4 client with the +DBA-scoped credential — not the application's. + +- `MongoAdminAuthorization` checks the caller's role against the operation. +- `MongoAdminRuntimeGuard` refuses high-risk operations (`highRisk()`) unless the runtime profile + explicitly permits them; a `dropCollection` reachable from a running application is a data-loss + vector regardless of how well-reviewed the calling code is. +- `MongoAdminAuditRecord` records who ran what, when and against which collection profile — before + execution, so a failed attempt is recorded too. + +The native capability gateway (D3) refuses any admin-category command, so there is no path from the +application plane into the admin plane. + +## 5. Observability tags + +`MongoObservationConvention` allowlists exactly eight tag names: + +``` +mongoProfile, databaseProfile, collectionProfile, operationName, +operationType, result, failureCategory, consistencyProfile +``` + +and explicitly forbids: + +``` +documentId, rawTenantId, tenantId, dynamicCollectionName, queryParameter, +query, fullBson, resumeToken, shardKeyValue, plaintextPII, credential +``` + +Two reasons, and both matter. Cardinality: a tag whose values are document ids produces one time +series per document, which is how a metrics backend falls over. Confidentiality: a metric label is +stored, shipped and retained by systems with a different access model than the database. +`requireAllowed(tagName)` throws on anything outside the list, so a new tag is a deliberate change to +the convention rather than a line in a service. + +`MicrometerMongoOperationObserver` implements the `MongoOperationObserver` port; +`NoOpMongoOperationObserver` is the default so observation is opt-in and never a hard dependency. + +## 6. Driver-native listeners + +`MongoDriverObservabilityConfiguration` registers three driver listeners, because they answer +questions the application-level timer cannot: + +| Listener | Answers | +|---|---| +| `MongoCommandObservationListener` | How long did the *server* take, versus how long the caller waited? | +| `MongoPoolObservationListener` | Was the wait time connection checkout rather than query execution? | +| `MongoSdamObservationListener` | Did the topology change — an election, a node removed — during the window? | + +Without pool and SDAM events, every failover looks like "the database got slow", and the difference +between "we need a bigger pool" and "we lost a primary" is invisible. + +## 7. Command redaction + +`MongoObservationRedactor.describe(commandName)`: + +- Authentication and user-management commands (`authenticate`, `saslStart`, `saslContinue`, + `getnonce`, `createUser`, `updateUser`, `copydb*`) render as `` — their arguments carry + credentials and key material. +- Structural commands (`ping`, `hello`, `buildInfo`, `listCollections`, `listIndexes`, `collStats`) + render by name; their arguments are not data-bearing. +- Everything else renders as `name(...)`: you get the command, never the filter or the document. + +`isAlwaysRedacted(...)` is the assertion hook so a test can prove no logging path can render an auth +command's arguments. + +## 8. Startup validation + +`MongoStartupValidator` runs at context refresh, before the first request: + +1. `MongoTopologyProbe` reports the actual `MongoTopology`. +2. Each declared `MongoTopologyRequirement` is checked against it — a transaction, causal-session or + change-stream requirement fails closed on `STANDALONE`. +3. `MongoSecurityProfileValidator` checks credentials and TLS. +4. `MongoCapabilitySupport` checks declared capabilities against the server version, with + `MongoSupportLevel` distinguishing `STABLE` / `ADVANCED` / `EXPERIMENTAL` / `UNSUPPORTED`. +5. `MongoPlatformHealthIndicator` reports the outcome for the readiness probe. + +A misconfiguration found at startup costs a failed deploy. The same misconfiguration found at runtime +costs an incident, and the failing operation is rarely the one that reveals the cause. + +## 9. How the security lane proves any of this + +```bash +cd src +./gradlew :adapter:outbound:persistence-mongo:mongoSecurityIntegrationTest --console=plain +``` + +The lane runs against `MongoAuthenticatedReplicaSetContainer`, which starts mongod with `--auth` and a +generated keyfile. That detail is the whole lane: Testcontainers' `MongoDBContainer` starts mongod +*without* `--auth`, so users created on it all have every privilege and a least-privilege assertion +passes no matter how wrong the roles are. A security test that cannot fail is not a security test. + +What the lane asserts is the refusal: the `read` role's insert is rejected, and the application role's +`dropDatabase` is rejected. Then it checks that `MongoSecurityProfileValidator` names the same +privilege the server just refused. diff --git a/docs/mongodb/support-matrix.md b/docs/mongodb/support-matrix.md new file mode 100644 index 00000000..3398f366 --- /dev/null +++ b/docs/mongodb/support-matrix.md @@ -0,0 +1,91 @@ +# MongoDB Platform — Support Matrix + +Design §4. This file records what the platform is *certified* on, not what it happens to run on. +A configuration absent from this table is unsupported until someone runs the gate against it and +adds a row. + +## 1. Runtime baseline + +| Component | Version | Policy | +|---|---|---| +| Java | 21 | Repository runtime baseline. | +| Spring Boot | 4.0.0 | BOM-managed. Individual driver overrides are forbidden. | +| Spring Data MongoDB | 5.0.0 | Repository and `MongoTemplate` integration. Version comes from the Boot BOM. | +| MongoDB Java Driver | 5.6.1 | BOM-managed. Never pinned directly in the module. | +| Reactor | 3.8.0 | Reactive execution path. | +| Micrometer | 1.16.0 | Driver-native observability. | +| Testcontainers | 2.0.2 | Replica-set, failover, migration and compatibility lanes. | + +The design's baseline is Spring Boot 4.1.x / Spring Data MongoDB 5.1.x. This repository is on +4.0.0 / 5.0.0, so the platform targets only the API surface common to both. See +[repository-adaptation.md](repository-adaptation.md) §3. + +## 2. Server versions + +| Lane | Version | Pinned image | Gradle task | +|---|---|---|---| +| Primary certification | MongoDB 8.0 | `mongo:8.0.16` | `mongoReplicaSetTest`, `mongoFailoverTest` | +| Compatibility | MongoDB 7.0 | `mongo:7.0.28` | `mongoCompatibilityTest` | +| Network fault injection | — | `ghcr.io/shopify/toxiproxy:2.12.0` | `mongoFailoverTest` | + +Images are pinned, never `latest`: a mutable tag means the certification result describes whatever +was pulled that morning, not the version in the row. Override with +`-PmongoPrimaryImage=…` / `-PmongoCompatibilityImage=…` when testing a new patch level, and update +the row once the gate passes. + +`MongoVersionMatrix.standard()` is the machine-readable form of this table; a version outside it +fails `certifies()`. + +## 3. Topologies + +| Topology | Status | What is certified | What is not | +|---|---|---|---| +| Standalone | **Smoke only** | Basic CRUD and mapping. | Not a production profile and never counts as Stable release evidence (D-03). Transactions, retryable writes and change streams are refused at startup by `MongoStartupValidator`. | +| Single-node replica set | **Local default** (D-02) | Transactions, retryable writes, change streams — the same semantics as production. | Elections. A single-node set never holds one, so failover behaviour is untested here. | +| 3-node replica set | **Stable production gate** | Everything above plus primary failover, unknown-commit handling and change-stream resume across an election. | Shard routing. | +| Sharded cluster | **Advanced gate** | Shard-key routing classification, scatter-gather refusal, `admin` plane operations. | Not included in the Stable gate. | +| Atlas / provider-managed | **Per-capability gate** | Search, vector search and encryption against the actual target deployment. | Atlas Local in a container is a pull-request convenience, explicitly **not** release evidence (`MongoAtlasCapabilityContractSuite.Environment`). | + +## 4. Stable API and client generations + +| Plane | Stable API | Purpose | +|---|---|---| +| D1 Standard document persistence | V1, `apiStrict=true` | Repositories, typed queries, atomic updates, optimistic revision. | +| D2 Advanced document operations | V1, `apiStrict=true` | `MongoTemplate`, transactions, bulk, aggregation, keyset cursors, change streams. | +| D3 Explicit Mongo capability | Not strict | Native BSON, time series, search/vector, CSFLE/QE, shard-aware operations — each behind a registered capability. | +| D4 Admin plane | Not strict | Collection, validator, index, migration, shard and repair commands. Separate credential, separate client. | + +D3 is not a raw-client escape. Every call passes capability registration → database profile → +collection allowlist → operation name → timeout → consistency profile → result limit → trace → +redaction → command category → D4 refusal, in that order. + +## 5. Validation actions + +Stable validation actions are `error` and `warn`. `errorAndLog` is **not** part of the Stable +contract on 7.0 or 8.0 and `MongoValidatorDescriptor` refuses it. + +## 6. Explicitly unsupported + +Per design §3.4, none of the following is provided, and adding one is a design change rather than a +feature request: + +- A generic `CommonMongoRepository`. +- Arbitrary runtime `runCommand`. +- Automatic index creation in production. +- A Standalone production contract. +- TTL as an exact business scheduler or as the only access control. +- Publishing raw change events as external business integration events. +- GridFS as the source of truth for new files. +- Java fully-qualified class names as a long-lived BSON schema. +- Unbounded skip pagination, unbounded aggregation pipelines, unbounded regex, unbounded results. + +## 7. Capability tiers + +| Tier | Capabilities | Enablement | +|---|---|---| +| Stable | Mapping, imperative/reactive execution, atomic update, optimistic lock, transactions, consistency profiles, retry/translation, query and aggregation guardrails, schema/index manifests, keyset pagination, bulk partial results, change streams, TTL contract, GeoJSON, security, observability | On when `ca-skeleton.persistence-mongo.enabled=true`. | +| Advanced | Sharding-aware query, time series, CSFLE, Queryable Encryption (equality/range), change-stream→messaging bridge, shared-collection multi-tenancy | Each behind `ca-skeleton.persistence-mongo.advanced..enabled`. | +| Experimental | Search, vector search, hybrid search, database-per-tenant, collection-per-tenant, reshard orchestration, provider-specific features | Same flag mechanism; promotion additionally requires the evidence in [ADR-MONGO-ADV-001](../adr/ADR-MONGO-ADV-001-capability-promotion.md). | + +`MongoAdvancedCapabilityFlags.propertyFor(capability)` is the authoritative property name for any +capability; the table above is its prose form. diff --git a/docs/notification/adr/NOTIF-ADR-001-durable-acceptance.md b/docs/notification/adr/NOTIF-ADR-001-durable-acceptance.md new file mode 100644 index 00000000..e3d6e92c --- /dev/null +++ b/docs/notification/adr/NOTIF-ADR-001-durable-acceptance.md @@ -0,0 +1,27 @@ +# NOTIF-ADR-001 — `submit()` means durable acceptance + +## Status + +Accepted. + +## Context + +The obvious API for a notification platform is `send()` returning success or failure. Every channel +this platform supports makes that return value a lie: + +- SES accepts a request, returns a `MessageId`, and can still decline to send. +- Twilio separates `accepted`, `sent` and `delivered` into distinct, later events. +- APNs accepts a notification and may then deliver, store or discard it. +- Web Push separates push-service acceptance from user-agent acknowledgement at the protocol level. + +## Decision + +`submit()` and `schedule()` return once the logical request and its recipient jobs are committed to +the database. The receipt carries `notificationId`, `RequestStatus` and `acceptedAt`, and has no +`delivered`, `sent` or `read` component. No provider is contacted while the transaction is open. + +## Consequences + +Callers cannot mistake acceptance for delivery, because the type does not offer that reading. +Delivery state is a separate query against the projection built from the provider event ledger. The +cost is that "did it arrive?" is a second question — which is the honest number of questions. diff --git a/docs/notification/adr/NOTIF-ADR-002-event-ledger-projection.md b/docs/notification/adr/NOTIF-ADR-002-event-ledger-projection.md new file mode 100644 index 00000000..c7d990e7 --- /dev/null +++ b/docs/notification/adr/NOTIF-ADR-002-event-ledger-projection.md @@ -0,0 +1,25 @@ +# NOTIF-ADR-002 — append-only event ledger with channel projectors + +## Status + +Accepted. + +## Context + +A single linear delivery status has to be updated in place, which forces a rule for deciding whether +a new event outranks the stored one. The natural rule — compare ordinals — is wrong for real provider +traffic. Twilio does not guarantee callback ordering, so `sent` arrives after `delivered`. Email +generates complaints after deliveries. Both cases lose information under an ordinal rule. + +## Decision + +Provider events are appended to an immutable ledger before any projection runs. Channel-specific +projectors merge events into `SubmissionOutcome`, `DeliveryOutcome`, `EvidenceLevel`, +`EngagementFacts` and `SuppressionFacts` using explicit transition tables. Projection is idempotent +and can be replayed from the ledger. + +## Consequences + +Duplicate, out-of-order and late events are normal inputs rather than defects. A projector bug is +recoverable, because the events it mis-projected are still stored. Projector versions can be migrated +by replay. The cost is a second write per event and a projection that can lag its ledger. diff --git a/docs/notification/adr/NOTIF-ADR-003-ambiguous-submission.md b/docs/notification/adr/NOTIF-ADR-003-ambiguous-submission.md new file mode 100644 index 00000000..71730392 --- /dev/null +++ b/docs/notification/adr/NOTIF-ADR-003-ambiguous-submission.md @@ -0,0 +1,37 @@ +# NOTIF-ADR-003 — ambiguous submission is a first-class state + +## Status + +Accepted. + +## Context + +The most common serious failure is not a rejection. It is a request whose body reached the provider +and whose response never came back. The platform has no provider request id, and the user may or may +not have received the notification. + +Treating that as a failure produces duplicates: a retry sends a second message, and a cross-channel +fallback sends the SMS next to the push that already arrived. Treating it as a success loses real +failures. + +## Decision + +`AMBIGUOUS` is a stored `SubmissionOutcome` and `AttemptConfirmation`. Attempts record +`requestStarted`, `requestBodyCommitted` and `providerResponseReceived`, each with an +`EvidenceCertainty` of `PROVEN`, `INFERRED` or `UNKNOWN`, so an adapter that does not know is not +forced to answer `false`. + +While an ambiguous attempt exists on a recipient delivery: + +- automatic retry is blocked unless the provider proves per-request idempotency +- automatic cross-channel fallback is blocked unconditionally +- reconciliation runs where the provider supports a status query +- otherwise the delivery stops and waits for an operator + +Operator redrive of an ambiguous attempt requires explicit duplicate-risk approval. + +## Consequences + +Some notifications stop in a state that needs a human or a reconciliation pass. That is the intended +trade: an unresolved unknown is cheaper than a guaranteed duplicate, and the state is visible rather +than silently resolved in either direction. diff --git a/docs/notification/adr/NOTIF-ADR-004-fcm-fid-primary.md b/docs/notification/adr/NOTIF-ADR-004-fcm-fid-primary.md new file mode 100644 index 00000000..dd6bc2b1 --- /dev/null +++ b/docs/notification/adr/NOTIF-ADR-004-fcm-fid-primary.md @@ -0,0 +1,27 @@ +# NOTIF-ADR-004 — FCM installation id is the primary target + +## Status + +Accepted. + +## Context + +Firebase now recommends the installation id (FID) and treats registration-token multicast paths as +legacy. A contact point model built on a single `token` string would encode the older model as the +only one, and a later migration would be a runtime interpretation problem: the same string field +would mean different things for different rows. + +## Decision + +`MobilePushTarget` is a sealed hierarchy of `FcmInstallationId`, `LegacyFcmRegistrationToken` and +`ApnsDeviceToken`. The kinds are separate types, never a discriminator on one string field, and each +carries its own `ContactPointType` so the uniqueness scope and the encryption associated data differ. + +APNs tokens additionally carry their environment, because sandbox and production are separate +namespaces rather than a flag. + +## Consequences + +Migrating a target kind is a compile-time change with an exhaustive `switch`, not a runtime guess. +The adapter maps each kind to its own wire representation, so a provider changing one path cannot +silently change the other. The cost is one more type than a string field would need. diff --git a/docs/notification/callback-reconciliation.md b/docs/notification/callback-reconciliation.md new file mode 100644 index 00000000..46a9db58 --- /dev/null +++ b/docs/notification/callback-reconciliation.md @@ -0,0 +1,52 @@ +# Callbacks and reconciliation + +## Ingestion order + +```text +body size limit + → content type + → profile lookup + → signature verification + → append to the ledger + → duplicate detection + → normalization + → attempt resolution + → projection + → side effects + → 2xx +``` + +Appending before projecting is what makes a fast 2xx honest. The provider is told the event is +recorded, and a projector defect becomes a replay problem rather than a lost event. + +A rejected signature is recorded in the security audit, never in the provider event ledger. Writing +it to the ledger would let anyone who can reach the endpoint fill a delivery history with noise. + +## Duplicates and ordering + +Duplicate suppression uses `(providerProfileId, providerEventId)` where the provider supplies an +event id, and a deterministic fingerprint over profile, request id, event type, occurrence time and +payload digest where it does not. A duplicate is acknowledged and projected exactly once. + +Out-of-order callbacks are normal. Ordering is resolved by event semantics, not by arrival time. + +## Unknown fields + +Callback parsers tolerate unknown JSON fields. Normalization only rejects a payload when a field +required to identify the attempt is missing. Providers add fields; that must not stop ingestion. + +## Reconciliation + +Reconciliation targets: + +- attempts stuck in `DISPATCHING` past their lease +- ambiguous submissions +- accepted attempts whose callback SLA has expired +- unmatched provider events + +A confirmed query result is appended to the same ledger with `source = RECONCILIATION` and projected +by the same projector, so projection replay stays possible: there is no privileged second path that +writes projections directly. + +Where a provider has no status-query capability, the platform records `Unsupported` and leaves the +attempt ambiguous. It does not infer a final status. diff --git a/docs/notification/configuration-reference.md b/docs/notification/configuration-reference.md new file mode 100644 index 00000000..bee67c07 --- /dev/null +++ b/docs/notification/configuration-reference.md @@ -0,0 +1,41 @@ +# Configuration reference + +## 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 | + +Every value is bounded. "Unlimited" is not an accepted configuration. + +## 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. + +## Startup failures + +Startup fails rather than degrading when: + +- a payload or queue setting is unbounded +- a timeout is negative +- a TTL-required profile has no expiry source +- a callback signing secret is missing +- a production profile enables trust-all +- an APNs profile is missing its environment or topic +- 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 + +## 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. diff --git a/docs/notification/delivery-evidence.md b/docs/notification/delivery-evidence.md new file mode 100644 index 00000000..a01437d9 --- /dev/null +++ b/docs/notification/delivery-evidence.md @@ -0,0 +1,62 @@ +# Delivery evidence model + +## The shape + +```text +NotificationRequest + └─ RecipientDelivery + └─ DeliveryAttempt + └─ ProviderEvent (append-only) + └─ channel projector + └─ SubmissionOutcome / DeliveryOutcome / EvidenceLevel + + EngagementFacts + SuppressionFacts +``` + +Four identities, four lifecycles. A logical request is not a recipient job, a recipient job is not a +provider attempt, and a provider attempt is not the event stream that describes it. + +## Why not one status enum + +A single linear status would have to answer "what happened?" with one value, and the real answers do +not fit on one line: + +- An email can be `DELIVERED` and then generate a complaint. Both facts are true and both matter: + one for reporting, the other for suppression. +- Twilio does not guarantee callback ordering, so `sent` routinely arrives after `delivered`. Under + an ordinal rule the later, weaker event silently overwrites the stronger one. +- APNs may accept a notification and then store, replace or discard it. + +So the ledger stores events and a channel projector merges them through an explicit transition table. +`StandardDeliveryProjector` holds the shared rules; provider projectors add only their own event +vocabulary. + +## Merge rules + +| Transition | Result | +|---|---| +| `sent` → `delivered` | applied | +| `delivered` → `sent` | ignored, event still stored | +| `delivered` → `complaint` | complaint fact added, delivery preserved | +| `complaint` → `delivered` | delivery applied, complaint preserved | +| `accepted` → `bounced` | applied | +| `read` → `displayed` | ignored | +| hard bounce → `delivered` | ignored, hard bounce is terminal | + +Engagement (`opened`, `clicked`) is stored beside the delivery outcome and never changes it. + +## Ambiguity + +```text +platform ──── send ────▶ provider + │ + └── accepted + ✗ connection reset +``` + +The platform may hold no provider request id while the notification really was sent. The attempt +records `requestStarted`, `requestBodyCommitted`, `providerResponseReceived` and an +`EvidenceCertainty` for each, so a later decision can tell "we know nothing was sent" apart from "we +could not read the answer". + +`ProviderSubmissionResult` enforces this: an ambiguous result may not claim `PROVIDER_ACCEPTED`, and +no submission result of any kind may carry a delivery outcome. diff --git a/docs/notification/migration-guide.md b/docs/notification/migration-guide.md new file mode 100644 index 00000000..61dd23e3 --- /dev/null +++ b/docs/notification/migration-guide.md @@ -0,0 +1,31 @@ +# Migration guide + +## From the R0 routing seam + +The pre-existing `dev.caskeleton.adapter.outbound.notification` router (`RoutingNotifier`, +`FailOpenNotificationProvider`, the Google email and Slack webhook seams) stays untouched. The +delivery platform lives beside it under `…notification.platform` and does not modify or delete any +R0 class. + +Migration order per capability: + +1. Register the contact points behind `ContactPointStorePort` so the platform owns protected values. +2. Publish the template version, and pin the template id, version and locale at every call site. +3. Move the call site from the router to the N1 typed facade for the channel. +4. Verify evidence in the snapshot rather than in the caller's return value: `submit()` is durable + acceptance and nothing more. +5. Remove the R0 route only after the platform route has produced provider evidence in the target + environment. + +## Return-value semantics change + +The R0 seam returned a send-shaped result. `NotificationReceipt` returns `notificationId`, a request +status and an acceptance time. Callers that treated the old return value as proof of delivery must be +changed; there is no compatibility shim, because a shim would have to invent the delivery claim this +platform exists to avoid. + +## FCM target migration + +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. diff --git a/docs/notification/module-mapping.md b/docs/notification/module-mapping.md new file mode 100644 index 00000000..0eb882c1 --- /dev/null +++ b/docs/notification/module-mapping.md @@ -0,0 +1,94 @@ +# Notification Delivery Platform — module mapping + +> Source design: `notification-superpowers-package/docs/superpowers/specs/2026-08-10-notification-platform-design.md` +> +> Source plan: `notification-superpowers-package/docs/superpowers/plans/2026-08-10-notification-platform-implementation-plan.md` + +## Why a mapping exists + +The plan was written against a hypothetical repository (`modules/notification/**`, root package +`io.backend.skeleton.notification`, 31 Gradle projects). This repository is a fail-closed +19-leaf Clean Architecture template: `src/settings.gradle` rejects any registry that does not +contain exactly the 19 modules in `src/config/architecture/modules.json`, and +`verifyCleanArchitectureDependencies` rejects any project edge outside `allowed_dependencies`. + +Creating 31 new Gradle projects would violate HARD-STOP #5 of `AGENTS.md`. The package README +anticipates this and instructs the implementer to map dependency catalog and package/file paths onto +the host repository's rules while preserving the public contracts and reliability semantics. + +Every logical module of the plan is therefore implemented as a **package** inside the registered leaf +that owns its responsibility. No public contract, evidence rule, or reliability semantic is dropped. + +## Logical module → registered leaf + +| Plan module | Registered leaf | Package | +|---|---|---| +| `notification-core-api` | `application-core` | `dev.caskeleton.application.notification.platform.api` | +| `notification-content-api` | `application-core` | `…platform.api.content` | +| `notification-contact-api` | `application-core` | `…platform.contact` | +| `notification-template-api` | `application-core` | `…platform.template` | +| `notification-policy` | `application-core` | `…platform.policy` | +| `notification-provider-spi` | `application-core` | `…platform.provider` | +| `notification-callback-api` | `application-core` | `…platform.callback` | +| `notification-email-api` | `application-core` | `…platform.email` | +| `notification-sms-api` | `application-core` | `…platform.sms` | +| `notification-push-api` | `application-core` | `…platform.push` | +| `notification-webpush` (API half) | `application-core` | `…platform.webpush` | +| `notification-inbox-api` | `application-core` | `…platform.inbox` | +| `notification-admin-api` | `application-core` | `…platform.admin` | +| `notification-security` (ports + redaction) | `application-core` | `…platform.security` | +| `notification-observability` (ports) | `application-core` | `…platform.observation` | +| `notification-dispatch-runtime` | `adapter:outbound:notification` | `dev.caskeleton.adapter.outbound.notification.platform.dispatch` | +| `notification-security` (AES-GCM/HMAC impl) | `adapter:outbound:notification` | `…platform.security` | +| `notification-template-thymeleaf` (reference renderer) | `adapter:outbound:notification` | `…platform.template` | +| `notification-email-smtp` | `adapter:outbound:notification` | `…platform.provider.smtp` | +| `notification-email-ses` | `adapter:outbound:notification` | `…platform.provider.ses` | +| `notification-sms-twilio` | `adapter:outbound:notification` | `…platform.provider.twilio` | +| `notification-push-fcm` | `adapter:outbound:notification` | `…platform.provider.fcm` | +| `notification-push-apns` | `adapter:outbound:notification` | `…platform.provider.apns` | +| `notification-webpush` (transport + crypto) | `adapter:outbound:notification` | `…platform.provider.webpush` | +| `notification-webhook-extension` | `adapter:outbound:notification` | `…platform.provider.webhook` | +| `notification-observability` (Micrometer impl) | `adapter:outbound:notification` | `…platform.observation` | +| `notification-admin-runtime` | `adapter:outbound:notification` | `…platform.admin` | +| `notification-reactor` | `adapter:outbound:notification` | `…platform.reactor` | +| `notification-spring-boot-starter` | `adapter:outbound:notification` (+ `app-bootstrap` wiring) | `…platform.autoconfigure` | +| `notification-persistence-jpa` | `adapter:outbound:persistence-jpa` | `dev.caskeleton.adapter.outbound.persistence.notification.platform` | +| `notification-inbox-jpa` | `adapter:outbound:persistence-jpa` | `…persistence.notification.platform.inbox` | +| `notification-callback-mvc` | `adapter:inbound:web` | `dev.caskeleton.adapter.inbound.web.notification.platform.callback` | +| `notification-callback-webflux` | `adapter:inbound:web` | `…callback.reactive` | +| `notification-testkit` | test source sets of the owning leaves | `…platform.testkit` | + +## Dependency-direction consequences + +The plan's module DAG (`*-api` → `provider-spi`/`policy` → runtime/adapters → starter) is preserved +by the leaf DAG that the registry already enforces: + +```text +application-core (all *-api, provider SPI, policy, callback contracts) + ↑ ↑ ↑ +adapter:outbound:notification adapter:outbound:persistence-jpa adapter:inbound:web + ↑ ↑ ↑ + app-bootstrap +``` + +Two plan edges cannot be expressed as project edges in this repository, and are replaced by ports: + +1. `notification-email-ses`, `notification-sms-twilio`, `notification-push-fcm`, + `notification-push-apns`, `notification-webpush`, `notification-webhook-extension` + → `httpclient platform`. + `adapter-outbound-notification` is not allowed to depend on `adapter-outbound-httpclient`. + The provider adapters therefore call + `dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway`, + an adapter-local port with a JDK `java.net.http.HttpClient` default implementation. + `app-bootstrap` sees both leaves and is the supported place to substitute an implementation backed + by the HTTP Client Platform (TLS/timeout/circuit-breaker/SSRF/dynamic-target policy reuse). +2. `notification-inbox-jpa` → `optional messaging outbox integration`. + `adapter-outbound-persistence-jpa` may not depend on `adapter-outbound-messaging`; the inbox + publishes through the existing persistence outbox tables plus the + `NotificationInboxSignalPort` application port, and `app-bootstrap` binds the relay. + +## Commit policy + +`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. diff --git a/docs/notification/operations.md b/docs/notification/operations.md new file mode 100644 index 00000000..c817c9d2 --- /dev/null +++ b/docs/notification/operations.md @@ -0,0 +1,47 @@ +# Operations + +## Runtime shape + +```text +durable queue (PostgreSQL, FOR UPDATE SKIP LOCKED) + → expiry check + → suppression and eligibility re-check + → provider health gate + → rate limiter + → concurrency limiter + → provider adapter +``` + +Provider calls run outside every database transaction. The attempt row is committed first, so after a +crash the row is either absent (nothing was sent) or present in `DISPATCHING` (reconciliation has +something to ask about). + +## Guards that exist for specific incidents + +| Guard | The incident it prevents | +|---|---| +| Credential failure opens the provider route | One expired key multiplied by a queue becomes a self-inflicted outage | +| Retry budget per provider profile | A provider outage turning every queued notification into its own retry loop | +| Ambiguous attempts block automatic fallback | A push whose response was lost arriving alongside the "just in case" SMS | +| Permits released during backoff | A slow provider pinning the whole concurrency budget on work that is only waiting | +| Bounded drain on rotation | A provider that never answers holding a credential rotation open forever | +| Fail-fast intake on capacity | An unbounded in-memory queue absorbing a burst it cannot survive | + +## Scheduling + +`scheduleAt` activates the job, `notBefore` is the earliest permitted provider submission, and +`expiresAt` blocks new attempts, retries and fallbacks. Suppression and expiry are re-checked +immediately before dispatch, because a scheduled notification can sit in the queue for hours and the +user may have opted out in the meantime. + +## Redrive + +A redrive preserves `NotificationId` and `RecipientDeliveryId`, creates a new `DeliveryAttemptId`, and +reuses the pinned template version and rendered digest. Sending different content is a new +notification, not a redrive. Redriving an ambiguous attempt requires explicit duplicate-risk approval, +because the platform genuinely cannot tell whether the first submission reached the user. + +## Actuator surface + +Provider runtime states and generations, queue depth and age, callback and reconciliation health. +Never addresses, never credentials. diff --git a/docs/notification/provider-runbooks.md b/docs/notification/provider-runbooks.md new file mode 100644 index 00000000..6c2092e0 --- /dev/null +++ b/docs/notification/provider-runbooks.md @@ -0,0 +1,65 @@ +# Provider runbooks + +## SMTP + +| Symptom | Classification | Action | +|---|---|---| +| Final `2xx` after `DATA` | `CONFIRMED_ACCEPTED` / `PROVIDER_ACCEPTED` | None; this is acceptance, not inbox delivery | +| `4yz` | `TRANSIENT_PROVIDER` | Retry under budget and deadline | +| `5yz` | `PERMANENT_PROVIDER` or `INVALID_RECIPIENT` | Stop, or invalidate the contact point | +| Connection lost after `DATA` | `AMBIGUOUS_SUBMISSION` | Reconcile or escalate; do not resend automatically | + +Connection, read, write and pool-acquire timeouts are all finite. There is no unbounded timeout. + +## Amazon SES + +`MessageId` is acceptance evidence. SES itself documents that it can accept a request and then not +send, so `MessageId` is never mapped to `DELIVERED`. + +| Event | Normalized | +|---|---| +| `Send` | reinforces `PROVIDER_ACCEPTED` | +| `Delivery` | `DELIVERY_CONFIRMED` / `NETWORK_OR_CARRIER_ACCEPTED` | +| `DeliveryDelay` | delay fact | +| `Bounce` (permanent) | `BOUNCED_HARD` plus hard-bounce suppression | +| `Bounce` (transient) | `BOUNCED_SOFT`; retry policy input, not a suppression reason | +| `Complaint` | complaint fact plus suppression | +| `Reject` | `PROVIDER_REJECTED` | +| `RenderingFailure` | `TEMPLATE_FAILURE` | + +## Twilio + +`accepted`/`queued` is acceptance only. `sent` is carrier acceptance. `delivered` is device delivery. + +Callbacks are not ordered. A `sent` arriving after `delivered` is stored and ignored by the +projection. Missing callbacks are corrected by status polling under the provider rate limit. + +Signature verification uses the canonical external URL from the profile, not the URL the servlet +container reconstructed behind a proxy. + +## FCM + +| Error | Classification | +|---|---| +| `UNREGISTERED` | `INVALID_RECIPIENT`; invalidate the contact point, never retry | +| `INVALID_ARGUMENT` | `INVALID_PAYLOAD` | +| `QUOTA_EXCEEDED` | `THROTTLED`, exponential backoff | +| `UNAVAILABLE` | `TRANSIENT_PROVIDER`, honour `Retry-After`, add jitter | +| Credential failure | `AUTHENTICATION`; opens the provider route | + +A batch is one transport call and many attempts. Partial results map back by input index; one +transport failure does not become one shared outcome unless the adapter can prove it. + +## APNs + +2xx is acceptance. Environment and topic mismatches are configuration failures, not delivery +failures. Sandbox and production tokens are separate namespaces. + +## Web Push + +`TTL` is mandatory by protocol. `201` is acceptance. `404` is an expired subscription per RFC 8030; +provider-documented `410` maps the same way. Payloads use `aes128gcm` per RFC 8291 and VAPID JWTs are +signed per RFC 8292 with the audience taken from the endpoint origin. + +VAPID key rotation is not ordinary credential rotation: a restricted subscription may need to be +re-created, so it is a migration operation. diff --git a/docs/notification/security-privacy.md b/docs/notification/security-privacy.md new file mode 100644 index 00000000..cf068210 --- /dev/null +++ b/docs/notification/security-privacy.md @@ -0,0 +1,56 @@ +# Security and privacy + +## Protected values + +Email addresses, phone numbers, FCM installation ids and legacy tokens, APNs device tokens, Web Push +endpoints and keys, VAPID private keys, provider credentials, callback signing secrets, template +variables, rendered bodies, attachment references and unsubscribe tokens. + +## At rest + +Contact points are encrypted with AES-256-GCM. Equality lookup uses a separate HMAC-SHA-256 +fingerprint. + +Two keys, not one, because the requirements are opposite: the ciphertext must be non-deterministic so +two records of the same address are not visibly identical, while equality lookup must be +deterministic. The fingerprint is keyed rather than a plain digest because phone numbers and email +addresses come from a small, enumerable space — an unkeyed hash of a phone number is recoverable in +seconds. + +The contact point kind is bound into the GCM associated data, so a ciphertext cannot be moved between +contact kinds without failing the authentication tag. + +An unknown key id is refused rather than silently falling back to the current key: a silent fallback +would turn every historical row into a tag failure at read time. + +## Never logged, never a metric tag + +Addresses, tokens, Web Push endpoints and keys, message bodies, template variables, provider +credentials, unsubscribe tokens, attachment URLs, raw callback payloads and raw provider request ids. + +Two mechanisms enforce this rather than convention: + +- `CardinalityGuard` validates every metric tag against a closed allowlist. +- `SafeDiagnosticContext` rejects any structured-diagnostic field outside its allowlist. + +An allowlist rather than a denylist, because the failure mode of a denylist is that the one field +nobody thought of is the one that leaks. + +Every contact point value type overrides `toString()` to print `[redacted]`. That covers the case a +central redactor cannot: a value interpolated into a log line by accident. + +## Web Push endpoints + +RFC 8030 defines the push URI as a capability URL — knowing it is sufficient to push to the +subscriber. It is handled as a secret, not as a URL. + +## Callbacks + +TLS, provider signature verification over the exact received bytes and external URL, replay defence +where a timestamp or nonce is available, body-size and content-type limits, profile binding, rate +limiting, idempotent ingestion and a security audit trail for rejections. + +## Tenant isolation + +Every store port carries the tenant boundary in its signature. Administrative operations require an +explicit tenant or a global authority. diff --git a/docs/notification/support-matrix.md b/docs/notification/support-matrix.md new file mode 100644 index 00000000..0e97a725 --- /dev/null +++ b/docs/notification/support-matrix.md @@ -0,0 +1,68 @@ +# Notification support matrix + +What each channel can actually prove, and what the platform refuses to claim. + +## 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 | +| In-app inbox | Own database | Optional stable | `PERSISTED`, `SEEN`, `READ` | +| Webhook | HTTP client platform | Extension | Whatever the receiving HTTP contract states | + +## Evidence levels + +`NONE` → `PLATFORM_QUEUED` → `PROVIDER_ACCEPTED` → `NETWORK_OR_CARRIER_ACCEPTED` → +`DEVICE_DELIVERED` → `USER_AGENT_DISPLAYED` → `USER_READ` + +| Provider signal | Highest evidence it may produce | +|---|---| +| Internal queue commit | `PLATFORM_QUEUED` | +| SES `MessageId` | `PROVIDER_ACCEPTED` | +| SES `Delivery` | `NETWORK_OR_CARRIER_ACCEPTED` | +| Twilio `accepted` / `queued` | `PROVIDER_ACCEPTED` | +| Twilio `sent` | `NETWORK_OR_CARRIER_ACCEPTED` | +| Twilio `delivered` | `DEVICE_DELIVERED` | +| FCM send success | `PROVIDER_ACCEPTED` | +| APNs 2xx | `PROVIDER_ACCEPTED` | +| Web Push `201` | `PROVIDER_ACCEPTED` | +| Web Push receipt capability | `DEVICE_DELIVERED` | +| In-app row commit | `PROVIDER_ACCEPTED` | +| In-app `seen` endpoint | `USER_AGENT_DISPLAYED` | +| In-app `read` endpoint, authenticated app receipt | `USER_READ` | + +Promotions the platform will not make, in code or in configuration: + +- FCM send success is not `DEVICE_DELIVERED`. +- An APNs 2xx is not `DELIVERED`. +- An SES `MessageId` is not `DELIVERED`. +- An SMTP `250` is not inbox delivery. + +## Submission outcomes + +`NOT_SUBMITTED`, `CONFIRMED_ACCEPTED`, `CONFIRMED_REJECTED`, `AMBIGUOUS`. + +`AMBIGUOUS` is a first-class stored state, not an error path. It means the request body was committed +to the provider and the outcome could not be read. While an ambiguous attempt exists on a recipient +delivery, automatic retry and automatic cross-channel fallback are both blocked. + +## Not supported + +The platform will not claim any of the following, because no channel above can support them: + +- guaranteed delivery +- guaranteed read +- exactly-once human notification +- unconditional multi-provider failover after an unread response +- provider SDK types in the public API +- audience selection, campaign segmentation or jurisdiction rulings + +## Target model + +`FCM_FID` is the primary mobile push target. `FCM_REGISTRATION_TOKEN_LEGACY` and +`APNS_DEVICE_TOKEN` are separate types with separate lifecycles; they are never flattened into one +string field. diff --git a/docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md b/docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md new file mode 100644 index 00000000..bc9c1ae1 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md @@ -0,0 +1,771 @@ +# JPA Experimental Expansion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stable JPA 플랫폼을 변경하지 않고 Multi-tenancy, PostgreSQL RLS, schema/database tenant 분리, consistency-aware Read Replica, Jakarta Persistence 4.0, Hibernate ORM 8, PostgreSQL 19 호환성을 독립 Experimental 모듈과 승격 Gate로 검증한다. + +**Architecture:** Experimental module은 Stable `jpa-core-api` 계약만 소비하며 Stable starter에 자동 포함되지 않는다. 각 기능은 명시적 feature flag와 별도 compatibility/failure suite를 요구한다. 실험 결과가 Stable 의미론과 충돌하면 Core를 왜곡하지 않고 capability 또는 별도 profile로 유지한다. + +**Tech Stack:** Stable 계획의 Java 21·Spring Boot 4.1·PostgreSQL Testcontainers 기반, PostgreSQL RLS, AbstractRoutingDataSource, tenant-specific DataSource registry, Jakarta Persistence 4.0 preview/final compatibility lane, Hibernate ORM 8 compatibility lane, PostgreSQL 19 compatibility lane. + +## Global Constraints + +- Stable 계획 Task 1~53이 완료되고 Release Gate가 통과한 뒤 시작한다. +- 모듈 루트는 `modules/jpa-experimental`이다. +- Experimental module은 `jpa-spring-boot-starter`의 기본 dependency가 아니다. +- 모든 기능은 `backend.jpa.experimental.*` feature flag를 요구한다. +- Tenant ID와 consistency token은 metric label에 기록하지 않는다. +- Tenant context 누락은 fail-closed다. +- `readOnly=true`만으로 replica routing하지 않는다. +- Lock query, write transaction, read-after-write pin은 primary를 사용한다. +- JPA4/Hibernate8/PG19 결과로 Stable 3.2/7.4/PG16~18 contract를 수정하지 않는다. +- 승격 전 별도 security, failure, migration and compatibility evidence가 필요하다. + +--- + +## 1. Experimental 파일 구조 + +```text +modules/jpa-experimental/ +├── jpa-experimental-core/ +├── jpa-multitenancy-column/ +├── jpa-multitenancy-rls/ +├── jpa-multitenancy-schema/ +├── jpa-multitenancy-database/ +├── jpa-read-replica/ +└── jpa-next-compatibility/ +``` + +--- +### Task 1: Experimental Module·Feature Gate·Dependency Isolation 구성 + +**Files:** +- Create: `modules/jpa-experimental/jpa-experimental-core/build.gradle.kts` +- Create: `modules/jpa-experimental/jpa-multitenancy-column/build.gradle.kts` +- Create: `modules/jpa-experimental/jpa-multitenancy-rls/build.gradle.kts` +- Create: `modules/jpa-experimental/jpa-multitenancy-schema/build.gradle.kts` +- Create: `modules/jpa-experimental/jpa-multitenancy-database/build.gradle.kts` +- Create: `modules/jpa-experimental/jpa-read-replica/build.gradle.kts` +- Create: `modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts` +- Create: `modules/jpa-experimental/jpa-experimental-core/src/main/java/io/backend/skeleton/jpa/experimental/ExperimentalFeature.java` +- Create: `modules/jpa-experimental/jpa-experimental-core/src/main/java/io/backend/skeleton/jpa/experimental/ExperimentalFeatureGate.java` +- Modify: `settings.gradle.kts` +- Test: `modules/jpa-experimental/jpa-experimental-core/src/test/java/io/backend/skeleton/jpa/experimental/ExperimentalFeatureGateTest.java` + +**Interfaces:** +- Consumes: Stable `jpa-core-api` and explicit environment feature flags. +- Produces: Isolated experimental projects that cannot enter the Stable starter transitively. + +**Implementation requirements:** +- Every module depends only on Stable public contracts, never on Stable internal packages. +- Feature gate fails startup when module is present but flag is absent. +- Add a dependency graph test proving the Stable starter has no experimental dependency. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.experimental; + +class ExperimentalFeatureGateTest { + @Test + void featureIsDisabledUnlessExplicitlyEnabled() { + assertThatThrownBy(() -> gate.requireEnabled(MULTITENANCY_COLUMN, Map.of())) + .hasMessageContaining("backend.jpa.experimental.multitenancy-column=true"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-experimental-core:test --tests 'io.backend.skeleton.jpa.experimental.ExperimentalFeatureGateTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.experimental; + +public final class ExperimentalFeatureGate { + public void requireEnabled( + ExperimentalFeature feature, + Map flags) { + if (!Boolean.TRUE.equals(flags.get(feature.property()))) { + throw new IllegalStateException(feature.property() + "=true is required"); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-experimental-core:test --tests 'io.backend.skeleton.jpa.experimental.ExperimentalFeatureGateTest' +./gradlew :modules:jpa-experimental:jpa-experimental-core:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa-experimental/jpa-experimental-core/build.gradle.kts' 'modules/jpa-experimental/jpa-multitenancy-column/build.gradle.kts' 'modules/jpa-experimental/jpa-multitenancy-rls/build.gradle.kts' 'modules/jpa-experimental/jpa-multitenancy-schema/build.gradle.kts' 'modules/jpa-experimental/jpa-multitenancy-database/build.gradle.kts' 'modules/jpa-experimental/jpa-read-replica/build.gradle.kts' 'modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts' 'modules/jpa-experimental/jpa-experimental-core/src/main/java/io/backend/skeleton/jpa/experimental/ExperimentalFeature.java' 'modules/jpa-experimental/jpa-experimental-core/src/main/java/io/backend/skeleton/jpa/experimental/ExperimentalFeatureGate.java' 'settings.gradle.kts' 'modules/jpa-experimental/jpa-experimental-core/src/test/java/io/backend/skeleton/jpa/experimental/ExperimentalFeatureGateTest.java' +git commit -m "build: isolate jpa experimental modules" +``` + +### Task 2: Shared-schema Tenant Context와 Column Guard 구현 + +**Files:** +- Create: `modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantId.java` +- Create: `modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantContext.java` +- Create: `modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantAwareRepositoryGuard.java` +- Create: `modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantEntityListenerGuard.java` +- Test: `modules/jpa-experimental/jpa-multitenancy-column/src/integrationTest/java/io/backend/skeleton/jpa/experimental/tenant/TenantColumnIsolationTest.java` + +**Interfaces:** +- Consumes: Explicit request/job tenant context and domain Entity tenant-column contracts. +- Produces: Fail-closed tenant context propagation and query/write isolation evidence. + +**Implementation requirements:** +- Reject Repository access when tenant context is absent outside an audited admin scope. +- Require tenant column in unique/index requirements where isolation depends on it. +- Test async job context propagation and cleanup. +- Do not rely on Hibernate filter alone as the final security boundary. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.experimental.tenant; + +class TenantColumnIsolationTest { + @Test + void tenantARepositoryCannotReadTenantBRows() { + insertFor(TENANT_A, "a"); + insertFor(TENANT_B, "b"); + + assertThat(withTenant(TENANT_A, repository::findAll)) + .extracting(Item::value) + .containsExactly("a"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-multitenancy-column:integrationTest --tests 'io.backend.skeleton.jpa.experimental.tenant.TenantColumnIsolationTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.experimental.tenant; + +public final class TenantContext { + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + + public static TenantId require() { + TenantId tenant = CURRENT.get(); + if (tenant == null) throw new IllegalStateException("tenant context is required"); + return tenant; + } + + public static void clear() { CURRENT.remove(); } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-multitenancy-column:integrationTest --tests 'io.backend.skeleton.jpa.experimental.tenant.TenantColumnIsolationTest' +./gradlew :modules:jpa-experimental:jpa-multitenancy-column:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantId.java' 'modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantContext.java' 'modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantAwareRepositoryGuard.java' 'modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantEntityListenerGuard.java' 'modules/jpa-experimental/jpa-multitenancy-column/src/integrationTest/java/io/backend/skeleton/jpa/experimental/tenant/TenantColumnIsolationTest.java' +git commit -m "feat: add experimental tenant column isolation" +``` + +### Task 3: PostgreSQL RLS Tenant Policy와 Connection Reuse Guard 구현 + +**Files:** +- Create: `modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsTenantSessionBinder.java` +- Create: `modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsPolicyVerifier.java` +- Create: `modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsAdminBypassToken.java` +- Create: `modules/jpa-experimental/jpa-multitenancy-rls/src/main/resources/db/experimental-rls/V1__tenant_rls.sql` +- Test: `modules/jpa-experimental/jpa-multitenancy-rls/src/failureTest/java/io/backend/skeleton/jpa/experimental/rls/RlsIsolationFailureTest.java` + +**Interfaces:** +- Consumes: TenantContext, PostgreSQL transaction-local settings and restricted runtime role. +- Produces: Database-enforced tenant isolation that resets safely across pooled connections. + +**Implementation requirements:** +- Set tenant context with transaction-local `set_config` before tenant queries. +- Prove a pooled connection cannot leak the prior tenant into the next transaction. +- Runtime role must not own tables or bypass RLS. +- Admin bypass requires a separate DataSource and audit token. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.experimental.rls; + +class RlsIsolationFailureTest { + @Test + void pooledConnectionDoesNotLeakPriorTenantSetting() { + withTenant(TENANT_A, () -> assertThat(repository.count()).isEqualTo(1)); + withTenant(TENANT_B, () -> assertThat(repository.count()).isEqualTo(1)); + withoutTenant(() -> assertThatThrownBy(repository::count).isInstanceOf(DataAccessException.class)); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-multitenancy-rls:failureTest --tests 'io.backend.skeleton.jpa.experimental.rls.RlsIsolationFailureTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.experimental.rls; + +public final class RlsTenantSessionBinder { + public void bind(EntityManager entityManager, TenantId tenant) { + entityManager.createNativeQuery( + "select set_config('app.tenant_id', :tenant, true)") + .setParameter("tenant", tenant.value()) + .getSingleResult(); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-multitenancy-rls:failureTest --tests 'io.backend.skeleton.jpa.experimental.rls.RlsIsolationFailureTest' +./gradlew :modules:jpa-experimental:jpa-multitenancy-rls:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsTenantSessionBinder.java' 'modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsPolicyVerifier.java' 'modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsAdminBypassToken.java' 'modules/jpa-experimental/jpa-multitenancy-rls/src/main/resources/db/experimental-rls/V1__tenant_rls.sql' 'modules/jpa-experimental/jpa-multitenancy-rls/src/failureTest/java/io/backend/skeleton/jpa/experimental/rls/RlsIsolationFailureTest.java' +git commit -m "feat: add experimental postgresql rls isolation" +``` + +### Task 4: Schema-per-tenant Connection Provider와 Migration Orchestrator 구현 + +**Files:** +- Create: `modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantRegistry.java` +- Create: `modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaMultiTenantConnectionProvider.java` +- Create: `modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantMigrationOrchestrator.java` +- Test: `modules/jpa-experimental/jpa-multitenancy-schema/src/migrationTest/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantMigrationContractTest.java` + +**Interfaces:** +- Consumes: Validated tenant→schema catalog and Flyway migration gate. +- Produces: Bounded schema selection and per-tenant migration status without accepting raw schema names. + +**Implementation requirements:** +- Map TenantId to a pre-registered schema identifier; no user-provided SQL identifier. +- Reset schema/search_path when returning pooled connections. +- Track migration version and failure per tenant. +- Rate-limit tenant migrations and support resume without auto-repair. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.experimental.schema; + +class SchemaTenantMigrationContractTest { + @Test + void migratesOnlyRegisteredSchemasAndResumesAfterFailure() { + orchestrator.migrateAll(List.of(TENANT_A, TENANT_B)); + assertThat(status(TENANT_A).version()).isEqualTo(LATEST); + assertThatThrownBy(() -> orchestrator.migrate(new TenantId("../public"))) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-multitenancy-schema:migrationTest --tests 'io.backend.skeleton.jpa.experimental.schema.SchemaTenantMigrationContractTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.experimental.schema; + +public final class SchemaTenantRegistry { + public String requireSchema(TenantId tenant) { + return Optional.ofNullable(schemaByTenant.get(tenant)) + .orElseThrow(() -> new IllegalArgumentException("unregistered tenant schema")); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-multitenancy-schema:migrationTest --tests 'io.backend.skeleton.jpa.experimental.schema.SchemaTenantMigrationContractTest' +./gradlew :modules:jpa-experimental:jpa-multitenancy-schema:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantRegistry.java' 'modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaMultiTenantConnectionProvider.java' 'modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantMigrationOrchestrator.java' 'modules/jpa-experimental/jpa-multitenancy-schema/src/migrationTest/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantMigrationContractTest.java' +git commit -m "feat: add experimental schema per tenant persistence" +``` + +### Task 5: Database-per-tenant DataSource Registry와 Capacity Guard 구현 + +**Files:** +- Create: `modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantDataSourceRegistry.java` +- Create: `modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantEntityManagerFactoryRegistry.java` +- Create: `modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantPoolBudget.java` +- Create: `modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantDataSourceLifecycle.java` +- Test: `modules/jpa-experimental/jpa-multitenancy-database/src/performanceTest/java/io/backend/skeleton/jpa/experimental/database/TenantPoolCapacityContractTest.java` + +**Interfaces:** +- Consumes: Secret-backed tenant connection profiles and global DB connection budget. +- Produces: Lazy bounded per-tenant pools with eviction, credential rotation and migration status. + +**Implementation requirements:** +- Never create an unbounded Hikari pool per tenant. +- Enforce global maximum pools and connections before creating a DataSource. +- Drain and close pools on tenant removal or credential rotation. +- Do not expose tenant JDBC URLs or credentials in diagnostics. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.experimental.database; + +class TenantPoolCapacityContractTest { + @Test + void refusesNewTenantPoolWhenGlobalConnectionBudgetIsExhausted() { + registry.openTenants(globalBudget().maxTenants()); + assertThatThrownBy(() -> registry.require(ANOTHER_TENANT)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("tenant pool budget"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-multitenancy-database:performanceTest --tests 'io.backend.skeleton.jpa.experimental.database.TenantPoolCapacityContractTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.experimental.database; + +public record TenantPoolBudget( + int maxOpenPools, + int maxConnectionsAcrossPools) { + public void requireCapacity(int openPools, int allocatedConnections) { + if (openPools >= maxOpenPools || allocatedConnections >= maxConnectionsAcrossPools) { + throw new IllegalStateException("tenant pool budget exhausted"); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-multitenancy-database:performanceTest --tests 'io.backend.skeleton.jpa.experimental.database.TenantPoolCapacityContractTest' +./gradlew :modules:jpa-experimental:jpa-multitenancy-database:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantDataSourceRegistry.java' 'modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantEntityManagerFactoryRegistry.java' 'modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantPoolBudget.java' 'modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantDataSourceLifecycle.java' 'modules/jpa-experimental/jpa-multitenancy-database/src/performanceTest/java/io/backend/skeleton/jpa/experimental/database/TenantPoolCapacityContractTest.java' +git commit -m "feat: add experimental database per tenant registry" +``` + +### Task 6: Consistency-aware Read Replica Routing 구현 + +**Files:** +- Create: `modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReadConsistency.java` +- Create: `modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ConsistencyToken.java` +- Create: `modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReplicaRoutingDecision.java` +- Create: `modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ConsistencyAwareDataSourceRouter.java` +- Create: `modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReplicaLagMonitor.java` +- Test: `modules/jpa-experimental/jpa-read-replica/src/failureTest/java/io/backend/skeleton/jpa/experimental/replica/ReadAfterWriteRoutingContractTest.java` + +**Interfaces:** +- Consumes: Primary/replica DataSources, transaction state, lock intent and replica lag evidence. +- Produces: Routing decisions for PRIMARY_REQUIRED, BOUNDED_STALENESS and EVENTUAL reads. + +**Implementation requirements:** +- Writes, lock queries, REQUIRES_NEW writes and active write transactions always use primary. +- Read-after-write uses a consistency token or primary pin, not `readOnly=true` alone. +- Fallback to primary when replica lag exceeds policy or evidence is unavailable. +- Keep routing fixed for the life of one transaction. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.experimental.replica; + +class ReadAfterWriteRoutingContractTest { + @Test + void immediateReadAfterWriteUsesPrimaryUntilConsistencyTokenIsSatisfied() { + var token = service.writeAndReturnConsistencyToken(); + var decision = router.route(readOnlyTransaction(), ReadConsistency.after(token)); + assertThat(decision.target()).isEqualTo(PRIMARY); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-read-replica:failureTest --tests 'io.backend.skeleton.jpa.experimental.replica.ReadAfterWriteRoutingContractTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.experimental.replica; + +public final class ConsistencyAwareDataSourceRouter { + public ReplicaRoutingDecision route( + TransactionContext transaction, + ReadConsistency consistency) { + if (transaction.write() || transaction.locking() || + !lagMonitor.satisfies(consistency)) { + return ReplicaRoutingDecision.primary(); + } + return ReplicaRoutingDecision.replica(); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-read-replica:failureTest --tests 'io.backend.skeleton.jpa.experimental.replica.ReadAfterWriteRoutingContractTest' +./gradlew :modules:jpa-experimental:jpa-read-replica:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReadConsistency.java' 'modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ConsistencyToken.java' 'modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReplicaRoutingDecision.java' 'modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ConsistencyAwareDataSourceRouter.java' 'modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReplicaLagMonitor.java' 'modules/jpa-experimental/jpa-read-replica/src/failureTest/java/io/backend/skeleton/jpa/experimental/replica/ReadAfterWriteRoutingContractTest.java' +git commit -m "feat: add experimental consistency aware replica routing" +``` + +### Task 7: Jakarta Persistence 4.0 Compatibility Lane 구현 + +**Files:** +- Create: `modules/jpa-experimental/jpa-next-compatibility/src/compatibilityJpa4/java/io/backend/skeleton/jpa/experimental/next/Jpa4CompatibilityTest.java` +- Create: `.github/workflows/jpa-next-jpa4.yml` +- Modify: `modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts` +- Test: `modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/CompatibilityLaneDefinitionTest.java` + +**Interfaces:** +- Consumes: Published Jakarta Persistence 4.0 milestone/final artifact when available and the Stable contract suite. +- Produces: A non-blocking compatibility report that does not alter Stable JPA 3.2 APIs. + +**Implementation requirements:** +- Run the Stable public API compilation and selected mapping contracts against JPA 4. +- Record removed/changed APIs and provider support separately. +- Do not publish JPA4 compiled artifacts under Stable coordinates. + +- [ ] **Step 1: Write the failing test** + +```kotlin +package io.backend.skeleton.jpa.experimental.next; + +class CompatibilityLaneDefinitionTest { + @Test + void jpaFourLaneIsExperimentalAndSeparateFromStablePublication() { + assertThat(lane("jpa4").publicationEnabled()).isFalse(); + assertThat(lane("jpa4").supportLevel()).isEqualTo(EXPERIMENTAL); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.CompatibilityLaneDefinitionTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```kotlin +testing { + suites { + register("compatibilityJpa4") { + useJUnitJupiter() + dependencies { + implementation(project(":modules:jpa:jpa-core-api")) + implementation(libs.jakarta.persistence.next) + } + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.CompatibilityLaneDefinitionTest' +./gradlew :modules:jpa-experimental:jpa-next-compatibility:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa-experimental/jpa-next-compatibility/src/compatibilityJpa4/java/io/backend/skeleton/jpa/experimental/next/Jpa4CompatibilityTest.java' '.github/workflows/jpa-next-jpa4.yml' 'modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts' 'modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/CompatibilityLaneDefinitionTest.java' +git commit -m "test: add jakarta persistence four compatibility lane" +``` + +### Task 8: Hibernate ORM 8 Compatibility Lane 구현 + +**Files:** +- Create: `modules/jpa-experimental/jpa-next-compatibility/src/compatibilityHibernate8/java/io/backend/skeleton/jpa/experimental/next/Hibernate8CompatibilityTest.java` +- Create: `.github/workflows/jpa-next-hibernate8.yml` +- Modify: `modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts` +- Test: `modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/HibernateCompatibilityPolicyTest.java` + +**Interfaces:** +- Consumes: Hibernate ORM 8 milestone/final artifact and Stable Hibernate 7.4 regression suites. +- Produces: Generated SQL, fetch pagination, statistics, batch and extension compatibility evidence. + +**Implementation requirements:** +- Re-run collection fetch pagination, StatementInspector, Statistics, JSONB, Batch and StatelessSession contracts. +- Record SQL and performance differences without weakening the 7.4 Stable gate. +- Do not allow Hibernate 8 dependencies in Stable published modules. + +- [ ] **Step 1: Write the failing test** + +```kotlin +package io.backend.skeleton.jpa.experimental.next; + +class HibernateCompatibilityPolicyTest { + @Test + void hibernateEightCannotReplaceStableProviderWithoutPromotion() { + assertThat(policy.stableProvider()).isEqualTo("7.4"); + assertThat(policy.experimentalProviders()).contains("8"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.HibernateCompatibilityPolicyTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```kotlin +testing { + suites { + register("compatibilityHibernate8") { + useJUnitJupiter() + dependencies { + implementation(project(":modules:jpa:jpa-testkit-postgresql")) + implementation(libs.hibernate.orm.next) + } + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.HibernateCompatibilityPolicyTest' +./gradlew :modules:jpa-experimental:jpa-next-compatibility:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa-experimental/jpa-next-compatibility/src/compatibilityHibernate8/java/io/backend/skeleton/jpa/experimental/next/Hibernate8CompatibilityTest.java' '.github/workflows/jpa-next-hibernate8.yml' 'modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts' 'modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/HibernateCompatibilityPolicyTest.java' +git commit -m "test: add hibernate eight compatibility lane" +``` + +### Task 9: PostgreSQL 19 Compatibility와 Stable 승격 Gate 구현 + +**Files:** +- Create: `modules/jpa-experimental/jpa-next-compatibility/src/compatibilityPostgresql19/java/io/backend/skeleton/jpa/experimental/next/PostgreSql19CompatibilityTest.java` +- Create: `docs/jpa/experimental-support-matrix.md` +- Create: `docs/jpa/experimental-promotion-checklist.md` +- Create: `.github/workflows/jpa-next-postgresql19.yml` +- Modify: `modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts` +- Test: `modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/ExperimentalPromotionGateTest.java` + +**Interfaces:** +- Consumes: PG19 image when GA, all Stable contracts, experimental security/failure/migration/performance reports. +- Produces: A promotion decision that requires evidence rather than version availability alone. + +**Implementation requirements:** +- Run mapping, SQLSTATE, lock, batch, Flyway, plan and native extension contracts on PG19. +- Promotion requires two supported patch runs and no unresolved semantic regression. +- Multi-tenancy/replica promotion requires tenant leakage, failover, lag and pool-capacity evidence. +- Update Stable support matrix only through a reviewed ADR. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.experimental.next; + +class ExperimentalPromotionGateTest { + @Test + void promotionRequiresAllEvidenceAndReviewedAdr() { + var evidence = evidence().withCompatibility(true).withSecurity(true).withFailure(true) + .withMigration(true).withPerformance(true).withReviewedAdr(false); + assertThat(gate.evaluate(evidence)).isEqualTo(BLOCKED_MISSING_ADR); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.ExperimentalPromotionGateTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.experimental.next; + +public final class ExperimentalPromotionGate { + public PromotionDecision evaluate(PromotionEvidence evidence) { + if (!evidence.allTechnicalGatesPassed()) return BLOCKED_TECHNICAL; + if (!evidence.reviewedAdr()) return BLOCKED_MISSING_ADR; + return ELIGIBLE_FOR_STABLE_REVIEW; + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.ExperimentalPromotionGateTest' +./gradlew :modules:jpa-experimental:jpa-next-compatibility:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa-experimental/jpa-next-compatibility/src/compatibilityPostgresql19/java/io/backend/skeleton/jpa/experimental/next/PostgreSql19CompatibilityTest.java' 'docs/jpa/experimental-support-matrix.md' 'docs/jpa/experimental-promotion-checklist.md' '.github/workflows/jpa-next-postgresql19.yml' 'modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts' 'modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/ExperimentalPromotionGateTest.java' +git commit -m "docs: add jpa experimental promotion gates" +``` +## 2. Experimental 완료 조건 + +```text +Stable starter가 Experimental module에 의존하지 않는다. +Tenant context 누락과 connection reuse에서 fail-closed다. +RLS runtime role이 policy를 bypass하지 못한다. +Schema/database tenant migration과 pool capacity가 bounded다. +Replica routing이 read-after-write와 lock query를 primary에 고정한다. +JPA4/Hibernate8/PG19 lane이 Stable artifacts를 변경하지 않는다. +승격은 ADR와 compatibility/security/failure/migration/performance 증거를 요구한다. +``` diff --git a/docs/superpowers/plans/2026-08-11-jpa-persistence-platform-implementation-plan.md b/docs/superpowers/plans/2026-08-11-jpa-persistence-platform-implementation-plan.md new file mode 100644 index 00000000..43d000df --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-jpa-persistence-platform-implementation-plan.md @@ -0,0 +1,4716 @@ +# JPA 관계형 영속성 플랫폼 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Java/Spring Backend Skeleton에 도메인 Repository 소유권, Application Use Case Transaction, SQLSTATE 기반 오류, 전체 Transaction Retry, Fetch·Pagination·Batch 검증, PostgreSQL Native Extension, Flyway Schema Gate, 관측성·보안·실제 PostgreSQL Release Matrix를 갖춘 JPA 관계형 영속성 플랫폼을 구현한다. + +**Architecture:** `jpa-core-api`는 Spring·JPA 비종속 안정 계약을 소유하고, `jpa-transaction`, `jpa-spring-data`, `jpa-hibernate`, `jpa-postgresql`, `jpa-migration-flyway`가 이를 구현한다. 도메인 모듈은 Entity와 Repository를 직접 소유하며 플랫폼은 Generic CRUD Repository를 만들지 않는다. Retry는 새 Persistence Context의 전체 Use Case 단위이고 Commit 결과 불명은 자동 Retry하지 않는다. + +**Tech Stack:** Java 21, Gradle Kotlin DSL, Spring Boot 4.1 dependency management, Spring Data JPA 4.1, Jakarta Persistence 3.2, Hibernate ORM 7.4, PostgreSQL 16·17·18, HikariCP, Flyway, Micrometer, Spring Observation, JUnit 5, AssertJ, ArchUnit, Testcontainers, Toxiproxy. + +## Global Constraints + +- Root package는 `io.backend.skeleton.jpa`이다. +- 모듈 루트는 `modules/jpa`이다. +- Java 21과 Spring Boot 4.1 BOM 조합을 사용하며 개별 Hibernate·Flyway·Hikari 버전을 임의로 override하지 않는다. +- Stable JPA 규격은 Jakarta Persistence 3.2, Stable Provider는 Hibernate ORM 7.4다. +- Stable DB Matrix는 PostgreSQL 16·17·18이다. +- H2는 Local Convenience이며 PostgreSQL 계약 증거로 사용하지 않는다. +- 도메인 모듈이 Entity, Embeddable, Repository, Query, Index Requirement, Lock·Soft Delete·Audit 정책을 소유한다. +- `GenericRepository` 또는 Spring Data CRUD를 재구현하는 Base Repository를 만들지 않는다. +- 일반 애플리케이션의 Transaction 경계는 Application Service다. +- OSIV는 모든 운영 profile에서 명시적으로 false다. +- 운영 Schema 변경의 Source of Truth는 Flyway이고 Hibernate는 validate만 수행한다. +- 운영에서 `ddl-auto=update`, `create`, `create-drop`을 허용하지 않는다. +- Optimistic Conflict·Deadlock·Serialization Failure Retry는 새 Persistence Context와 새 DB Transaction에서 전체 Use Case를 재실행한다. +- `TransactionCompletionUnknownException`은 자동 Retry하지 않는다. +- 외부 HTTP, Object Storage, Messaging 호출을 DB Transaction 안에서 대기하지 않는다. +- PostgreSQL write-heavy Entity의 기본 ID 전략은 Sequence이며 IDENTITY는 제한한다. +- Entity를 Controller 응답, Message payload, Redis Java serialization 값으로 직접 노출하지 않는다. +- Fetch 전략은 Use Case별 EntityGraph·Fetch Join·Projection·Batch Fetch로 결정한다. +- Hibernate 7.4 collection fetch pagination은 PG16·17·18 generated SQL과 row amplification을 계약 테스트한다. +- Dynamic Sort는 allowlist를 사용하고 Native SQL 값은 parameter binding한다. +- JDBC Batch 완료는 실제 batch 통계로 증명한다. +- Bulk DML은 flush → bulk → clear 규칙을 따른다. +- Runtime·Migration·Admin DB credential을 분리한다. +- SQL parameter, Entity ID, Tenant ID 원문, PII를 metric label과 일반 로그에 기록하지 않는다. +- Multi-tenancy, Read Replica, JPA 4, Hibernate 8, PostgreSQL 19는 별도 Experimental 계획으로 구현한다. +- 각 Task는 실패 테스트 → 실패 확인 → 최소 구현 → 통과 확인 → 커밋 순서로 수행한다. +- 각 Task는 독립적으로 검토 가능한 하나의 커밋으로 종료한다. + +--- + +## 1. 확정 파일 구조 + +```text +backend-skeleton/ +├── settings.gradle.kts +├── build-logic/src/main/kotlin/jpa-library-conventions.gradle.kts +├── modules/jpa/ +│ ├── jpa-core-api/ +│ ├── jpa-transaction/ +│ ├── jpa-spring-data/ +│ ├── jpa-querydsl/ +│ ├── jpa-hibernate/ +│ ├── jpa-postgresql/ +│ ├── jpa-postgresql-copy/ +│ ├── jpa-migration-flyway/ +│ ├── jpa-auditing/ +│ ├── jpa-envers/ +│ ├── jpa-cache-hibernate/ +│ ├── jpa-observability/ +│ ├── jpa-security/ +│ ├── jpa-spring-boot-starter/ +│ ├── jpa-testkit/ +│ ├── jpa-testkit-postgresql/ +│ ├── jpa-testkit-migration/ +│ └── jpa-testkit-queryplan/ +├── infra/jpa/ +│ ├── postgres/ +│ ├── roles/ +│ └── toxiproxy/ +├── docs/jpa/ +│ ├── support-matrix.md +│ ├── entity-mapping-guide.md +│ ├── transaction-guide.md +│ ├── query-fetch-guide.md +│ ├── migration-guide.md +│ ├── postgresql-extensions.md +│ ├── observability.md +│ ├── security.md +│ └── runbooks.md +└── docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md +``` + +## 2. 핵심 package + +```text +io.backend.skeleton.jpa.api +io.backend.skeleton.jpa.api.capability +io.backend.skeleton.jpa.api.error +io.backend.skeleton.jpa.api.query +io.backend.skeleton.jpa.api.transaction +io.backend.skeleton.jpa.transaction +io.backend.skeleton.jpa.springdata +io.backend.skeleton.jpa.querydsl +io.backend.skeleton.jpa.hibernate +io.backend.skeleton.jpa.postgresql +io.backend.skeleton.jpa.migration +io.backend.skeleton.jpa.auditing +io.backend.skeleton.jpa.envers +io.backend.skeleton.jpa.cache +io.backend.skeleton.jpa.observation +io.backend.skeleton.jpa.security +io.backend.skeleton.jpa.autoconfigure +io.backend.skeleton.jpa.testkit +``` + +## 3. Module dependency map + +```text +jpa-core-api + → no project dependency + +jpa-transaction + → jpa-core-api + +jpa-spring-data + → jpa-core-api + +jpa-querydsl + → jpa-core-api + → jpa-spring-data + +jpa-hibernate + → jpa-core-api + +jpa-postgresql + → jpa-core-api + → jpa-hibernate + +jpa-postgresql-copy + → jpa-core-api + → jpa-postgresql + +jpa-migration-flyway + → jpa-core-api + +jpa-auditing + → jpa-core-api + +jpa-envers + → jpa-core-api + → jpa-hibernate + +jpa-cache-hibernate + → jpa-core-api + → jpa-hibernate + +jpa-observability + → jpa-core-api + → jpa-hibernate + +jpa-security + → jpa-core-api + +jpa-spring-boot-starter + → jpa-core-api + → jpa-transaction + → jpa-spring-data + → jpa-hibernate + → jpa-postgresql + → jpa-migration-flyway + → jpa-auditing + → jpa-observability + → jpa-security + +jpa-testkit + → jpa-core-api + +jpa-testkit-postgresql + → jpa-testkit + → jpa-postgresql + +jpa-testkit-migration + → jpa-testkit-postgresql + → jpa-migration-flyway + +jpa-testkit-queryplan + → jpa-testkit-postgresql + → jpa-observability +``` + +Provider SDK, Spring Data, Hibernate, Flyway, Querydsl, PostgreSQL JDBC dependencies are added only in the owning module. `jpa-core-api` remains framework-free. + +--- +### Task 1: Gradle 멀티모듈과 JPA 품질 Test Suite 구성 + +**Files:** +- Create: `build-logic/src/main/kotlin/jpa-library-conventions.gradle.kts` +- Create: `modules/jpa/jpa-core-api/build.gradle.kts` +- Create: `modules/jpa/jpa-transaction/build.gradle.kts` +- Create: `modules/jpa/jpa-spring-data/build.gradle.kts` +- Create: `modules/jpa/jpa-querydsl/build.gradle.kts` +- Create: `modules/jpa/jpa-hibernate/build.gradle.kts` +- Create: `modules/jpa/jpa-postgresql/build.gradle.kts` +- Create: `modules/jpa/jpa-postgresql-copy/build.gradle.kts` +- Create: `modules/jpa/jpa-migration-flyway/build.gradle.kts` +- Create: `modules/jpa/jpa-auditing/build.gradle.kts` +- Create: `modules/jpa/jpa-envers/build.gradle.kts` +- Create: `modules/jpa/jpa-cache-hibernate/build.gradle.kts` +- Create: `modules/jpa/jpa-observability/build.gradle.kts` +- Create: `modules/jpa/jpa-security/build.gradle.kts` +- Create: `modules/jpa/jpa-spring-boot-starter/build.gradle.kts` +- Create: `modules/jpa/jpa-testkit/build.gradle.kts` +- Create: `modules/jpa/jpa-testkit-postgresql/build.gradle.kts` +- Create: `modules/jpa/jpa-testkit-migration/build.gradle.kts` +- Create: `modules/jpa/jpa-testkit-queryplan/build.gradle.kts` +- Modify: `settings.gradle.kts` +- Test: `build-logic/src/test/kotlin/JpaModuleBoundaryTest.kt` + +**Interfaces:** +- Consumes: Host repository version catalog and Spring Boot 4.1 dependency management. +- Produces: 18 isolated JPA modules and `test`, `integrationTest`, `contractTest`, `migrationTest`, `failureTest`, `performanceTest`, `compatibilityTest` suites. + +**Implementation requirements:** +- Register every module under `:modules:jpa:*` and apply Java 21 toolchains. +- Do not pin Hibernate, Flyway, Hikari, Spring Data versions outside the Boot BOM. +- Expose integration suites only in modules that own external resources. +- Make `check` depend on unit and architecture tests; release aggregates are added in Task 53. +- Ensure experimental modules are not included in this Stable dependency graph. + +- [ ] **Step 1: Write the failing test** + +```kotlin +class JpaModuleBoundaryTest { + @Test + fun `core api has no framework dependency`() { + val core = project(":modules:jpa:jpa-core-api") + assertThat(core.directDependencies()) + .noneMatch { it.startsWith("org.springframework") || + it.startsWith("org.hibernate") || + it.startsWith("jakarta.persistence") } + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'JpaModuleBoundaryTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```kotlin +plugins { + `java-library` + `jvm-test-suite` +} + +java { + toolchain.languageVersion.set(JavaLanguageVersion.of(21)) +} + +testing { + suites { + named("test") { useJUnitJupiter() } + register("contractTest") { + useJUnitJupiter() + dependencies { implementation(project()) } + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'JpaModuleBoundaryTest' +./gradlew :modules:jpa:jpa-core-api:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'build-logic/src/main/kotlin/jpa-library-conventions.gradle.kts' 'modules/jpa/jpa-core-api/build.gradle.kts' 'modules/jpa/jpa-transaction/build.gradle.kts' 'modules/jpa/jpa-spring-data/build.gradle.kts' 'modules/jpa/jpa-querydsl/build.gradle.kts' 'modules/jpa/jpa-hibernate/build.gradle.kts' 'modules/jpa/jpa-postgresql/build.gradle.kts' 'modules/jpa/jpa-postgresql-copy/build.gradle.kts' 'modules/jpa/jpa-migration-flyway/build.gradle.kts' 'modules/jpa/jpa-auditing/build.gradle.kts' 'modules/jpa/jpa-envers/build.gradle.kts' 'modules/jpa/jpa-cache-hibernate/build.gradle.kts' 'modules/jpa/jpa-observability/build.gradle.kts' 'modules/jpa/jpa-security/build.gradle.kts' 'modules/jpa/jpa-spring-boot-starter/build.gradle.kts' 'modules/jpa/jpa-testkit/build.gradle.kts' 'modules/jpa/jpa-testkit-postgresql/build.gradle.kts' 'modules/jpa/jpa-testkit-migration/build.gradle.kts' 'modules/jpa/jpa-testkit-queryplan/build.gradle.kts' 'settings.gradle.kts' 'build-logic/src/test/kotlin/JpaModuleBoundaryTest.kt' +git commit -m "build: add jpa platform modules and test suites" +``` + +### Task 2: Core Operation Name과 Capability 계약 구현 + +**Files:** +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/PersistenceOperationName.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/capability/JpaCapability.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/capability/SupportLevel.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/capability/CapabilitySupport.java` +- Test: `modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/PersistenceOperationNameTest.java` + +**Interfaces:** +- Consumes: Only Java 21 standard library. +- Produces: Bounded operation names and explicit Stable/Advanced/Experimental capability metadata. + +**Implementation requirements:** +- Operation names must match `[a-z][a-z0-9.-]{2,95}`. +- Capability constraints must be immutable and must not store provider objects. +- Include capabilities for transaction retry, completion evidence, keyset pagination, batch, PostgreSQL native write, schema gate, L2 cache, Envers. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.api; + +class PersistenceOperationNameTest { + @Test + void rejectsDynamicIdentifiers() { + assertThatThrownBy(() -> new PersistenceOperationName("order/" + UUID.randomUUID())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void acceptsRegisteredLowCardinalityName() { + assertThat(new PersistenceOperationName("order.place").value()) + .isEqualTo("order.place"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.PersistenceOperationNameTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.api; + +public record PersistenceOperationName(String value) { + private static final Pattern FORMAT = + Pattern.compile("[a-z][a-z0-9.-]{2,95}"); + + public PersistenceOperationName { + if (value == null || !FORMAT.matcher(value).matches()) { + throw new IllegalArgumentException("invalid persistence operation name"); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.PersistenceOperationNameTest' +./gradlew :modules:jpa:jpa-core-api:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/PersistenceOperationName.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/capability/JpaCapability.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/capability/SupportLevel.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/capability/CapabilitySupport.java' 'modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/PersistenceOperationNameTest.java' +git commit -m "feat: add jpa operation and capability contracts" +``` + +### Task 3: 안정 JPA 오류 계층과 Failure Context 구현 + +**Files:** +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/JpaPersistenceException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/JpaFailureContext.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/FailureCategory.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/OptimisticConflictException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/PessimisticLockTimeoutException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/DeadlockDetectedException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/SerializationFailureException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/ConstraintViolationDetails.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/UniqueConstraintViolationException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/ForeignKeyViolationException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/CheckConstraintViolationException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/QueryTimeoutException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/TransactionTimeoutException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/ConnectionUnavailableException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/SchemaMismatchException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/DataCorruptionException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/TransactionCompletionUnknownException.java` +- Test: `modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/error/JpaFailureContextTest.java` + +**Interfaces:** +- Consumes: `PersistenceOperationName` from Task 2. +- Produces: Provider-independent, structured, sanitized persistence exceptions. + +**Implementation requirements:** +- Every exception preserves operation, SQLSTATE, attempt, retryable, completionUnknown, elapsed and trace ID. +- Constraint exceptions preserve a registered constraint code and optional bounded database constraint name. +- Exception messages must never contain SQL parameter values, Entity IDs or PII. +- `TransactionCompletionUnknownException` must always report `completionUnknown=true` and `retryable=false`. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.api.error; + +class JpaFailureContextTest { + @Test + void completionUnknownCanNeverBeMarkedRetryable() { + var context = JpaFailureContext.completionUnknown( + new PersistenceOperationName("payment.commit"), "40003", 1, Duration.ofMillis(50), "trace"); + + assertThat(context.retryable()).isFalse(); + assertThat(context.completionUnknown()).isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.error.JpaFailureContextTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.api.error; + +public record JpaFailureContext( + PersistenceOperationName operation, + String sqlState, + int transactionAttempt, + boolean retryable, + boolean completionUnknown, + Duration elapsed, + String traceId) { + + public static JpaFailureContext completionUnknown( + PersistenceOperationName operation, + String sqlState, + int attempt, + Duration elapsed, + String traceId) { + return new JpaFailureContext( + operation, sqlState, attempt, false, true, elapsed, traceId); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.error.JpaFailureContextTest' +./gradlew :modules:jpa:jpa-core-api:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/JpaPersistenceException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/JpaFailureContext.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/FailureCategory.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/OptimisticConflictException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/PessimisticLockTimeoutException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/DeadlockDetectedException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/SerializationFailureException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/ConstraintViolationDetails.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/UniqueConstraintViolationException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/ForeignKeyViolationException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/CheckConstraintViolationException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/QueryTimeoutException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/TransactionTimeoutException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/ConnectionUnavailableException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/SchemaMismatchException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/DataCorruptionException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/TransactionCompletionUnknownException.java' 'modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/error/JpaFailureContextTest.java' +git commit -m "feat: add stable jpa persistence error model" +``` + +### Task 4: PostgreSQL SQLSTATE 분류와 예외 변환 구현 + +**Files:** +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlState.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlFailureClassifier.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlExceptionTranslator.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/ConstraintCatalog.java` +- Test: `modules/jpa/jpa-postgresql/src/test/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlFailureClassifierTest.java` + +**Interfaces:** +- Consumes: Stable exceptions from Task 3 and PostgreSQL `PSQLException` structured fields. +- Produces: Message-text-independent SQLSTATE classification for `40001`, `40003`, `40P01`, `23505`, `23503`, `23514`, `55P03`. + +**Implementation requirements:** +- Unwrap Spring, Hibernate, JDBC and PostgreSQL exception chains without parsing localized message text. +- Map constraint names through a bounded `ConstraintCatalog` before exposing them. +- Unknown SQLSTATE must remain an explicit UNKNOWN category, not an optimistic guess. +- Do not classify every connection exception as completion unknown; commit phase evidence is required by Task 6. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.postgresql.error; + +class PostgreSqlFailureClassifierTest { + @ParameterizedTest + @CsvSource({ + "40001,SERIALIZATION_FAILURE", + "40003,COMPLETION_UNKNOWN", + "40P01,DEADLOCK", + "23505,UNIQUE_CONSTRAINT", + "55P03,LOCK_NOT_AVAILABLE" + }) + void classifiesBySqlState(String state, FailureCategory expected) { + assertThat(new PostgreSqlFailureClassifier().classify(state)) + .isEqualTo(expected); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:test --tests 'io.backend.skeleton.jpa.postgresql.error.PostgreSqlFailureClassifierTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.postgresql.error; + +public final class PostgreSqlFailureClassifier { + public FailureCategory classify(String sqlState) { + return switch (sqlState) { + case "40001" -> FailureCategory.SERIALIZATION_FAILURE; + case "40003" -> FailureCategory.COMPLETION_UNKNOWN; + case "40P01" -> FailureCategory.DEADLOCK; + case "23505" -> FailureCategory.UNIQUE_CONSTRAINT; + case "23503" -> FailureCategory.FOREIGN_KEY_CONSTRAINT; + case "23514" -> FailureCategory.CHECK_CONSTRAINT; + case "55P03" -> FailureCategory.LOCK_NOT_AVAILABLE; + default -> FailureCategory.UNKNOWN; + }; + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:test --tests 'io.backend.skeleton.jpa.postgresql.error.PostgreSqlFailureClassifierTest' +./gradlew :modules:jpa:jpa-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlState.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlFailureClassifier.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlExceptionTranslator.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/ConstraintCatalog.java' 'modules/jpa/jpa-postgresql/src/test/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlFailureClassifierTest.java' +git commit -m "feat: translate postgresql sqlstate failures" +``` + +### Task 5: Transaction Profile과 Retry Profile Core 계약 구현 + +**Files:** +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/PropagationMode.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/IsolationLevel.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/JitterMode.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/RetryProfile.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/TransactionProfile.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/TransactionAttempt.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/RetryDisposition.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/RetryDecision.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/JpaRetryPolicy.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/JpaTransactionExecutor.java` +- Test: `modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/transaction/TransactionProfileTest.java` + +**Interfaces:** +- Consumes: `PersistenceOperationName`, `JpaPersistenceException` and `FailureCategory`. +- Produces: Framework-free transaction, attempt and retry contracts. + +**Implementation requirements:** +- Stable propagation values are REQUIRED, MANDATORY and explicitly opt-in REQUIRES_NEW. +- Expose DEFAULT, READ_COMMITTED, REPEATABLE_READ and SERIALIZABLE isolation. +- Require positive finite timeout for write profiles. +- Require `maxAttempts >= 1`; completion unknown is never a retryable failure category. +- RetryDecision must distinguish full transaction retry, reconciliation and fail. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.api.transaction; + +class TransactionProfileTest { + @Test + void writeProfileRequiresFiniteTimeout() { + assertThatThrownBy(() -> new TransactionProfile( + "write", PropagationMode.REQUIRED, IsolationLevel.READ_COMMITTED, + Duration.ZERO, false, RetryProfile.none())) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.transaction.TransactionProfileTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.api.transaction; + +public record TransactionProfile( + String name, + PropagationMode propagation, + IsolationLevel isolation, + Duration timeout, + boolean readOnly, + RetryProfile retryProfile) { + + public TransactionProfile { + if (!readOnly && (timeout == null || timeout.isZero() || timeout.isNegative())) { + throw new IllegalArgumentException("write transaction requires positive timeout"); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.transaction.TransactionProfileTest' +./gradlew :modules:jpa:jpa-core-api:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/PropagationMode.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/IsolationLevel.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/JitterMode.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/RetryProfile.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/TransactionProfile.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/TransactionAttempt.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/RetryDisposition.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/RetryDecision.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/JpaRetryPolicy.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/JpaTransactionExecutor.java' 'modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/transaction/TransactionProfileTest.java' +git commit -m "feat: define jpa transaction and retry profiles" +``` + +### Task 6: Commit Evidence를 추적하는 JpaTransactionManager 구현 + +**Files:** +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionCompletionEvidence.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionEvidenceContext.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/EvidenceAwareJpaTransactionManager.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/CommitFailureClassifier.java` +- Test: `modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/EvidenceAwareJpaTransactionManagerTest.java` + +**Interfaces:** +- Consumes: Spring ORM `JpaTransactionManager`, Task 3 error model and Task 4 classifier SPI. +- Produces: Transaction phase evidence and commit-phase-only completion unknown translation. + +**Implementation requirements:** +- Track NOT_STARTED, ACTIVE, COMMITTING, COMMITTED, ROLLED_BACK and UNKNOWN per transaction. +- Set COMMITTING immediately before delegating to the provider commit. +- Only convert transport/SQLSTATE failures during COMMITTING to completion unknown. +- Clear ThreadLocal evidence in every success and failure path. +- Preserve the original provider exception as cause without leaking parameters. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.transaction; + +class EvidenceAwareJpaTransactionManagerTest { + @Test + void connectionLossDuringCommitBecomesCompletionUnknown() { + var manager = fixtureThatCommitsThenDropsResponse(); + + assertThatThrownBy(() -> inTransaction(manager, () -> repository.insert("key-1"))) + .isInstanceOf(TransactionCompletionUnknownException.class) + .satisfies(error -> assertThat(((JpaPersistenceException) error) + .context().completionUnknown()).isTrue()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.EvidenceAwareJpaTransactionManagerTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.transaction; + +public final class EvidenceAwareJpaTransactionManager extends JpaTransactionManager { + private final CommitFailureClassifier classifier; + + @Override + protected void doCommit(DefaultTransactionStatus status) { + TransactionEvidenceContext.mark(TransactionCompletionEvidence.COMMITTING); + try { + super.doCommit(status); + TransactionEvidenceContext.mark(TransactionCompletionEvidence.COMMITTED); + } catch (RuntimeException failure) { + TransactionEvidenceContext.mark(TransactionCompletionEvidence.UNKNOWN); + throw classifier.translateCommitFailure(failure); + } finally { + TransactionEvidenceContext.clear(); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.EvidenceAwareJpaTransactionManagerTest' +./gradlew :modules:jpa:jpa-transaction:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionCompletionEvidence.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionEvidenceContext.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/EvidenceAwareJpaTransactionManager.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/CommitFailureClassifier.java' 'modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/EvidenceAwareJpaTransactionManagerTest.java' +git commit -m "feat: track jpa transaction completion evidence" +``` + +### Task 7: Programmatic JpaTransactionExecutor 구현 + +**Files:** +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/SpringJpaTransactionExecutor.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionDefinitionMapper.java` +- Test: `modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/SpringJpaTransactionExecutorTest.java` + +**Interfaces:** +- Consumes: Task 5 transaction contracts and Spring `PlatformTransactionManager`. +- Produces: A programmatic transaction boundary that maps profile propagation, isolation, timeout and read-only exactly. + +**Implementation requirements:** +- Use a fresh `TransactionTemplate` definition per call without mutable global state. +- Map timeout to whole seconds only after rejecting sub-second truncation or documenting rounding. +- Propagate `PersistenceOperationName` into observation context. +- Do not implement retry in this class; Task 8 owns retry coordination. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.transaction; + +class SpringJpaTransactionExecutorTest { + @Test + void mapsSerializableReadOnlyProfile() { + var profile = profile(SERIALIZABLE, Duration.ofSeconds(3), true); + executor.execute(OPERATION, profile, () -> null); + + assertThat(transactionProbe.isolation()).isEqualTo(Connection.TRANSACTION_SERIALIZABLE); + assertThat(transactionProbe.readOnly()).isTrue(); + assertThat(transactionProbe.timeoutSeconds()).isEqualTo(3); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.SpringJpaTransactionExecutorTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.transaction; + +public final class SpringJpaTransactionExecutor implements JpaTransactionExecutor { + private final PlatformTransactionManager transactionManager; + + @Override + public T execute( + PersistenceOperationName operation, + TransactionProfile profile, + Supplier work) { + var template = new TransactionTemplate(transactionManager); + TransactionDefinitionMapper.apply(template, profile); + return template.execute(status -> work.get()); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.SpringJpaTransactionExecutorTest' +./gradlew :modules:jpa:jpa-transaction:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/SpringJpaTransactionExecutor.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionDefinitionMapper.java' 'modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/SpringJpaTransactionExecutorTest.java' +git commit -m "feat: execute jpa transaction profiles" +``` + +### Task 8: 전체 Transaction Retry Coordinator 구현 + +**Files:** +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/FullTransactionRetryCoordinator.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/BackoffCalculator.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/RetryBudget.java` +- Test: `modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/FullTransactionRetryCoordinatorTest.java` + +**Interfaces:** +- Consumes: `JpaTransactionExecutor`, `JpaRetryPolicy`, `RetryProfile` and stable exceptions. +- Produces: Bounded retry that calls the transaction executor anew for every attempt. + +**Implementation requirements:** +- Every attempt must create a new transaction and new Persistence Context. +- Never retry completion unknown, constraint, schema or data corruption failures. +- Apply exponential backoff, configured jitter, max elapsed deadline and attempt budget. +- Emit one logical operation result and attempt-level events without logging every retry as WARN. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.transaction; + +class FullTransactionRetryCoordinatorTest { + @Test + void retriesWholeUseCaseWithFreshPersistenceContext() { + var contexts = new ArrayList(); + var result = coordinator.execute(OPERATION, RETRY_PROFILE, () -> { + contexts.add(entityManagerIdentity()); + if (contexts.size() == 1) throw optimisticConflict(); + return "ok"; + }); + + assertThat(result).isEqualTo("ok"); + assertThat(contexts).hasSize(2).doesNotHaveDuplicates(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.FullTransactionRetryCoordinatorTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.transaction; + +public final class FullTransactionRetryCoordinator { + public T execute( + PersistenceOperationName operation, + TransactionProfile profile, + Supplier work) { + for (int attempt = 1; ; attempt++) { + try { + return transactionExecutor.execute(operation, profile, work); + } catch (JpaPersistenceException failure) { + RetryDecision decision = retryPolicy.classify( + failure, new TransactionAttempt(attempt, clock.instant())); + if (decision.disposition() != RETRY_FULL_TRANSACTION) throw failure; + sleeper.sleep(decision.delay()); + } + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.FullTransactionRetryCoordinatorTest' +./gradlew :modules:jpa:jpa-transaction:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/FullTransactionRetryCoordinator.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/BackoffCalculator.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/RetryBudget.java' 'modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/FullTransactionRetryCoordinatorTest.java' +git commit -m "feat: retry complete jpa transactions safely" +``` + +### Task 9: RetryableJpaTransaction Annotation과 AOP ordering 구현 + +**Files:** +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/RetryableJpaTransaction.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/RetryableJpaTransactionInterceptor.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionProfileRegistry.java` +- Test: `modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/RetryableJpaTransactionInterceptorTest.java` + +**Interfaces:** +- Consumes: Task 8 coordinator and named transaction profiles. +- Produces: An opt-in public-method annotation whose retry interceptor wraps the Spring transaction interceptor. + +**Implementation requirements:** +- Require a registered operation name and profile name in the annotation. +- Order retry advice outside transaction advice so each attempt creates a transaction. +- Reject self-invocation in documentation and architecture tests. +- Reject methods that return reactive types because JPA is blocking. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.transaction; + +class RetryableJpaTransactionInterceptorTest { + @Test + void retryAdviceRunsOutsideTransactionAdvice() { + service.failOnceWithSerializationFailure(); + service.execute(); + + assertThat(probe.transactionIds()).containsExactly("tx-1", "tx-2"); + assertThat(probe.retryAdviceOrder()).isLessThan(probe.transactionAdviceOrder()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.RetryableJpaTransactionInterceptorTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.transaction; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface RetryableJpaTransaction { + String operation(); + String profile(); +} + +@Order(Ordered.HIGHEST_PRECEDENCE + 100) +public final class RetryableJpaTransactionInterceptor implements MethodInterceptor { + public Object invoke(MethodInvocation invocation) { + var policy = annotation(invocation.getMethod()); + return coordinator.execute( + new PersistenceOperationName(policy.operation()), + profiles.require(policy.profile()), + () -> proceed(invocation)); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.RetryableJpaTransactionInterceptorTest' +./gradlew :modules:jpa:jpa-transaction:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/RetryableJpaTransaction.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/RetryableJpaTransactionInterceptor.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionProfileRegistry.java' 'modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/RetryableJpaTransactionInterceptorTest.java' +git commit -m "feat: add retryable jpa transaction advice" +``` + +### Task 10: Completion Unknown Reconciliation SPI와 Audit 구현 + +**Files:** +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionCompletionResolver.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/CompletionResolution.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/CompletionUnknownRecord.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/CompletionUnknownRecorder.java` +- Test: `modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/CompletionUnknownRecorderTest.java` + +**Interfaces:** +- Consumes: `TransactionCompletionUnknownException` and domain-provided transaction keys. +- Produces: A durable/auditable handoff for domain-specific committed/not-committed/unknown reconciliation. + +**Implementation requirements:** +- Core resolver returns COMMITTED, NOT_COMMITTED or STILL_UNKNOWN without guessing. +- Recording must happen outside the unknown transaction using a separate durable channel chosen by the application. +- Preserve operation, transaction key, SQLSTATE, trace ID and occurrence time; never persist SQL parameters. +- Do not automatically call the original use case from the resolver. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.transaction; + +class CompletionUnknownRecorderTest { + @Test + void recordsUnknownWithoutRetryingOriginalWork() { + recorder.record(exception("payment-42")); + + assertThat(audit.last().transactionKey()).isEqualTo("payment-42"); + assertThat(originalUseCase.invocations()).isZero(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.CompletionUnknownRecorderTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.transaction; + +public interface TransactionCompletionResolver { + CompletionResolution resolve(K transactionKey); +} + +public enum CompletionResolution { + COMMITTED, + NOT_COMMITTED, + STILL_UNKNOWN +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.CompletionUnknownRecorderTest' +./gradlew :modules:jpa:jpa-transaction:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionCompletionResolver.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/CompletionResolution.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/CompletionUnknownRecord.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/CompletionUnknownRecorder.java' 'modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/CompletionUnknownRecorderTest.java' +git commit -m "feat: add transaction completion reconciliation contracts" +``` + +### Task 11: OSIV와 DDL Auto 위험 설정 Startup Guard 구현 + +**Files:** +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaSafetyProperties.java` +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaDangerousConfigurationGuard.java` +- Modify: `modules/jpa/jpa-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` +- Test: `modules/jpa/jpa-spring-boot-starter/src/test/java/io/backend/skeleton/jpa/autoconfigure/JpaDangerousConfigurationGuardTest.java` + +**Interfaces:** +- Consumes: Spring Boot Environment and the design global constraints. +- Produces: Fail-fast startup validation for OSIV and production schema mutation settings. + +**Implementation requirements:** +- Fail when `spring.jpa.open-in-view=true` outside an explicit local convenience profile. +- Fail in dev/staging/prod when ddl-auto is update/create/create-drop. +- Allow validate or none according to schema-management policy. +- Error messages must name the unsafe property and approved alternatives. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.autoconfigure; + +class JpaDangerousConfigurationGuardTest { + @Test + void productionRejectsOpenSessionInViewAndDdlUpdate() { + context.withPropertyValues( + "spring.profiles.active=prod", + "spring.jpa.open-in-view=true", + "spring.jpa.hibernate.ddl-auto=update") + .run(result -> assertThat(result).hasFailed()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-boot-starter:test --tests 'io.backend.skeleton.jpa.autoconfigure.JpaDangerousConfigurationGuardTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.autoconfigure; + +public final class JpaDangerousConfigurationGuard { + public void validate(Environment environment) { + boolean osiv = environment.getProperty( + "spring.jpa.open-in-view", Boolean.class, false); + String ddl = environment.getProperty( + "spring.jpa.hibernate.ddl-auto", "none"); + if (osiv) throw new IllegalStateException("spring.jpa.open-in-view must be false"); + if (Set.of("update", "create", "create-drop").contains(ddl)) { + throw new IllegalStateException("Flyway owns schema changes; use validate or none"); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-boot-starter:test --tests 'io.backend.skeleton.jpa.autoconfigure.JpaDangerousConfigurationGuardTest' +./gradlew :modules:jpa:jpa-spring-boot-starter:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaSafetyProperties.java' 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaDangerousConfigurationGuard.java' 'modules/jpa/jpa-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports' 'modules/jpa/jpa-spring-boot-starter/src/test/java/io/backend/skeleton/jpa/autoconfigure/JpaDangerousConfigurationGuardTest.java' +git commit -m "feat: reject unsafe jpa startup configuration" +``` + +### Task 12: Hikari·PostgreSQL Runtime Profile 검증 구현 + +**Files:** +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaDataSourceProperties.java` +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaDataSourceProfileValidator.java` +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/PostgreSqlVersionPolicy.java` +- Test: `modules/jpa/jpa-spring-boot-starter/src/test/java/io/backend/skeleton/jpa/autoconfigure/JpaDataSourceProfileValidatorTest.java` + +**Interfaces:** +- Consumes: Configured DataSource metadata, Hikari configuration and Stable PG16·17·18 policy. +- Produces: Runtime validation for database product/version, explicit pool limits and finite acquisition timeout. + +**Implementation requirements:** +- Reject non-PostgreSQL production datasource unless a future profile is explicitly installed. +- Accept PostgreSQL 16, 17 and 18; report but do not Stable-enable 19. +- Require explicit maximumPoolSize and connectionTimeout in production properties. +- Do not impose a universal pool size; validate consistency with positive bounds only. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.autoconfigure; + +class JpaDataSourceProfileValidatorTest { + @Test + void rejectsPostgreSqlNineteenFromStableProfile() { + var metadata = metadata("PostgreSQL", 19); + assertThatThrownBy(() -> validator.validateStable(metadata, properties())) + .hasMessageContaining("PostgreSQL 16, 17 or 18"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-boot-starter:test --tests 'io.backend.skeleton.jpa.autoconfigure.JpaDataSourceProfileValidatorTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.autoconfigure; + +public final class PostgreSqlVersionPolicy { + private static final Set STABLE = Set.of(16, 17, 18); + + public void requireStable(DatabaseMetaData metadata) throws SQLException { + if (!"PostgreSQL".equals(metadata.getDatabaseProductName()) || + !STABLE.contains(metadata.getDatabaseMajorVersion())) { + throw new IllegalStateException("Stable JPA profile requires PostgreSQL 16, 17 or 18"); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-boot-starter:test --tests 'io.backend.skeleton.jpa.autoconfigure.JpaDataSourceProfileValidatorTest' +./gradlew :modules:jpa:jpa-spring-boot-starter:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaDataSourceProperties.java' 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaDataSourceProfileValidator.java' 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/PostgreSqlVersionPolicy.java' 'modules/jpa/jpa-spring-boot-starter/src/test/java/io/backend/skeleton/jpa/autoconfigure/JpaDataSourceProfileValidatorTest.java' +git commit -m "feat: validate jpa datasource and postgresql profile" +``` + +### Task 13: Entity Mapping ArchUnit Rule Pack 구현 + +**Files:** +- Create: `modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/JpaArchitectureRules.java` +- Create: `modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/EntityMappingCondition.java` +- Create: `modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/EntityExposureCondition.java` +- Test: `modules/jpa/jpa-security/src/test/java/io/backend/skeleton/jpa/security/JpaArchitectureRulesTest.java` + +**Interfaces:** +- Consumes: ArchUnit and Jakarta Persistence annotations in the inspected application. +- Produces: Reusable rules for field access, non-final Entity, protected no-arg constructor, no web exposure and no Hibernate dependency in domain packages. + +**Implementation requirements:** +- Detect Controller methods returning an `@Entity` type or collection of Entity. +- Detect Entity classes in web/controller packages. +- Detect `org.hibernate` dependencies from domain packages. +- Detect final Entity classes and missing protected/public no-arg constructors. +- Provide separate warning-level rules for Cascade.ALL and EAGER associations rather than silently rewriting them. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.security; + +class JpaArchitectureRulesTest { + @Test + void controllerMayNotReturnEntity() { + var classes = new ClassFileImporter().importClasses(BadOrderController.class, OrderEntity.class); + assertThatThrownBy(() -> JpaArchitectureRules.noEntityFromWeb().check(classes)) + .hasMessageContaining("OrderEntity"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-security:test --tests 'io.backend.skeleton.jpa.security.JpaArchitectureRulesTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.security; + +public final class JpaArchitectureRules { + public static ArchRule noEntityFromWeb() { + return methods().that().areDeclaredInClassesThat() + .resideInAPackage("..web..") + .should(new EntityExposureCondition()); + } + + public static ArchRule entitiesFollowPortableMappingRules() { + return classes().that().areAnnotatedWith(Entity.class) + .should(new EntityMappingCondition()); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-security:test --tests 'io.backend.skeleton.jpa.security.JpaArchitectureRulesTest' +./gradlew :modules:jpa:jpa-security:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/JpaArchitectureRules.java' 'modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/EntityMappingCondition.java' 'modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/EntityExposureCondition.java' 'modules/jpa/jpa-security/src/test/java/io/backend/skeleton/jpa/security/JpaArchitectureRulesTest.java' +git commit -m "feat: enforce jpa entity architecture rules" +``` + +### Task 14: Sequence·UUID ID Strategy Contract Testkit 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/id/UuidV7Generator.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/id/SequenceEntity.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/id/IdentityEntity.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/contractTest/java/io/backend/skeleton/jpa/testkit/id/IdStrategyContractTest.java` +- Test: `modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/id/UuidV7GeneratorTest.java` + +**Interfaces:** +- Consumes: PostgreSQL Testcontainer foundation and Hibernate statistics. +- Produces: Application UUIDv7 generator and evidence that Sequence batches while IDENTITY is classified as limited. + +**Implementation requirements:** +- UUIDv7 output must be monotonic enough for the test clock and set RFC variant/version bits. +- Sequence fixture must align allocationSize with the migration sequence increment. +- Contract test records actual prepared statements and JDBC batches. +- Do not expose PostgreSQL 18 `uuidv7()` as PG16·17 common behavior. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.id; + +class UuidV7GeneratorTest { + @Test + void producesVersionSevenUuidInTimeOrder() { + var first = generator.next(Instant.parse("2026-08-11T00:00:00Z")); + var second = generator.next(Instant.parse("2026-08-11T00:00:01Z")); + + assertThat(first.version()).isEqualTo(7); + assertThat(first.compareTo(second)).isLessThan(0); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.id.UuidV7GeneratorTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.testkit.id; + +public final class UuidV7Generator { + public UUID next(Instant instant) { + long unixMillis = instant.toEpochMilli() & 0x0000_FFFF_FFFF_FFFFL; + long most = (unixMillis << 16) | 0x7000L | random.nextLong(0x1000L); + long least = (random.nextLong() & 0x3FFF_FFFF_FFFF_FFFFL) | + 0x8000_0000_0000_0000L; + return new UUID(most, least); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.id.UuidV7GeneratorTest' +./gradlew :modules:jpa:jpa-testkit-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/id/UuidV7Generator.java' 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/id/SequenceEntity.java' 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/id/IdentityEntity.java' 'modules/jpa/jpa-testkit-postgresql/src/contractTest/java/io/backend/skeleton/jpa/testkit/id/IdStrategyContractTest.java' 'modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/id/UuidV7GeneratorTest.java' +git commit -m "test: add jpa id strategy contracts" +``` + +### Task 15: JPA 3.2 Value Mapping Contract Fixture 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/mapping/Money.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/mapping/MappingEntity.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/mapping/DurationMillisConverter.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/contractTest/java/io/backend/skeleton/jpa/testkit/mapping/JpaValueMappingContractTest.java` +- Test: `modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/mapping/DurationMillisConverterTest.java` + +**Interfaces:** +- Consumes: JPA 3.2, Hibernate 7.4 and PostgreSQL round-trip test infrastructure. +- Produces: Round-trip contracts for Instant, OffsetDateTime, LocalDate, UUID, String Enum, record Embeddable and Duration converter. + +**Implementation requirements:** +- Use STRING or explicit converter for Enum; never ORDINAL. +- Verify record Embeddable construction and dirty checking under Hibernate 7.4. +- Specify timezone and precision assertions explicitly. +- Malformed database values must produce stable data corruption errors. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.mapping; + +class DurationMillisConverterTest { + @Test + void roundTripsDurationAsMilliseconds() { + var duration = Duration.ofSeconds(42).plusMillis(7); + assertThat(converter.convertToEntityAttribute( + converter.convertToDatabaseColumn(duration))).isEqualTo(duration); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.mapping.DurationMillisConverterTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.testkit.mapping; + +@Converter(autoApply = false) +public final class DurationMillisConverter + implements AttributeConverter { + public Long convertToDatabaseColumn(Duration value) { + return value == null ? null : value.toMillis(); + } + public Duration convertToEntityAttribute(Long value) { + return value == null ? null : Duration.ofMillis(value); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.mapping.DurationMillisConverterTest' +./gradlew :modules:jpa:jpa-testkit-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/mapping/Money.java' 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/mapping/MappingEntity.java' 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/mapping/DurationMillisConverter.java' 'modules/jpa/jpa-testkit-postgresql/src/contractTest/java/io/backend/skeleton/jpa/testkit/mapping/JpaValueMappingContractTest.java' 'modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/mapping/DurationMillisConverterTest.java' +git commit -m "test: define jpa value mapping contracts" +``` + +### Task 16: Entity Lifecycle·Association Persistence Context Contract 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/lifecycle/LifecycleParent.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/lifecycle/LifecycleChild.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/lifecycle/EntityStateProbe.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/contractTest/java/io/backend/skeleton/jpa/testkit/lifecycle/JpaLifecycleAssociationContractTest.java` +- Test: `modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/lifecycle/EntityStateProbeTest.java` + +**Interfaces:** +- Consumes: Jakarta Persistence 3.2 EntityManager lifecycle and domain-style parent/child fixtures. +- Produces: Explicit persist, merge, find, getReference, dirty-check, flush, clear, detach, refresh, owning-side, cascade and orphan-removal contracts. + +**Implementation requirements:** +- Prove `merge` returns the managed copy and does not attach the passed detached instance. +- Prove flush writes SQL but does not imply transaction commit. +- Prove clear/detach stop dirty checking and refresh reloads database state. +- Prove only the owning side updates the foreign key and helper methods synchronize both sides. +- Test cascade/orphan removal only on an aggregate-owned child fixture; do not define a platform-wide default. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.lifecycle; + +class EntityStateProbeTest { + @Test + void distinguishesManagedDetachedAndMergedInstances() { + var original = new LifecycleParent("p-1"); + entityManager.persist(original); + entityManager.flush(); + entityManager.detach(original); + + var merged = entityManager.merge(original); + assertThat(entityManager.contains(original)).isFalse(); + assertThat(entityManager.contains(merged)).isTrue(); + assertThat(merged).isNotSameAs(original); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.lifecycle.EntityStateProbeTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.testkit.lifecycle; + +public final class EntityStateProbe { + private final EntityManager entityManager; + + public EntityState stateOf(Object entity) { + if (entityManager.contains(entity)) return EntityState.MANAGED; + Object id = entityManager.getEntityManagerFactory() + .getPersistenceUnitUtil().getIdentifier(entity); + return id == null ? EntityState.TRANSIENT : EntityState.DETACHED; + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.lifecycle.EntityStateProbeTest' +./gradlew :modules:jpa:jpa-testkit-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/lifecycle/LifecycleParent.java' 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/lifecycle/LifecycleChild.java' 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/lifecycle/EntityStateProbe.java' 'modules/jpa/jpa-testkit-postgresql/src/contractTest/java/io/backend/skeleton/jpa/testkit/lifecycle/JpaLifecycleAssociationContractTest.java' 'modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/lifecycle/EntityStateProbeTest.java' +git commit -m "test: add jpa lifecycle and association contracts" +``` + +### Task 17: Spring Data Auditing Opt-in 모듈 구현 + +**Files:** +- Create: `modules/jpa/jpa-auditing/src/main/java/io/backend/skeleton/jpa/auditing/AuditMetadata.java` +- Create: `modules/jpa/jpa-auditing/src/main/java/io/backend/skeleton/jpa/auditing/JpaAuditorProvider.java` +- Create: `modules/jpa/jpa-auditing/src/main/java/io/backend/skeleton/jpa/auditing/JpaAuditingConfiguration.java` +- Test: `modules/jpa/jpa-auditing/src/test/java/io/backend/skeleton/jpa/auditing/JpaAuditingContractTest.java` + +**Interfaces:** +- Consumes: Spring Data auditing and application-provided current actor resolver. +- Produces: Embeddable technical auditing without a mandatory BaseEntity. + +**Implementation requirements:** +- Provide createdAt, createdBy, modifiedAt and modifiedBy as an opt-in Embeddable. +- Use `Instant` and a bounded opaque actor identifier. +- Do not confuse technical auditing with business audit or Entity history. +- Allow system/background jobs to use an explicit system actor. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.auditing; + +class JpaAuditingContractTest { + @Test + void persistsTechnicalAuditWhenEntityOptsIn() { + var saved = repository.save(new AuditedFixture("value")); + entityManager.flush(); + + assertThat(saved.audit().createdAt()).isEqualTo(clock.instant()); + assertThat(saved.audit().createdBy()).isEqualTo("user-42"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-auditing:test --tests 'io.backend.skeleton.jpa.auditing.JpaAuditingContractTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.auditing; + +@Embeddable +public class AuditMetadata { + @CreatedDate private Instant createdAt; + @CreatedBy private String createdBy; + @LastModifiedDate private Instant modifiedAt; + @LastModifiedBy private String modifiedBy; + + protected AuditMetadata() {} +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-auditing:test --tests 'io.backend.skeleton.jpa.auditing.JpaAuditingContractTest' +./gradlew :modules:jpa:jpa-auditing:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-auditing/src/main/java/io/backend/skeleton/jpa/auditing/AuditMetadata.java' 'modules/jpa/jpa-auditing/src/main/java/io/backend/skeleton/jpa/auditing/JpaAuditorProvider.java' 'modules/jpa/jpa-auditing/src/main/java/io/backend/skeleton/jpa/auditing/JpaAuditingConfiguration.java' 'modules/jpa/jpa-auditing/src/test/java/io/backend/skeleton/jpa/auditing/JpaAuditingContractTest.java' +git commit -m "feat: add opt in spring data jpa auditing" +``` + +### Task 18: QueryName과 QueryObservation Core 구현 + +**Files:** +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/QueryName.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/QueryObservation.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/QueryScope.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/NoopQueryObservation.java` +- Test: `modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/query/QueryNameTest.java` + +**Interfaces:** +- Consumes: Java 21 only and the operation-name validation pattern. +- Produces: Low-cardinality query identity and framework-neutral observation scopes. + +**Implementation requirements:** +- Query names use a bounded registry format and never contain IDs or raw SQL. +- QueryScope records row count, failure and close exactly once. +- Provide a no-op implementation for modules that do not install observability. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.api.query; + +class QueryNameTest { + @Test + void rejectsRawSqlAsMetricIdentity() { + assertThatThrownBy(() -> new QueryName("select * from orders where id=42")) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.query.QueryNameTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.api.query; + +public record QueryName(String value) { + public QueryName { + if (value == null || !value.matches("[a-z][a-z0-9.-]{2,95}")) { + throw new IllegalArgumentException("invalid query name"); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.query.QueryNameTest' +./gradlew :modules:jpa:jpa-core-api:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/QueryName.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/QueryObservation.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/QueryScope.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/NoopQueryObservation.java' 'modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/query/QueryNameTest.java' +git commit -m "feat: add bounded jpa query observation contract" +``` + +### Task 19: Custom Repository Fragment 지원과 Generic Repository 금지 규칙 구현 + +**Files:** +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/JpaRepositoryFragmentSupport.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/EntityManagerAccess.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/RegisteredQuery.java` +- Modify: `modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/JpaArchitectureRules.java` +- Test: `modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/JpaRepositoryFragmentSupportTest.java` + +**Interfaces:** +- Consumes: Spring Data JPA custom fragment model and Task 18 query names. +- Produces: A helper base for domain-owned custom implementations, not a CRUD repository. + +**Implementation requirements:** +- Do not declare save, findById, findAll or delete methods in platform interfaces. +- Expose EntityManager only to custom repository implementation packages. +- Require a registered QueryName for helper-created typed/native queries. +- Add an architecture test that fails if a platform type named GenericRepository or BaseRepository extends CrudRepository. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.springdata; + +class JpaRepositoryFragmentSupportTest { + @Test + void platformDoesNotReimplementCrudRepository() { + assertThat(JpaRepositoryFragmentSupport.class.getMethods()) + .extracting(Method::getName) + .doesNotContain("save", "findById", "findAll", "delete"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.JpaRepositoryFragmentSupportTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.springdata; + +public abstract class JpaRepositoryFragmentSupport { + private final EntityManager entityManager; + + protected JpaRepositoryFragmentSupport(EntityManager entityManager) { + this.entityManager = entityManager; + } + + protected final TypedQuery typedQuery( + QueryName name, String jpql, Class resultType) { + return entityManager.createQuery(jpql, resultType) + .setHint("org.hibernate.comment", name.value()); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.JpaRepositoryFragmentSupportTest' +./gradlew :modules:jpa:jpa-spring-data:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/JpaRepositoryFragmentSupport.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/EntityManagerAccess.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/RegisteredQuery.java' 'modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/JpaArchitectureRules.java' 'modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/JpaRepositoryFragmentSupportTest.java' +git commit -m "feat: support domain owned jpa repository fragments" +``` + +### Task 20: Specification과 Querydsl 선택 Integration 구현 + +**Files:** +- Create: `modules/jpa/jpa-querydsl/src/main/java/io/backend/skeleton/jpa/querydsl/QuerydslJpaSupport.java` +- Create: `modules/jpa/jpa-querydsl/src/main/java/io/backend/skeleton/jpa/querydsl/PredicatePolicy.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/SpecificationPolicy.java` +- Test: `modules/jpa/jpa-querydsl/src/test/java/io/backend/skeleton/jpa/querydsl/QuerydslJpaSupportTest.java` + +**Interfaces:** +- Consumes: Optional Querydsl JPA dependency, Spring Data Specification and registered QueryName. +- Produces: Explicit Q2 dynamic query helpers without changing J1 repository contracts. + +**Implementation requirements:** +- Keep Querydsl as an optional module; starter must not pull it transitively unless enabled. +- Reject an unbounded query when no predicate and no explicit allow-all token is present. +- Require page size and sort allowlist for collection queries. +- Do not accept user-provided path expressions. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.querydsl; + +class QuerydslJpaSupportTest { + @Test + void rejectsUnboundedPredicateForCollectionQuery() { + assertThatThrownBy(() -> support.select(ORDER_QUERY, order, null, page(100))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("bounded predicate"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-querydsl:test --tests 'io.backend.skeleton.jpa.querydsl.QuerydslJpaSupportTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.querydsl; + +public final class QuerydslJpaSupport { + public JPAQuery select( + QueryName name, + EntityPath root, + Predicate predicate, + QueryPage page) { + PredicatePolicy.requireBounded(predicate, page); + return queryFactory.selectFrom(root) + .where(predicate) + .limit(page.size()) + .setHint("org.hibernate.comment", name.value()); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-querydsl:test --tests 'io.backend.skeleton.jpa.querydsl.QuerydslJpaSupportTest' +./gradlew :modules:jpa:jpa-querydsl:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-querydsl/src/main/java/io/backend/skeleton/jpa/querydsl/QuerydslJpaSupport.java' 'modules/jpa/jpa-querydsl/src/main/java/io/backend/skeleton/jpa/querydsl/PredicatePolicy.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/SpecificationPolicy.java' 'modules/jpa/jpa-querydsl/src/test/java/io/backend/skeleton/jpa/querydsl/QuerydslJpaSupportTest.java' +git commit -m "feat: add optional jpa specification and querydsl support" +``` + +### Task 21: Dynamic Sort Allowlist와 Safe Sort Mapper 구현 + +**Files:** +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/SafeSortField.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/SafeSortRegistry.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/SafeSortMapper.java` +- Test: `modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/SafeSortMapperTest.java` + +**Interfaces:** +- Consumes: Spring Data `Sort` and a domain-registered field catalog. +- Produces: Injection-safe sort mapping with deterministic tie-breakers. + +**Implementation requirements:** +- Reject unknown field, function expression, whitespace and punctuation from user input. +- Map public sort names to fixed entity paths. +- Append the configured stable tie-breaker when absent. +- Do not use `JpaSort.unsafe` for user-controlled values. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.springdata; + +class SafeSortMapperTest { + @Test + void rejectsSqlExpressionAndAddsTieBreaker() { + assertThatThrownBy(() -> mapper.map(List.of("name desc nulls last; drop table"))) + .isInstanceOf(IllegalArgumentException.class); + + assertThat(mapper.map(List.of("createdAt,desc"))) + .extracting(Sort.Order::getProperty) + .containsExactly("createdAt", "id"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.SafeSortMapperTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.springdata; + +public final class SafeSortMapper { + public Sort map(List requested) { + var orders = requested.stream() + .map(value -> registry.require(value.field()).toOrder(value.direction())) + .collect(Collectors.toCollection(ArrayList::new)); + if (orders.stream().noneMatch(order -> order.getProperty().equals(registry.tieBreaker()))) { + orders.add(Sort.Order.desc(registry.tieBreaker())); + } + return Sort.by(orders); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.SafeSortMapperTest' +./gradlew :modules:jpa:jpa-spring-data:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/SafeSortField.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/SafeSortRegistry.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/SafeSortMapper.java' 'modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/SafeSortMapperTest.java' +git commit -m "feat: enforce allowlisted deterministic jpa sorting" +``` + +### Task 22: Hibernate Statement Inspector와 Statistics Snapshot 구현 + +**Files:** +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/QueryNameContext.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/NamedStatementInspector.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/HibernateStatisticsSnapshot.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/HibernateStatisticsCollector.java` +- Test: `modules/jpa/jpa-hibernate/src/test/java/io/backend/skeleton/jpa/hibernate/HibernateStatisticsCollectorTest.java` + +**Interfaces:** +- Consumes: Hibernate 7.4 StatementInspector/Statistics and `QueryName`. +- Produces: Per-scope statement, entity, collection, flush and batch statistics without SQL parameter capture. + +**Implementation requirements:** +- Use query-name comments or context metadata without including dynamic values. +- Snapshot entity load/fetch and collection load/fetch separately. +- Record prepared statement count, flush count and JDBC batch execution count. +- Clear query context in finally blocks. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.hibernate; + +class HibernateStatisticsCollectorTest { + @Test + void separatesEntityLoadFromEntityFetch() { + var before = collector.snapshot(); + fixture.loadOrdersWithSharedUser(); + var delta = collector.snapshot().minus(before); + + assertThat(delta.entityLoadCount()).isPositive(); + assertThat(delta.entityFetchCount()).isGreaterThanOrEqualTo(0); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-hibernate:test --tests 'io.backend.skeleton.jpa.hibernate.HibernateStatisticsCollectorTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.hibernate; + +public record HibernateStatisticsSnapshot( + long preparedStatements, + long entityLoads, + long entityFetches, + long collectionLoads, + long collectionFetches, + long flushes, + long jdbcBatches) { + + public HibernateStatisticsSnapshot minus(HibernateStatisticsSnapshot before) { + return new HibernateStatisticsSnapshot( + preparedStatements - before.preparedStatements, + entityLoads - before.entityLoads, + entityFetches - before.entityFetches, + collectionLoads - before.collectionLoads, + collectionFetches - before.collectionFetches, + flushes - before.flushes, + jdbcBatches - before.jdbcBatches); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-hibernate:test --tests 'io.backend.skeleton.jpa.hibernate.HibernateStatisticsCollectorTest' +./gradlew :modules:jpa:jpa-hibernate:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/QueryNameContext.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/NamedStatementInspector.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/HibernateStatisticsSnapshot.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/HibernateStatisticsCollector.java' 'modules/jpa/jpa-hibernate/src/test/java/io/backend/skeleton/jpa/hibernate/HibernateStatisticsCollectorTest.java' +git commit -m "feat: collect hibernate query and fetch statistics" +``` + +### Task 23: Query Count·N+1 Assertion Testkit 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/query/QueryExpectation.java` +- Create: `modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/query/FetchExpectation.java` +- Create: `modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/query/JpaQueryAssertions.java` +- Create: `modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/query/QueryMeasurement.java` +- Test: `modules/jpa/jpa-testkit/src/test/java/io/backend/skeleton/jpa/testkit/query/JpaQueryAssertionsTest.java` + +**Interfaces:** +- Consumes: Task 22 statistics snapshots and a statement/row measurement adapter. +- Produces: Assertions for statement count, fetch count, hydrated entities, rows and bounded execution time. + +**Implementation requirements:** +- Do not reduce N+1 verification to statement count only. +- Allow upper bounds and exact expectations separately. +- Error output must show queryName and each measured dimension. +- Support skewed and shared-association fixtures in PG contract suites. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.query; + +class JpaQueryAssertionsTest { + @Test + void reportsCartesianAmplificationEvenForOneStatement() { + var measurement = new QueryMeasurement(1, 100, 2000, 2000, Duration.ofMillis(40)); + assertThatThrownBy(() -> assertions.assertMatches( + measurement, QueryExpectation.maxRows(500))) + .hasMessageContaining("rows=2000"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit:test --tests 'io.backend.skeleton.jpa.testkit.query.JpaQueryAssertionsTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.testkit.query; + +public final class JpaQueryAssertions { + public void assertMatches( + QueryMeasurement actual, + QueryExpectation expected) { + if (!expected.matches(actual)) { + throw new AssertionError("JPA query expectation failed: " + actual.summary()); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit:test --tests 'io.backend.skeleton.jpa.testkit.query.JpaQueryAssertionsTest' +./gradlew :modules:jpa:jpa-testkit:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/query/QueryExpectation.java' 'modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/query/FetchExpectation.java' 'modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/query/JpaQueryAssertions.java' 'modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/query/QueryMeasurement.java' 'modules/jpa/jpa-testkit/src/test/java/io/backend/skeleton/jpa/testkit/query/JpaQueryAssertionsTest.java' +git commit -m "test: add quantitative jpa query assertions" +``` + +### Task 24: Use Case Fetch Plan과 EntityGraph Helper 구현 + +**Files:** +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/FetchPlanName.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/EntityGraphCatalog.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/FetchPlanApplier.java` +- Test: `modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/FetchPlanApplierTest.java` + +**Interfaces:** +- Consumes: EntityManager graphs, registered QueryName and domain-defined graph names. +- Produces: Use-case-specific EntityGraph selection without changing mapping fetch defaults. + +**Implementation requirements:** +- Require a registered fetch-plan name; no arbitrary attribute strings from API input. +- Support fetchgraph and loadgraph semantics explicitly. +- Do not mutate global Entity mapping or turn associations EAGER. +- Expose applied fetch plan to observation context. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.springdata; + +class FetchPlanApplierTest { + @Test + void appliesRegisteredGraphAndRejectsUnknownGraph() { + var query = fixtureQuery(); + applier.apply(query, new FetchPlanName("order.detail")); + assertThat(query.getHints()).containsKey("jakarta.persistence.fetchgraph"); + + assertThatThrownBy(() -> applier.apply(query, new FetchPlanName("order.secret"))) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.FetchPlanApplierTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.springdata; + +public final class FetchPlanApplier { + public TypedQuery apply(TypedQuery query, FetchPlanName name) { + EntityGraph graph = catalog.require(name); + return query.setHint("jakarta.persistence.fetchgraph", graph); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.FetchPlanApplierTest' +./gradlew :modules:jpa:jpa-spring-data:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/FetchPlanName.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/EntityGraphCatalog.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/FetchPlanApplier.java' 'modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/FetchPlanApplierTest.java' +git commit -m "feat: add use case specific entity graph support" +``` + +### Task 25: Hibernate 7.4 Collection Fetch Pagination 회귀 Suite 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/fetch/PagedParent.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/fetch/PagedChild.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/compatibilityTest/java/io/backend/skeleton/jpa/testkit/fetch/HibernateCollectionFetchPaginationContractTest.java` +- Test: `modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/fetch/FetchPaginationExpectationTest.java` + +**Interfaces:** +- Consumes: Hibernate 7.4, PG16·17·18, Task 23 measurement and a parent/child skew fixture. +- Produces: A version-specific gate for SQL limit/subquery behavior, parent count, row amplification and count correctness. + +**Implementation requirements:** +- Test one fetched collection with Page and exact parent limit. +- Capture generated SQL and prove DB-level bounded selection under Hibernate 7.4. +- Keep a negative multiple-collection Cartesian test. +- Run on all Stable PostgreSQL versions and every Boot/Hibernate patch upgrade. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.fetch; + +class FetchPaginationExpectationTest { + @Test + void oneCollectionPageRequiresBoundedParentSelection() { + var expected = FetchPaginationExpectation.hibernate74PostgreSql(20); + assertThat(expected.maxReturnedParents()).isEqualTo(20); + assertThat(expected.requiresDatabaseLimit()).isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.fetch.FetchPaginationExpectationTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.testkit.fetch; + +public record FetchPaginationExpectation( + int maxReturnedParents, + boolean requiresDatabaseLimit, + int maxRowAmplification) { + + public static FetchPaginationExpectation hibernate74PostgreSql(int pageSize) { + return new FetchPaginationExpectation(pageSize, true, pageSize * 100); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.fetch.FetchPaginationExpectationTest' +./gradlew :modules:jpa:jpa-testkit-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/fetch/PagedParent.java' 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/fetch/PagedChild.java' 'modules/jpa/jpa-testkit-postgresql/src/compatibilityTest/java/io/backend/skeleton/jpa/testkit/fetch/HibernateCollectionFetchPaginationContractTest.java' 'modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/fetch/FetchPaginationExpectationTest.java' +git commit -m "test: certify hibernate collection fetch pagination" +``` + +### Task 26: Keyset Pagination Core Cursor 계약 구현 + +**Files:** +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/SortDirection.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/KeysetPageRequest.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/KeysetSlice.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/CursorCodec.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/SignedJsonCursorCodec.java` +- Test: `modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/query/SignedJsonCursorCodecTest.java` + +**Interfaces:** +- Consumes: Java JSON codec adapter and an application-provided HMAC key. +- Produces: Versioned, bounded, tamper-evident cursor API independent of Spring Data. + +**Implementation requirements:** +- Require page size between 1 and a configured maximum. +- Cursor payload includes version and all ordering tie-breakers. +- Do not place JPQL, SQL fragments or raw entity paths in cursor data. +- Reject signature mismatch and unknown cursor version. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.api.query; + +class SignedJsonCursorCodecTest { + @Test + void detectsTamperingAndRoundTripsTieBreaker() { + var cursor = new OrderCursor(Instant.parse("2026-08-11T00:00:00Z"), UUID.randomUUID()); + var encoded = codec.encode(cursor); + assertThat(codec.decode(encoded)).isEqualTo(cursor); + assertThatThrownBy(() -> codec.decode(encoded + "x")) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.query.SignedJsonCursorCodecTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.api.query; + +public record KeysetPageRequest( + Optional after, + int size, + SortDirection direction) { + public KeysetPageRequest { + if (size < 1 || size > 500) throw new IllegalArgumentException("invalid page size"); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.query.SignedJsonCursorCodecTest' +./gradlew :modules:jpa:jpa-core-api:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/SortDirection.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/KeysetPageRequest.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/KeysetSlice.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/CursorCodec.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/SignedJsonCursorCodec.java' 'modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/query/SignedJsonCursorCodecTest.java' +git commit -m "feat: add signed keyset cursor contracts" +``` + +### Task 27: Spring Data Keyset Query Support 구현 + +**Files:** +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/JpaKeysetQuerySupport.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/KeysetPredicateBuilder.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/KeysetSliceAssembler.java` +- Test: `modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/JpaKeysetQuerySupportTest.java` + +**Interfaces:** +- Consumes: Task 26 cursor types, Criteria API and domain-provided keyset adapters. +- Produces: Deterministic size+1 keyset query execution and next-cursor assembly. + +**Implementation requirements:** +- Use lexicographic predicates matching the exact sort direction and null policy. +- Require a unique tie-breaker. +- Fetch at most `size + 1` rows and return only `size`. +- Do not execute a count query. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.springdata; + +class JpaKeysetQuerySupportTest { + @Test + void duplicateCreatedAtUsesIdTieBreakerWithoutGap() { + var first = repository.findRecent(request(Optional.empty(), 2)); + var second = repository.findRecent(request(first.nextCursor(), 2)); + + assertThat(Stream.concat(first.items().stream(), second.items().stream())) + .extracting(OrderSummary::id) + .doesNotHaveDuplicates(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.JpaKeysetQuerySupportTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.springdata; + +public final class KeysetSliceAssembler { + public KeysetSlice assemble( + List fetched, + int requestedSize, + Function cursorExtractor) { + boolean hasNext = fetched.size() > requestedSize; + List items = List.copyOf(fetched.subList(0, Math.min(fetched.size(), requestedSize))); + Optional next = hasNext ? Optional.of(cursorExtractor.apply(items.getLast())) : Optional.empty(); + return new KeysetSlice<>(items, next, hasNext); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.JpaKeysetQuerySupportTest' +./gradlew :modules:jpa:jpa-spring-data:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/JpaKeysetQuerySupport.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/KeysetPredicateBuilder.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/KeysetSliceAssembler.java' 'modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/JpaKeysetQuerySupportTest.java' +git commit -m "feat: implement deterministic jpa keyset pagination" +``` + +### Task 28: Scroll·Stream Resource Guard 구현 + +**Files:** +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/JpaStreamScope.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/JpaStreamExecutor.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/ScrollPolicy.java` +- Test: `modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/JpaStreamExecutorTest.java` + +**Interfaces:** +- Consumes: Spring Data Scroll/Stream APIs, Transaction synchronization and QueryName. +- Produces: A bounded resource scope that closes Stream/ResultSet and forbids returning it beyond the transaction. + +**Implementation requirements:** +- Require an active read-only transaction for stream execution. +- Close the stream in normal, exception and cancellation paths. +- Require fetch size, maximum rows or explicit admin token. +- Reject WebFlux/Reactor return types in this blocking module. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.springdata; + +class JpaStreamExecutorTest { + @Test + void closesStreamWhenConsumerFails() { + assertThatThrownBy(() -> executor.consume(QUERY, policy(100), stream -> { + stream.findFirst(); + throw new IllegalStateException("boom"); + })).isInstanceOf(IllegalStateException.class); + + assertThat(resourceProbe.closed()).isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.JpaStreamExecutorTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.springdata; + +public final class JpaStreamExecutor { + public R consume( + QueryName query, + ScrollPolicy policy, + Supplier> supplier, + Function, R> consumer) { + TransactionGuard.requireActiveReadOnly(); + try (Stream stream = supplier.get()) { + return consumer.apply(stream.limit(policy.maxRows())); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.JpaStreamExecutorTest' +./gradlew :modules:jpa:jpa-spring-data:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/JpaStreamScope.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/JpaStreamExecutor.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/ScrollPolicy.java' 'modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/JpaStreamExecutorTest.java' +git commit -m "feat: guard jpa scroll and stream resources" +``` + +### Task 29: Optimistic Lock 오류 변환과 전체 Use Case Retry 계약 구현 + +**Files:** +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/OptimisticConflictTranslator.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/DefaultJpaRetryPolicy.java` +- Modify: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/FullTransactionRetryCoordinator.java` +- Test: `modules/jpa/jpa-transaction/src/integrationTest/java/io/backend/skeleton/jpa/transaction/OptimisticRetryIntegrationTest.java` + +**Interfaces:** +- Consumes: JPA `OptimisticLockException`, Spring optimistic locking exceptions and Task 8 coordinator. +- Produces: Stable `OptimisticConflictException` and bounded full-transaction recomputation. + +**Implementation requirements:** +- Translate conflicts thrown at flush or commit. +- Ensure retry reloads the entity and reruns domain rules. +- Do not retry when the use case declared external irreversible side effects. +- Record conflict entity type only from a bounded catalog, never Entity ID. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.transaction; + +class OptimisticRetryIntegrationTest { + @Test + void secondAttemptReloadsAndRecomputesAggregate() { + concurrentWriterUpdatesVersion(); + var result = retryingService.increaseQuantity(orderId, 2); + + assertThat(result.attempts()).isEqualTo(2); + assertThat(repository.findById(orderId).orElseThrow().quantity()).isEqualTo(5); + assertThat(probe.persistenceContextIds()).doesNotHaveDuplicates(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:integrationTest --tests 'io.backend.skeleton.jpa.transaction.OptimisticRetryIntegrationTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.transaction; + +public final class DefaultJpaRetryPolicy implements JpaRetryPolicy { + public RetryDecision classify( + JpaPersistenceException failure, + TransactionAttempt attempt) { + if (failure instanceof TransactionCompletionUnknownException) { + return RetryDecision.reconcile("transaction completion is unknown"); + } + if (failure instanceof OptimisticConflictException || + failure instanceof SerializationFailureException || + failure instanceof DeadlockDetectedException) { + return RetryDecision.retry(backoff.forAttempt(attempt.number())); + } + return RetryDecision.fail("non-retryable persistence failure"); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:integrationTest --tests 'io.backend.skeleton.jpa.transaction.OptimisticRetryIntegrationTest' +./gradlew :modules:jpa:jpa-transaction:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/OptimisticConflictTranslator.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/DefaultJpaRetryPolicy.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/FullTransactionRetryCoordinator.java' 'modules/jpa/jpa-transaction/src/integrationTest/java/io/backend/skeleton/jpa/transaction/OptimisticRetryIntegrationTest.java' +git commit -m "feat: retry optimistic conflicts as complete transactions" +``` + +### Task 30: Pessimistic Lock Timeout과 Deadlock 변환 구현 + +**Files:** +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlLockOptions.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlLockExceptionTranslator.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/LockWaitObservation.java` +- Test: `modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlPessimisticLockContractTest.java` + +**Interfaces:** +- Consumes: JPA Pessimistic lock hints, SQLSTATE classifier and PostgreSQL Testcontainers. +- Produces: Distinct lock-timeout, NOWAIT and deadlock errors with lock-wait metrics. + +**Implementation requirements:** +- Distinguish statement-level lock timeout from transaction-aborting deadlock. +- Map `55P03` to lock-not-available/timeout and `40P01` to deadlock. +- Require finite lock timeout for pessimistic lock profiles. +- Hold locks only inside the Application Transaction. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.postgresql.lock; + +class PostgreSqlPessimisticLockContractTest { + @Test + void nowaitFailsImmediatelyWhileBlockingLockTimesOutSeparately() { + lockRowInOtherTransaction(); + + assertThatThrownBy(() -> repository.findForUpdateNowait(id)) + .isInstanceOf(PessimisticLockTimeoutException.class); + assertThat(lockProbe.lastWait()).isLessThan(Duration.ofSeconds(1)); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.lock.PostgreSqlPessimisticLockContractTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.postgresql.lock; + +public record PostgreSqlLockOptions( + LockModeType mode, + Duration timeout, + boolean nowait) { + public PostgreSqlLockOptions { + if (timeout == null || timeout.isNegative()) { + throw new IllegalArgumentException("lock timeout must be finite"); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.lock.PostgreSqlPessimisticLockContractTest' +./gradlew :modules:jpa:jpa-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlLockOptions.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlLockExceptionTranslator.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/LockWaitObservation.java' 'modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlPessimisticLockContractTest.java' +git commit -m "feat: classify postgresql pessimistic lock failures" +``` + +### Task 31: PostgreSQL NOWAIT·SKIP LOCKED Work Claim Extension 구현 + +**Files:** +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/WorkQueueName.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/WorkClaimExecutor.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlWorkClaimExecutor.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/WorkClaim.java` +- Test: `modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlWorkClaimContractTest.java` + +**Interfaces:** +- Consumes: EntityManager native query, registered queue SQL and PostgreSQL `FOR UPDATE SKIP LOCKED`. +- Produces: Queue-specific batch claim semantics instead of a generic inconsistent-read API. + +**Implementation requirements:** +- Require a registered queue name and fixed SQL template. +- Claim rows in deterministic priority/id order. +- Return lease owner and lease-until evidence in the same transaction. +- Do not expose `skipLocked=true` on arbitrary repository methods. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.postgresql.lock; + +class PostgreSqlWorkClaimContractTest { + @Test + void competingWorkersClaimDisjointRows() { + var first = workerA.claimNextBatch(QUEUE, 10, Duration.ofMinutes(1)); + var second = workerB.claimNextBatch(QUEUE, 10, Duration.ofMinutes(1)); + + assertThat(first).extracting(WorkClaim::id) + .doesNotContainAnyElementsOf(second.stream().map(WorkClaim::id).toList()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.lock.PostgreSqlWorkClaimContractTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.postgresql.lock; + +public interface WorkClaimExecutor { + List> claimNextBatch( + WorkQueueName queue, + int size, + Duration lease); +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.lock.PostgreSqlWorkClaimContractTest' +./gradlew :modules:jpa:jpa-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/WorkQueueName.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/WorkClaimExecutor.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlWorkClaimExecutor.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/WorkClaim.java' 'modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlWorkClaimContractTest.java' +git commit -m "feat: add postgresql skip locked work claims" +``` + +### Task 32: Constraint Violation Catalog와 Race-safe 오류 변환 구현 + +**Files:** +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/constraint/ConstraintCode.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/constraint/PostgreSqlConstraintCatalog.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/constraint/PostgreSqlConstraintViolationTranslator.java` +- Modify: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlExceptionTranslator.java` +- Test: `modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/constraint/ConstraintRaceContractTest.java` + +**Interfaces:** +- Consumes: Structured PostgreSQL server error fields and design-time constraint registry. +- Produces: Stable application constraint codes for unique, foreign-key, not-null and check violations. + +**Implementation requirements:** +- Two concurrent inserts of the same logical key must result in one commit and one unique exception. +- Do not rely on a prior `exists` query for correctness. +- Unknown constraint names map to a generic bounded code and secure diagnostic metadata. +- Support partial unique index and `NULLS NOT DISTINCT` migration names. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.postgresql.constraint; + +class ConstraintRaceContractTest { + @Test + void concurrentCreateIsResolvedByDatabaseConstraint() { + var results = runConcurrently( + () -> service.create("same@example.test"), + () -> service.create("same@example.test")); + + assertThat(results.successCount()).isEqualTo(1); + assertThat(results.failure()).isInstanceOf(UniqueConstraintViolationException.class); + assertThat(((UniqueConstraintViolationException) results.failure()) + .details().code()).isEqualTo(new ConstraintCode("user.active-email.unique")); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.constraint.ConstraintRaceContractTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.postgresql.constraint; + +public final class PostgreSqlConstraintCatalog { + private final Map byDatabaseName; + + public ConstraintCode resolve(String databaseName) { + return byDatabaseName.getOrDefault( + databaseName, new ConstraintCode("database.constraint.unknown")); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.constraint.ConstraintRaceContractTest' +./gradlew :modules:jpa:jpa-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/constraint/ConstraintCode.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/constraint/PostgreSqlConstraintCatalog.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/constraint/PostgreSqlConstraintViolationTranslator.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlExceptionTranslator.java' 'modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/constraint/ConstraintRaceContractTest.java' +git commit -m "feat: map database constraints to stable error codes" +``` + +### Task 33: Hibernate JDBC Batch Profile과 Configuration Guard 구현 + +**Files:** +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/JpaBatchProfile.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/JpaBatchProfileRegistry.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/HibernateBatchConfigurationGuard.java` +- Test: `modules/jpa/jpa-hibernate/src/test/java/io/backend/skeleton/jpa/hibernate/batch/HibernateBatchConfigurationGuardTest.java` + +**Interfaces:** +- Consumes: Hibernate batch settings and Entity identifier metadata. +- Produces: Named batch profiles and startup diagnostics for IDENTITY and sequence mismatch. + +**Implementation requirements:** +- Require positive batch, flush and clear sizes for enabled profiles. +- Warn/fail when a write-heavy batch profile targets IDENTITY entities. +- Validate sequence allocation size against migration metadata in the contract suite. +- Treat `order_inserts` and `order_updates` as profile options, not universal defaults. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.hibernate.batch; + +class HibernateBatchConfigurationGuardTest { + @Test + void rejectsIdentityEntityInRequiredBatchProfile() { + var profile = new JpaBatchProfile("import", 50, 50, 50, true, true, true); + assertThatThrownBy(() -> guard.validate(profile, IdentityEntity.class)) + .hasMessageContaining("IDENTITY disables insert batching"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-hibernate:test --tests 'io.backend.skeleton.jpa.hibernate.batch.HibernateBatchConfigurationGuardTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.hibernate.batch; + +public record JpaBatchProfile( + String name, + int jdbcBatchSize, + int flushSize, + int clearSize, + boolean orderInserts, + boolean orderUpdates, + boolean batchingRequired) { + public JpaBatchProfile { + if (jdbcBatchSize < 1 || flushSize < 1 || clearSize < 1) { + throw new IllegalArgumentException("batch sizes must be positive"); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-hibernate:test --tests 'io.backend.skeleton.jpa.hibernate.batch.HibernateBatchConfigurationGuardTest' +./gradlew :modules:jpa:jpa-hibernate:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/JpaBatchProfile.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/JpaBatchProfileRegistry.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/HibernateBatchConfigurationGuard.java' 'modules/jpa/jpa-hibernate/src/test/java/io/backend/skeleton/jpa/hibernate/batch/HibernateBatchConfigurationGuardTest.java' +git commit -m "feat: define verified hibernate batch profiles" +``` + +### Task 34: Chunked Batch Persist Executor 구현 + +**Files:** +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/JpaBatchExecutor.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/HibernateJpaBatchExecutor.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/BatchExecutionResult.java` +- Test: `modules/jpa/jpa-hibernate/src/integrationTest/java/io/backend/skeleton/jpa/hibernate/batch/HibernateJpaBatchExecutorIntegrationTest.java` + +**Interfaces:** +- Consumes: Task 33 profile, EntityManager and Hibernate statistics. +- Produces: Flush/clear bounded batch persistence with measured JDBC batch execution. + +**Implementation requirements:** +- Persist each item exactly once inside a caller-owned transaction. +- Flush and clear at configured boundaries and once at the end. +- Reject a Stream that cannot report or enforce a maximum input count unless admin capability is present. +- Return processed rows, flush count, statement count and actual batch count. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.hibernate.batch; + +class HibernateJpaBatchExecutorIntegrationTest { + @Test + void executesActualJdbcBatchesAndBoundsPersistenceContext() { + var result = executor.persist(BATCH_PROFILE, fixtures(1_000), entityManager::persist); + + assertThat(result.processed()).isEqualTo(1_000); + assertThat(result.jdbcBatches()).isGreaterThan(1); + assertThat(result.maxManagedEntities()).isLessThanOrEqualTo(50); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-hibernate:integrationTest --tests 'io.backend.skeleton.jpa.hibernate.batch.HibernateJpaBatchExecutorIntegrationTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.hibernate.batch; + +public final class HibernateJpaBatchExecutor implements JpaBatchExecutor { + public BatchExecutionResult persist( + JpaBatchProfile profile, + Iterable items, + Consumer persister) { + int processed = 0; + for (T item : items) { + persister.accept(item); + processed++; + if (processed % profile.flushSize() == 0) { + entityManager.flush(); + entityManager.clear(); + } + } + entityManager.flush(); + entityManager.clear(); + return measurements.result(processed); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-hibernate:integrationTest --tests 'io.backend.skeleton.jpa.hibernate.batch.HibernateJpaBatchExecutorIntegrationTest' +./gradlew :modules:jpa:jpa-hibernate:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/JpaBatchExecutor.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/HibernateJpaBatchExecutor.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/BatchExecutionResult.java' 'modules/jpa/jpa-hibernate/src/integrationTest/java/io/backend/skeleton/jpa/hibernate/batch/HibernateJpaBatchExecutorIntegrationTest.java' +git commit -m "feat: execute bounded hibernate jdbc batches" +``` + +### Task 35: Bulk DML flush-clear Executor 구현 + +**Files:** +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/bulk/BulkOperationName.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/bulk/BulkDmlExecutor.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/bulk/HibernateBulkDmlExecutor.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/bulk/BulkDmlResult.java` +- Test: `modules/jpa/jpa-hibernate/src/integrationTest/java/io/backend/skeleton/jpa/hibernate/bulk/HibernateBulkDmlExecutorIntegrationTest.java` + +**Interfaces:** +- Consumes: EntityManager, registered bulk operation and Task 18 QueryObservation. +- Produces: Explicit flush → bulk SQL → clear execution with affected-row guard. + +**Implementation requirements:** +- Require an active transaction and registered operation name. +- Flush before query execution and clear immediately after it. +- Require minimum/maximum expected affected rows; fail on unexpected blast radius. +- Document that callbacks and optimistic version checks are bypassed. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.hibernate.bulk; + +class HibernateBulkDmlExecutorIntegrationTest { + @Test + void clearsStaleManagedEntitiesAfterBulkUpdate() { + var managed = repository.findById(id).orElseThrow(); + executor.execute(OPERATION, () -> query.executeUpdate(), expectedRows(1)); + + assertThat(entityManager.contains(managed)).isFalse(); + assertThat(repository.findById(id).orElseThrow().status()).isEqualTo("ARCHIVED"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-hibernate:integrationTest --tests 'io.backend.skeleton.jpa.hibernate.bulk.HibernateBulkDmlExecutorIntegrationTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.hibernate.bulk; + +public final class HibernateBulkDmlExecutor implements BulkDmlExecutor { + public BulkDmlResult execute( + BulkOperationName name, + IntSupplier statement, + AffectedRowsExpectation expectation) { + entityManager.flush(); + int affected = statement.getAsInt(); + entityManager.clear(); + expectation.verify(affected); + return new BulkDmlResult(name, affected); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-hibernate:integrationTest --tests 'io.backend.skeleton.jpa.hibernate.bulk.HibernateBulkDmlExecutorIntegrationTest' +./gradlew :modules:jpa:jpa-hibernate:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/bulk/BulkOperationName.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/bulk/BulkDmlExecutor.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/bulk/HibernateBulkDmlExecutor.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/bulk/BulkDmlResult.java' 'modules/jpa/jpa-hibernate/src/integrationTest/java/io/backend/skeleton/jpa/hibernate/bulk/HibernateBulkDmlExecutorIntegrationTest.java' +git commit -m "feat: execute safe jpa bulk dml with context clearing" +``` + +### Task 36: Hibernate StatelessSession Advanced Runner 구현 + +**Files:** +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/stateless/StatelessWorkName.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/stateless/StatelessSessionRunner.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/stateless/HibernateStatelessSessionRunner.java` +- Test: `modules/jpa/jpa-hibernate/src/integrationTest/java/io/backend/skeleton/jpa/hibernate/stateless/HibernateStatelessSessionRunnerIntegrationTest.java` + +**Interfaces:** +- Consumes: Hibernate SessionFactory and J4/Advanced authorization token. +- Produces: An opt-in bulk session with explicit no-dirty-checking/no-cascade semantics. + +**Implementation requirements:** +- Do not register this runner as the default Repository implementation. +- Require a named operation, row cap and explicit transaction mode. +- Document that returned objects are not managed and aliases may occur. +- Measure rows, statements and memory independent of persistence-context size. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.hibernate.stateless; + +class HibernateStatelessSessionRunnerIntegrationTest { + @Test + void insertsWithoutGrowingPersistenceContext() { + var result = runner.execute(WORK, 10_000, session -> { + fixtures(10_000).forEach(session::insert); + return 10_000; + }); + + assertThat(result).isEqualTo(10_000); + assertThat(hibernateSessionStatistics.managedEntityCount()).isZero(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-hibernate:integrationTest --tests 'io.backend.skeleton.jpa.hibernate.stateless.HibernateStatelessSessionRunnerIntegrationTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.hibernate.stateless; + +public final class HibernateStatelessSessionRunner implements StatelessSessionRunner { + public T execute( + StatelessWorkName name, + long maxRows, + Function work) { + try (StatelessSession session = sessionFactory.openStatelessSession()) { + Transaction tx = session.beginTransaction(); + try { + T result = work.apply(session); + tx.commit(); + return result; + } catch (RuntimeException failure) { + tx.rollback(); + throw failure; + } + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-hibernate:integrationTest --tests 'io.backend.skeleton.jpa.hibernate.stateless.HibernateStatelessSessionRunnerIntegrationTest' +./gradlew :modules:jpa:jpa-hibernate:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/stateless/StatelessWorkName.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/stateless/StatelessSessionRunner.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/stateless/HibernateStatelessSessionRunner.java' 'modules/jpa/jpa-hibernate/src/integrationTest/java/io/backend/skeleton/jpa/hibernate/stateless/HibernateStatelessSessionRunnerIntegrationTest.java' +git commit -m "feat: add opt in hibernate stateless session runner" +``` + +### Task 37: PostgreSQL JSONB Mapping과 Query Contract 구현 + +**Files:** +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/json/JsonDocument.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/json/JsonDocumentCodec.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/json/PostgreSqlJsonQuerySupport.java` +- Test: `modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/json/PostgreSqlJsonbContractTest.java` + +**Interfaces:** +- Consumes: Hibernate JSON JDBC type, Jackson adapter and PostgreSQL JSONB operators. +- Produces: Versioned JSONB value mapping and parameter-bound JSON path/containment queries. + +**Implementation requirements:** +- Do not store Java class names in JSON payload. +- Require schema name/version in `JsonDocument`. +- Use parameters for values and a registered catalog for JSON paths. +- Test GIN index plan separately in Task 44. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.postgresql.json; + +class PostgreSqlJsonbContractTest { + @Test + void roundTripsVersionedDocumentAndQueriesByRegisteredPath() { + repository.save(entity(json("profile", 2, Map.of("tier", "pro")))); + entityManager.flush(); + + assertThat(querySupport.contains(PATH_TIER, "pro")) + .extracting(Result::schemaVersion) + .containsExactly(2); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.json.PostgreSqlJsonbContractTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.postgresql.json; + +public record JsonDocument( + String schema, + int version, + JsonNode payload) { + public JsonDocument { + if (schema == null || schema.isBlank() || version < 1) { + throw new IllegalArgumentException("invalid json document envelope"); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.json.PostgreSqlJsonbContractTest' +./gradlew :modules:jpa:jpa-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/json/JsonDocument.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/json/JsonDocumentCodec.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/json/PostgreSqlJsonQuerySupport.java' 'modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/json/PostgreSqlJsonbContractTest.java' +git commit -m "feat: add postgresql jsonb persistence support" +``` + +### Task 38: PostgreSQL Array·Range Mapping Contract 구현 + +**Files:** +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/array/PostgreSqlArraySupport.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/range/PgRange.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/range/PgRangeJdbcType.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/range/PostgreSqlRangeQuerySupport.java` +- Test: `modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/range/PostgreSqlArrayRangeContractTest.java` + +**Interfaces:** +- Consumes: Hibernate JDBC type SPI and PostgreSQL array/range types. +- Produces: Typed array and bounded/unbounded range round-trip and overlap/containment query support. + +**Implementation requirements:** +- Represent open/closed and unbounded endpoints explicitly. +- Reject invalid ranges in Java before sending them. +- Do not flatten ranges into two unrelated columns in this extension. +- Run identical contracts on PG16·17·18. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.postgresql.range; + +class PostgreSqlArrayRangeContractTest { + @Test + void roundTripsClosedOpenRangeAndArray() { + var saved = repository.save(fixture( + List.of("a", "b"), PgRange.closedOpen(Instant.EPOCH, Instant.EPOCH.plusSeconds(60)))); + entityManager.flush(); + entityManager.clear(); + + var loaded = repository.findById(saved.id()).orElseThrow(); + assertThat(loaded.tags()).containsExactly("a", "b"); + assertThat(loaded.window().upperInclusive()).isFalse(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.range.PostgreSqlArrayRangeContractTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.postgresql.range; + +public record PgRange>( + Optional lower, + boolean lowerInclusive, + Optional upper, + boolean upperInclusive) { + public PgRange { + if (lower.isPresent() && upper.isPresent() && + lower.get().compareTo(upper.get()) > 0) { + throw new IllegalArgumentException("range lower bound exceeds upper bound"); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.range.PostgreSqlArrayRangeContractTest' +./gradlew :modules:jpa:jpa-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/array/PostgreSqlArraySupport.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/range/PgRange.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/range/PgRangeJdbcType.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/range/PostgreSqlRangeQuerySupport.java' 'modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/range/PostgreSqlArrayRangeContractTest.java' +git commit -m "feat: add postgresql array and range mappings" +``` + +### Task 39: PostgreSQL ON CONFLICT·RETURNING Native Write 구현 + +**Files:** +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/write/NativeWriteName.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/write/UpsertConflictTarget.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/write/PostgreSqlUpsertExecutor.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/write/RegisteredPostgreSqlUpsertExecutor.java` +- Test: `modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/write/PostgreSqlUpsertContractTest.java` + +**Interfaces:** +- Consumes: Registered native SQL, parameter binder, QueryObservation and Persistence Context clear policy. +- Produces: Explicit upsert result with inserted/updated disposition and returned projection. + +**Implementation requirements:** +- Require a registered conflict target and fixed update column set. +- Parameter-bind all values; dynamic table/column names are forbidden. +- Return whether insert or conflict-update occurred when SQL can expose it. +- Clear or refresh affected managed Entity state before returning to JPA code. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.postgresql.write; + +class PostgreSqlUpsertContractTest { + @Test + void concurrentUpsertReturnsOneLogicalRow() { + runConcurrently( + () -> executor.execute(UPSERT, command("key", 1)), + () -> executor.execute(UPSERT, command("key", 2))); + + assertThat(jdbc.queryForObject("select count(*) from counters where key='key'", Long.class)) + .isEqualTo(1L); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.write.PostgreSqlUpsertContractTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.postgresql.write; + +public interface PostgreSqlUpsertExecutor { + UpsertResult execute(NativeWriteName operation, C command); +} + +public record UpsertResult(WriteDisposition disposition, R value) {} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.write.PostgreSqlUpsertContractTest' +./gradlew :modules:jpa:jpa-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/write/NativeWriteName.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/write/UpsertConflictTarget.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/write/PostgreSqlUpsertExecutor.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/write/RegisteredPostgreSqlUpsertExecutor.java' 'modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/write/PostgreSqlUpsertContractTest.java' +git commit -m "feat: add registered postgresql upsert writes" +``` + +### Task 40: PostgreSQL COPY Bulk Loader J4 Extension 구현 + +**Files:** +- Create: `modules/jpa/jpa-postgresql-copy/src/main/java/io/backend/skeleton/jpa/postgresql/copy/CopyOperationName.java` +- Create: `modules/jpa/jpa-postgresql-copy/src/main/java/io/backend/skeleton/jpa/postgresql/copy/PostgreSqlCopyLoader.java` +- Create: `modules/jpa/jpa-postgresql-copy/src/main/java/io/backend/skeleton/jpa/postgresql/copy/CopyFormat.java` +- Create: `modules/jpa/jpa-postgresql-copy/src/main/java/io/backend/skeleton/jpa/postgresql/copy/CopyResult.java` +- Test: `modules/jpa/jpa-postgresql-copy/src/integrationTest/java/io/backend/skeleton/jpa/postgresql/copy/PostgreSqlCopyLoaderIntegrationTest.java` + +**Interfaces:** +- Consumes: PostgreSQL JDBC `CopyManager`, admin capability token and bounded input stream. +- Produces: Explicit J4 bulk load with row/byte limits, transaction policy and audit identity. + +**Implementation requirements:** +- Require a registered COPY statement; no caller-provided table or column strings. +- Enforce max rows, max bytes and finite timeout. +- Run only under a configured bulk/admin role. +- Return rows and bytes; never use Entity callbacks or Persistence Context. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.postgresql.copy; + +class PostgreSqlCopyLoaderIntegrationTest { + @Test + void loadsBoundedCsvWithoutEntityHydration() { + var result = loader.load(IMPORT, csvOf(10_000), limits(10_000, 5_000_000)); + + assertThat(result.rows()).isEqualTo(10_000); + assertThat(hibernateStatistics.entityLoadCount()).isZero(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql-copy:integrationTest --tests 'io.backend.skeleton.jpa.postgresql.copy.PostgreSqlCopyLoaderIntegrationTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.postgresql.copy; + +public interface PostgreSqlCopyLoader { + CopyResult load( + CopyOperationName operation, + InputStream source, + CopyLimits limits); +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql-copy:integrationTest --tests 'io.backend.skeleton.jpa.postgresql.copy.PostgreSqlCopyLoaderIntegrationTest' +./gradlew :modules:jpa:jpa-postgresql-copy:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-postgresql-copy/src/main/java/io/backend/skeleton/jpa/postgresql/copy/CopyOperationName.java' 'modules/jpa/jpa-postgresql-copy/src/main/java/io/backend/skeleton/jpa/postgresql/copy/PostgreSqlCopyLoader.java' 'modules/jpa/jpa-postgresql-copy/src/main/java/io/backend/skeleton/jpa/postgresql/copy/CopyFormat.java' 'modules/jpa/jpa-postgresql-copy/src/main/java/io/backend/skeleton/jpa/postgresql/copy/CopyResult.java' 'modules/jpa/jpa-postgresql-copy/src/integrationTest/java/io/backend/skeleton/jpa/postgresql/copy/PostgreSqlCopyLoaderIntegrationTest.java' +git commit -m "feat: add guarded postgresql copy bulk loader" +``` + +### Task 41: Flyway Schema Policy와 Hibernate Validate Gate 구현 + +**Files:** +- Create: `modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/SchemaManagementMode.java` +- Create: `modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/FlywaySchemaPolicy.java` +- Create: `modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/FlywayValidationGate.java` +- Create: `modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/SchemaVersionSnapshot.java` +- Test: `modules/jpa/jpa-migration-flyway/src/test/java/io/backend/skeleton/jpa/migration/FlywayValidationGateTest.java` + +**Interfaces:** +- Consumes: Flyway validate/migrate information and environment profile. +- Produces: Environment-specific migration policy that never auto-repairs or allows runtime DDL mutation. + +**Implementation requirements:** +- Local/test/dev may migrate with migration credential; staging/prod support deployment-owned migration. +- Hibernate validate must run after migration in tests and runtime startup. +- Checksum mismatch, missing migration and schema mismatch fail closed. +- Repair is represented only as an admin operation descriptor, not startup behavior. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.migration; + +class FlywayValidationGateTest { + @Test + void checksumMismatchFailsAndNeverRepairsAutomatically() { + var result = validationResultWithChecksumMismatch(); + assertThatThrownBy(() -> gate.requireValid(result)) + .isInstanceOf(SchemaMismatchException.class); + assertThat(flywayProbe.repairInvocations()).isZero(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-migration-flyway:test --tests 'io.backend.skeleton.jpa.migration.FlywayValidationGateTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.migration; + +public final class FlywayValidationGate { + public void requireValid(ValidateResult result) { + if (!result.validationSuccessful) { + throw new SchemaMismatchException( + "Flyway validation failed: " + sanitizedErrorCodes(result)); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-migration-flyway:test --tests 'io.backend.skeleton.jpa.migration.FlywayValidationGateTest' +./gradlew :modules:jpa:jpa-migration-flyway:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/SchemaManagementMode.java' 'modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/FlywaySchemaPolicy.java' 'modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/FlywayValidationGate.java' 'modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/SchemaVersionSnapshot.java' 'modules/jpa/jpa-migration-flyway/src/test/java/io/backend/skeleton/jpa/migration/FlywayValidationGateTest.java' +git commit -m "feat: enforce flyway schema validation policy" +``` + +### Task 42: Migration Snapshot Upgrade Testkit 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit-migration/src/main/java/io/backend/skeleton/jpa/testkit/migration/MigrationSnapshot.java` +- Create: `modules/jpa/jpa-testkit-migration/src/main/java/io/backend/skeleton/jpa/testkit/migration/MigrationScenario.java` +- Create: `modules/jpa/jpa-testkit-migration/src/main/java/io/backend/skeleton/jpa/testkit/migration/MigrationContractRunner.java` +- Create: `modules/jpa/jpa-testkit-migration/src/migrationTest/java/io/backend/skeleton/jpa/testkit/migration/FlywayUpgradeContractTest.java` +- Test: `modules/jpa/jpa-testkit-migration/src/test/java/io/backend/skeleton/jpa/testkit/migration/MigrationScenarioTest.java` + +**Interfaces:** +- Consumes: PostgreSQL containers, schema snapshots and Task 41 validation gate. +- Produces: Repeatable empty, N-1 and oldest-supported upgrade scenarios plus checksum/missing migration failures. + +**Implementation requirements:** +- Restore snapshots into a clean database before each scenario. +- Run migrations and Hibernate validate after upgrade. +- Assert data invariants as well as schema version. +- Persist recovery instructions for non-transactional migration failures. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.migration; + +class MigrationScenarioTest { + @Test + void requiresEmptyPreviousAndOldestSupportedScenarios() { + assertThat(MigrationScenario.required()) + .extracting(MigrationScenario::name) + .containsExactlyInAnyOrder("empty", "previous-release", "oldest-supported"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-migration:test --tests 'io.backend.skeleton.jpa.testkit.migration.MigrationScenarioTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.testkit.migration; + +public record MigrationScenario( + String name, + MigrationSnapshot snapshot, + Consumer invariant) { + public static List required() { + return List.of(empty(), previousRelease(), oldestSupported()); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-migration:test --tests 'io.backend.skeleton.jpa.testkit.migration.MigrationScenarioTest' +./gradlew :modules:jpa:jpa-testkit-migration:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit-migration/src/main/java/io/backend/skeleton/jpa/testkit/migration/MigrationSnapshot.java' 'modules/jpa/jpa-testkit-migration/src/main/java/io/backend/skeleton/jpa/testkit/migration/MigrationScenario.java' 'modules/jpa/jpa-testkit-migration/src/main/java/io/backend/skeleton/jpa/testkit/migration/MigrationContractRunner.java' 'modules/jpa/jpa-testkit-migration/src/migrationTest/java/io/backend/skeleton/jpa/testkit/migration/FlywayUpgradeContractTest.java' 'modules/jpa/jpa-testkit-migration/src/test/java/io/backend/skeleton/jpa/testkit/migration/MigrationScenarioTest.java' +git commit -m "test: add flyway upgrade snapshot contracts" +``` + +### Task 43: Non-transactional Concurrent Index Migration Guard 구현 + +**Files:** +- Create: `modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/NonTransactionalMigrationPolicy.java` +- Create: `modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/ConcurrentIndexMigrationInspector.java` +- Create: `modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/FailedConcurrentIndexRecovery.java` +- Test: `modules/jpa/jpa-migration-flyway/src/test/java/io/backend/skeleton/jpa/migration/ConcurrentIndexMigrationInspectorTest.java` + +**Interfaces:** +- Consumes: Flyway migration resource metadata and PostgreSQL index catalog. +- Produces: A gate ensuring `CREATE INDEX CONCURRENTLY` is explicitly non-transactional and recoverable. + +**Implementation requirements:** +- Detect concurrent index SQL in transactional migrations and fail validation. +- Require a companion `.conf` or registered policy marking execute-in-transaction false. +- Detect invalid indexes after failed migration and generate a bounded recovery report. +- Do not auto-drop invalid indexes in application startup. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.migration; + +class ConcurrentIndexMigrationInspectorTest { + @Test + void concurrentIndexMustBeMarkedNonTransactional() { + var migration = sql("V42__order_index.sql", "create index concurrently ix_order on orders(created_at)"); + assertThatThrownBy(() -> inspector.validate(migration, transactionEnabled())) + .hasMessageContaining("executeInTransaction=false"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-migration-flyway:test --tests 'io.backend.skeleton.jpa.migration.ConcurrentIndexMigrationInspectorTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.migration; + +public final class ConcurrentIndexMigrationInspector { + public void validate(MigrationResource migration, boolean executeInTransaction) { + if (migration.sql().toLowerCase(Locale.ROOT).contains("create index concurrently") && + executeInTransaction) { + throw new IllegalStateException( + migration.name() + " must set executeInTransaction=false"); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-migration-flyway:test --tests 'io.backend.skeleton.jpa.migration.ConcurrentIndexMigrationInspectorTest' +./gradlew :modules:jpa:jpa-migration-flyway:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/NonTransactionalMigrationPolicy.java' 'modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/ConcurrentIndexMigrationInspector.java' 'modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/FailedConcurrentIndexRecovery.java' 'modules/jpa/jpa-migration-flyway/src/test/java/io/backend/skeleton/jpa/migration/ConcurrentIndexMigrationInspectorTest.java' +git commit -m "feat: guard concurrent index migrations" +``` + +### Task 44: PostgreSQL Query Plan Testkit 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit-queryplan/src/main/java/io/backend/skeleton/jpa/testkit/queryplan/QueryPlanExpectation.java` +- Create: `modules/jpa/jpa-testkit-queryplan/src/main/java/io/backend/skeleton/jpa/testkit/queryplan/PostgreSqlExplainRunner.java` +- Create: `modules/jpa/jpa-testkit-queryplan/src/main/java/io/backend/skeleton/jpa/testkit/queryplan/NormalizedPlan.java` +- Create: `modules/jpa/jpa-testkit-queryplan/src/main/java/io/backend/skeleton/jpa/testkit/queryplan/QueryPlanAssertions.java` +- Test: `modules/jpa/jpa-testkit-queryplan/src/test/java/io/backend/skeleton/jpa/testkit/queryplan/QueryPlanAssertionsTest.java` + +**Interfaces:** +- Consumes: Registered SQL/parameters under a test/admin role and `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)`. +- Produces: Structural plan assertions for node types, row-estimate ratio, sort spill and buffer use. + +**Implementation requirements:** +- Do not globally fail every sequential scan. +- Normalize volatile cost/time fields before snapshot comparison. +- Require representative parameters and fixture statistics. +- Never run ANALYZE write queries outside isolated test databases. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.queryplan; + +class QueryPlanAssertionsTest { + @Test + void detectsUnexpectedSortSpillAndEstimateError() { + var plan = planWithDiskSortAndEstimateRatio(100.0); + assertThatThrownBy(() -> assertions.assertMatches(plan, + expectation().maxEstimateRatio(10).forbidDiskSort())) + .hasMessageContaining("Disk Sort"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-queryplan:test --tests 'io.backend.skeleton.jpa.testkit.queryplan.QueryPlanAssertionsTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.testkit.queryplan; + +public record QueryPlanExpectation( + Set requiredNodeTypes, + Set forbiddenNodeTypes, + double maxEstimateRatio, + boolean forbidDiskSort) { +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-queryplan:test --tests 'io.backend.skeleton.jpa.testkit.queryplan.QueryPlanAssertionsTest' +./gradlew :modules:jpa:jpa-testkit-queryplan:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit-queryplan/src/main/java/io/backend/skeleton/jpa/testkit/queryplan/QueryPlanExpectation.java' 'modules/jpa/jpa-testkit-queryplan/src/main/java/io/backend/skeleton/jpa/testkit/queryplan/PostgreSqlExplainRunner.java' 'modules/jpa/jpa-testkit-queryplan/src/main/java/io/backend/skeleton/jpa/testkit/queryplan/NormalizedPlan.java' 'modules/jpa/jpa-testkit-queryplan/src/main/java/io/backend/skeleton/jpa/testkit/queryplan/QueryPlanAssertions.java' 'modules/jpa/jpa-testkit-queryplan/src/test/java/io/backend/skeleton/jpa/testkit/queryplan/QueryPlanAssertionsTest.java' +git commit -m "test: add postgresql query plan regression toolkit" +``` + +### Task 45: Database Role·search_path Security Verifier 구현 + +**Files:** +- Create: `modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/DatabaseRolePolicy.java` +- Create: `modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/PostgreSqlRuntimeRoleVerifier.java` +- Create: `modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/SearchPathPolicy.java` +- Create: `modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/DatabasePrivilegeReport.java` +- Test: `modules/jpa/jpa-security/src/integrationTest/java/io/backend/skeleton/jpa/security/PostgreSqlRuntimeRoleVerifierIntegrationTest.java` + +**Interfaces:** +- Consumes: Runtime DataSource, `current_user`, `current_setting(search_path)` and privilege functions. +- Produces: Fail-fast proof that runtime role has DML but lacks DDL and untrusted schema CREATE privilege. + +**Implementation requirements:** +- Verify current user and schema against configured allowlists. +- Reject runtime role with CREATE on application schema or database. +- Reject untrusted writable schemas in search_path. +- Do not expose usernames or JDBC URLs in Actuator output beyond bounded profile names. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.security; + +class PostgreSqlRuntimeRoleVerifierIntegrationTest { + @Test + void runtimeRoleCanWriteRowsButCannotCreateTable() { + verifier.requireSafe(runtimeDataSource, policy()); + assertThatThrownBy(() -> jdbc.execute("create table forbidden(id bigint)")) + .isInstanceOf(DataAccessException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-security:integrationTest --tests 'io.backend.skeleton.jpa.security.PostgreSqlRuntimeRoleVerifierIntegrationTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.security; + +public final class PostgreSqlRuntimeRoleVerifier { + public DatabasePrivilegeReport verify(DataSource dataSource, DatabaseRolePolicy policy) { + return jdbc(dataSource).queryForObject(""" + select current_user, + current_setting('search_path'), + has_schema_privilege(current_user, current_schema(), 'CREATE') + """, reportMapper); + } + + public void requireSafe(DataSource dataSource, DatabaseRolePolicy policy) { + DatabasePrivilegeReport report = verify(dataSource, policy); + policy.requireSafe(report); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-security:integrationTest --tests 'io.backend.skeleton.jpa.security.PostgreSqlRuntimeRoleVerifierIntegrationTest' +./gradlew :modules:jpa:jpa-security:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/DatabaseRolePolicy.java' 'modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/PostgreSqlRuntimeRoleVerifier.java' 'modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/SearchPathPolicy.java' 'modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/DatabasePrivilegeReport.java' 'modules/jpa/jpa-security/src/integrationTest/java/io/backend/skeleton/jpa/security/PostgreSqlRuntimeRoleVerifierIntegrationTest.java' +git commit -m "feat: verify postgresql runtime role safety" +``` + +### Task 46: Hibernate Second-level Cache Opt-in 모듈 구현 + +**Files:** +- Create: `modules/jpa/jpa-cache-hibernate/src/main/java/io/backend/skeleton/jpa/cache/HibernateCachePolicy.java` +- Create: `modules/jpa/jpa-cache-hibernate/src/main/java/io/backend/skeleton/jpa/cache/CacheRegionCatalog.java` +- Create: `modules/jpa/jpa-cache-hibernate/src/main/java/io/backend/skeleton/jpa/cache/HibernateCacheGuard.java` +- Test: `modules/jpa/jpa-cache-hibernate/src/test/java/io/backend/skeleton/jpa/cache/HibernateCacheGuardTest.java` + +**Interfaces:** +- Consumes: Hibernate L2 cache settings and Entity metadata. +- Produces: ENABLE_SELECTIVE, Entity-by-Entity cache enrollment while keeping Query Cache disabled by default. + +**Implementation requirements:** +- Fail if Query Cache is enabled without an explicit experimental approval. +- Require registered cache region and concurrency strategy for each cached Entity. +- Require a Bulk DML eviction strategy. +- Document external DB writer and cluster invalidation assumptions. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.cache; + +class HibernateCacheGuardTest { + @Test + void queryCacheIsOffAndOnlyRegisteredEntitiesAreCacheable() { + assertThatThrownBy(() -> guard.validate(settings(queryCacheEnabled()), catalog())) + .hasMessageContaining("Query Cache is disabled by default"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-cache-hibernate:test --tests 'io.backend.skeleton.jpa.cache.HibernateCacheGuardTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.cache; + +public final class HibernateCacheGuard { + public void validate(HibernateCacheSettings settings, CacheRegionCatalog catalog) { + if (settings.queryCacheEnabled()) { + throw new IllegalStateException("Query Cache is disabled by default"); + } + if (settings.sharedCacheMode() != SharedCacheMode.ENABLE_SELECTIVE) { + throw new IllegalStateException("Use ENABLE_SELECTIVE for L2 cache"); + } + catalog.validate(settings.cacheableEntities()); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-cache-hibernate:test --tests 'io.backend.skeleton.jpa.cache.HibernateCacheGuardTest' +./gradlew :modules:jpa:jpa-cache-hibernate:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-cache-hibernate/src/main/java/io/backend/skeleton/jpa/cache/HibernateCachePolicy.java' 'modules/jpa/jpa-cache-hibernate/src/main/java/io/backend/skeleton/jpa/cache/CacheRegionCatalog.java' 'modules/jpa/jpa-cache-hibernate/src/main/java/io/backend/skeleton/jpa/cache/HibernateCacheGuard.java' 'modules/jpa/jpa-cache-hibernate/src/test/java/io/backend/skeleton/jpa/cache/HibernateCacheGuardTest.java' +git commit -m "feat: add opt in hibernate second level cache guard" +``` + +### Task 47: Hibernate Envers Entity History Opt-in 모듈 구현 + +**Files:** +- Create: `modules/jpa/jpa-envers/src/main/java/io/backend/skeleton/jpa/envers/EnversHistoryPolicy.java` +- Create: `modules/jpa/jpa-envers/src/main/java/io/backend/skeleton/jpa/envers/EnversRevisionMetadata.java` +- Create: `modules/jpa/jpa-envers/src/main/java/io/backend/skeleton/jpa/envers/EnversHistoryReader.java` +- Create: `modules/jpa/jpa-envers/src/main/java/io/backend/skeleton/jpa/envers/EnversConfigurationGuard.java` +- Test: `modules/jpa/jpa-envers/src/integrationTest/java/io/backend/skeleton/jpa/envers/EnversHistoryContractTest.java` + +**Interfaces:** +- Consumes: Hibernate Envers and application-provided revision actor/context. +- Produces: Entity-specific history without conflating it with technical or business audit. + +**Implementation requirements:** +- Require explicit `@Audited` or catalog enrollment. +- Record bounded actor/correlation metadata, not entire security principals. +- Require retention and PII deletion policy before production enablement. +- Do not enable Envers for every Entity through a global base class. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.envers; + +class EnversHistoryContractTest { + @Test + void storesHistoryOnlyForOptedInEntity() { + updateAuditedEntity(); + updateNonAuditedEntity(); + + assertThat(reader.revisions(AuditedFixture.class, auditedId)).hasSize(2); + assertThat(reader.revisions(PlainFixture.class, plainId)).isEmpty(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-envers:integrationTest --tests 'io.backend.skeleton.jpa.envers.EnversHistoryContractTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.envers; + +public interface EnversHistoryReader { + List> revisions(Class entityType, Object id); +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-envers:integrationTest --tests 'io.backend.skeleton.jpa.envers.EnversHistoryContractTest' +./gradlew :modules:jpa:jpa-envers:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-envers/src/main/java/io/backend/skeleton/jpa/envers/EnversHistoryPolicy.java' 'modules/jpa/jpa-envers/src/main/java/io/backend/skeleton/jpa/envers/EnversRevisionMetadata.java' 'modules/jpa/jpa-envers/src/main/java/io/backend/skeleton/jpa/envers/EnversHistoryReader.java' 'modules/jpa/jpa-envers/src/main/java/io/backend/skeleton/jpa/envers/EnversConfigurationGuard.java' 'modules/jpa/jpa-envers/src/integrationTest/java/io/backend/skeleton/jpa/envers/EnversHistoryContractTest.java' +git commit -m "feat: add opt in hibernate envers history" +``` + +### Task 48: JPA Metrics·Tracing·Log Redaction 구현 + +**Files:** +- Create: `modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/MicrometerQueryObservation.java` +- Create: `modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/JpaTransactionObservation.java` +- Create: `modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/JpaRetryObservation.java` +- Create: `modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/JpaMetricTags.java` +- Create: `modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/SqlDiagnosticRedactor.java` +- Test: `modules/jpa/jpa-observability/src/test/java/io/backend/skeleton/jpa/observation/JpaObservabilityContractTest.java` + +**Interfaces:** +- Consumes: Micrometer, Spring Observation, QueryName, PersistenceOperationName and Hibernate statistics. +- Produces: Logical transaction/query/retry metrics with bounded tags and PII-safe diagnostics. + +**Implementation requirements:** +- Measure transaction count/duration/rollback/timeout/retry/completion-unknown. +- Measure query count/duration/rows/fetch metrics and JDBC batch count. +- Allow only registered operation/query/entity type tags. +- Reject SQL parameters, IDs, tenant values and dynamic exception messages from metric tags. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.observation; + +class JpaObservabilityContractTest { + @Test + void metricsNeverUseEntityIdOrSqlParameterAsTag() { + observation.recordFailure(OPERATION, QUERY, uniqueViolation("secret@example.test")); + + assertThat(registry.getMeters()) + .flatExtracting(meter -> meter.getId().getTags()) + .extracting(Tag::getValue) + .noneMatch(value -> value.contains("secret@example.test") || value.contains("entity-42")); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-observability:test --tests 'io.backend.skeleton.jpa.observation.JpaObservabilityContractTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.observation; + +public record JpaMetricTags( + String persistenceUnit, + String operationName, + String queryName, + String outcome, + String failureCategory) { + public JpaMetricTags { + LowCardinality.requireRegistered(operationName, queryName, failureCategory); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-observability:test --tests 'io.backend.skeleton.jpa.observation.JpaObservabilityContractTest' +./gradlew :modules:jpa:jpa-observability:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/MicrometerQueryObservation.java' 'modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/JpaTransactionObservation.java' 'modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/JpaRetryObservation.java' 'modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/JpaMetricTags.java' 'modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/SqlDiagnosticRedactor.java' 'modules/jpa/jpa-observability/src/test/java/io/backend/skeleton/jpa/observation/JpaObservabilityContractTest.java' +git commit -m "feat: add safe jpa observability contracts" +``` + +### Task 49: PostgreSQL 16·17·18 공통 Contract Suite 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/postgresql/PostgreSqlVersion.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/postgresql/PostgreSqlContainerFactory.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/postgresql/PostgreSqlContractExtension.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/contractTest/java/io/backend/skeleton/jpa/testkit/postgresql/StablePostgreSqlMatrixContractTest.java` +- Test: `modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/postgresql/PostgreSqlVersionTest.java` + +**Interfaces:** +- Consumes: All Stable mapping, transaction, query, fetch, batch, extension and security contracts. +- Produces: A parameterized release matrix over real PostgreSQL 16, 17 and 18 containers. + +**Implementation requirements:** +- PR profile runs 16 and 18; release profile runs 16, 17 and 18. +- Pin image digests or approved tags and record exact server version. +- Run Flyway before Hibernate validate. +- H2 results must not satisfy this suite. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.postgresql; + +class PostgreSqlVersionTest { + @Test + void stableVersionsAreExactlySixteenSeventeenAndEighteen() { + assertThat(PostgreSqlVersion.stable()) + .containsExactly(PG_16, PG_17, PG_18); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.postgresql.PostgreSqlVersionTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.testkit.postgresql; + +public enum PostgreSqlVersion { + PG_16("postgres:16"), + PG_17("postgres:17"), + PG_18("postgres:18"); + + public static List stable() { + return List.of(PG_16, PG_17, PG_18); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.postgresql.PostgreSqlVersionTest' +./gradlew :modules:jpa:jpa-testkit-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/postgresql/PostgreSqlVersion.java' 'modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/postgresql/PostgreSqlContainerFactory.java' 'modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/postgresql/PostgreSqlContractExtension.java' 'modules/jpa/jpa-testkit-postgresql/src/contractTest/java/io/backend/skeleton/jpa/testkit/postgresql/StablePostgreSqlMatrixContractTest.java' 'modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/postgresql/PostgreSqlVersionTest.java' +git commit -m "test: add postgresql stable compatibility matrix" +``` + +### Task 50: Deadlock·Serialization·Commit Ambiguity Failure Injection Suite 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/failure/PostgreSqlFailureScenario.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/failure/CommitAmbiguityProxy.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/failureTest/java/io/backend/skeleton/jpa/testkit/failure/PostgreSqlConcurrencyFailureContractTest.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/failureTest/java/io/backend/skeleton/jpa/testkit/failure/CommitAmbiguityContractTest.java` +- Modify: `infra/jpa/toxiproxy/docker-compose.yml` +- Test: `modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/failure/PostgreSqlFailureScenarioTest.java` + +**Interfaces:** +- Consumes: Toxiproxy, deterministic transaction barriers, Task 6 evidence manager and Task 8 retry coordinator. +- Produces: Reproducible `40P01`, `40001` and commit-response-loss scenarios. + +**Implementation requirements:** +- Deadlock uses opposite lock order and confirms bounded full-TX retry. +- Serialization uses SERIALIZABLE invariant contention. +- Commit ambiguity distinguishes before-COMMIT, during-COMMIT and after-server-commit response loss. +- After-server-commit loss must emit completion unknown and must not rerun the original mutation. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.failure; + +class PostgreSqlFailureScenarioTest { + @Test + void commitAmbiguityHasThreeDistinctInjectionPoints() { + assertThat(PostgreSqlFailureScenario.commitPoints()) + .containsExactly(BEFORE_COMMIT, DURING_COMMIT, AFTER_SERVER_COMMIT_BEFORE_RESPONSE); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.failure.PostgreSqlFailureScenarioTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.testkit.failure; + +public enum PostgreSqlFailureScenario { + BEFORE_COMMIT, + DURING_COMMIT, + AFTER_SERVER_COMMIT_BEFORE_RESPONSE; + + public static List commitPoints() { + return List.of(values()); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.failure.PostgreSqlFailureScenarioTest' +./gradlew :modules:jpa:jpa-testkit-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/failure/PostgreSqlFailureScenario.java' 'modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/failure/CommitAmbiguityProxy.java' 'modules/jpa/jpa-testkit-postgresql/src/failureTest/java/io/backend/skeleton/jpa/testkit/failure/PostgreSqlConcurrencyFailureContractTest.java' 'modules/jpa/jpa-testkit-postgresql/src/failureTest/java/io/backend/skeleton/jpa/testkit/failure/CommitAmbiguityContractTest.java' 'infra/jpa/toxiproxy/docker-compose.yml' 'modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/failure/PostgreSqlFailureScenarioTest.java' +git commit -m "test: add jpa concurrency and commit ambiguity failures" +``` + +### Task 51: Hikari Pool·REQUIRES_NEW Saturation Contract 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit-postgresql/src/performanceTest/java/io/backend/skeleton/jpa/testkit/pool/HikariPoolSaturationContractTest.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/performanceTest/java/io/backend/skeleton/jpa/testkit/pool/RequiresNewPoolPressureContractTest.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/pool/PoolMeasurement.java` +- Test: `modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/pool/PoolMeasurementTest.java` + +**Interfaces:** +- Consumes: Hikari metrics, bounded executor and nested transaction fixtures. +- Produces: Evidence for pending/acquire latency, connection timeout and outer+inner connection pressure. + +**Implementation requirements:** +- Test finite pool saturation without changing production defaults. +- Show that concurrent REQUIRED uses one connection per transaction while REQUIRES_NEW can require two. +- Ensure rejected/acquire-timeout work releases all connections. +- Record transaction duration and pending acquire latency together. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.pool; + +class PoolMeasurementTest { + @Test + void reportsPendingAndAcquireLatencyTogether() { + var measurement = new PoolMeasurement(4, 2, 3, Duration.ofMillis(80)); + assertThat(measurement.pending()).isEqualTo(3); + assertThat(measurement.acquireLatency()).isEqualTo(Duration.ofMillis(80)); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.pool.PoolMeasurementTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.testkit.pool; + +public record PoolMeasurement( + int active, + int idle, + int pending, + Duration acquireLatency) { +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.pool.PoolMeasurementTest' +./gradlew :modules:jpa:jpa-testkit-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit-postgresql/src/performanceTest/java/io/backend/skeleton/jpa/testkit/pool/HikariPoolSaturationContractTest.java' 'modules/jpa/jpa-testkit-postgresql/src/performanceTest/java/io/backend/skeleton/jpa/testkit/pool/RequiresNewPoolPressureContractTest.java' 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/pool/PoolMeasurement.java' 'modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/pool/PoolMeasurementTest.java' +git commit -m "test: certify hikari and requires new pool behavior" +``` + +### Task 52: Spring Boot Starter·Actuator·Capability Report 완성 + +**Files:** +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaPlatformAutoConfiguration.java` +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaTransactionAutoConfiguration.java` +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaObservabilityAutoConfiguration.java` +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaPlatformEndpoint.java` +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaPlatformReport.java` +- Modify: `modules/jpa/jpa-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` +- Test: `modules/jpa/jpa-spring-boot-starter/src/test/java/io/backend/skeleton/jpa/autoconfigure/JpaPlatformAutoConfigurationTest.java` + +**Interfaces:** +- Consumes: Tasks 6~12, 18, 22~25, 41, 45 and 48. +- Produces: Conditional Stable auto-configuration and a sanitized actuator endpoint. + +**Implementation requirements:** +- Back off when the application supplies its own transaction manager or observation implementation. +- Auto-configure only Stable modules; Querydsl, Envers, L2 and COPY require explicit dependencies/properties. +- Endpoint reports DB major version, provider version, schema version, OSIV, role verification and capabilities. +- Do not expose JDBC URL, username, SQL, credentials or Entity catalog. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.autoconfigure; + +class JpaPlatformAutoConfigurationTest { + @Test + void configuresStablePlatformAndSanitizesEndpoint() { + context.withUserConfiguration(TestJpaApplication.class) + .run(result -> { + assertThat(result).hasSingleBean(JpaTransactionExecutor.class); + assertThat(result.getBean(JpaPlatformEndpoint.class).platform()) + .doesNotHaveToString(".*jdbc:.*|.*password.*"); + }); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-boot-starter:test --tests 'io.backend.skeleton.jpa.autoconfigure.JpaPlatformAutoConfigurationTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.autoconfigure; + +@AutoConfiguration +@EnableConfigurationProperties({JpaSafetyProperties.class, JpaDataSourceProperties.class}) +public class JpaPlatformAutoConfiguration { + @Bean + JpaPlatformReport jpaPlatformReport( + DatabaseMetadata metadata, + FlywaySchemaPolicy schema, + DatabasePrivilegeReport privileges) { + return JpaPlatformReport.sanitized(metadata, schema, privileges); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-boot-starter:test --tests 'io.backend.skeleton.jpa.autoconfigure.JpaPlatformAutoConfigurationTest' +./gradlew :modules:jpa:jpa-spring-boot-starter:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaPlatformAutoConfiguration.java' 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaTransactionAutoConfiguration.java' 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaObservabilityAutoConfiguration.java' 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaPlatformEndpoint.java' 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaPlatformReport.java' 'modules/jpa/jpa-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports' 'modules/jpa/jpa-spring-boot-starter/src/test/java/io/backend/skeleton/jpa/autoconfigure/JpaPlatformAutoConfigurationTest.java' +git commit -m "feat: complete jpa spring boot starter and actuator" +``` + +### Task 53: CI Matrix·문서·ADR·Release Gate 완성 + +**Files:** +- Create: `.github/workflows/jpa-pr.yml` +- Create: `.github/workflows/jpa-nightly.yml` +- Create: `.github/workflows/jpa-release.yml` +- Create: `docs/jpa/support-matrix.md` +- Create: `docs/jpa/entity-mapping-guide.md` +- Create: `docs/jpa/transaction-guide.md` +- Create: `docs/jpa/query-fetch-guide.md` +- Create: `docs/jpa/migration-guide.md` +- Create: `docs/jpa/postgresql-extensions.md` +- Create: `docs/jpa/observability.md` +- Create: `docs/jpa/security.md` +- Create: `docs/jpa/runbooks.md` +- Create: `docs/adr/ADR-JPA-001-domain-owns-persistence-model.md` +- Create: `docs/adr/ADR-JPA-002-full-transaction-retry.md` +- Create: `docs/adr/ADR-JPA-003-completion-unknown.md` +- Create: `docs/adr/ADR-JPA-004-flyway-schema-source-of-truth.md` +- Create: `docs/adr/ADR-JPA-005-postgresql-real-contract.md` +- Modify: `build.gradle.kts` +- Test: `modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/release/JpaReleaseManifestTest.java` + +**Interfaces:** +- Consumes: All Stable modules, test suites, design decisions and support matrix. +- Produces: PR/nightly/release aggregation, operator documentation and a machine-readable release manifest. + +**Implementation requirements:** +- PR runs unit, architecture, PG16·18 contract and migration smoke. +- Nightly runs PG16·17·18, failure, plan, pool and security suites. +- Release runs all Stable contracts, upgrade snapshots, performance and artifact compatibility checks. +- Document Stable/Advanced/Experimental/Unsupported features exactly as the design. +- Release fails if H2 is the only database test, OSIV is on, ddl-auto mutates schema, completion unknown retry exists or runtime DDL succeeds. + +- [ ] **Step 1: Write the failing test** + +```kotlin +package io.backend.skeleton.jpa.testkit.release; + +class JpaReleaseManifestTest { + @Test + void manifestContainsAllStableVersionsAndMandatoryGates() { + var manifest = JpaReleaseManifest.load("docs/jpa/support-matrix.md"); + assertThat(manifest.postgreSqlVersions()).containsExactly(16, 17, 18); + assertThat(manifest.gates()).contains( + "completion-unknown-no-retry", + "osiv-disabled", + "flyway-validate", + "runtime-role-no-ddl", + "hibernate-7.4-fetch-pagination"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.release.JpaReleaseManifestTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```kotlin +plugins { + base +} + +tasks.register("jpaReleaseGate") { + dependsOn( + ":modules:jpa:jpa-testkit-postgresql:contractTest", + ":modules:jpa:jpa-testkit-postgresql:failureTest", + ":modules:jpa:jpa-testkit-postgresql:performanceTest", + ":modules:jpa:jpa-testkit-migration:migrationTest", + ":modules:jpa:jpa-testkit-queryplan:test" + ) +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.release.JpaReleaseManifestTest' +./gradlew :modules:jpa:jpa-testkit-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add '.github/workflows/jpa-pr.yml' '.github/workflows/jpa-nightly.yml' '.github/workflows/jpa-release.yml' 'docs/jpa/support-matrix.md' 'docs/jpa/entity-mapping-guide.md' 'docs/jpa/transaction-guide.md' 'docs/jpa/query-fetch-guide.md' 'docs/jpa/migration-guide.md' 'docs/jpa/postgresql-extensions.md' 'docs/jpa/observability.md' 'docs/jpa/security.md' 'docs/jpa/runbooks.md' 'docs/adr/ADR-JPA-001-domain-owns-persistence-model.md' 'docs/adr/ADR-JPA-002-full-transaction-retry.md' 'docs/adr/ADR-JPA-003-completion-unknown.md' 'docs/adr/ADR-JPA-004-flyway-schema-source-of-truth.md' 'docs/adr/ADR-JPA-005-postgresql-real-contract.md' 'build.gradle.kts' 'modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/release/JpaReleaseManifestTest.java' +git commit -m "docs: add jpa release matrix and runbooks" +``` +## 4. 최종 실행 순서와 Review Gate + +```text +Task 1~12 +→ 모듈·Core·오류·Transaction·Starter Guard + +Task 13~28 +→ Mapping·Persistence Context·Repository·Query·Fetch·Pagination + +Task 29~32 +→ Optimistic/Pessimistic·Constraint + +Task 33~40 +→ Batch·Bulk·Hibernate·PostgreSQL Native + +Task 41~45 +→ Flyway·Migration·Plan·Security + +Task 46~48 +→ L2 Cache·Envers·Observability + +Task 49~53 +→ PostgreSQL Matrix·Failure·Pool·Starter·Release +``` + +각 Task 뒤에는 두 단계 review를 수행한다. + +1. **Specification review:** 설계서의 계약과 exact type/signature가 일치하는가. +2. **Quality review:** 테스트가 failure mode를 실제로 재현하고 위험한 우회 경로를 남기지 않는가. + +Stable 계획이 끝나기 전 Experimental module을 구현하지 않는다. + +## 5. 계획 완료 기준 + +```text +53개 Task가 순서대로 존재한다. +각 Task에 정확한 파일 경로와 public interface가 있다. +각 Task가 failing test와 예상 실패를 포함한다. +각 Task가 최소 구현 코드와 pass command를 포함한다. +각 Task가 독립 commit으로 종료한다. +Generic Repository 재구현 Task가 없다. +Commit Unknown 자동 Retry가 없다. +PG16·17·18 Release Matrix가 있다. +Flyway, Security, Fetch, Batch, Pool, Failure Gate가 구현 순서에 포함된다. +``` diff --git a/docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md b/docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md new file mode 100644 index 00000000..a907c6b0 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md @@ -0,0 +1,3276 @@ +# JPA 관계형 영속성 플랫폼 설계서 + +- 문서 상태: 구현 기준 설계 +- 기준일: 2026-08-11 +- 대상 저장소: `backend-skeleton` +- 설계 경로: `docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md` +- 요구사항 원본: `붙여넣은 마크다운(1)(20260811-071252).md` + +--- + +## 1. 문서 목적 + +이 문서는 Java/Spring Backend Skeleton에서 사용할 JPA 관계형 영속성 플랫폼의 공개 계약, 모듈 경계, 트랜잭션 의미론, Hibernate·PostgreSQL 확장, Flyway 스키마 관리, 오류·Retry·관측성·보안·검증 기준을 구현 가능한 수준으로 확정한다. + +이 플랫폼은 `JpaRepository`를 다시 감싸는 CRUD 라이브러리가 아니다. 도메인 모듈이 Entity, Embeddable, Repository, 업무 Query, Index Requirement, Lock·Soft Delete·Audit 정책을 소유하고, 플랫폼은 다음 기술적 기반을 제공한다. + +```text +도메인 소유 +├─ Entity / Embeddable +├─ Repository Interface +├─ 도메인 Query +├─ 도메인 Constraint·Index 요구 +└─ 도메인 Lock·Soft-delete·Audit 정책 + +플랫폼 소유 +├─ Persistence Context·Transaction 정책 +├─ SQLSTATE 기반 오류 모델 +├─ 전체 Use Case Retry +├─ Fetch·Query·Pagination 검증 도구 +├─ Hibernate Batch·Statistics 확장 +├─ PostgreSQL Native Capability +├─ Flyway Migration·Schema Gate +├─ 관측성·보안 규칙 +└─ PostgreSQL 실제 계약 Testkit +``` + +구현자가 이 문서를 읽은 뒤 다시 결정하지 않아야 하는 핵심 질문은 다음과 같다. + +```text +어디에 Transaction을 시작하는가? +어떤 실패에서 전체 업무를 다시 실행할 수 있는가? +Commit 결과를 모르면 무엇을 하는가? +어떤 Fetch Plan을 선택하고 어떻게 N+1을 검증하는가? +어떤 Query는 JPQL이고 어떤 Query는 Native SQL인가? +Batch가 실제 JDBC Batch인지 어떻게 증명하는가? +Entity Mapping과 Schema 중 무엇이 Source of Truth인가? +PostgreSQL 고유 기능을 어디까지 공개하는가? +어떤 DB 계정이 어떤 권한을 갖는가? +어떤 PostgreSQL 버전에서 Stable을 선언하는가? +``` + +--- + +## 2. 목표와 성공 기준 + +### 2.1 목표 + +1. 도메인 Repository를 보존하면서 JPA·Hibernate·PostgreSQL 사용 규칙을 일관되게 제공한다. +2. Application Use Case 단위 Transaction과 전체 Transaction Retry를 구현한다. +3. Optimistic Conflict, Deadlock, Serialization Failure, Lock Timeout, Constraint Violation, Commit 결과 불명을 안정 오류로 변환한다. +4. OSIV, 전역 EAGER, 전역 Cascade, 전역 Soft Delete, 운영 `ddl-auto=update` 같은 위험한 기본값을 구조적으로 차단한다. +5. EntityGraph, Fetch Join, Projection, Batch Fetch, Keyset Pagination을 Use Case별 Fetch·Query 전략으로 제공한다. +6. JDBC Batch, Bulk DML, StatelessSession, PostgreSQL Native Write를 서로 다른 Capability로 제공한다. +7. Flyway를 운영 Schema 변경의 Source of Truth로 고정하고 빈 DB·이전 Release Snapshot·최장 지원 Snapshot 업그레이드를 검증한다. +8. H2가 아닌 PostgreSQL 16·17·18 실제 의미론으로 Stable을 인증한다. +9. Query Count, Entity/Collection Fetch, Row Load, Query Plan, Pool·Transaction·Retry를 관측한다. +10. 일반 애플리케이션이 Hibernate Session·Native SQL·운영 DDL을 무제한으로 사용하지 못하게 한다. + +### 2.2 성공 기준 + +| 영역 | 완료 기준 | +|---|---| +| Repository | 플랫폼에 `GenericRepository` 재구현이 없고 도메인 Repository가 Spring Data를 직접 확장할 수 있다. | +| Mapping | Field Access, protected no-arg constructor, Entity 직렬화 금지, association 규칙이 정적·통합 테스트로 검증된다. | +| Transaction | Application Service 경계, propagation, isolation, timeout, rollback rule이 계약 테스트로 고정된다. | +| Retry | 새 Persistence Context와 새 DB Transaction에서 전체 Use Case만 재실행된다. | +| Completion Unknown | Commit 단계 연결 손실이 일반 transient 오류와 분리되고 자동 Retry되지 않는다. | +| Fetch | N+1, Multiple Collection Cartesian Product, Collection Fetch Pagination을 정량 검증한다. | +| Pagination | Page·Slice·Keyset·Scroll의 사용 기준과 stable ordering이 코드로 제공된다. | +| Batch | SQL log가 아니라 Hibernate/JDBC 통계로 실제 batch 실행을 증명한다. | +| Migration | `Flyway migrate + Hibernate validate`, checksum·missing migration 실패, N-1/oldest snapshot 업그레이드가 CI에 연결된다. | +| PostgreSQL | JSONB·Array·Range·`ON CONFLICT`·`NOWAIT`·`SKIP LOCKED`가 PG16·17·18에서 검증된다. | +| Security | Runtime·Migration·Admin 역할이 분리되고 Runtime 역할의 DDL이 실패한다. | +| Observability | queryName 기반 저카디널리티 지표를 제공하고 SQL parameter·PII를 기록하지 않는다. | +| Release | Stable·Advanced·Experimental 경계가 문서, 의존성, CI lane에서 일치한다. | + +--- + +## 3. 입력 자료와 명시적 구현 가정 + +### 3.1 요구사항 원본이 확정한 사항 + +- Java 21을 Stable baseline으로 사용한다. +- Spring Boot BOM이 관리하는 Spring Data JPA·Hibernate·Flyway·Hikari 조합을 사용한다. +- Spring Data JPA 4.1, Jakarta Persistence 3.2, Hibernate ORM 7.4를 Stable 기준으로 삼는다. +- PostgreSQL 16·17·18을 Stable DB Matrix로 삼는다. +- H2는 Local Convenience이며 PostgreSQL 호환성 증거가 아니다. +- Jakarta Persistence 4.0, Hibernate ORM 8, PostgreSQL 19는 별도 compatibility lane이다. +- J1 Standard, J2 Advanced, J3 Provider/DB Extension, J4 Admin/Operations 계층을 사용한다. +- 도메인이 Entity와 Repository를 소유하고 플랫폼은 Generic CRUD Repository를 만들지 않는다. +- Persistence Context는 transaction-scoped이며 OSIV를 명시적으로 비활성화한다. +- Transaction 경계는 Application Service에 둔다. +- Optimistic Conflict·Deadlock·Serialization Failure Retry는 전체 Transaction 재실행이다. +- Commit 결과 불명은 `TransactionCompletionUnknown`으로 분류하고 자동 Retry하지 않는다. +- PostgreSQL write-heavy Entity의 기본 ID 전략은 Sequence이며 IDENTITY는 JDBC Batch 제약 때문에 제한한다. +- Fetch 전략은 Use Case별 Fetch Plan으로 관리한다. +- Hibernate 7.4의 Collection Fetch Join + Pagination은 과거 금지 규칙을 복사하지 않고 실제 SQL·row amplification을 검증한다. +- Flyway가 실제 Schema 변경의 Source of Truth이며 운영 `ddl-auto=update`를 금지한다. +- Application·Migration·Admin DB credential을 분리한다. +- Multi-tenancy와 Read Replica는 초기 Experimental이다. + +### 3.2 실제 저장소가 제공되지 않아 고정한 가정 + +| 항목 | 설계 가정 | +|---|---| +| 저장소 | Gradle Kotlin DSL 멀티모듈 `backend-skeleton` | +| 모듈 루트 | `modules/jpa` | +| Root package | `io.backend.skeleton.jpa` | +| Spring Boot | 4.1 계열 BOM. 정확한 patch는 host 저장소 version catalog가 소유한다. | +| Runtime DB | PostgreSQL 16 이상 | +| 기본 Provider | Hibernate ORM 7.4 | +| Migration | Flyway | +| Connection Pool | HikariCP | +| 테스트 | JUnit 5, AssertJ, ArchUnit, Testcontainers, Toxiproxy | +| 관측성 | Micrometer, Spring Observation, OpenTelemetry exporter adapter | +| CI | PR: PG16·18, Release: PG16·17·18 | + +연구 자료가 범용 numeric timeout, pool size, batch size를 확정하지 않았으므로 플랫폼은 이를 보편 상수로 하드코딩하지 않는다. Production profile은 명시적 값을 요구하고, Testkit만 결정적인 fixture 값을 제공한다. + +### 3.3 우선순위 + +```text +사용자 지시 +→ 이 설계서의 명시적 계약 +→ 심층 리서치 원본 +→ host 저장소의 기존 convention +→ Spring Boot BOM 기본값 +``` + +기존 저장소 구조가 다르면 경로와 convention plugin 이름은 매핑할 수 있지만, 공개 계약과 불변 조건은 유지한다. + +--- + +## 4. 범위 + +### 4.1 Stable 범위 + +```text +Spring Data domain repository +Jakarta Persistence 3.2 +Hibernate ORM 7.4 +PostgreSQL 16·17·18 +REQUIRED transaction +READ COMMITTED 기본 isolation +read-only·timeout +Optimistic Lock +표준 Pessimistic Lock +Derived Query·JPQL·Projection +EntityGraph·Fetch Join +Page·Slice·Keyset +JDBC Batch +Flyway migrate·validate +SQLSTATE 기반 오류 +bounded full-transaction retry +OSIV off +L1 Persistence Context +Spring Data auditing opt-in +PG Testcontainers contract +``` + +### 4.2 Advanced opt-in 범위 + +```text +MANDATORY·REQUIRES_NEW +Specification·Querydsl +Query Hint·Scroll·Stream +NOWAIT·SKIP LOCKED +Batch Fetch·Subselect Fetch +Bulk DML +StatelessSession +PostgreSQL JSONB·Array·Range·INET +ON CONFLICT·RETURNING +COPY 기반 대량 import +Envers +Hibernate L2 Cache +Concurrent Index migration +Query Plan regression +``` + +### 4.3 Experimental 범위 + +```text +Shared schema tenant column +PostgreSQL RLS +Schema-per-tenant +Database-per-tenant +Read Replica routing +Jakarta Persistence 4.0 +Hibernate ORM 8 +PostgreSQL 19 +``` + +Experimental 기능은 별도 모듈과 CI lane에서만 활성화하며 Stable Core의 공개 API를 변경하지 않는다. + +### 4.4 명시적 비지원 + +```text +GenericRepository CRUD 재구현 +Entity를 Web/API DTO로 직접 반환 +Extended Persistence Context 일반 사용 +OSIV +전역 EAGER +전역 Cascade.ALL +전역 implicit Soft Delete +운영 ddl-auto update/create/create-drop +Repository method 단위 부분 Retry +Commit 결과 불명 자동 Retry +Remote distributed transaction 기본화 +임의 XA 기본 지원 +무제한 findAll +자유로운 raw SQL +H2 결과로 PostgreSQL Stable 선언 +annotation 하나만으로 Read Replica 자동 routing +Hibernate Query Cache 기본 활성화 +``` + +### 4.5 Reactive 경계 + +JPA와 JDBC는 Blocking 기술이다. 이 플랫폼은 Reactor 타입을 공개 API에 넣지 않는다. WebFlux 애플리케이션이 JPA를 사용할 경우 애플리케이션 또는 별도 execution adapter가 bounded blocking executor로 격리해야 하며, Reactor event-loop에서 Repository를 호출하는 것은 금지한다. Reactive relational persistence가 필요하면 별도 R2DBC 모듈을 설계한다. + +--- + +## 5. 핵심 설계 원칙 + +1. **도메인 소유권 유지:** Entity·Embeddable·Repository·업무 Query·Index Requirement는 도메인이 소유한다. +2. **추상화 중복 금지:** Spring Data의 CRUD 추상화를 다시 감싸지 않는다. +3. **Use Case Transaction:** Transaction은 Application Use Case 단위다. +4. **전체 Transaction Retry:** Retry는 새 Persistence Context와 새 Transaction에서 전체 작업을 다시 실행한다. +5. **불명확성 보존:** Commit 결과를 모르면 성공 또는 실패로 추정하지 않는다. +6. **Fetch Plan 명시:** Mapping annotation 하나로 모든 Use Case의 Fetch를 결정하지 않는다. +7. **Schema Source of Truth 분리:** Entity Mapping은 객체-관계 매핑 계약이고 실제 Schema 변경은 Flyway가 소유한다. +8. **PostgreSQL 실제 검증:** H2나 mock으로 Lock·Constraint·SQLSTATE·Plan 의미론을 증명하지 않는다. +9. **Provider 차이 노출:** Hibernate·PostgreSQL 고유 기능은 J3 Extension으로 명시한다. +10. **위험 기능 opt-in:** REQUIRES_NEW, Native SQL, Bulk DML, StatelessSession, L2 Cache, Envers는 선택 모듈이다. +11. **정량 성능 검증:** Query 수뿐 아니라 rows, hydrated entity, collection fetch, batch, pool wait를 측정한다. +12. **권한 최소화:** Runtime 계정은 DML만, Migration·Admin 계정은 별도다. + +--- + +## 6. 전체 아키텍처 + +```text +Domain / Application +├─ Entity +├─ Embeddable +├─ Repository Interface +├─ Custom Repository Contract +├─ Projection / Read Model +└─ Application Service @Transactional + │ + ▼ +┌──────────────────────────────────────────────────┐ +│ JPA Persistence Platform │ +│ │ +│ J1 Standard │ +│ ├─ Spring Data integration │ +│ ├─ Transaction defaults │ +│ ├─ Stable error model │ +│ └─ Auditing opt-in │ +│ │ +│ J2 Advanced │ +│ ├─ Fetch / Query support │ +│ ├─ Keyset / Scroll │ +│ ├─ Full-TX retry │ +│ ├─ Batch / Bulk │ +│ └─ Pessimistic lock │ +│ │ +│ J3 Provider / DB Extension │ +│ ├─ Hibernate Session / Statistics │ +│ ├─ StatelessSession │ +│ ├─ PostgreSQL types │ +│ ├─ ON CONFLICT / RETURNING │ +│ └─ NOWAIT / SKIP LOCKED / COPY │ +│ │ +│ J4 Admin / Operations │ +│ ├─ Flyway │ +│ ├─ Index / Backfill │ +│ ├─ Plan regression │ +│ └─ Role / Schema validation │ +└───────────────────────┬──────────────────────────┘ + │ + ▼ + PostgreSQL 16~18 +``` + +### 6.1 일반 Write 흐름 + +```text +Controller +→ Application Service +→ @Transactional 시작 +→ Domain Repository +→ Entity persist/update +→ flush +→ DB constraint/lock 검증 +→ commit +→ 결과 반환 +``` + +외부 HTTP, Object Storage, Messaging 호출은 DB Transaction 밖으로 이동한다. DB 변경과 메시지 발행은 기존 Messaging Platform의 Transactional Outbox를 사용한다. + +### 6.2 Retry 흐름 + +```text +Application Use Case +→ Attempt 1: 새 EntityManager + 새 Transaction +→ OptimisticConflict / Deadlock / SerializationFailure +→ Retry Policy 분류 +→ bounded backoff + jitter +→ Attempt 2: 새 EntityManager + 새 Transaction +→ commit +``` + +부분 SQL만 다시 실행하거나 동일 Persistence Context를 재사용하지 않는다. + +### 6.3 Completion Unknown 흐름 + +```text +Application +→ COMMIT 전송 +→ PostgreSQL commit 가능 +→ 응답 전에 connection loss +→ EvidenceAwareJpaTransactionManager +→ TransactionCompletionUnknown +→ 자동 Retry 금지 +→ transactionKey / unique key / outbox / 상태 조회 +→ domain-specific reconciliation +``` + +### 6.4 Read 흐름 + +```text +Application Query +→ QueryName +→ Projection / EntityGraph / Fetch Join / Native Query +→ QueryObservation +→ Statement + Hibernate statistics +→ DTO / Projection 반환 +``` + +Entity를 Controller에 반환하지 않는다. + +--- + +## 7. 모듈 구조 + +```text +backend-skeleton/ +├── modules/jpa/ +│ ├── jpa-core-api/ +│ ├── jpa-transaction/ +│ ├── jpa-spring-data/ +│ ├── jpa-querydsl/ +│ ├── jpa-hibernate/ +│ ├── jpa-postgresql/ +│ ├── jpa-postgresql-copy/ +│ ├── jpa-migration-flyway/ +│ ├── jpa-auditing/ +│ ├── jpa-envers/ +│ ├── jpa-cache-hibernate/ +│ ├── jpa-observability/ +│ ├── jpa-security/ +│ ├── jpa-spring-boot-starter/ +│ ├── jpa-testkit/ +│ ├── jpa-testkit-postgresql/ +│ ├── jpa-testkit-migration/ +│ └── jpa-testkit-queryplan/ +├── modules/jpa-experimental/ +│ ├── jpa-multitenancy-column/ +│ ├── jpa-multitenancy-rls/ +│ ├── jpa-multitenancy-schema/ +│ ├── jpa-multitenancy-database/ +│ ├── jpa-read-replica/ +│ └── jpa-next-compatibility/ +├── infra/jpa/ +│ ├── postgres/ +│ ├── toxiproxy/ +│ └── roles/ +└── docs/jpa/ + ├── entity-mapping-guide.md + ├── transaction-guide.md + ├── query-fetch-guide.md + ├── migration-guide.md + ├── postgresql-extensions.md + ├── observability.md + ├── security.md + ├── support-matrix.md + └── runbooks.md +``` + +### 7.1 모듈 책임 + +| 모듈 | 책임 | +|---|---| +| `jpa-core-api` | Spring/JPA 비종속 안정 오류·Transaction Profile·Query Name·Capability 계약 | +| `jpa-transaction` | Spring Transaction Adapter, full-TX retry, completion evidence | +| `jpa-spring-data` | Custom Fragment 기반 지원, Safe Sort, Projection·EntityGraph helper | +| `jpa-querydsl` | 선택 Querydsl integration | +| `jpa-hibernate` | Statistics, Fetch·Batch·Bulk·StatelessSession extension | +| `jpa-postgresql` | SQLSTATE, JSONB·Array·Range, native write, lock extension | +| `jpa-postgresql-copy` | J4 대량 import/backfill COPY | +| `jpa-migration-flyway` | Migration policy, validate, snapshot upgrade gate | +| `jpa-auditing` | Spring Data auditing opt-in | +| `jpa-envers` | Entity history opt-in | +| `jpa-cache-hibernate` | Hibernate L2 Cache opt-in; Query Cache 기본 비활성 | +| `jpa-observability` | queryName·transaction·retry·Hibernate statistics 관측 | +| `jpa-security` | ArchUnit rule, DB role/search_path validation, log redaction | +| `jpa-spring-boot-starter` | AutoConfiguration·Properties·Actuator·startup guard | +| `jpa-testkit*` | PostgreSQL·Migration·Query Plan·Concurrency 계약 테스트 | + +### 7.2 의존 방향 + +```text +jpa-core-api +↑ +├─ jpa-transaction +├─ jpa-spring-data +├─ jpa-hibernate +├─ jpa-postgresql +├─ jpa-migration-flyway +├─ jpa-auditing +├─ jpa-observability +└─ jpa-security + +jpa-spring-boot-starter +→ 위 Stable 모듈 조합 + +jpa-testkit* +→ 테스트 대상 모듈 +``` + +`jpa-core-api`는 `jakarta.persistence`, Spring, Hibernate, PostgreSQL JDBC, Flyway에 의존하지 않는다. + +### 7.3 ArchUnit 경계 + +```text +jpa-core-api → provider/framework dependency 금지 +platform → domain Entity 정의 금지 +domain → org.hibernate 직접 의존 금지 +web/controller → @Entity 반환 금지 +@Entity → web DTO annotation 금지 +application → J4 admin package 접근 금지 +``` + +--- + +## 8. 공개 계층 J1~J4 + +### 8.1 J1 Standard Persistence + +일반 애플리케이션이 기본으로 사용한다. + +```text +Spring Data Repository +Derived Query +JPQL +DTO / Interface Projection +Application Service @Transactional +@Version Optimistic Lock +Spring Data Auditing opt-in +Page / Slice +``` + +도메인 Repository 예시: + +```java +public interface OrderRepository + extends JpaRepository, OrderRepositoryCustom { + + Optional findByOrderNumber(OrderNumber orderNumber); +} + +public interface OrderRepositoryCustom { + KeysetSlice findRecent( + OrderSearchCondition condition, + KeysetPageRequest page); +} +``` + +### 8.2 J2 Advanced Persistence + +```text +Specification +Querydsl +EntityGraph +Query Hint +Pessimistic Lock +Keyset / Scroll / Stream +JDBC Batch +Bulk DML +Full Transaction Retry +``` + +J2 사용은 명시적 모듈 의존성과 Query Name 등록을 요구한다. + +### 8.3 J3 Provider / Database Extension + +```text +Hibernate Session +Hibernate Fetch Profile +StatelessSession +PostgreSQL JSONB·Array·Range·INET +ON CONFLICT·RETURNING +NOWAIT·SKIP LOCKED +Native SQL +``` + +J3 API는 `io.backend.skeleton.jpa.postgresql` 또는 `io.backend.skeleton.jpa.hibernate` package에 격리하고 application service가 provider type을 직접 받지 않게 한다. + +### 8.4 J4 Admin / Operations + +```text +Flyway migrate·validate·repair 승인 +Concurrent Index +Backfill +COPY +Partition +Maintenance SQL +Schema Drift +Plan Regression +Role Verification +``` + +J4는 일반 Runtime credential로 실행하지 않는다. `repair`, purge, destructive migration은 operation ID, operator, reason, dry-run 또는 승인 절차를 요구한다. + +--- + +## 9. Core 공개 계약 + +### 9.1 Operation Name + +```java +public record PersistenceOperationName(String value) { + public PersistenceOperationName { + if (value == null || !value.matches("[a-z][a-z0-9.-]{2,95}")) { + throw new IllegalArgumentException("invalid persistence operation name"); + } + } +} +``` + +Operation Name은 metric·trace·retry policy의 bounded key이다. 동적 SQL이나 Entity ID를 넣지 않는다. + +### 9.2 Transaction Profile + +```java +public record TransactionProfile( + String name, + PropagationMode propagation, + IsolationLevel isolation, + Duration timeout, + boolean readOnly, + RetryProfile retryProfile) { +} + +public enum PropagationMode { + REQUIRED, + MANDATORY, + REQUIRES_NEW +} + +public enum IsolationLevel { + DEFAULT, + READ_COMMITTED, + REPEATABLE_READ, + SERIALIZABLE +} +``` + +Stable 기본은 `REQUIRED + READ_COMMITTED`. `REQUIRES_NEW`는 별도 opt-in profile과 pool pressure test를 요구한다. + +### 9.3 Transaction Executor + +```java +public interface JpaTransactionExecutor { + T execute( + PersistenceOperationName operation, + TransactionProfile profile, + Supplier work); +} +``` + +일반 Use Case는 `@Transactional`을 사용할 수 있다. Programmatic retry·동적 profile이 필요한 Use Case는 executor를 사용한다. + +### 9.4 Retry Policy + +```java +public interface JpaRetryPolicy { + RetryDecision classify( + JpaPersistenceException failure, + TransactionAttempt attempt); +} + +public record RetryDecision( + RetryDisposition disposition, + Duration delay, + String reason) { +} + +public enum RetryDisposition { + RETRY_FULL_TRANSACTION, + RECONCILE, + FAIL +} +``` + +### 9.5 Query Observation + +```java +public interface QueryObservation { + QueryScope start(QueryName queryName); +} + +public interface QueryScope extends AutoCloseable { + void rows(long count); + void failure(Throwable failure); + @Override void close(); +} +``` + +--- + +## 10. Entity 소유권과 Mapping 규칙 + +### 10.1 소유권 + +플랫폼은 업무 Entity를 정의하지 않는다. 도메인 모듈이 다음을 소유한다. + +```text +@Table 이름 +@Column 의미 +PK·FK·Unique·Check 요구 +Association +Cascade +Soft Delete +Audit +Index Requirement +Lock 정책 +``` + +플랫폼은 규칙, annotation helper, test fixture, static check만 제공한다. + +### 10.2 기본 규칙 + +| 항목 | 기본 계약 | +|---|---| +| Access | Field Access | +| Constructor | `protected` no-arg | +| Entity class | non-final | +| Persistent field | proxy 호환성을 해치지 않게 설계 | +| API 반환 | Entity 금지, DTO·Projection 사용 | +| `toString` | LAZY association 제외 | +| equals/hashCode | mutable association·mutable business field 제외 | +| Callback | 외부 HTTP·Messaging·File I/O 금지 | +| BaseEntity | 전역 강제 금지 | +| Soft Delete | 전역 강제 금지 | +| Audit | opt-in | + +### 10.3 equals/hashCode + +ID가 DB 생성이면 transient 상태에서 ID가 없음을 고려한다. mutable generated ID를 hash-based collection에 넣은 뒤 hashCode가 바뀌는 설계를 피한다. 권장 패턴은 domain-assigned immutable ID 또는 class + stable immutable key를 사용하되 각 Aggregate가 계약을 명시하는 것이다. + +### 10.4 Entity 외부 노출 금지 + +다음은 금지한다. + +```text +Controller method 반환형이 @Entity +Entity에 Jackson API contract annotation 사용 +Lazy collection을 JSON serializer가 탐색 +Entity를 Message payload로 직접 사용 +Entity를 Redis value로 직접 Java serialize +``` + +--- + +## 11. ID 생성 전략 + +### 11.1 기본 선택 + +| 전략 | 등급 | 계약 | +|---|---|---| +| PostgreSQL Sequence | Stable 기본 | write-heavy Entity, JDBC Batch와 호환 | +| JPA UUID | Stable | 분산 ID, insert 전 identity 확보 | +| Application-assigned UUID/UUIDv7 | Stable | PG16~18 공통 방식 | +| PostgreSQL 18 `uuidv7()` | J3 PG18 전용 | Stable Matrix 공통 기본으로 사용하지 않음 | +| IDENTITY | 제한 | insert batching 제약; 소규모 write만 | +| Composite ID | Domain-specific | 실제 composite identity일 때만 | +| Natural ID | 별도 unique index | PK와 혼동하지 않음 | + +### 11.2 Sequence 규칙 + +```java +@SequenceGenerator( + name = "order_seq", + sequenceName = "order_seq", + allocationSize = 50 +) +@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_seq") +``` + +`allocationSize=50`은 universal constant가 아니라 reference profile이다. 실제 workload benchmark와 sequence increment가 일치해야 하며 플랫폼은 mismatch를 테스트한다. + +### 11.3 UUIDv7 + +PG16·17·18 공통 지원을 위해 application-generated UUIDv7을 기본 extension으로 제공할 수 있다. DB-generated PG18 UUIDv7은 별도 Capability로 노출한다. + +--- + +## 12. Value Mapping + +| 타입 | 기본 계약 | +|---|---| +| `Instant` | 서버 간 절대 시점 | +| `OffsetDateTime` | offset 자체가 업무 의미일 때 | +| `LocalDate` | 날짜 | +| `LocalDateTime` | timezone 없는 업무 시간에만 | +| `Duration` | converter/provider mapping contract test | +| `UUID` | Stable | +| Enum | STRING 또는 명시적 converter; ordinal 금지 | +| Money | Embeddable value object | +| Record Embeddable | JPA 3.2 Stable, provider round-trip test 필수 | +| JSONB·Array·Range·INET | `jpa-postgresql` | +| LOB | 일반 목록 fetch에서 제한 | +| 암호화 값 | key rotation·queryability 포함 별도 capability | + +### 12.1 Converter 규칙 + +- Converter는 null, unknown version, malformed value를 명확히 처리한다. +- Java class name을 wire/schema 값으로 저장하지 않는다. +- Enum rename은 DB migration 없이 수행하지 않는다. +- `AttributeConverter` 내부에서 외부 I/O를 수행하지 않는다. + +--- + +## 13. Association·Cascade·Collection + +### 13.1 ToOne + +- 기본적으로 명시적 LAZY를 검토한다. +- 실제 lazy proxy 동작을 Hibernate contract test로 보증한다. +- FK nullable과 `optional`을 일치시킨다. +- 목록 조회에서 필요한 ToOne은 EntityGraph·Fetch Join·Projection으로 가져온다. + +### 13.2 ToMany + +- LAZY가 기본이다. +- `List`, `Set`, `Map` 선택은 중복·순서 의미를 반영한다. +- `List` 두 개를 동시에 join fetch하는 설계를 피한다. +- collection 전체를 항상 필요한 aggregate가 아니면 Projection 또는 별도 Query를 사용한다. + +### 13.3 Cascade + +```text +Cascade.ALL +→ 전역 기본값 금지 + +orphanRemoval +→ Parent가 Child lifecycle을 독점 소유할 때만 + +ManyToMany +→ 단순 연결 외에는 Join Entity 우선 +``` + +### 13.4 양방향 관계 + +Owning side가 DB 변경을 결정한다. `addChild/removeChild` helper가 양쪽 in-memory graph를 항상 동기화해야 한다. + +--- + +## 14. Persistence Context 계약 + +```text +Transient +Managed +Detached +Removed +``` + +### 14.1 기본 계약 + +```text +persist != merge +find != getReference +save != immediate INSERT +flush != commit +Entity mutation != immediate UPDATE +``` + +### 14.2 Scope + +- transaction-scoped Persistence Context만 Stable이다. +- Extended Persistence Context는 지원하지 않는다. +- EntityManager는 thread-safe로 취급하지 않는다. +- OSIV는 false다. +- Lazy association 접근은 Application Transaction 내부에서만 허용한다. + +### 14.3 Flush + +- Query 전에 AUTO flush가 발생할 수 있다. +- 명시적 flush는 SQL 동기화 지점이지 commit 증거가 아니다. +- Batch는 chunk마다 flush·clear한다. +- Bulk DML 전 flush, 후 clear 또는 refresh한다. + +### 14.4 Merge + +`merge()` 반환값이 managed instance다. 전달한 detached instance가 managed로 변한다고 가정하지 않는다. 신규 Entity 판정과 ID strategy를 이해하지 못한 무분별한 `save()` 사용을 코드리뷰 규칙으로 제한한다. + +--- + +## 15. Transaction 경계 + +### 15.1 기본 경계 + +```text +Controller +→ Application Service @Transactional +→ Domain Repository +``` + +Repository가 독립 업무 Transaction을 임의로 시작하지 않는다. + +### 15.2 금지 경계 + +```text +Controller 전체 요청 Transaction +Entity Listener가 새 Transaction 시작 +동일 Bean self-invocation으로 Propagation 기대 +DB Transaction 안에서 장시간 HTTP/Object Storage/Messaging 대기 +``` + +### 15.3 Rollback Rule + +RuntimeException·Error 기본 rollback을 사용한다. Checked exception rollback이 필요하면 안정 application exception hierarchy 또는 `rollbackFor`를 명시한다. + +### 15.4 Timeout + +모든 write Transaction profile은 유한 timeout을 요구한다. read-only query도 long-running admin query가 아니라면 timeout을 지정한다. 숫자는 환경 SLO가 소유한다. + +--- + +## 16. Propagation·Isolation + +### 16.1 Propagation + +| Mode | 등급 | 규칙 | +|---|---|---| +| REQUIRED | Stable 기본 | Use Case Transaction | +| MANDATORY | Advanced | 상위 Transaction 필수 내부 write service | +| SUPPORTS | 제한 | read helper | +| REQUIRES_NEW | Advanced 위험 | 별도 physical connection, pool capacity test 필수 | +| NESTED | J3/JDBC savepoint | portable JPA로 광고하지 않음 | +| NOT_SUPPORTED | Advanced | 긴 외부 I/O 분리 등에 제한 | + +### 16.2 Isolation + +| Isolation | 기본 사용 | +|---|---| +| READ COMMITTED | 일반 업무 기본 | +| REPEATABLE READ | transaction snapshot 일관성 필요 시 | +| SERIALIZABLE | 좁은 핵심 invariant, abort/retry 전제 | +| READ UNCOMMITTED | PostgreSQL profile에서 공개하지 않음 | + +### 16.3 Self-invocation + +`this.method()` 호출은 Spring transaction proxy를 통과하지 않는다. Retry·REQUIRES_NEW method는 별도 Bean의 public method 또는 programmatic executor로 구성한다. + +--- + +## 17. Commit 결과 불명확성 + +### 17.1 상태 + +```java +public enum TransactionCompletionEvidence { + NOT_STARTED, + ACTIVE, + COMMITTING, + COMMITTED, + ROLLED_BACK, + UNKNOWN +} +``` + +### 17.2 감지 + +`EvidenceAwareJpaTransactionManager`가 `doCommit` 진입 전 evidence를 `COMMITTING`으로 기록한다. 다음 조건에서 `TransactionCompletionUnknownException`으로 변환한다. + +```text +SQLSTATE 40003 +OR +commit phase의 connection loss / transport exception +AND +rollback 또는 commit 여부를 driver가 확정하지 못함 +``` + +일반 query 단계 connection failure를 completion unknown으로 과대 분류하지 않는다. + +### 17.3 오류 계약 + +```java +public final class TransactionCompletionUnknownException + extends JpaPersistenceException { + + private final String transactionKey; + private final TransactionCompletionEvidence evidence; +} +``` + +### 17.4 복구 + +```text +자동 Retry 금지 +→ transactionKey로 상태 조회 +→ Unique Constraint / Idempotency Record 확인 +→ 업무 Row 확인 +→ Outbox 확인 +→ 결과 확정 불가 시 Reconciliation Queue +``` + +`TransactionCompletionResolver`는 domain-specific SPI이며 Core가 업무 성공을 추측하지 않는다. + +--- + +## 18. 안정 오류 모델과 SQLSTATE + +```text +JpaPersistenceException +├─ JpaEntityNotFoundException +├─ OptimisticConflictException +├─ PessimisticLockTimeoutException +├─ DeadlockDetectedException +├─ SerializationFailureException +├─ UniqueConstraintViolationException +├─ ForeignKeyViolationException +├─ CheckConstraintViolationException +├─ QueryTimeoutException +├─ TransactionTimeoutException +├─ ConnectionUnavailableException +├─ SchemaMismatchException +├─ DataCorruptionException +└─ TransactionCompletionUnknownException +``` + +### 18.1 공통 Metadata + +```java +public record JpaFailureContext( + PersistenceOperationName operation, + String sqlState, + String constraintName, + int transactionAttempt, + boolean retryable, + boolean completionUnknown, + Duration elapsed, + String traceId) { +} +``` + +SQL parameter, Entity ID, Tenant ID, 전체 SQL 원문, PII는 exception message에 넣지 않는다. + +### 18.2 SQLSTATE 분류 + +| 분류 | 대표 코드 | +|---|---| +| Serialization Failure | `40001` | +| Statement Completion Unknown | `40003` | +| Deadlock | `40P01` | +| Unique Violation | `23505` | +| Foreign Key Violation | `23503` | +| Check Violation | `23514` | +| Not Null Violation | `23502` | +| Lock Not Available | `55P03` | + +문자열 오류 메시지를 parsing하지 않고 SQLSTATE와 structured server error field를 사용한다. + +--- + +## 19. Retry 정책 + +### 19.1 Retry 대상 + +| 오류 | 기본 | +|---|---| +| Optimistic Conflict | 조건부 전체 Transaction Retry | +| Serialization Failure | bounded 전체 Transaction Retry | +| Deadlock | bounded 전체 Transaction Retry | +| Lock Timeout | deadline·업무 정책에 따라 | +| Transaction 시작 전 Connection 실패 | 제한적 Retry | +| Unique Violation | 기본 Retry 금지; idempotent create면 기존 결과 조회 | +| FK·Check Violation | Retry 금지 | +| Query Timeout | 기본 Retry 금지 | +| Schema Mismatch | Retry 금지 | +| Completion Unknown | 자동 Retry 금지, reconcile | + +### 19.2 안전 조건 + +```text +전체 Use Case가 재계산 가능 +AND +외부 irreversible side effect 없음 +AND +새 Persistence Context 생성 +AND +새 Transaction 생성 +AND +deadline 남음 +AND +retry budget 남음 +``` + +### 19.3 Retry Profile + +```java +public record RetryProfile( + String name, + int maxAttempts, + Duration initialBackoff, + Duration maxBackoff, + double multiplier, + JitterMode jitter, + Set retryableFailures) { +} +``` + +### 19.4 Annotation Adapter + +```java +@RetryableJpaTransaction(profile = "order-write") +@Transactional +public OrderId place(PlaceOrder command) { ... } +``` + +Retry interceptor는 Transaction interceptor보다 바깥에서 실행되어 각 attempt가 새 transaction을 생성해야 한다. 같은 클래스 self-invocation은 지원하지 않는다. + +--- + +## 20. Optimistic Lock + +- mutable aggregate에는 `@Version` 사용을 기본 검토한다. +- version은 API update command에 전달하거나 서버가 re-read 후 검증한다. +- Conflict는 flush 또는 commit 시점에 나타날 수 있다. +- Bulk DML은 version을 자동 검증하지 않는다. +- 일부 Repository method만 Retry하지 않는다. + +```java +@Entity +public class Order { + @Version + private long version; +} +``` + +Retry 후에는 최신 Entity를 다시 조회하고 업무 규칙을 다시 계산한다. + +--- + +## 21. Pessimistic Lock·PostgreSQL Lock Extension + +### 21.1 표준 Lock + +```text +PESSIMISTIC_READ +PESSIMISTIC_WRITE +PESSIMISTIC_FORCE_INCREMENT +``` + +Transaction timeout, lock timeout, deadlock을 구분한다. + +### 21.2 NOWAIT + +대기 없이 즉시 실패해야 하는 use case에서 J3 Native Query로 제공한다. 일반 Repository API에 전역 옵션으로 넣지 않는다. + +### 21.3 `FOR UPDATE SKIP LOCKED` + +일반 일관된 조회가 아니라 work queue claim에만 제공한다. + +```java +public interface WorkClaimExecutor { + List claimNextBatch( + WorkQueueName queue, + int size, + Duration lease); +} +``` + +### 21.4 Lock Ordering + +여러 Row를 잠글 때 stable key order를 사용한다. deadlock fixture로 규칙을 검증한다. + +--- + +## 22. Constraint와 경쟁 조건 + +### 22.1 최종 불변식 + +```text +Bean Validation +→ 조기 사용자 오류 + +Database Constraint +→ concurrency에서도 지켜지는 최종 invariant +``` + +### 22.2 지원 + +```text +PRIMARY KEY +FOREIGN KEY +NOT NULL +UNIQUE +CHECK +EXCLUSION +Partial Unique Index +NULLS NOT DISTINCT +``` + +### 22.3 Exists-before-insert + +`exists()`는 UX 검증일 뿐 경쟁을 차단하지 않는다. Unique Constraint 위반을 안정 오류로 변환한다. + +### 22.4 Constraint Catalog + +Constraint name을 bounded registry에 등록해 `user-email-active-unique` 같은 안정 code로 변환한다. raw table·column·value는 외부 오류에 노출하지 않는다. + +--- + +## 23. Repository와 Query 선택 + +### 23.1 Query 등급 + +| 등급 | 방식 | +|---|---| +| Q1 | Derived Query, JPQL, DTO/Interface Projection | +| Q2 | Specification, Criteria, Querydsl, EntityGraph | +| Q3 | Native SQL, Hibernate Query API, PostgreSQL CTE·Window·JSONB | +| Q4 | Backfill, Maintenance, Bulk/Admin SQL | + +### 23.2 선택 규칙 + +- Derived method가 업무 의미보다 SQL 구조를 설명하기 시작하면 Custom Query로 승격한다. +- 고정 query는 JPQL과 DTO Projection을 우선한다. +- optional filter 조합은 Specification 또는 Querydsl을 사용한다. +- PostgreSQL plan·syntax 제어가 필요하면 J3 Native Query를 사용한다. +- 모든 nontrivial query에는 `QueryName`을 등록한다. + +### 23.3 Custom Fragment + +플랫폼은 `BaseRepository`를 강제하지 않는다. 도메인이 `OrderRepositoryCustom`을 정의하고 구현에서 플랫폼 helper를 사용한다. + +### 23.4 Dynamic Sort + +사용자 문자열을 `JpaSort.unsafe()`에 연결하지 않는다. `SafeSortRegistry`가 허용된 field enum을 실제 JPA path로 변환한다. + +--- + +## 24. Projection + +### 24.1 DTO Projection + +목록·read model의 기본 후보다. Entity 전체 hydration과 Lazy association을 줄인다. + +### 24.2 Interface Projection + +간단한 projection에 사용하되 nested association이 추가 query를 유발하는지 검증한다. + +### 24.3 Dynamic Projection + +public API에서 임의 class를 입력받지 않는다. 등록된 projection catalog만 사용한다. + +### 24.4 Entity 직접 반환 + +Application 내부 aggregate mutation use case에만 Entity를 사용하고 Web/API boundary에서는 DTO로 변환한다. + +--- + +## 25. Fetch Plan과 N+1 + +### 25.1 전략 + +```text +Mapping +→ 최소 graph + +Use Case Query +→ EntityGraph / Fetch Join / Projection / Batch Fetch +``` + +### 25.2 선택표 + +| 상황 | 우선 선택 | +|---|---| +| 단일 aggregate 상세 | EntityGraph / Fetch Join | +| 여러 ToOne | Fetch Join / EntityGraph | +| 하나의 bounded ToMany | Fetch Join 검토 | +| 여러 ToMany | DTO / 분할 Query / Batch Fetch | +| 목록 화면 | DTO Projection | +| 대규모 read model | Native Projection | +| 반복 LAZY N+1 | explicit fetch plan 또는 batch fetch | + +### 25.3 정량 지표 + +```text +statementCount +entityLoadCount +entityFetchCount +collectionLoadCount +collectionFetchCount +returnedParents +hydratedEntities +rowsFromDatabase +executionTime +``` + +### 25.4 Fixture + +```text +0 child +1 child +10~100 children +shared ToOne +multiple collections +Zipf skew +``` + +SQL 1개라는 이유만으로 좋은 Query로 판정하지 않는다. + +--- + +## 26. Hibernate 7.4 Collection Fetch Pagination + +과거 Hibernate의 collection fetch join + pagination 전체 로드 문제를 영구 금지 규칙으로 복사하지 않는다. Stable baseline인 Hibernate 7.4 + PostgreSQL 16~18에서 다음을 검증한다. + +```text +generated SQL에 DB limit/subquery가 적용되는가 +반환 parent 수가 정확한가 +hydrated row 수가 허용 범위인가 +count query가 정확한가 +여러 collection Cartesian amplification이 없는가 +``` + +`hibernate.query.fail_on_pagination_over_collection_fetch`는 호환성 lane에서 회귀 감지를 위해 사용하되, 7.4 지원 경로를 무조건 차단하지 않는다. + +--- + +## 27. Pagination·Cursor·Scroll + +### 27.1 사용 기준 + +| 방식 | 용도 | +|---|---| +| Page | 작은 관리자 목록, total count 필요 | +| Slice | count 불필요 일반 목록 | +| Offset | 작은 데이터·얕은 page | +| Keyset/Cursor | 대규모·시간순 목록 | +| Scroll/Stream | batch/read processing | + +### 27.2 Keyset 계약 + +```java +public record KeysetPageRequest( + Optional after, + int size, + SortDirection direction) { +} + +public record KeysetSlice( + List items, + Optional nextCursor, + boolean hasNext) { +} +``` + +정렬이 `created_at DESC, id DESC`이면 Cursor도 두 값을 모두 포함한다. + +### 27.3 Cursor 보안 + +Cursor는 versioned JSON을 Base64URL로 encoding하고 HMAC signature를 선택적으로 제공한다. raw SQL fragment를 포함하지 않는다. + +### 27.4 Stream + +Stream은 transaction과 ResultSet 수명을 가진다. try-with-resources와 fetch size를 강제하고 Web/API에 그대로 반환하지 않는다. + +--- + +## 28. JDBC Batch + +### 28.1 의미 + +```text +saveAll != one SQL +JDBC Batch != one SQL +IDENTITY != batch-friendly +``` + +### 28.2 Profile + +```yaml +backend: + jpa: + batch-profiles: + order-import: + jdbc-batch-size: 50 + order-inserts: true + order-updates: true + flush-size: 50 + clear-size: 50 +``` + +숫자는 profile이 소유한다. Platform은 batch size와 flush/clear invariant를 검증한다. + +### 28.3 Verification + +Hibernate statistics와 datasource proxy를 통해 실제 `executeBatch` 횟수와 statement 수를 확인한다. + +--- + +## 29. Bulk DML + +### 29.1 계약 + +```text +flush +→ JPQL / Native Bulk DML +→ clear +→ 필요 시 재조회 +``` + +### 29.2 제한 + +- Bulk DML은 Entity callback과 optimistic version check를 자동 실행하지 않는다. +- 도메인 invariant를 우회할 수 있으므로 Q4 또는 명시적 J2 API에서만 사용한다. +- 영향 Row 수를 반환하고 예상 범위를 검증한다. + +```java +public interface BulkDmlExecutor { + int execute(BulkOperationName operation, Runnable bulkStatement); +} +``` + +--- + +## 30. StatelessSession·COPY + +### 30.1 StatelessSession + +Persistence Context·dirty checking이 없는 Hibernate extension이다. 일반 Repository를 대체하지 않고 대량 import/backfill에만 사용한다. + +### 30.2 PostgreSQL COPY + +`jpa-postgresql-copy`는 JDBC connection을 명시적으로 unwrap해 COPY를 실행한다. J4 credential·operation name·row/byte cap·transaction policy를 요구한다. + +### 30.3 선택표 + +```text +일반 업무 write → JPA Entity +수천~수만 rows → JPA JDBC Batch +대규모 import/backfill → StatelessSession / COPY +``` + +--- + +## 31. PostgreSQL Extension + +### 31.1 Stable J3 + +```text +JSONB +Array +Range +UUID +ON CONFLICT +RETURNING +NOWAIT +SKIP LOCKED Work Claim +Window Function +``` + +### 31.2 Advanced + +```text +INET +Native Enum +CTE / Recursive CTE +Advisory Lock +Generated Column +Full-text Search +``` + +### 31.3 Admin + +```text +Partial / Expression / INCLUDE Index +Partition +RLS Policy +Extension 설치 +``` + +### 31.4 Native SQL 제한 + +- 등록된 Query Name 필수 +- 값은 parameter binding +- 동적 table/column 문자열 금지 +- row mapping 명시 +- PG16·17·18 Contract Test 필수 + +--- + +## 32. ON CONFLICT·RETURNING + +Upsert 의미를 단순 `save()`로 숨기지 않는다. + +```java +public interface PostgreSqlUpsertExecutor { + R execute( + NativeWriteName operation, + C command, + UpsertConflictTarget target); +} +``` + +Conflict target, update columns, version semantics, returned columns을 호출 계약으로 고정한다. 동일 업무에 JPA Entity update와 Native Upsert를 섞을 때 Persistence Context를 clear하거나 해당 Entity를 다시 조회한다. + +--- + +## 33. Flyway와 Schema Source of Truth + +### 33.1 환경 정책 + +| 환경 | Flyway | Hibernate DDL | +|---|---|---| +| local PostgreSQL | migrate | validate | +| H2 convenience | 선택 create/drop | 호환성 증거 아님 | +| test | migrate | validate | +| dev | migrate | validate | +| staging | deployment migration | validate | +| prod | 별도 migration role/process | validate | + +### 33.2 금지 + +```text +prod ddl-auto update/create/create-drop +runtime credential DDL +적용 완료 Versioned Migration 수정 +startup auto repair +``` + +### 33.3 Validation + +```text +checksum mismatch → fail +missing migration → fail +schema mismatch → fail +unsupported DB version → fail +``` + +### 33.4 Repair + +Flyway repair는 J4 승인 operation이다. 자동 실행하지 않고 operator, reason, before/after report를 남긴다. + +--- + +## 34. 무중단 Migration + +```text +Expand +→ 새 nullable column/table/index + +Migrate +→ chunked backfill / dual read·write + +Contract +→ old column/index 제거, constraint 강화 +``` + +### 34.1 Concurrent Index + +PostgreSQL `CREATE INDEX CONCURRENTLY`는 transaction block 밖에서 실행해야 하므로 non-transactional Flyway migration으로 명시한다. 실패한 invalid index 정리 runbook을 제공한다. + +### 34.2 Snapshot Gate + +```text +empty → latest +N-1 release → latest +oldest supported snapshot → latest +checksum modified → validation failure +missing migration → validation failure +failed non-transactional migration → documented recovery +``` + +--- + +## 35. Constraint·Index·Query Plan + +### 35.1 Index Requirement + +각 도메인 Query는 다음 문서를 소유한다. + +```text +queryName +predicate +sort +expected cardinality +data distribution +required index +representative parameters +expected plan shape +``` + +### 35.2 Query Plan Testkit + +`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)`을 Test/Admin 계정으로 실행한다. 모든 Seq Scan을 실패시키지 않고 기대 node, row estimate ratio, sort spill, execution time budget을 Query별로 검증한다. + +### 35.3 Plan Snapshot + +PostgreSQL minor version과 statistics에 따라 plan이 달라질 수 있으므로 raw JSON 전체 byte snapshot보다 normalized structural expectation을 사용한다. + +--- + +## 36. Auditing·History·Soft Delete + +### 36.1 Auditing + +`createdAt`, `createdBy`, `modifiedAt`, `modifiedBy`를 opt-in Embeddable 또는 annotation set으로 제공한다. 전역 BaseEntity 상속을 강제하지 않는다. + +### 36.2 구분 + +```text +Technical Auditing != Business Audit != Entity History != Security Audit +``` + +### 36.3 Envers + +별도 모듈이며 Entity별 opt-in이다. 대용량 audit table, relation revision, 개인정보 보존 정책을 검토한 뒤 활성화한다. + +### 36.4 Soft Delete + +전역 filter를 제공하지 않는다. 도메인 상태 또는 `deletedAt`을 명시하고 필요하면 Flyway partial unique index를 사용한다. 물리 삭제·개인정보 파기와 복구 가능한 삭제를 구분한다. + +--- + +## 37. Cache + +### 37.1 기본 + +```text +L1 Persistence Context → 항상 +L2 Cache → Entity별 opt-in +Query Cache → OFF +Application Cache → Redis 플랫폼 +``` + +### 37.2 L2 Gate + +- `ENABLE_SELECTIVE` +- Cache Region 명시 +- 외부 DB writer가 있을 때 invalidation 정책 +- Bulk DML 후 eviction +- cluster node 일관성 +- hit/miss/stale metric + +Redis application cache와 Hibernate L2 Cache는 같은 기능으로 취급하지 않는다. + +--- + +## 38. Multi-tenancy·Replica Experimental + +### 38.1 Multi-tenancy + +```text +Shared schema + tenant column +PostgreSQL RLS +Schema per tenant +Database per tenant +``` + +Stable Core는 tenant context를 강제하지 않는다. Experimental module이 query, connection, cache, async propagation, admin cross-tenant access를 별도 검증한다. + +### 38.2 Read Replica + +`readOnly=true`만으로 routing하지 않는다. Read-after-write, replica lag, transaction pinning, lock query primary 강제, consistency token을 설계한 뒤 별도 module에서 제공한다. + +--- + +## 39. Connection Pool과 Hikari + +### 39.1 관측 + +```text +active +idle +pending +max +acquire duration +timeout +connection lifetime +transaction duration +``` + +### 39.2 규칙 + +- pool size를 무작정 크게 하지 않는다. +- `REQUIRES_NEW`는 outer + inner connection을 동시에 요구할 수 있다. +- long transaction과 external I/O를 제거한다. +- pending/acquire latency가 alert의 핵심이다. +- DB max connections와 인스턴스 수를 함께 계산한다. + +### 39.3 Startup Validation + +Production profile은 maximumPoolSize, connectionTimeout, maxLifetime 등의 명시 여부를 검사할 수 있다. Universal numeric default를 플랫폼 상수로 고정하지 않는다. + +--- + +## 40. Observability + +### 40.1 Metric + +```text +jdbc.connections.* +hikaricp.* +jpa.transaction.count +duration +rollback +timeout +retry +completion-unknown +jpa.query.count +duration +rows +lock-wait +jpa.fetch.entity +collection +jpa.batch.execute +jpa.constraint.failure +jpa.migration.duration +``` + +### 40.2 Low-cardinality Tag + +허용: + +```text +persistenceUnit +operationName +bounded entityType +queryName +outcome +failureCategory +isolation +attemptBucket +``` + +금지: + +```text +entityId +userId +tenantId 원문 +SQL parameter +전체 동적 SQL +PII +constraint value +``` + +### 40.3 Query Name + +등록된 `QueryName`을 metric·trace의 primary key로 사용한다. SQL fingerprint는 secure diagnostic에서만 사용하고 metric label로 raw SQL을 사용하지 않는다. + +### 40.4 Logging + +SQL parameter logging은 production 기본 OFF다. exception message에 parameter와 Entity state를 넣지 않는다. + +--- + +## 41. Security + +### 41.1 DB 역할 + +```text +Application Role +├─ SELECT +├─ INSERT +├─ UPDATE +├─ DELETE +└─ required sequence usage + +Migration Role +├─ CREATE +├─ ALTER +├─ DROP +└─ index / constraint / schema + +Read-only Role +└─ bounded SELECT + +Admin Role +└─ approved operations +``` + +### 41.2 search_path + +Application role의 `search_path`를 고정하고 untrusted schema의 object resolution을 차단한다. startup verifier가 current_user, current_schema, search_path, schema CREATE privilege를 검사한다. + +### 41.3 Injection 방어 + +```text +JPQL/Native values → parameter binding +Dynamic sort → allowlist +Dynamic table/column → enum/catalog mapping만 +Entity → API mass binding 금지 +``` + +### 41.4 Secret + +DB password는 secret manager/workload identity에서 주입하고 config·log·metric에 기록하지 않는다. + +--- + +## 42. Spring Boot AutoConfiguration + +### 42.1 Properties + +```yaml +backend: + jpa: + enabled: true + require-postgresql: true + open-in-view: false + schema-management: VALIDATE + transaction-profiles: {} + retry-profiles: {} + observability: + hibernate-statistics: true + sql-parameters: false + security: + verify-runtime-role: true + verify-search-path: true +``` + +### 42.2 Startup Failures + +```text +spring.jpa.open-in-view=true +prod ddl-auto != validate/none +unsupported PostgreSQL version +runtime role has DDL privilege +migration checksum mismatch +required transaction profile timeout missing +Experimental module enabled without feature flag +``` + +### 42.3 Actuator + +```text +jpaPlatform +├─ database version +├─ provider version +├─ schema version +├─ OSIV state +├─ DDL mode +├─ role verification +├─ retry profile count +└─ capability list +``` + +민감 URL·username·schema secrets는 노출하지 않는다. + +--- + +## 43. Test Architecture + +### 43.1 층위 + +```text +Pure Unit +→ domain logic / classifier + +@DataJpaTest +→ quick mapping / repository wiring + +PostgreSQL Testcontainers +→ real semantics + +PG16·17·18 Matrix +→ release compatibility + +Toxiproxy / DB restart +→ failure evidence + +Migration Snapshot +→ real upgrade path +``` + +### 43.2 공통 Fixture + +```text +JpaTestEntity +VersionedEntity +Parent / Child +TwoCollectionsAggregate +SkewedFeedFixture +UniqueConstraintFixture +WorkQueueFixture +BatchEntity +JSONB / Array / Range Entity +``` + +공용 fixture만 testkit에 두고 업무 Entity를 플랫폼 production module에 넣지 않는다. + +### 43.3 계약 목록 + +```text +Mapping +Lifecycle +Transaction +Propagation +Isolation +Optimistic Lock +Pessimistic Lock +Deadlock +Serialization Failure +Constraint Race +Query / Projection +Fetch / N+1 +Pagination +Batch +Bulk +PostgreSQL Extension +Flyway +Security +Pool +Completion Unknown +Observability +``` + +--- + +## 44. Failure Injection + +### 44.1 Deterministic Deadlock + +두 transaction이 서로 반대 순서로 row를 잠그게 해 `40P01`을 재현한다. + +### 44.2 Serialization Failure + +SERIALIZABLE에서 동일 invariant를 변경하는 transaction을 경쟁시켜 `40001`을 재현한다. + +### 44.3 Completion Unknown + +DB proxy가 COMMIT 전, COMMIT 전송 중, server commit 후 response 전에 connection을 끊는 세 지점을 구분한다. 마지막 경우 자동 Retry가 발생하지 않고 `TransactionCompletionUnknownException`이 기록돼야 한다. + +### 44.4 DB Restart + +Transaction 시작 전, query 중, commit 중 PostgreSQL restart를 구분한다. + +--- + +## 45. 성능 인증 + +### 45.1 Query + +```text +p50 / p95 / p99 +statement count +rows +entity hydration +collection fetch +plan node +buffer hit/read +sort spill +``` + +### 45.2 Write + +```text +records/sec +JDBC batch count +statement count +flush count +Persistence Context size +heap allocation +transaction duration +``` + +### 45.3 Pool + +```text +active +pending +acquire p95/p99 +REQUIRES_NEW saturation +connection timeout +``` + +### 45.4 Gate + +성능 숫자는 workload별 문서가 소유한다. Platform release는 bounded memory, actual batching, no unbounded query, pool recovery, no retry storm을 증명한다. + +--- + +## 46. 지원 Matrix와 Release Lane + +| Lane | 실행 | +|---|---| +| PR | PostgreSQL 16·18, mapping/query/transaction/migration smoke | +| Nightly | PG16·17·18, failure injection, query plan, batch, security | +| Release | 전체 Stable Contract, upgrade snapshots, performance, role separation | +| Experimental | JPA4/Hibernate8, PG19, multitenancy, replica | + +### 46.1 H2 + +H2는 빠른 local smoke에만 사용한다. H2-only test가 release gate를 대체하지 않는다. + +### 46.2 Upgrade + +Spring Boot BOM patch 변경 시 Hibernate generated SQL, collection pagination, SQLSTATE mapping, Flyway validate, metrics 이름을 회귀 검증한다. + +--- + +## 47. 완료 정의 + +다음 질문에 모두 구현·테스트 증거로 답할 수 있어야 한다. + +```text +도메인이 Entity와 Repository를 소유하는가? +플랫폼이 GenericRepository를 만들지 않았는가? +OSIV가 모든 운영 profile에서 꺼져 있는가? +Transaction 경계가 Application Service인가? +Retry가 새 Persistence Context에서 전체 Use Case를 실행하는가? +Commit 결과 불명에서 자동 Retry가 금지되는가? +SQLSTATE로 오류를 안정 분류하는가? +Unique 경쟁을 DB Constraint가 최종 보장하는가? +N+1과 Cartesian amplification을 정량 검증하는가? +Hibernate 7.4 collection fetch pagination SQL을 실제 PG에서 검증하는가? +Keyset cursor가 tie-breaker를 포함하는가? +saveAll과 JDBC Batch를 구분하는가? +Bulk DML 후 Persistence Context가 정리되는가? +Flyway가 Schema Source of Truth인가? +운영 Runtime 계정으로 DDL이 실패하는가? +PG16·17·18에서 Stable Contract를 통과하는가? +Metric과 로그에 SQL parameter·PII가 없는가? +Experimental 기능이 Stable dependency에 유입되지 않는가? +``` + +--- + +## 48. ADR 목록 + +```text +ADR-JPA-001 Domain owns entities and repositories +ADR-JPA-002 No generic repository wrapper +ADR-JPA-003 Application service transaction boundary +ADR-JPA-004 Full transaction retry only +ADR-JPA-005 Transaction completion unknown is first-class +ADR-JPA-006 OSIV disabled +ADR-JPA-007 Use-case fetch plans +ADR-JPA-008 Flyway owns schema changes +ADR-JPA-009 PostgreSQL real-service contract tests +ADR-JPA-010 PostgreSQL extensions are J3 +ADR-JPA-011 L2 cache and Envers are opt-in +ADR-JPA-012 Multitenancy and replicas are experimental +``` + +--- + +## 49. 단계별 구현 순서 + +```text +Foundation +→ Error / Transaction Semantics +→ Mapping / Repository Rules +→ Query / Fetch / Pagination +→ Concurrency / Constraint +→ Batch / Bulk +→ PostgreSQL Extension +→ Flyway / Migration +→ Observability / Security +→ Advanced Opt-in +→ PostgreSQL Matrix / Failure / Performance +→ Experimental Expansion +``` + +Stable 계획의 Task가 모두 끝난 뒤 Experimental 계획으로 이동한다. + +--- + +## 50. 요구사항 추적표 + +| 조사 결론 | 설계 위치 | 구현 계획 | +|---|---|---| +| GenericRepository 금지 | 1, 5, 7, 8 | Task 1, 19, 53 | +| J1~J4 계층 | 8 | Task 1, 53 | +| Entity Mapping | 10~13 | Task 13~16 | +| Persistence Context | 14 | Task 11, 16, 19 | +| Application TX | 15~16 | Task 5~9 | +| Completion Unknown | 17 | Task 6, 10, 50 | +| SQLSTATE Error | 18 | Task 3~4, 29~32 | +| Full-TX Retry | 19 | Task 7~9 | +| Optimistic/Pessimistic | 20~21 | Task 29~31 | +| Query·Projection | 23~24 | Task 18~21 | +| Fetch·N+1 | 25~26 | Task 22~25 | +| Pagination | 27 | Task 26~28 | +| Batch·Bulk | 28~30 | Task 33~36 | +| PostgreSQL Extension | 31~32 | Task 30~31, 37~40 | +| Flyway | 33~34 | Task 41~43 | +| Query Plan | 35 | Task 44 | +| Audit·Cache | 36~37 | Task 17, 46~47 | +| Multitenancy·Replica | 38 | Experimental Plan | +| Pool | 39 | Task 12, 51 | +| Observability | 40 | Task 48 | +| Security | 41 | Task 45 | +| Test·Release | 43~46 | Task 49~53 | + +--- + +## 51. 구현 시 금지되는 즉흥 결정 + +```text +새 BaseRepository를 만들어 모든 Repository가 상속하게 한다. +Entity를 Controller 응답에 바로 사용한다. +OSIV를 편의를 위해 켠다. +Deadlock에서 Repository method 하나만 retry한다. +Commit 응답 유실을 connection transient로 보고 자동 retry한다. +모든 ToOne을 EAGER로 바꾼다. +Collection Fetch Join + Pagination을 버전 검증 없이 무조건 금지하거나 허용한다. +saveAll 호출만 보고 batching을 완료로 판정한다. +Flyway migration 대신 ddl-auto update를 켠다. +H2 테스트 통과로 PostgreSQL 지원을 선언한다. +Native SQL 문자열에 사용자 입력 sort/column을 연결한다. +Runtime DB 사용자에게 DDL 권한을 준다. +ReadOnly annotation만 보고 replica로 routing한다. +모든 Entity에 Soft Delete나 Envers를 강제한다. +``` + +--- + +## 52. 설계 승인 상태 + +이 설계는 첨부 심층 리서치와 사용자가 반복적으로 확정한 Backend Skeleton 방향을 기준으로 작성됐다. 구현자는 Stable 계획을 순서대로 수행하고, 각 Task의 계약 테스트가 통과하기 전 다음 Task의 의미론을 임의로 완화하지 않는다. + + +--- + +# 부록 A. 심층 리서치 원문 보존본 + +> 아래 내용은 설계 판단의 원본 근거를 보존하기 위해 첨부 파일을 변경 없이 수록한 것이다. 상단 설계 본문이 구현 계약이며, 충돌 시 상단 설계 본문을 따른다. + +# JPA 관계형 영속성 플랫폼 심층 리서치 + +이번 조사의 결론부터 정리하면, `jpa`는 **`JpaRepository`를 한 번 더 감싸는 공통 Repository 라이브러리로 설계해서는 안 됩니다.** Spring Data JPA 자체가 이미 Repository, Query Method, Pagination, Auditing, Custom Repository, Querydsl 통합 등을 제공하고 있으므로, 공통 플랫폼이 다시 CRUD 추상화를 만드는 것은 기능 중복과 추상화 누수를 동시에 만듭니다. 현재 Spring Data JPA 공식 프로젝트 페이지의 안정 버전은 `4.1.0`입니다. citeturn20view0 + +따라서 권장 구조는 다음과 같습니다. + +```text +Domain / Application +├─ Entity +├─ Embeddable +├─ Repository Interface +├─ Domain Query +├─ Index Requirement +└─ Domain-specific Lock / Soft-delete / Audit policy + │ + ▼ +JPA Persistence Platform +├─ jpa-core +│ ├─ transaction policy +│ ├─ persistence-context policy +│ ├─ error model +│ └─ observability contract +├─ jpa-spring-data +│ ├─ repository fragments +│ ├─ specification +│ ├─ projection +│ └─ auditing support +├─ jpa-hibernate +│ ├─ batching +│ ├─ fetch extensions +│ ├─ statistics +│ └─ StatelessSession +├─ jpa-postgresql +│ ├─ PostgreSQL types +│ ├─ native write/query +│ ├─ lock extensions +│ └─ keyset pagination +├─ jpa-migration-flyway +│ ├─ migration +│ ├─ validation +│ └─ schema release gate +└─ jpa-testkit + ├─ PostgreSQL Testcontainers + ├─ query-count assertions + ├─ concurrency fixtures + ├─ migration fixtures + └─ failure injection +``` + +핵심 설계 질문도 사용자께서 제시한 방향이 맞습니다. + +> **현재 EntityManager 안에서 성공했는가가 아니라, 데이터베이스에 어떤 상태가 확정되었는지, 충돌·Deadlock·Serialization Failure 뒤 전체 업무 트랜잭션을 다시 실행해도 되는지, Commit 결과조차 알 수 없을 때 어떤 증거로 복구할지를 플랫폼 계약으로 만들어야 합니다.** + +## 지원 기준과 공개 계층 + +**기술 기준선.** 2026년 8월 기준 Spring Data JPA 공식 페이지는 `4.1.0`을 표시하고 있으며, Spring Boot `4.1.0`의 dependency management를 사용하는 것이 개별 Hibernate/Flyway/Hikari 버전을 임의로 조립하는 것보다 안전한 기준선입니다. Boot 4.1 BOM은 HikariCP `7.0.2`를 포함하고 있으며, 같은 BOM이 Spring Data JPA, Hibernate ORM, Flyway 등 Spring 생태계의 검증된 조합을 관리합니다. citeturn20view0turn20view1 + +Hibernate ORM의 현재 안정 계열은 **7.4**이며, Hibernate의 7.4 문서는 현재 `7.4.6.Final`을 기준으로 제공되고 있습니다. Jakarta Persistence의 완성된 현재 규격은 **3.2**이고, Persistence 4.0은 아직 개발 중이며 2026년 후반을 목표로 하고 있으므로 Stable 계약으로 고정하면 안 됩니다. citeturn13search0turn7search2turn7search1 + +따라서 지원 매트릭스는 다음이 적절합니다. + +| 구성요소 | 권장 등급 | 기준 | +|---|---|---| +| Java 21 | **Stable baseline** | 플랫폼 언어 기준선 | +| Spring Boot BOM | **Stable baseline** | 개별 dependency 임의 조합 금지 | +| Spring Data JPA 4.1 | **Stable** | Repository·Projection·Specification·Auditing의 기본 진입점 citeturn20view0 | +| Jakarta Persistence 3.2 | **Stable** | 표준 JPA 계약 citeturn7search2turn17search0 | +| Hibernate ORM 7.4 | **Stable provider** | 기본 JPA Provider citeturn13search0 | +| Hibernate Validator | **Stable** | Bean-level early validation | +| Flyway | **Stable migration** | 실제 Schema 변경 Source of Truth | +| HikariCP | **Stable pool** | Boot-managed pool | +| PostgreSQL 16·17·18 | **Stable DB matrix** | 세 버전 모두 공식 지원 기간 내이며 PostgreSQL은 일반적으로 major 버전을 약 5년 지원 citeturn0search3turn13search5 | +| H2 | **Local Convenience** | PostgreSQL 호환성 증명에 사용하지 않음 | +| Testcontainers PostgreSQL | **Required** | 실제 PostgreSQL 의미론을 검증하는 Contract 환경 | +| Jakarta Persistence 4.0 | **Experimental** | 아직 개발 중 citeturn7search1 | +| Hibernate ORM 8 | **Experimental** | 7.4 Stable 이후 차세대 호환성 lane | +| MySQL·MariaDB·Oracle | **Future Profile** | 초기 공통 계약 밖 | + +PostgreSQL 18이 현재 정식 문서의 current 버전이고 PostgreSQL 19는 2026년 8월 현재 beta 단계이므로, **PG19를 Stable에 포함해서는 안 됩니다.** PostgreSQL 공식 문서는 현재 18을 Current로 표시하고 19 Beta 문서를 별도로 제공합니다. citeturn13search5 + +**H2의 위치도 명확해야 합니다.** H2는 빠른 로컬 개발이나 순수 Mapping smoke test에는 쓸 수 있지만, PostgreSQL의 locking, SQLSTATE, partial index, `NULLS NOT DISTINCT`, JSONB, Array, Range, `SKIP LOCKED`, isolation, query planner 동작을 증명하지 못합니다. Stable 선언은 실제 PostgreSQL 테스트를 통해서만 이루어져야 합니다. + +공개 계층은 다음처럼 나누는 것이 가장 자연스럽습니다. + +| 계층 | 공개 범위 | 대표 기능 | 정책 | +|---|---|---|---| +| **J1 Standard Persistence** | 일반 애플리케이션 | Spring Data Repository, JPQL, Projection, 기본 Transaction, `@Version` | 기본 경로 | +| **J2 Advanced Persistence** | 명시적 고급 사용 | Specification, EntityGraph, Query Hint, Pessimistic Lock, Batch, Scrolling | 공통 정책 적용 | +| **J3 Provider / DB Extension** | 제한형 | Hibernate Session, StatelessSession, JSONB, `ON CONFLICT`, `SKIP LOCKED`, Native SQL | 별도 모듈·명시적 의존성 | +| **J4 Admin / Operations** | 운영 계층 | Flyway, Index 생성, Backfill, Partition, maintenance SQL | 일반 서비스 코드에서 금지 | + +Spring Data의 `CrudRepository.save()` 자체도 Entity가 신규인지 판단해 `EntityManager.persist()` 또는 `merge()`를 호출합니다. 즉 `GenericRepository.save()`를 한 계층 더 추가해도 JPA의 `persist`/`merge` 차이를 없애지 못하며 오히려 숨길 뿐입니다. citeturn9search0 + +**권장 공개 구조는 따라서 다음입니다.** + +```java +// Domain owns this +public interface OrderRepository extends JpaRepository, + OrderRepositoryCustom { + Optional findByOrderNumber(OrderNumber orderNumber); +} + +// Domain-specific custom query contract +public interface OrderRepositoryCustom { + Slice findRecentOrders(OrderCursor cursor, int size); +} + +// J3 implementation may internally use: +// EntityManager +// Hibernate Session +// PostgreSQL native SQL +// +// but those types do not leak into application services. +``` + +`EntityManager`를 금지할 필요는 없습니다. 다만 **애플리케이션 전체에 자유롭게 노출하는 것이 아니라 Custom Repository 구현 또는 J3 Extension 내부에서 사용**하는 것이 좋습니다. Spring Data 역시 단순 Repository를 넘는 데이터 접근 코드를 custom fragment로 결합할 수 있도록 설계되어 있습니다. citeturn20view0 + +## Entity Mapping과 Persistence Context 계약 + +Jakarta Persistence 3.2는 Entity가 top-level 또는 static nested class여야 하고, public/protected no-arg constructor가 필요하며, portable Entity는 non-final class와 non-final persistent members를 사용하도록 규정합니다. Field access와 property access는 annotation 위치에 의해 결정되고, 계층 안에서 이를 암묵적으로 뒤섞으면 동작이 정의되지 않으므로 접근 전략을 일관되게 유지해야 합니다. citeturn17search0 + +따라서 Entity Mapping 기본 규칙은 다음이 적절합니다. + +| 항목 | 플랫폼 기본 정책 | +|---|---| +| Access | **Field Access 기본**, 특별한 이유가 있을 때만 `@Access(PROPERTY)` | +| Entity final | 금지 | +| no-arg constructor | `protected` 권장 | +| Entity API 직렬화 | 기본 금지 | +| Controller 반환 | DTO / Projection 사용 | +| `toString()` | LAZY association 포함 금지 | +| `equals/hashCode` | mutable association·mutable business field 포함 금지 | +| Entity callback | 데이터 정규화·감사 필드 같은 로컬 작업만; HTTP/Messaging 등 외부 I/O 금지 | +| BaseEntity | 전역 강제 상속 금지 | +| Soft Delete | 전역 강제 금지 | +| Audit | Opt-in capability | +| Association | 기본적으로 use-case fetch plan과 분리 | + +Jakarta Persistence 3.2에서는 `Instant`, `Year`, `UUID` 등이 표준 basic type에 포함되고, **Java record를 Embeddable로 사용할 수 있습니다.** 반면 record는 Entity가 될 수 없습니다. 따라서 record Embeddable은 Stable JPA 3.2 기능으로 볼 수 있지만, 실제 Boot-managed Hibernate 조합의 round-trip·dirty checking·nested embeddable 계약 테스트를 통과하는 것을 release gate로 두는 것이 안전합니다. citeturn17search0turn7search2 + +**Value Mapping 권고안은 다음과 같습니다.** + +| Java/domain type | 권장 | +|---|---| +| `Instant` | Stable, 서버 간 절대 시점 | +| `OffsetDateTime` | Stable, offset 자체가 업무적으로 필요한 경우 | +| `LocalDate` | Stable | +| `LocalDateTime` | timezone 없는 업무 시간에만 사용 | +| `Duration` | Converter 또는 provider mapping 검증 | +| `UUID` | Stable | +| Enum | 기본은 STRING 또는 명시적 converter; ordinal 금지 권고 | +| Money | Embeddable/value object | +| JSONB | `jpa-postgresql` | +| Array | `jpa-postgresql` | +| Range | `jpa-postgresql` | +| INET | Advanced PostgreSQL extension | +| LOB | 일반 Entity 조회에서 신중하게 사용 | +| Encrypted value | AttributeConverter만으로 끝내지 말고 key rotation·queryability 포함 별도 capability | + +**ID 생성 전략에서 PostgreSQL용 기본값은 `SEQUENCE`가 가장 안전합니다.** Hibernate 7.4는 `IDENTITY` 사용 시 INSERT JDBC batching을 수행할 수 없다고 명시하며, `IDENTITY`는 `persist()` 시 식별자를 얻기 위해 INSERT가 즉시 필요할 수 있습니다. 반대로 sequence 계열은 insert 전에 ID를 확보해 batching과 write-behind를 유지하기 쉽습니다. citeturn14view0turn14view1 + +| ID 전략 | Batch | 분산 생성 | Insert 전 ID | 권장 범위 | +|---|---:|---:|---:|---| +| `SEQUENCE` | 좋음 | DB 의존 | 가능 | **PostgreSQL 기본 추천** | +| `IDENTITY` | 나쁨 | DB 의존 | 불가 | 소규모 write에 한정 | +| JPA `UUID` | 좋음 | 가능 | 가능 | Stable | +| Application-assigned UUID | 좋음 | 가능 | 가능 | Stable | +| UUIDv7 | 좋음 | 가능 | 가능 | PG16~18 공통 생성 방식을 별도 정의 | +| Composite ID | 상황별 | 상황별 | 가능 | 도메인이 실제 composite identity인 경우만 | +| Natural ID | 별도 index 필요 | 상황별 | 보통 가능 | PK와 혼동하지 않음 | + +PostgreSQL 18은 native `uuidv7()`을 제공하지만 PostgreSQL 16·17 Stable 범위 전체에서 공통으로 사용할 수 있는 기능은 아닙니다. 따라서 DB-generated UUIDv7을 J1 표준으로 만들지 말고, **application-generated UUIDv7 또는 PostgreSQL 18 전용 extension**으로 분류해야 합니다. 또한 JPA의 `GenerationType.UUID`가 곧 UUIDv7을 뜻하지도 않습니다. citeturn8search0turn8search12turn17search0 + +Sequence를 쓸 때는 `allocationSize`를 명시적으로 관리해야 합니다. 값은 글로벌 상수 하나보다 write profile에 맞춰 benchmark해야 하며, 여러 프로세스가 같은 sequence를 이용하는 경우 allocation 동작도 실제 PostgreSQL에서 검증해야 합니다. + +**Association 정책은 FetchType보다 Fetch Plan이 더 중요합니다.** JPA에서 `EAGER`는 반드시 eager fetch 해야 하는 요구이고 `LAZY`는 provider에 대한 hint입니다. EntityGraph는 query/find 단위 fetch plan을 표현하기 위한 표준 기능입니다. 따라서 mapping에서 연관관계를 무조건 EAGER로 만들어 use case마다 필요 없는 graph를 끌고 오는 것보다, 최소 graph + explicit fetch plan을 기본으로 삼는 것이 적절합니다. citeturn17search0 + +권장 Association 계약은 다음과 같습니다. + +```text +ToOne +→ 기본적으로 명시적 LAZY를 검토 +→ 실제 proxy/lazy 동작을 Hibernate Contract Test로 보증 + +ToMany +→ LAZY +→ List 화면에서는 DTO Projection / EntityGraph / Fetch Join 선택 + +Cascade +→ lifecycle이 실제로 동일한 aggregate 내부에서만 + +Cascade.ALL +→ 전역 기본값 금지 + +orphanRemoval +→ child lifecycle을 parent가 독점 소유할 때만 + +ManyToMany +→ 단순 연결 외에는 join entity 우선 검토 +``` + +JPA 규격상 양방향 관계에서 persistence 동작에 중요한 것은 owning side이며, 양쪽 in-memory 객체 graph를 서로 맞추는 책임은 애플리케이션에게 있습니다. 따라서 양방향 association에는 `addChild/removeChild` 같은 편의 메서드 계약을 두는 것이 좋습니다. citeturn12view0 + +**Persistence Context 계약도 API 문서보다 중요합니다.** `persist`, `merge`, `flush`, `commit`은 서로 다른 의미를 가집니다. `merge()`는 detached instance 자체를 managed로 바꾸는 것이 아니라 그 state를 managed instance에 복사하는 방식이고, `flush()`는 Persistence Context를 DB와 동기화하지만 transaction commit과 동일하지 않습니다. citeturn12view0 + +플랫폼 계약은 아래처럼 고정하는 것이 좋습니다. + +```text +Persistence Context +→ transaction-scoped + +Extended Persistence Context +→ Stable 비지원 + +EntityManager +→ thread-safe로 간주하지 않음 + +OSIV +→ 명시적으로 false + +Lazy loading +→ application transaction 내부 + +Web/API +→ Entity 직접 반환 금지 + +flush() +→ SQL 반영 시점 제어 +→ commit 보장 아님 + +clear() +→ managed state 제거 + +refresh() +→ DB state 재조회 + +Bulk DML +→ flush +→ bulk DML +→ clear 또는 필요한 entity refresh +``` + +JPA Bulk UPDATE/DELETE는 persistence context를 자동으로 동기화하지 않고 optimistic locking check도 자동 적용하지 않습니다. 따라서 Bulk DML 후 이미 managed 상태인 Entity를 계속 사용하는 것은 stale-state 오류의 직접 원인이 됩니다. citeturn12view1 + +`Open Session in View`는 **플랫폼 차원에서 명시적으로 비활성화**하는 것이 좋습니다. 중요한 것은 Spring Boot의 특정 버전 기본값에 의존하지 않고 다음 invariant를 만드는 것입니다. + +```properties +spring.jpa.open-in-view=false +``` + +그 결과 `LazyInitializationException`은 Web serialization에서 우연히 발생하는 production 장애가 아니라, use case에 필요한 Fetch Plan을 Repository 계층에서 빠뜨렸다는 **개발 시점 계약 위반**으로 취급할 수 있습니다. + +## Transaction·Lock·Retry와 Commit 불명확성 + +Spring Data JPA도 여러 Repository를 묶는 unit of work에서는 service/facade 수준에 transaction boundary를 두는 방식을 권장합니다. 외부 transaction이 있으면 내부 Repository 설정보다 외부 unit-of-work transaction이 실제 경계를 결정합니다. citeturn15search1 + +따라서 기본 계약은 다음입니다. + +```text +Controller + │ + ▼ +Application Service ← @Transactional boundary + │ + ├─ Repository A + ├─ Repository B + └─ Domain operation +``` + +그리고 다음 구조는 피해야 합니다. + +```text +@Transactional +DB UPDATE +→ 3초 HTTP 호출 +→ Object Storage 전송 +→ Kafka publish +→ DB COMMIT +``` + +Spring Framework는 transaction context가 일반적인 remote call까지 전파되는 모델이 아니며, 긴 외부 작업을 로컬 DB transaction 내부에 넣으면 connection과 row lock의 보유 시간이 외부 시스템 latency에 종속됩니다. DB 변경과 메시지 발행을 연계해야 한다면 XA처럼 보이게 숨기기보다 Transactional Outbox를 사용하는 것이 더 안전한 경계입니다. citeturn15search7 + +**Propagation 정책은 다음 정도로 강하게 제한하는 것이 좋습니다.** + +| Propagation | 등급 | 플랫폼 규칙 | +|---|---|---| +| `REQUIRED` | 기본 | Application use case 기본 | +| `MANDATORY` | 선택 Stable | 반드시 상위 transaction이 필요한 내부 write service | +| `SUPPORTS` | 제한 | read helper 정도 | +| `REQUIRES_NEW` | 주의 | 명시적 독립 commit이 업무적으로 필요한 경우만 | +| `NESTED` | Advanced | JPA portable 기능처럼 취급하지 않고 savepoint 호환성 검증 | +| `NOT_SUPPORTED` | Advanced | 긴 외부 I/O 분리 등에 제한적으로 사용 | + +Spring의 `REQUIRES_NEW`는 별도의 physical transaction과 resource를 사용합니다. 외부 transaction이 connection을 붙잡은 채 내부 transaction이 또 다른 connection을 요구하므로, 동시 호출이 많으면 pool exhaustion 또는 deadlock으로 이어질 수 있다고 Spring 문서가 명시적으로 경고합니다. `NESTED`는 JDBC savepoint를 기반으로 하는 의미론입니다. citeturn15search0 + +또한 Spring의 기본 proxy transaction model에서는 **self-invocation이 transactional interception을 거치지 않습니다.** 따라서 같은 클래스 안에서 `this.someRequiresNewMethod()`를 호출하고 별도 transaction이 생성된다고 가정하는 코드는 금지 대상이 되어야 합니다. citeturn15search6turn15search9 + +Spring `@Transactional`의 기본값은 `REQUIRED`, isolation `DEFAULT`, read-write이며, 기본 rollback 규칙은 `RuntimeException`과 `Error`입니다. Checked exception까지 rollback해야 하는 업무에서는 `rollbackFor` 또는 안정적인 application exception hierarchy를 명시해야 합니다. citeturn15search6 + +**Isolation은 PostgreSQL 실제 의미론을 기준으로 계약해야 합니다.** + +| Isolation | PostgreSQL 관점 | 권장 | +|---|---|---| +| `READ COMMITTED` | 기본 isolation | 일반 업무 기본 | +| `REPEATABLE READ` | snapshot 내 일관성 강화; concurrent update 시 serialization failure 가능 | 명시적 use case | +| `SERIALIZABLE` | serial execution과 동등한 결과를 목표로 하며 abort/retry 가능 | 좁은 핵심 invariant | +| `READ UNCOMMITTED` | PostgreSQL에서는 실질적으로 READ COMMITTED 의미 | 공개 profile로 권장하지 않음 | + +PostgreSQL은 Repeatable Read/Serializable에서 concurrency anomaly를 해결하기 위해 transaction을 abort시킬 수 있으며, Serializable 문서는 실패한 경우 **transaction 전체를 처음부터 다시 실행**해야 한다고 명시합니다. citeturn13search9turn8search6 + +이 때문에 Retry 단위는 다음과 같아야 합니다. + +```text +잘못된 방식 + +@Transactional +service() + repository.update() // 실패 + retry(repository.update) // 일부 SQL만 재실행 + + +권장 방식 + +retryTransaction( + () -> applicationUseCase() +) +``` + +즉 **새 Persistence Context와 새 DB transaction에서 전체 use case를 재실행**해야 합니다. + +**Optimistic Lock은 기본 동시성 제어의 첫 번째 선택지**로 두는 것이 적절합니다. + +```java +@Version +private long version; +``` + +JPA는 optimistic version check가 flush 또는 commit 시점까지 지연될 수 있음을 허용하며, 충돌 시 `OptimisticLockException`을 발생시킵니다. 즉 update method 호출 직후 충돌이 반드시 드러난다고 가정하면 안 됩니다. citeturn12view2 + +Optimistic retry는 다음 조건을 모두 만족해야 합니다. + +```text +전체 application transaction을 다시 계산할 수 있음 +AND +외부 irreversible side effect가 없음 +AND +업무 deadline이 남아 있음 +AND +retry 횟수가 제한됨 +``` + +**Pessimistic Lock은 다음 계약으로 제한**하는 것이 좋습니다. + +| 기능 | 용도 | 위험 | +|---|---|---| +| `PESSIMISTIC_READ` | shared-style lock 요구 | 장시간 transaction | +| `PESSIMISTIC_WRITE` | 쓰기 경쟁 직렬화 | lock wait·deadlock | +| `PESSIMISTIC_FORCE_INCREMENT` | version까지 증가 | contention | +| `NOWAIT` | 기다리지 않고 즉시 실패 | 실패율 증가 | +| `SKIP LOCKED` | work queue형 competing worker | 일반 조회에는 inconsistent view | + +JPA의 pessimistic lock은 transaction 종료까지 유지되어야 하며, database transaction rollback 수준의 lock 실패와 statement 수준 timeout을 `PessimisticLockException`/`LockTimeoutException`으로 구분합니다. PostgreSQL의 `SKIP LOCKED`는 일관된 일반 조회 view를 제공하지 않기 때문에 queue-like consumer에 적합하다고 공식 문서가 명시합니다. citeturn12view3turn8search4turn8search5 + +따라서 `SKIP LOCKED`를 `findAllUnlocked()` 같은 공통 Repository API로 제공해서는 안 되고, + +```text +jpa-postgresql +└─ WorkClaimExtension + └─ claimNextBatch(...) +``` + +처럼 semantics가 드러나는 API로 한정하는 것이 좋습니다. + +**DB Constraint는 최종 불변식입니다.** 다음 코드는 경쟁을 막지 못합니다. + +```java +if (!repository.existsByEmail(email)) { + repository.save(new User(email)); +} +``` + +동시에 두 transaction이 `false`를 읽을 수 있기 때문입니다. 최종 uniqueness는 `UNIQUE` constraint/index가 담당하고 애플리케이션의 `exists` 검사는 빠른 UX validation 정도로만 사용해야 합니다. PostgreSQL은 unique constraint/primary key에 unique index를 자동 생성하며, `NULLS NOT DISTINCT`를 사용해 NULL도 동일 값처럼 취급하는 unique semantics를 제공할 수 있습니다. citeturn17search1 + +PostgreSQL 전용 partial unique index가 필요하다면 Entity annotation에 억지로 추상화하지 말고 Flyway migration으로 관리합니다. + +```sql +CREATE UNIQUE INDEX uq_user_active_email +ON users (email) +WHERE deleted_at IS NULL; +``` + +이는 Soft Delete와 Unique Constraint 충돌을 해결하는 대표적인 PostgreSQL extension 패턴입니다. + +**Commit 결과 불명확성은 별도 오류로 모델링해야 합니다.** + +예를 들어: + +```text +Application + │ + │ COMMIT + ▼ +PostgreSQL + │ + │ 실제 commit 완료 + X TCP connection loss + │ +Application + └─ commit 결과를 받지 못함 +``` + +이때 같은 업무를 자동 재실행하면 이미 commit된 INSERT나 상태 변경을 두 번 실행할 수 있습니다. PostgreSQL의 SQLSTATE 체계 자체에도 `40003 statement_completion_unknown`이라는 별도 completion-unknown condition이 정의되어 있고, SQLSTATE는 문자열 오류 메시지보다 안정적인 기계 판독 기준으로 사용하도록 PostgreSQL이 권고합니다. citeturn13search1 + +따라서 플랫폼에는 JPA 표준 exception이 아닌 **플랫폼 고유 분류**로 다음을 두는 것을 권장합니다. + +```java +final class TransactionCompletionUnknown + extends JpaPersistenceException { + + String operationName; + String transactionKey; + String sqlState; + boolean commitAttempted; + String traceId; +} +``` + +이 오류에 대한 정책은 명확해야 합니다. + +```text +TransactionCompletionUnknown +→ 자동 Retry 금지 +→ 동일 업무 key로 상태 재조회 +→ Unique Constraint / Idempotency Record 확인 +→ Outbox / transaction record 확인 +→ 결과 확정 불가 시 reconciliation +``` + +즉 error taxonomy는 단순히 “transient/non-transient” 두 종류로 나누면 부족합니다. + +## Query·Fetch·Pagination과 Write 성능 + +Spring Data JPA는 derived query, custom query, pagination, custom repository, Querydsl integration 등을 공식 지원하므로, 플랫폼의 역할은 이를 하나의 API로 대체하는 것이 아니라 **어떤 레벨에서 무엇을 쓸지 결정하는 것**입니다. citeturn20view0 + +권장 Query 등급은 다음과 같습니다. + +| 등급 | 방식 | 사용 기준 | +|---|---|---| +| Q1 | Derived Query | 짧고 명확한 equality/range 조회 | +| Q1 | JPQL `@Query` | 고정 query, domain repository 안에서 읽기 쉬운 경우 | +| Q1 | DTO Projection | 목록·read model 기본 후보 | +| Q2 | Specification | optional filter 조합 | +| Q2 | Criteria | framework-level dynamic query | +| Q2 | Querydsl | 복잡한 type-safe dynamic query의 선택 capability | +| Q2 | EntityGraph | use-case fetch plan | +| Q3 | Native SQL | PostgreSQL 기능·계획 통제가 필요한 경우 | +| Q3 | Hibernate Query API | provider 기능 필요 시 | +| Q4 | Bulk/Admin SQL | backfill, maintenance | + +Derived query에 “최대 단어 수” 같은 임의 숫자를 플랫폼에 박는 것은 좋지 않습니다. 대신 **method name이 업무 의미보다 SQL 구조를 설명하기 시작하면 custom query로 승격한다**는 코드리뷰 규칙이 더 안정적입니다. + +Dynamic sort는 field allowlist가 필요합니다. Spring Data는 일반적인 domain property 기반 `Sort`와 명시적으로 unsafe한 expression sort를 구분하기 때문에, 사용자 입력 문자열을 `JpaSort.unsafe()` 등에 직접 연결하지 않는 정책이 필요합니다. citeturn18search12 + +**Fetch 전략은 Mapping이 아니라 Use Case 계약으로 관리**해야 합니다. + +| 상황 | 우선 선택 | +|---|---| +| 단일 aggregate 상세 | EntityGraph / Fetch Join | +| 여러 ToOne | Fetch Join 또는 EntityGraph | +| 하나의 필요한 ToMany | Fetch Join 검토 | +| 여러 ToMany | DTO / 다단계 query / batch fetch | +| 목록 화면 | DTO Projection | +| 페이지형 parent + collection | Hibernate 버전과 SQL plan 검증 | +| 대규모 read model | Projection / Native Query | +| 반복 LAZY N+1 | Batch Fetch 또는 explicit fetch plan | + +Hibernate는 여러 to-one fetch를 한 query에서 사용하는 것은 비교적 안전하지만, 여러 collection을 병렬 join fetch하면 DB 레벨 Cartesian product가 발생해 row 수와 hydration cost가 크게 증가할 수 있음을 문서화하고 있습니다. citeturn13search14 + +여기에는 **2026년 기준 중요한 변경점**이 있습니다. + +기존 Hibernate 6 또는 초기 Hibernate 7에서는 collection fetch join과 pagination을 조합하면 limit이 JVM에서 적용되어 전체 결과를 읽어버리는 심각한 문제가 있었습니다. 그러나 **Hibernate ORM 7.4에서는 PostgreSQL처럼 subquery 안의 limit/offset을 지원하는 DB에서 이 문제가 해결되었습니다.** Hibernate 7.4의 “What’s New”가 이를 명시적으로 새 기능으로 소개합니다. citeturn13search0turn13search11 + +따라서 기존 규칙인 + +```text +Collection Fetch Join + Pagination +→ 무조건 금지 +``` + +는 현재 baseline에서는 너무 강합니다. + +정확한 규칙은 다음이어야 합니다. + +```text +Hibernate 7.4 + PostgreSQL 16~18 +→ 지원 가능 +→ generated SQL / rows / count query / cartesian amplification을 Contract Test + +Hibernate 이전 버전 또는 다른 provider +→ capability 재검증 + +여러 collection fetch +→ pagination 해결 여부와 별개로 Cartesian 위험 때문에 기본 제한 +``` + +이 부분은 반드시 회귀 테스트에 넣어야 합니다. “과거 성능 장애 사례”와 “현재 지원 기능”을 구분하지 않으면 JPA 플랫폼이 이미 수정된 Hibernate 제한을 영구 정책으로 굳히게 됩니다. citeturn13search0turn13search2 + +**N+1 테스트는 SQL 개수 하나만 보면 부족합니다.** + +```text +statementCount +entityLoadCount +entityFetchCount +collectionFetchCount +returnedParents +hydratedEntities +rowsFromDatabase +duration +``` + +를 함께 보아야 합니다. 예컨대 SQL 1개라도 100 parent × 100 child × 20 second-child Cartesian product가 만들어지면 좋은 Fetch Plan이 아닙니다. + +테스트 fixture 역시: + +```text +0 child +1 child +10~100 children +shared ToOne +두 개 이상의 collection +skewed distribution +``` + +을 포함해야 합니다. + +**Pagination 계약은 세 종류로 나누는 것이 좋습니다.** + +| 방식 | 장점 | 단점 | 기본 용도 | +|---|---|---|---| +| `Page` | total count 제공 | count query 비용 | 작은 관리자 화면 | +| `Slice` | count 불필요 | 전체 개수 없음 | 일반 목록 | +| Offset | 구현 간단 | 깊은 페이지 비용·삽입 시 이동 | 작은 데이터 | +| Keyset/Cursor | 큰 데이터에 유리 | stable ordering·cursor 설계 필요 | 일반 대규모 목록 | +| Stream/Scroll | 전체 적재 회피 | transaction/resource lifetime | batch/read processing | + +Spring Data의 Scroll API는 offset/keyset scrolling을 지원하지만 query 방식에 따라 지원 범위가 다르며, 공식 문서는 string-based `@Query`나 stored procedure에서 scrolling을 지원하지 않는 제한을 명시합니다. citeturn18search12turn9search8 + +Keyset cursor에는 반드시 전체 순서를 결정하는 tie-breaker가 필요합니다. + +```sql +ORDER BY created_at DESC, id DESC +``` + +라면 cursor도: + +```text +(createdAt, id) +``` + +두 값을 모두 저장해야 합니다. `created_at` 하나만 cursor로 쓰면 같은 timestamp를 가진 row가 누락되거나 반복될 수 있습니다. + +**Batch Write는 `saveAll()`과 동일하지 않습니다.** Hibernate의 JDBC batching은 `hibernate.jdbc.batch_size`가 0 이하이면 꺼져 있고, batching 활성화 뒤에도 ID generator와 SQL shape에 따라 실제 batch 여부가 달라집니다. Hibernate 7.4는 `order_inserts`, `order_updates`를 제공하지만 이 옵션 역시 overhead가 있으므로 benchmark를 권고합니다. citeturn14view1 + +권장 write profile은 다음입니다. + +```yaml +jpa: + write-profiles: + default: + batch-size: 0 + + batch: + jdbc-batch-size: 50 + order-inserts: true + order-updates: true + flush-size: 50 + clear-size: 50 +``` + +정확한 50이라는 값 자체가 universal optimum이라는 뜻은 아니며 프로파일 기본 예시입니다. 실제 완료 조건은 “configured batch size가 SQL/JDBC batch로 관찰됨”입니다. + +Hibernate는 대량 Entity를 하나의 stateful Session에 계속 넣으면 Persistence Context에 Entity가 누적되고 장기 transaction이 connection pool을 오래 점유한다고 설명하며, batch loop에서 주기적인 `flush()`와 `clear()`를 권장합니다. citeturn14view1 + +```java +for (int i = 0; i < records.size(); i++) { + entityManager.persist(records.get(i)); + + if (i > 0 && i % batchSize == 0) { + entityManager.flush(); + entityManager.clear(); + } +} +``` + +**대규모 Backfill은 JPA Entity lifecycle 자체가 필요하지 않을 수도 있습니다.** Hibernate `StatelessSession`은 Persistence Context와 연결되지 않은 detached-like object를 반환하고 insert/update/delete가 DB row에 직접 작용하는 다른 semantics를 갖습니다. 따라서 일반 Repository 대체가 아니라 J3/J4 대량 작업 extension으로 분류해야 합니다. citeturn14view4 + +권장 계층은 다음과 같습니다. + +```text +일반 업무 write +→ JPA Entity + +수천~수만 row +→ JPA + JDBC batch + chunk flush/clear + +대규모 migration/backfill +→ StatelessSession / JdbcTemplate / PostgreSQL COPY + +운영 대량 수정 +→ J4 Job +``` + +**Bulk DML**은 더 엄격합니다. + +```text +flush +→ JPQL/Native Bulk UPDATE +→ clear +→ 필요 시 재조회 +``` + +가 기본 계약입니다. Bulk JPQL/Criteria DML은 managed entity state를 자동 동기화하지 않으며 optimistic locking도 자동 적용하지 않습니다. citeturn12view1 + +**Cache 정책도 단순하게 가져가는 편이 안전합니다.** + +```text +First-level Cache +→ JPA 기본, 항상 존재 + +Second-level Cache +→ 기본 Opt-out / Entity별 명시 Opt-in + +Query Cache +→ 기본 OFF + +Application Cache +→ 별도 Redis/cache 플랫폼 +``` + +Hibernate 7.4는 query cache 기본값이 false이고, shared cache mode에서는 `ENABLE_SELECTIVE`를 기본·권장하여 명시적으로 cacheable인 Entity만 second-level cache에 넣도록 설명합니다. 또한 외부 애플리케이션이 DB를 변경하면 Hibernate cache가 이를 자동 인지하지 못한다는 제한도 있습니다. citeturn14view3 + +즉 Redis application cache와 Hibernate L2 cache를 “같은 Cache 기능”으로 묶으면 안 됩니다. + +## PostgreSQL·Schema Migration·확장 정책 + +JPA Mapping은 **애플리케이션의 object-relational mapping 계약**이고, 실제 schema 변경 Source of Truth는 **Flyway migration**으로 두는 것이 적절합니다. + +권장 환경 정책은 다음입니다. + +| 환경 | Flyway | Hibernate DDL | +|---|---|---| +| local PostgreSQL | migrate | `validate` | +| H2 convenience | 선택적 create/drop | 실제 호환성 증명 아님 | +| test | migrate | `validate` | +| dev | migrate | `validate` | +| staging | deployment migration | `validate` | +| prod | 별도 권한/배포 주체로 migration | `validate` | + +운영에서 다음은 기본 금지로 두는 것이 좋습니다. + +```text +hibernate.ddl-auto=update +hibernate.ddl-auto=create +hibernate.ddl-auto=create-drop +application runtime credential의 DDL 권한 +적용 완료된 Versioned Migration 수정 +startup 시 자동 Flyway repair +``` + +Flyway `validate`는 적용된 migration과 로컬 migration의 name/type/checksum 등을 비교하고 불일치나 누락을 실패로 보고합니다. SQL migration checksum은 현재 문서 기준 CRC32로 저장됩니다. citeturn19search0 + +Versioned migration은 순서대로 한 번 적용하고 이미 영구 환경에 적용한 파일은 수정하지 않고 새 migration으로 roll-forward하는 것이 Flyway가 권장하는 방식입니다. Repeatable migration은 checksum이 변경될 때 다시 실행됩니다. citeturn19search3turn19search6 + +`repair`는 단순한 “검증 복구” 기능이 아닙니다. 실패 migration 기록 제거, checksum/description/type 재정렬, missing migration을 deleted로 표시하는 등의 변경을 수행하며, DB에 남은 user object는 수동으로 정리해야 할 수 있습니다. 따라서 J4 승인 작업으로 두어야 합니다. citeturn19search1 + +**무중단 Migration의 기본 패턴은 Expand → Migrate → Contract입니다.** + +```text +Release A +ADD nullable column +ADD new table/index +Application can handle old + new schema + + ↓ + +Backfill +chunked data migration + + ↓ + +Release B +new column becomes authoritative + + ↓ + +Release C +old column/index/API removed +constraint tightened +``` + +큰 테이블에서 index를 만드는 경우 PostgreSQL의 `CREATE INDEX CONCURRENTLY`를 별도 migration 유형으로 취급해야 합니다. PostgreSQL은 concurrent index build를 transaction block 안에서 실행할 수 없다고 명시하므로, Flyway의 일반 transaction wrapping과 충돌하지 않도록 해당 migration을 non-transactional로 명시적으로 분리해야 합니다. citeturn17search3turn19search16 + +Flyway의 `group=true`는 여러 pending migrations를 한 transaction에 묶는 옵션이지만, DDL transaction을 적절히 지원하는 DB에서만 권장되며 기본은 false입니다. 무조건 활성화할 설정이 아닙니다. citeturn19search13 + +**Constraint 정책은 아래처럼 나누는 것이 좋습니다.** + +```text +Bean Validation +→ 빠른 입력/객체 검증 +→ 사용자 친화적 오류 + +Database Constraint +→ concurrency 하에서도 지켜져야 하는 최종 invariant +``` + +| Constraint | DB 필수성 | +|---|---| +| Primary Key | 필수 | +| Foreign Key | 관계 불변식에 기본 | +| `NOT NULL` | 실제 non-null invariant이면 DB에도 적용 | +| Unique | 경쟁 가능 uniqueness는 DB가 최종 보장 | +| Check | DB 자체로 표현 가능한 invariant에 적극 검토 | +| Exclusion | PostgreSQL 고유 overlap 등 고급 invariant | + +**Index 역시 Entity field에 자동 생성하는 문제가 아닙니다.** PostgreSQL은 B-tree, GiST, GIN, BRIN, multicolumn, expression, partial, covering `INCLUDE` 등 다양한 index 기능을 제공합니다. 특히 multicolumn index는 실제 predicate, sort와 data distribution을 기준으로 설계해야 합니다. citeturn17search2turn8search9 + +플랫폼은 “자동 Index 생성기”보다 다음을 제공하는 것이 더 유용합니다. + +```text +Query Name +→ representative parameters +→ EXPLAIN / EXPLAIN ANALYZE +→ estimated rows / actual rows +→ scan type +→ sort +→ temporary spill +→ buffers +→ execution time +→ expected index document +``` + +**PostgreSQL extension 지원표**는 다음이 적절합니다. + +| 기능 | 등급 | 비고 | +|---|---|---| +| JSONB | **P1 Stable Extension** | PostgreSQL-native value/query | +| Array | **P1 Stable Extension** | 타입별 contract test | +| Range | **P1 Stable Extension** | 기간·구간 도메인 | +| UUID | **P1 Stable** | standard/native | +| INET | P2 Advanced | networking domain | +| Native Enum | P2 Advanced | migration coupling 큼 | +| `ON CONFLICT` | **P1 Native Write Extension** | 명시적 upsert semantics | +| `RETURNING` | **P1 Native Write Extension** | native write 최적화 | +| Window Function | P1/P2 Query Extension | read model | +| CTE | P2 | 복잡한 read/write | +| Recursive CTE | P2 | 제한된 use case | +| `NOWAIT` | **P1 Lock Extension** | fast-fail lock | +| `SKIP LOCKED` | **P1 Worker Extension** | queue-like use case만 citeturn8search4 | +| Advisory Lock | P2 Advanced | transaction/session scope를 명시 | +| Partial Index | **J4 Migration** | query-specific | +| Expression Index | J4 Migration | query-specific | +| `NULLS NOT DISTINCT` | **J4 Stable Migration** | PG unique semantics citeturn17search1 | +| Generated Column | P2/J4 | mapping·migration 검증 | +| Full-text Search | P2 | 전문 검색 규모에서는 별도 검색 플랫폼과 비교 | +| Partitioning | **J4 Admin** | application Repository가 생성·삭제하지 않음 | +| Row-Level Security | Experimental/Admin | tenant context·connection reuse까지 검증 필요 | + +**Auditing은 강제 BaseEntity보다 선택형이 낫습니다.** Spring Data JPA는 created/modified user/time을 기록하는 auditing 기능을 이미 제공하므로 공통 플랫폼은 이를 활성화할 수 있는 primitive만 제공하고, 도메인이 필요한 Entity에 선택적으로 적용하도록 해야 합니다. citeturn20view0turn18search5 + +```text +Technical Auditing +createdAt / createdBy / modifiedAt / modifiedBy + +≠ + +Business Audit +“누가 주문 상태를 왜 취소했는가” + +≠ + +Entity History +과거 row revision + +≠ + +Security Audit +관리자 권한·DDL·replay +``` + +Hibernate Envers는 Entity History 선택 기능으로 둘 수 있지만 J1 기본 기능으로 만들 필요는 없습니다. + +**Soft Delete 역시 global 기능으로 제공하지 않는 것이 좋습니다.** + +```text +Global @Where deleted=false +→ 비추천 + +Domain-specific status/deletedAt +→ 필요 도메인에만 + +복구 가능한 삭제 +→ 도메인 계약 + +법적/개인정보 물리 삭제 +→ 별도 lifecycle +``` + +Soft Delete를 공통 필터로 숨기면 unique constraint, FK, admin query, archive, restore, 개인정보 삭제가 모두 암묵적 semantics에 묶입니다. PostgreSQL partial unique index 같은 기능이 필요한 이유도 이 경계 때문입니다. + +**Multi-tenancy는 초기 Stable Core에서 제외하는 것이 안전합니다.** + +| 모델 | 권장 초기 등급 | +|---|---| +| 단일 DB·schema | Stable | +| Shared schema + tenant column | Experimental capability | +| Schema per tenant | Experimental | +| DB per tenant | Experimental | +| RLS 기반 | Experimental | +| Multi DataSource | Advanced/Experimental | +| Read Replica routing | Experimental | + +Read replica는 `@Transactional(readOnly=true)`만 보고 자동 routing해서는 안 됩니다. replica lag 때문에 같은 사용자 흐름의 직전 write가 보이지 않을 수 있고 lock query는 primary가 필요하기 때문입니다. Stable Core에는 transaction read-only hint까지만 포함하고 routing은 별도 profile로 두는 것이 적절합니다. + +## 오류·보안·관측성·테스트 계약 + +Spring의 exception translation과 PostgreSQL SQLSTATE를 활용하되 애플리케이션에 provider/vendor exception을 그대로 노출하지 않는 것이 좋습니다. PostgreSQL 공식 문서는 오류 판단 시 locale에 따라 달라지는 message text가 아니라 SQLSTATE를 검사하라고 권장하며, integrity violation에서는 constraint name 같은 structured field도 전달합니다. citeturn13search1 + +권장 오류 모델은 다음과 같습니다. + +```text +JpaPersistenceException +├─ EntityNotFound +├─ OptimisticConflict +├─ PessimisticLockTimeout +├─ DeadlockDetected +├─ SerializationFailure +├─ UniqueConstraintViolation +├─ ForeignKeyViolation +├─ CheckConstraintViolation +├─ QueryTimeout +├─ TransactionTimeout +├─ ConnectionUnavailable +├─ SchemaMismatch +├─ DataCorruption +└─ TransactionCompletionUnknown +``` + +PostgreSQL SQLSTATE를 활용하면 대표적으로 serialization failure `40001`, deadlock `40P01`, 그리고 completion unknown 계열을 문자열 parsing 없이 분류할 수 있습니다. Constraint violation도 class 23을 기준으로 구조화할 수 있습니다. citeturn13search1 + +**Retry 판정표는 다음처럼 두는 것이 좋습니다.** + +| 오류 | 자동 Retry | 단위 | 조건 | +|---|---|---|---| +| `OptimisticConflict` | 조건부 | 전체 use case transaction | 재계산 가능, side effect 없음 | +| `SerializationFailure` | 조건부 | 전체 transaction | bounded attempts + jitter | +| `DeadlockDetected` | 조건부 | 전체 transaction | bounded attempts | +| Lock timeout | 조건부 | 전체 use case | deadline과 업무 정책 확인 | +| Connection acquire 전 실패 | 제한적 | 전체 use case | DB에 작업이 시작되지 않았음이 확실 | +| Unique violation | 기본 금지 | — | idempotent create라면 기존 record 재조회 가능 | +| FK violation | 금지 | — | 업무 순서/데이터 오류 | +| Check violation | 금지 | — | 업무 invariant 오류 | +| Query timeout | 기본 금지 | — | 동일 부하에서 반복하면 부하만 증폭 | +| Schema mismatch | 금지 | — | 배포 오류 | +| Commit 결과 불명 | **금지** | reconciliation | 중복 실행 위험 | + +모든 retry에는: + +```text +maxAttempts +maxElapsedTime +exponentialBackoff +jitter +transaction deadline +retry metrics +``` + +가 있어야 합니다. + +**보안 정책은 Repository API보다 DB credential과 dynamic query 제한이 중요합니다.** + +```text +Application Role +├─ SELECT +├─ INSERT +├─ UPDATE +├─ DELETE +└─ 필요한 sequence 사용 + +Migration Role +├─ CREATE +├─ ALTER +├─ DROP +└─ index / constraint / schema + +Read-only Role +└─ 필요한 SELECT + +Admin Role +└─ 승인된 운영 작업 +``` + +운영 application credential에는 `CREATE TABLE`, `ALTER TABLE`, `DROP TABLE`, extension 설치 권한을 주지 않는 것이 적절합니다. + +PostgreSQL은 `search_path`에 CREATE 권한을 가진 신뢰하지 않는 schema가 들어가면 object resolution이 보안 문제가 될 수 있음을 문서화하고 있으며, 안전한 schema privilege 패턴을 별도로 설명합니다. 따라서 migration schema를 명확히 하고 application role의 `search_path`를 고정·검증해야 합니다. citeturn21search6 + +추가 보안 규칙은 다음처럼 고정하는 것이 좋습니다. + +```text +JPQL +→ parameter binding + +Native SQL +→ J3 내부 +→ 값 문자열 연결 금지 + +Dynamic sort +→ allowlist + +Dynamic table/column +→ 원칙적 금지 +→ 불가피하면 enum/catalog mapping + +Entity +→ API request mass binding 금지 + +SQL parameter logging +→ production 기본 OFF + +Tenant ID +→ metric tag / raw log 금지 + +DB password +→ secret manager / workload identity 경로 +``` + +**관측성은 이미 Boot에서 상당 부분 제공됩니다.** Spring Boot는 DataSource에 `jdbc.connections` active/idle/max/min gauge를 만들고 Hikari-specific `hikaricp` metrics도 제공합니다. `hibernate-micrometer`가 있고 Hibernate statistics를 활성화하면 Hibernate metrics를, Spring Data Repository 호출에는 `spring.data.repository.invocations`를 제공합니다. citeturn21search0 + +공통 관측 계약은 이를 다음처럼 확장하는 것이 적절합니다. + +| 계층 | 필수 관측 | +|---|---| +| Pool | active, idle, pending, max, acquire latency, timeout | +| Transaction | count, latency, rollback, timeout, isolation, retry, completion-unknown | +| Query | queryName, count, latency, rows, timeout, lock wait | +| Fetch | statement count, entity load/fetch, collection fetch | +| Batch | batch count, batch size, flushed entities | +| Lock | optimistic conflict, pessimistic timeout, deadlock | +| Migration | version, validate result, migration duration | +| Retry | reason, attempt, elapsed | +| Cache | L2/query hit/miss when enabled | + +Metric cardinality는 낮게 유지합니다. + +**허용:** + +```text +persistenceUnit +operationName +bounded entityType +bounded queryName +outcome +failureCategory +isolation +``` + +**금지:** + +```text +entityId +userId +tenantId 원문 +SQL parameter +Email / Phone / PII +임의 SQL text +dynamic WHERE clause +``` + +SQL 전체 문자열을 metric dimension으로 쓰는 대신 정규화된 query fingerprint 또는 등록된 `queryName`을 사용합니다. SQL parameter logging은 production에서 기본 비활성화해야 합니다. + +HikariCP의 pool size 자체도 무작정 키우면 안 됩니다. Hikari는 maximum pool size 도달 시 connection 반환을 기다리다가 `connectionTimeout` 이후 실패하는 모델을 사용하므로, 관측해야 할 핵심은 단순 active count가 아니라 **pending/acquire latency와 transaction duration**입니다. citeturn11search2turn21search0 + +**테스트는 H2 중심이 아니라 PostgreSQL Contract 중심으로 설계해야 합니다.** + +테스트 피라미드는 다음이 적절합니다. + +```text +Pure Unit +→ domain logic + +@DataJpaTest +→ repository wiring / quick mapping + +PostgreSQL Testcontainers +→ real persistence semantics + +PostgreSQL 16 / 17 / 18 Matrix +→ release compatibility + +Fault Injection +→ lock / network / commit ambiguity + +Migration Snapshot +→ real upgrade path +``` + +Testcontainers는 실제 PostgreSQL image를 실행할 수 있으므로 DB 고유 기능에 의존하는 integration test를 H2 대체 구현이 아니라 실제 DB에서 수행하는 기반으로 적합합니다. citeturn11search0turn11search10 + +필수 Contract Test 목록은 다음과 같습니다. + +| 범주 | Release Gate | +|---|---| +| Mapping | ID, Embeddable, record Embeddable, Enum, time, converter, association | +| Lifecycle | persist, merge, dirty check, flush, clear, detach, refresh | +| Transaction | commit, rollback, checked/unchecked rollback rule, `REQUIRES_NEW`, self-invocation | +| Concurrency | optimistic conflict, pessimistic timeout, deadlock, serialization failure | +| Constraint | unique race, FK, check, partial unique | +| Query | derived, JPQL, projection, specification, native | +| Fetch | N+1, graph, fetch join, multiple collections, statement count | +| Pagination | Page, Slice, keyset, duplicate sort values, concurrent insertion | +| Hibernate 7.4 | **collection fetch join + pagination regression** | +| Batch | actual JDBC batching, IDENTITY no-batch, sequence batch, flush/clear | +| Bulk | bulk update 뒤 stale Entity | +| PostgreSQL | JSONB, Array, Range, `ON CONFLICT`, `SKIP LOCKED` | +| Flyway | empty DB, previous release snapshot, repeatable, checksum mismatch | +| Security | restricted application role, dynamic sort injection, SQL log masking | +| Pool | saturation, acquire timeout, `REQUIRES_NEW` pressure | +| Failure | process kill, network loss, DB restart, transaction retry | +| Commit ambiguity | COMMIT 전/중/후 connection loss simulation | +| Observability | cardinality, PII masking, queryName/failureCategory | + +PostgreSQL version matrix는 PR마다 최소 oldest/current인 `16 + 18`, release branch에서 `16 + 17 + 18` 전체를 실행하는 방식이 비용과 호환성 검증의 균형점입니다. 다만 “16·17·18 Stable”이라고 선언하려면 release gate에서는 세 버전을 모두 통과해야 합니다. + +Migration은 단순히 **빈 DB → latest**만 테스트하면 부족합니다. + +```text +empty +→ latest + +previous release N-1 +→ latest + +oldest supported upgrade snapshot +→ latest + +checksum modified +→ validation must fail + +missing migration +→ validation must fail + +failed non-transactional migration +→ known recovery procedure +``` + +를 함께 검증해야 합니다. Flyway가 checksum/name/type/missing migration을 validation 대상으로 삼기 때문입니다. citeturn19search0turn19search4 + +**실무 실패 사례를 플랫폼 규칙으로 변환하면 다음과 같습니다.** + +| 상황 | 직접 원인 | 설계 규칙 | 회귀 테스트 | +|---|---|---|---| +| OSIV 뒤에서 N+1 발생 | Web serialization 중 LAZY load | OSIV off, DTO/fetch plan | Controller 밖 Entity access 실패 | +| EAGER 폭증 | mapping이 use case fetch plan을 결정 | 최소 mapping + query fetch plan | SQL/row count | +| 여러 collection fetch | Cartesian product | DTO/분할 조회 | skewed collection fixture | +| Fetch Join + Page 전체 load | **구 Hibernate 동작** | 7.4+ PG에서는 새 SQL behavior 검증 | Hibernate 7.4 pagination regression citeturn13search0 | +| `saveAll()`인데 batch 없음 | JDBC batch 미설정/IDENTITY | actual batch 관측 | statement/batch count | +| IDENTITY batch 실패 | ID 얻기 위해 즉시 insert | write-heavy Entity는 sequence | ID strategy benchmark citeturn14view0 | +| Bulk update 후 stale | PC 미동기화 | flush → DML → clear | stale entity assertion citeturn12view1 | +| TX 안에서 API 대기 | DB resource 장기 보유 | 외부 I/O TX 밖 | pool pressure test | +| `REQUIRES_NEW` 고갈 | outer+inner connection 동시 점유 | 제한 + pool capacity test | concurrent nested tx citeturn15search0 | +| Optimistic 부분 Retry | stale PC에서 일부 코드 재실행 | 전체 unit-of-work retry | conflict fixture | +| Deadlock 무한 Retry | retry budget 없음 | bounded full-TX retry | deterministic deadlock | +| DDL auto update | runtime schema 변경 | Flyway only | app role DDL deny | +| H2만 통과 | DB semantics 차이 | PG contract mandatory | PG16~18 | +| Entity JSON 반환 | lazy graph serialization | DTO/projection | detached serialization | +| Soft Delete unique 충돌 | deleted row도 unique에 존재 | domain policy + partial index | recreate-after-delete | +| Replica stale read | replication lag | replica experimental | read-after-write lag | +| Commit 응답 유실 | 결과 모호 | no auto retry, reconciliation | protocol failure injection | + +## Stable 범위와 구현 순서 + +최종적인 **Stable / Experimental / 비지원 범위**는 다음이 현실적입니다. + +| 영역 | Stable | Experimental / Advanced | 초기 비지원 | +|---|---|---|---| +| Repository | Spring Data domain repository | custom fragments | GenericRepository 재구현 | +| JPA | Persistence 3.2 | Persistence 4.0 compatibility | Extended PC 일반 사용 | +| Provider | Hibernate 7.4 | Hibernate 8 lane | 임의 provider 동일 보장 선언 | +| DB | PostgreSQL 16·17·18 | PG19 compatibility | MySQL/Oracle 호환 선언 | +| Local DB | H2 convenience | — | H2를 PG 증명으로 사용 | +| Transaction | REQUIRED, read-only, timeout | MANDATORY, REQUIRES_NEW | remote distributed transaction 기본화 | +| Lock | Optimistic, standard pessimistic | NOWAIT/SKIP LOCKED extension | generic distributed lock | +| Query | Derived, JPQL, projection | Specification, Querydsl, native | 자유로운 raw SQL | +| Fetch | EntityGraph, fetch join, projection | batch/subselect fetch | global EAGER | +| Pagination | Page, Slice, keyset | Scroll/Stream | 무제한 findAll | +| Batch | JDBC batch | StatelessSession/COPY | `saveAll`을 batch guarantee로 정의 | +| Migration | Flyway migrate/validate | non-transactional/admin migration | prod ddl-auto update | +| Audit | Spring Data auditing opt-in | Envers | 모든 Entity 강제 history | +| Soft Delete | domain-specific | helper capability | global implicit soft delete | +| Cache | L1 | L2 opt-in | Query cache 기본 활성화 | +| Multi-tenancy | single tenant baseline | tenant column/RLS/schema/db | 투명 자동 multi-tenant | +| Replica | primary | read replica experimental | annotation만으로 자동 routing | +| Retry | bounded full-TX retry | domain-specific policy | repository-method retry | +| Completion unknown | error + reconciliation | domain-specific resolver | 자동 retry | + +이 조사에서 가장 중요한 결정은 **JPA 플랫폼이 많은 API를 제공하는 것보다 잘못된 사용을 어렵게 만드는 것**입니다. + +권장 핵심 API는 거대한 Repository가 아니라 다음과 같은 작은 기술 primitive입니다. + +```java +public interface JpaTransactionExecutor { + T execute(TransactionProfile profile, Supplier work); +} + +public record TransactionProfile( + String name, + IsolationLevel isolation, + Duration timeout, + boolean readOnly, + RetryProfile retryProfile +) {} + +public interface JpaRetryPolicy { + RetryDecision classify(JpaPersistenceException error); +} + +public interface QueryObservation { + QueryScope start(String queryName); +} + +public interface PostgreSqlExtension { + // marker / capability boundary +} +``` + +다만 평범한 application service는 이런 저수준 API조차 직접 다루지 않고 보통 Spring `@Transactional` + domain repository를 사용하게 하는 편이 좋습니다. + +```java +@Service +@RequiredArgsConstructor +public class PlaceOrderService { + + private final OrderRepository orders; + private final OutboxRepository outbox; + + @Transactional + public OrderId place(PlaceOrder command) { + Order order = Order.place(command); + orders.save(order); + + outbox.save(OutboxMessage.from(order)); + + return order.getId(); + } +} +``` + +**단계별 구현 순서와 완료 조건**은 다음과 같이 잡는 것이 좋습니다. + +| 단계 | 구현 | 완료 조건 | +|---|---|---| +| Foundation | `jpa-core`, Boot BOM, PostgreSQL profile, Hikari, OSIV off | PG16·17·18 bootstrap 및 기본 CRUD contract 통과 | +| Mapping | Entity/ID/association/value 규칙, test fixtures | Mapping rule 문서 + ArchUnit/static check + PG round trip | +| Transaction | profile, boundaries, propagation, timeout | rollback/self-invocation/REQUIRES_NEW tests | +| Concurrency | version, lock, SQLSTATE error mapper | optimistic/deadlock/serialization/lock timeout 재현 | +| Error/Retry | common exception + full-TX retry | retryable/non-retryable matrix 자동 테스트 | +| Query | projection/specification/custom fragments | query startup validation + queryName 체계 | +| Fetch | EntityGraph/fetch join/query-count toolkit | N+1 및 Cartesian regression gate | +| Pagination | Slice/keyset/cursor | duplicate sort·concurrent insert contract | +| Batch | sequence profile, JDBC batch, flush/clear | 실제 JDBC batching 관측 | +| PostgreSQL Extension | JSONB/Array/Range, ON CONFLICT, lock extension | PG16·17·18 native capability tests | +| Migration | Flyway, validation, snapshots | empty + N-1 + oldest-supported migration 통과 | +| Observability | pool/tx/query/retry metrics | cardinality·PII tests | +| Security | DB role separation, log masking | app credential로 DDL 실패 보장 | +| Advanced | Envers, L2 cache, StatelessSession | 기능별 opt-in contract | +| Experimental | multi-tenancy, replica, JPA4/Hibernate8 | 별도 compatibility suite 통과 전 Stable 승격 금지 | + +최종적으로 이번 조사에서 요구된 산출물은 다음과 같이 귀결됩니다. + +| 요구 산출물 | 조사 결론 | +|---|---| +| Java·Spring Data·Hibernate·PG 지원 매트릭스 | Java 21 + Boot BOM + JPA 3.2 + Hibernate 7.4 + PG16~18 | +| J1~J4 계층 | Standard / Advanced / Provider Extension / Admin | +| Entity Mapping | Field access 중심, Entity 외부 직렬화 금지, domain ownership | +| ID 전략 | PG 기본 Sequence, UUID stable, IDENTITY write-heavy 제한 | +| Association | global cascade/eager 금지, lifecycle 명시 | +| Persistence Context | transaction-scoped, OSIV off | +| Transaction | Application Service boundary | +| Commit Unknown | 별도 `TransactionCompletionUnknown`, 자동 retry 금지 | +| Optimistic/Pessimistic | optimistic 우선, lock extension 제한 | +| Query | Derived → JPQL/Projection → Dynamic → Native 단계화 | +| Fetch | use-case fetch plan, quantitative regression | +| Pagination | Page/Slice/Keyset 역할 분리 | +| Batch | saveAll과 JDBC batch 구분 | +| Migration | Flyway가 schema change source of truth | +| Constraint/Index | DB invariant + query-driven index | +| PostgreSQL Extension | 별도 `jpa-postgresql` | +| Auditing/Soft Delete/History | 각각 별개 capability | +| Cache | L1 기본, L2 opt-in, query cache off | +| Multi-tenancy/Replica | 초기 Experimental | +| 오류/Retry | SQLSTATE 기반 안정 오류 + full-TX retry | +| Metric/Trace/Logging | queryName 기반, parameter·PII 배제 | +| Security | runtime/migration/admin credential 분리 | +| Tests | PG Testcontainers + 실제 version matrix | +| Stable/Experimental | JPA4/Hibernate8/multitenancy/replica 분리 | +| 구현 순서 | Foundation → semantics → performance → operations | + +가장 중요한 최종 설계 규칙은 여섯 가지로 압축됩니다. + +**첫째**, 도메인이 Entity와 Repository를 소유하며 JPA 플랫폼은 `GenericRepository`를 만들지 않습니다. Spring Data JPA가 이미 그 추상화를 제공하기 때문입니다. citeturn20view0 + +**둘째**, transaction은 Repository method가 아니라 **Application Use Case** 단위이며, Optimistic conflict·Deadlock·Serialization Failure의 retry도 새 Persistence Context에서 전체 transaction을 다시 실행합니다. PostgreSQL Serializable 역시 transaction 재실행을 전제로 합니다. citeturn13search9 + +**셋째**, DB에 요청을 보냈다는 사실과 commit이 확정됐다는 사실을 구분합니다. Commit 결과가 모호하면 `TransactionCompletionUnknown`으로 올리고 자동 retry하지 않습니다. PostgreSQL도 completion-unknown을 SQLSTATE에서 별도 condition으로 정의합니다. citeturn13search1 + +**넷째**, Fetch 전략은 Entity annotation의 EAGER/LAZY만으로 결정하지 않고 **use-case-specific Fetch Plan**으로 관리합니다. 특히 Hibernate 7.4에서 PostgreSQL의 collection fetch join + pagination 동작이 과거 버전과 달라졌으므로, 오래된 금지 규칙을 그대로 복사하지 말고 현재 버전 SQL을 contract test해야 합니다. citeturn13search0turn13search11 + +**다섯째**, Entity Mapping이 schema의 Source of Truth가 아닙니다. 운영 schema는 Flyway가 소유하고 Hibernate는 `validate` 역할을 맡으며, `repair`, concurrent index, backfill, partition 같은 작업은 J4 Admin 영역으로 분리합니다. citeturn19search0turn19search1turn17search3 + +**여섯째**, `H2에서 된다`를 호환성 증거로 쓰지 않습니다. **PostgreSQL 16·17·18의 실제 locking, constraint, batch, migration, query plan, SQLSTATE를 통과하는 것**을 이 플랫폼의 Stable 완료 조건으로 삼는 것이 적절합니다. citeturn0search3turn13search5 diff --git a/infra/jpa/postgres/README.md b/infra/jpa/postgres/README.md new file mode 100644 index 00000000..728b1d4f --- /dev/null +++ b/infra/jpa/postgres/README.md @@ -0,0 +1,39 @@ +# infra/jpa/postgres + +Server-side settings the JPA platform's contracts assume, and why each one matters. + +The contract suites start their own containers through +`dev.caskeleton.adapter.outbound.persistence.testkit.postgresql.PostgreSqlContainerFactory`, so +nothing here is needed to run them. This directory records what a *deployed* PostgreSQL has to look +like for the platform's guarantees to hold, because several of them are server settings rather than +application code. + +## Settings the platform depends on + +| Setting | Why the platform cares | +|---|---| +| `statement_timeout` | The last bound on a runaway statement. The platform sets transaction timeouts, but a single statement inside a transaction can still outlive the request that asked for it. | +| `idle_in_transaction_session_timeout` | An idle open transaction holds its locks and its snapshot indefinitely, which blocks writers and prevents vacuum. This is what turns "someone left a transaction open" into a bounded incident. | +| `lock_timeout` | A cluster-wide floor under the per-request lock bounds in `PostgreSqlLockOptions`. | +| `max_connections` | The number `app.jpa-platform.datasource.maximum-pool-size` must be sized against — across every instance, and allowing for `REQUIRES_NEW` taking a second connection while pinning the first. | +| `default_transaction_isolation` | Left at `read committed`. The platform selects `repeatable read` or `serializable` per transaction profile; changing the default would silently change every transaction that did not ask. | + +## Suggested baseline + +```conf +statement_timeout = '30s' +idle_in_transaction_session_timeout = '60s' +lock_timeout = '10s' +default_transaction_isolation = 'read committed' +``` + +These are starting points, not recommendations: the right `statement_timeout` depends on the +slowest legitimate query in the application, and setting it below that turns a working report into +an error. Measure before pinning. + +## What is deliberately not configured here + +- **Roles.** Credential separation lives in [`../roles/runtime-roles.sql`](../roles/runtime-roles.sql). +- **Schema.** Flyway owns it (design §31). Nothing in this directory creates a table. +- **Extensions.** The platform's PostgreSQL support — JSONB, arrays, ranges, `SKIP LOCKED`, + `ON CONFLICT` — is all core PostgreSQL. No extension is required, and none should be assumed. diff --git a/infra/jpa/roles/runtime-roles.sql b/infra/jpa/roles/runtime-roles.sql new file mode 100644 index 00000000..06305b0f --- /dev/null +++ b/infra/jpa/roles/runtime-roles.sql @@ -0,0 +1,54 @@ +-- Runtime / migration / admin credential separation for the JPA persistence platform. +-- Design §36; enforced at startup by PostgreSqlRuntimeRoleVerifier + DatabaseRolePolicy. +-- +-- The separation is what makes "Flyway owns schema change" enforceable rather than aspirational. +-- If the application's own credential cannot execute DDL, then no code path, no library, and no +-- injected statement can alter the schema at runtime — regardless of what the application intended. +-- +-- Run as a superuser once per database. Replace the placeholder passwords with values from the +-- deployment's secret store; they are intentionally not committed. + +-- 1. The schema the application owns. Owned by the migration role, not the runtime role. +create schema if not exists app authorization app_migration; + +-- 2. Roles. +-- app_migration : owns the schema, applies Flyway migrations. DDL. +-- app_runtime : the application's credential. DML only, no DDL, no CREATE. +-- app_admin : J4 operations — COPY, backfill, maintenance. Never used by request paths. +create role app_migration login password 'REPLACE_FROM_SECRET_STORE'; +create role app_runtime login password 'REPLACE_FROM_SECRET_STORE'; +create role app_admin login password 'REPLACE_FROM_SECRET_STORE'; + +-- 3. Revoke the PUBLIC grants that make the checks in DatabaseRolePolicy necessary. +-- Before PostgreSQL 15, PUBLIC held CREATE on the public schema — which is how an unprivileged +-- role ends up able to plant an object that shadows a real one through search_path. +revoke all on database current_database() from public; +revoke create on schema public from public; + +-- 4. Runtime: read and write rows in the application schema. Nothing else. +grant connect on database current_database() to app_runtime; +grant usage on schema app to app_runtime; +grant select, insert, update, delete on all tables in schema app to app_runtime; +grant usage, select on all sequences in schema app to app_runtime; + +-- Tables created by future migrations must inherit the same grants, or the first deployment after +-- a new table silently fails at runtime with a permission error. +alter default privileges for role app_migration in schema app + grant select, insert, update, delete on tables to app_runtime; +alter default privileges for role app_migration in schema app + grant usage, select on sequences to app_runtime; + +-- 5. Explicitly deny the two privileges the startup verifier checks for. +revoke create on schema app from app_runtime; +revoke create on database current_database() from app_runtime; + +-- 6. Admin: bulk operations under an audited identity, still without schema ownership. +grant connect on database current_database() to app_admin; +grant usage on schema app to app_admin; +grant select, insert, update, delete on all tables in schema app to app_admin; +alter default privileges for role app_migration in schema app + grant select, insert, update, delete on tables to app_admin; + +-- 7. Pin the runtime search_path so an unqualified name cannot resolve anywhere unexpected. +alter role app_runtime set search_path = app, pg_catalog; +alter role app_admin set search_path = app, pg_catalog; diff --git a/infra/jpa/toxiproxy/docker-compose.yml b/infra/jpa/toxiproxy/docker-compose.yml new file mode 100644 index 00000000..d0faf6b7 --- /dev/null +++ b/infra/jpa/toxiproxy/docker-compose.yml @@ -0,0 +1,43 @@ +# Commit-ambiguity failure injection for the JPA platform (design §39). +# +# The suite needs a proxy rather than a kill switch because the scenario that matters cannot be +# produced any other way. Stopping the container, killing the process, or closing the client socket +# all break *before* the server commits — the easy case, where the transaction rolled back and the +# use case may simply be re-run. The hard case is a commit the server completed whose +# acknowledgement never came back, and it only exists if you can cut the return path while leaving +# the forward path intact. +# +# That is what CommitAmbiguityProxy does with a downstream-only toxic, and it is the one scenario +# that distinguishes a platform that reports completion-unknown from one that retries a write which +# already succeeded. +# +# Ordinary contract runs use Testcontainers and do not need this file; it exists for reproducing a +# failure scenario by hand. + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: jpa_failure + POSTGRES_USER: jpa_failure + POSTGRES_PASSWORD: jpa_failure + # No published port: the suite must reach PostgreSQL only through the proxy, or the injected + # fault can be bypassed by connecting directly and the test passes without testing anything. + expose: + - "5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U jpa_failure -d jpa_failure"] + interval: 2s + timeout: 3s + retries: 30 + + toxiproxy: + image: ghcr.io/shopify/toxiproxy:2.11.0 + depends_on: + postgres: + condition: service_healthy + ports: + # 8474 is the control API the suite drives; 8666 is the proxied PostgreSQL port. + - "8474:8474" + - "8666:8666" + command: ["-host", "0.0.0.0"] diff --git a/infra/messaging/kafka/docker-compose.yml b/infra/messaging/kafka/docker-compose.yml new file mode 100644 index 00000000..449ff900 --- /dev/null +++ b/infra/messaging/kafka/docker-compose.yml @@ -0,0 +1,33 @@ +# Kafka 4.3.x in KRaft mode. +# +# Single broker on purpose: this compose file exists to reproduce the platform's Stable profile +# locally, not to model a production cluster. The settings below are the ones the profile guard +# enforces, so a local run fails the same way a misconfigured deployment would. +services: + kafka: + image: apache/kafka:4.3.0 + container_name: messaging-kafka + ports: + - "9092:9092" + environment: + KAFKA_NODE_ID: 1 + KAFKA_PROCESS_ROLES: broker,controller + KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092 + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT + # acks=all is only a durability guarantee when more than one replica must acknowledge. + # With a single broker the platform still requires acks=all; min.insync.replicas is 1 here + # and is expected to be 2 in any environment that claims replication evidence. + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_MIN_INSYNC_REPLICAS: 1 + # Topology is created by infrastructure, never by the application. + KAFKA_AUTO_CREATE_TOPICS_ENABLE: "false" + healthcheck: + test: ["CMD-SHELL", "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 >/dev/null 2>&1"] + interval: 5s + timeout: 10s + retries: 20 diff --git a/infra/messaging/nats/docker-compose.yml b/infra/messaging/nats/docker-compose.yml new file mode 100644 index 00000000..08e19069 --- /dev/null +++ b/infra/messaging/nats/docker-compose.yml @@ -0,0 +1,21 @@ +# NATS 2.14.x with JetStream, Experimental tier. +# +# JetStream is mandatory: core NATS is fire-and-forget with no persistence and no acknowledgement, +# so an at-least-once destination configured against it would report success for messages that were +# never stored. The adapter's validator refuses that combination. +services: + nats: + image: nats:2.14-alpine + container_name: messaging-nats + ports: + - "4222:4222" + - "8222:8222" + command: + - "--jetstream" + - "--store_dir=/data" + - "--http_port=8222" + healthcheck: + test: ["CMD-SHELL", "wget -q -O- http://localhost:8222/healthz || exit 1"] + interval: 5s + timeout: 5s + retries: 20 diff --git a/infra/messaging/postgres/docker-compose.yml b/infra/messaging/postgres/docker-compose.yml new file mode 100644 index 00000000..1502cee6 --- /dev/null +++ b/infra/messaging/postgres/docker-compose.yml @@ -0,0 +1,27 @@ +# PostgreSQL 16 for the Outbox and Inbox. +# +# logical replication is enabled so the optional Debezium CDC relay can be exercised against the +# same database the polling relay uses; the two must produce an identical wire contract. +services: + postgres: + image: postgres:16-alpine + container_name: messaging-postgres + ports: + - "5432:5432" + environment: + POSTGRES_DB: messaging + POSTGRES_USER: messaging + POSTGRES_PASSWORD: messaging + command: + - "postgres" + - "-c" + - "wal_level=logical" + - "-c" + - "max_replication_slots=4" + - "-c" + - "max_wal_senders=4" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U messaging -d messaging"] + interval: 5s + timeout: 5s + retries: 20 diff --git a/infra/messaging/pulsar/docker-compose.yml b/infra/messaging/pulsar/docker-compose.yml new file mode 100644 index 00000000..5e950cfc --- /dev/null +++ b/infra/messaging/pulsar/docker-compose.yml @@ -0,0 +1,17 @@ +# Pulsar 4.0 LTS, Experimental tier. +# +# Present so the Experimental adapter can be exercised, not because it is supported. The adapter +# stays disabled unless backend.messaging.experimental.pulsar=true. +services: + pulsar: + image: apachepulsar/pulsar:4.0.3 + container_name: messaging-pulsar + ports: + - "6650:6650" + - "8080:8080" + command: bin/pulsar standalone --no-functions-worker --no-stream-storage + healthcheck: + test: ["CMD", "bin/pulsar-admin", "brokers", "healthcheck"] + interval: 10s + timeout: 10s + retries: 20 diff --git a/infra/messaging/rabbitmq/docker-compose.yml b/infra/messaging/rabbitmq/docker-compose.yml new file mode 100644 index 00000000..4e6c354c --- /dev/null +++ b/infra/messaging/rabbitmq/docker-compose.yml @@ -0,0 +1,22 @@ +# RabbitMQ 4.3.x. +# +# Quorum queues are the default for durable work queues in this platform, so the classic mirroring +# policy is deliberately absent: classic mirrored queues can lose acknowledged messages during a +# partition, which is precisely the guarantee a durable work queue exists to provide. +services: + rabbitmq: + image: rabbitmq:4.3-management + container_name: messaging-rabbitmq + ports: + - "5672:5672" + - "15672:15672" + environment: + RABBITMQ_DEFAULT_USER: messaging + RABBITMQ_DEFAULT_PASS: messaging + # Publisher confirms and returns are client-side settings; the profile guard enforces them. + RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS: "-rabbit consumer_timeout 1800000" + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "check_running"] + interval: 5s + timeout: 10s + retries: 20 diff --git a/infra/messaging/tls/README.md b/infra/messaging/tls/README.md new file mode 100644 index 00000000..e57113c6 --- /dev/null +++ b/infra/messaging/tls/README.md @@ -0,0 +1,35 @@ +# TLS material + +Production profiles require TLS **and** hostname verification; `MessageSecurityValidator` fails +startup without either. + +No key material is committed here, and none should be. Certificates are issued by the deployment's +own PKI and mounted at runtime; a keystore in a repository is a credential in a repository, and +rotating it means a commit. + +## Local development + +The compose files in the sibling directories run plaintext listeners deliberately. They exist to +reproduce the *messaging* semantics locally, not the transport security, and running them with +`production: false` in the destination profile is what keeps the validator honest — a profile marked +`production: true` against a plaintext broker must fail, and that is a test, not an inconvenience. + +## Generating a local CA for TLS testing + +```bash +openssl req -x509 -newkey rsa:4096 -sha256 -days 30 -nodes \ + -keyout ca.key -out ca.crt -subj "/CN=messaging-local-ca" + +openssl req -newkey rsa:4096 -nodes -keyout broker.key -out broker.csr \ + -subj "/CN=localhost" + +openssl x509 -req -in broker.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ + -out broker.crt -days 30 -sha256 \ + -extfile <(printf "subjectAltName=DNS:localhost,IP:127.0.0.1") +``` + +The `subjectAltName` is not optional. Hostname verification is required in production profiles, and +a certificate without a SAN fails it — which is the correct outcome, not something to work around by +disabling the check. + +Generated files are ignored by `.gitignore` in this directory. diff --git a/infra/messaging/toxiproxy/docker-compose.yml b/infra/messaging/toxiproxy/docker-compose.yml new file mode 100644 index 00000000..9aac0b49 --- /dev/null +++ b/infra/messaging/toxiproxy/docker-compose.yml @@ -0,0 +1,19 @@ +# Toxiproxy, for the failures that matter most. +# +# The platform's hardest guarantee is that a lost confirmation is reported as AMBIGUOUS rather than +# guessed. A healthy broker will not lose one on request, so the chaos suite puts a proxy in front of +# it and severs the connection after the record was accepted but before the acknowledgement arrives. +services: + toxiproxy: + image: ghcr.io/shopify/toxiproxy:2.12.0 + container_name: messaging-toxiproxy + ports: + - "8474:8474" # control API + - "19092:19092" # proxied Kafka + - "15673:15673" # proxied RabbitMQ + - "15433:15433" # proxied PostgreSQL + healthcheck: + test: ["CMD", "/toxiproxy-cli", "list"] + interval: 5s + timeout: 5s + retries: 20 diff --git a/infra/notification/toxiproxy/docker-compose.yml b/infra/notification/toxiproxy/docker-compose.yml new file mode 100644 index 00000000..ac103cad --- /dev/null +++ b/infra/notification/toxiproxy/docker-compose.yml @@ -0,0 +1,94 @@ +# Toxiproxy fault injection for the notification delivery platform. +# +# Scope, stated up front: this is the *nightly and release* fault suite, not the PR gate. The PR +# suite runs against a loopback socket harness in-process — deterministic, no Docker, no provider +# sandbox — because a gate that needs infrastructure is a gate people learn to skip. What lives +# here are the faults that harness cannot produce: real TCP behaviour under latency, bandwidth +# starvation, and connection resets at a point the JVM's own socket layer decides. +# +# Usage: +# docker compose -f infra/notification/toxiproxy/docker-compose.yml up -d +# ./gradlew :adapter:outbound:notification:test -Dnotification.faultProxy=http://127.0.0.1:8474 +# +# The proxies below front *stub* upstreams, never a provider's real API. Pointing a toxic proxy at +# a live provider sends real notifications to real people from a test run, and adds a rate-limit +# incident on an account the team shares. +services: + toxiproxy: + image: ghcr.io/shopify/toxiproxy:2.11.0 + container_name: notification-toxiproxy + ports: + - "8474:8474" # control API + - "18081:18081" # -> ses-stub + - "18082:18082" # -> twilio-stub + - "18083:18083" # -> push-stub (APNs / FCM / Web Push) + networks: [notification-fault] + healthcheck: + test: ["CMD", "/toxiproxy-cli", "list"] + interval: 5s + timeout: 3s + retries: 10 + + # Deterministic upstreams. Each returns the provider's success shape and nothing else; the + # interesting behaviour is injected by the proxy in front of it, not by the stub. + ses-stub: + image: mendhak/http-https-echo:35 + environment: + HTTP_PORT: "8080" + networks: [notification-fault] + + twilio-stub: + image: mendhak/http-https-echo:35 + environment: + HTTP_PORT: "8080" + networks: [notification-fault] + + push-stub: + image: mendhak/http-https-echo:35 + environment: + HTTP_PORT: "8080" + networks: [notification-fault] + + # Creates the proxies and the toxics once the control API is up. Kept as a job rather than a + # README step so the topology is reproducible and reviewable rather than typed from memory. + provision: + image: ghcr.io/shopify/toxiproxy:2.11.0 + depends_on: + toxiproxy: + condition: service_healthy + networks: [notification-fault] + entrypoint: + - /bin/sh + - -c + - | + set -e + CLI="/toxiproxy-cli -h toxiproxy:8474" + $$CLI create -l 0.0.0.0:18081 -u ses-stub:8080 ses + $$CLI create -l 0.0.0.0:18082 -u twilio-stub:8080 twilio + $$CLI create -l 0.0.0.0:18083 -u push-stub:8080 push + + # Response loss after the request was committed: the provider received and acted on the + # message, and the answer never came back. This is the AMBIGUOUS case, and it is the one + # fault no provider's documentation describes. + $$CLI toxic add -t timeout -a timeout=0 -n response_loss --downstream --toxicity 0 ses + $$CLI toxic add -t timeout -a timeout=0 -n response_loss --downstream --toxicity 0 twilio + $$CLI toxic add -t timeout -a timeout=0 -n response_loss --downstream --toxicity 0 push + + # Latency past the adapter's own timeout, to prove the timeout is the adapter's decision + # rather than the socket's. + $$CLI toxic add -t latency -a latency=8000 -n slow --toxicity 0 ses + $$CLI toxic add -t latency -a latency=8000 -n slow --toxicity 0 twilio + $$CLI toxic add -t latency -a latency=8000 -n slow --toxicity 0 push + + # Partial write: the connection dies mid-body. Distinct from response loss, because the + # provider never got a complete request and the attempt is genuinely retryable. + $$CLI toxic add -t limit_data -a bytes=64 -n partial_write --upstream --toxicity 0 ses + $$CLI toxic add -t limit_data -a bytes=64 -n partial_write --upstream --toxicity 0 twilio + $$CLI toxic add -t limit_data -a bytes=64 -n partial_write --upstream --toxicity 0 push + + echo "proxies ready; toxics are registered at toxicity=0 and enabled per test" + $$CLI list + +networks: + notification-fault: + driver: bridge diff --git a/scripts/verify-mongodb-advanced.sh b/scripts/verify-mongodb-advanced.sh new file mode 100755 index 00000000..39abdc39 --- /dev/null +++ b/scripts/verify-mongodb-advanced.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# +# The MongoDB Advanced capability gate (advanced plan Task 15). +# +# Advanced capabilities are opt-in modules. This script verifies the contracts that can be verified +# without provider infrastructure, and then reports -- explicitly -- which promotion evidence it +# could NOT produce. +# +# Required promotion categories (MongoAdvancedPromotionEvidence.REQUIRED): +# +# stable-platform, actual-topology, security, migration, failure, runbook +# +# `actual-topology` is the one that cannot be substituted. A container gives a functional pass for +# sharding, search, vector and encryption while exercising none of the behaviour that makes them +# Advanced rather than Stable: real shard distribution, a real analyzer, a real KMS. Atlas Local is +# a pull-request convenience and is not release evidence -- see +# MongoAtlasCapabilityContractSuite.Environment. +# +# Usage: +# bash scripts/verify-mongodb-advanced.sh +# MONGODB_DOCKER=1 bash scripts/verify-mongodb-advanced.sh +# MONGODB_SHARDED_URI=... MONGODB_ATLAS_URI=... MONGODB_KMS=... bash scripts/verify-mongodb-advanced.sh +# +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +GRADLE_DIR="${REPO_ROOT}/src" +MODULE=':adapter:outbound:persistence-mongo' +GRADLE=(./gradlew --console=plain) + +FAILED=() +MISSING_EVIDENCE=() + +echo "MongoDB Advanced capability gate" +echo "repository: ${REPO_ROOT}" + +# --- stable-platform ------------------------------------------------------------------------- +# An Advanced capability cannot be promoted over a Stable platform that does not itself pass. +echo "" +echo "=== [stable-platform] Stable gate" +if bash "${REPO_ROOT}/scripts/verify-mongodb-platform.sh"; then + echo "stable-platform: supplied" +else + status=$? + if (( status == 2 )); then + echo "stable-platform: INCOMPLETE (the Stable gate skipped lanes)" + MISSING_EVIDENCE+=("stable-platform (Stable gate incomplete)") + else + FAILED+=("stable-platform") + fi +fi + +# --- failure + runbook (hermetic) ------------------------------------------------------------- +# Every Advanced refusal contract: disabled capability refuses construction, CSFLE/QE cannot share a +# collection, QE substring/prefix/suffix unsupported on 8.0, a non-READY search index cannot serve, +# undeclared scatter-gather is rejected, a dimension mismatch is refused. +echo "" +echo "=== [failure] Advanced contract tests" +if (cd "${GRADLE_DIR}" && "${GRADLE[@]}" "${MODULE}:test" --tests '*advanced*'); then + echo "failure: supplied" +else + FAILED+=("failure") +fi + +echo "" +echo "=== [runbook] capability documentation" +for doc in sharding time-series encryption search-vector multi-tenancy gridfs-migration; do + path="${REPO_ROOT}/docs/mongodb/advanced/${doc}.md" + if [[ -f "${path}" ]]; then + echo " + ${doc}.md" + else + echo " - ${doc}.md MISSING" + FAILED+=("runbook:${doc}") + fi +done +if [[ ! -f "${REPO_ROOT}/docs/adr/ADR-MONGO-ADV-001-capability-promotion.md" ]]; then + echo " - ADR-MONGO-ADV-001 MISSING" + FAILED+=("runbook:ADR-MONGO-ADV-001") +fi + +# --- actual-topology ------------------------------------------------------------------------- +echo "" +echo "=== [actual-topology] provider environments" +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" + else + FAILED+=("actual-topology:sharded") + fi +else + echo "actual-topology(sharded): no MONGODB_SHARDED_URI" + MISSING_EVIDENCE+=("actual-topology: sharded cluster") +fi + +if [[ -n "${MONGODB_ATLAS_URI:-}" ]]; then + echo "actual-topology(search/vector): MONGODB_ATLAS_URI present" +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" +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") + +# --- Report ------------------------------------------------------------------------------------ +echo "" +echo "---------------------------------------------------------------" +if (( ${#FAILED[@]} > 0 )); then + echo "ADVANCED GATE: FAILED" + for entry in "${FAILED[@]}"; do echo " - ${entry}"; done + echo "---------------------------------------------------------------" + exit 1 +fi + +echo "verifiable contracts: PASSED" +if (( ${#MISSING_EVIDENCE[@]} > 0 )); then + echo "" + echo "ADVANCED GATE: NOT PROMOTABLE -- missing evidence:" + for entry in "${MISSING_EVIDENCE[@]}"; do echo " ~ ${entry}"; done + echo "" + echo "A capability stays opt-in until every category in" + echo "MongoAdvancedPromotionEvidence.REQUIRED is supplied. See" + echo "docs/adr/ADR-MONGO-ADV-001-capability-promotion.md." + echo "---------------------------------------------------------------" + exit 2 +fi + +echo "ADVANCED GATE: PASSED" +echo "---------------------------------------------------------------" diff --git a/scripts/verify-mongodb-platform.sh b/scripts/verify-mongodb-platform.sh new file mode 100755 index 00000000..f51255fc --- /dev/null +++ b/scripts/verify-mongodb-platform.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# +# The MongoDB Stable release gate (design §30, plan Task 50). +# +# Runs every lane that produces one of the Stable evidence categories: +# +# mapping, transaction, migration, change-stream, security, +# failover, performance, compatibility +# +# The gate exists because "the test suite is green" and "every category has evidence" are different +# statements. A suite passes happily with a whole lane skipped -- no Docker, a disabled tag, a +# renamed task -- and a release built on that suite has no failover or compatibility evidence at +# all, silently. Each lane below is therefore run by name, and a skipped lane is reported as skipped +# rather than counted as passed. +# +# Advanced capabilities are NOT promoted or transitively included here. See +# scripts/verify-mongodb-advanced.sh. +# +# Usage: +# bash scripts/verify-mongodb-platform.sh # hermetic lanes only +# MONGODB_DOCKER=1 bash scripts/verify-mongodb-platform.sh # + container lanes +# +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +GRADLE_DIR="${REPO_ROOT}/src" +MODULE=':adapter:outbound:persistence-mongo' +GRADLE=(./gradlew --console=plain) + +RESULTS_DIR="${GRADLE_DIR}/adapter/outbound/persistence-mongo/build/test-results" + +RAN=() +SKIPPED=() +FAILED=() + +# Counts the tests a lane actually executed, from its JUnit XML. +# +# A lane whose filter matches nothing passes: Gradle runs the task, discovers no tests, and reports +# success. That is the failure mode this whole gate exists to prevent -- an empty lane is not +# evidence, it is the absence of evidence wearing a green tick. Any lane that reports zero executed +# tests is treated as a failure. +executed_tests() { + local task="$1" + local dir="${RESULTS_DIR}/${task}" + [[ -d "${dir}" ]] || { echo 0; return; } + local total=0 + shopt -s nullglob + for xml in "${dir}"/*.xml; do + local count + count=$(sed -n 's/.*]* tests="\([0-9]*\)".*/\1/p' "${xml}" | head -1) + total=$(( total + ${count:-0} )) + done + shopt -u nullglob + echo "${total}" +} + +run_lane() { + local category="$1" + local task="$2" + shift 2 + echo "" + echo "=== [${category}] ${task}" + if ! (cd "${GRADLE_DIR}" && "${GRADLE[@]}" "${MODULE}:${task}" "$@"); then + FAILED+=("${category}:${task}") + return + fi + # `check` aggregates several tasks and has no results directory of its own. + if [[ "${task}" == "check" ]]; then + RAN+=("${category}:${task}") + return + fi + local executed + executed=$(executed_tests "${task}") + if (( executed == 0 )); then + echo "!!! ${task} passed without executing a single test — the lane's filter matches nothing," + echo "!!! so the '${category}' evidence category is empty." + FAILED+=("${category}:${task} (0 tests executed)") + else + RAN+=("${category}:${task} (${executed} tests)") + fi +} + +skip_lane() { + local category="$1" + local task="$2" + local reason="$3" + echo "" + echo "=== [${category}] ${task} -- SKIPPED (${reason})" + SKIPPED+=("${category}:${task} (${reason})") +} + +docker_available() { + [[ "${MONGODB_DOCKER:-0}" == "1" ]] && command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1 +} + +echo "MongoDB Stable release gate" +echo "repository: ${REPO_ROOT}" + +# --- Always-on lanes ------------------------------------------------------------------------- +# Static analysis, architecture boundaries, unit and hermetic contract tests. These produce the +# mapping, transaction, migration, change-stream and security evidence that does not need a server. +run_lane "static-analysis" "check" -x "mongoStableContractTest" +run_lane "mapping+transaction+migration+change-stream+security" "mongoStableContractTest" + +# --- Container lanes ------------------------------------------------------------------------- +# A lane that needs Docker inside `check` teaches people to skip `check`, so these are opt-in -- +# but opting out is recorded, not silent. +if docker_available; then + run_lane "compatibility" "mongoCompatibilityTest" + run_lane "migration" "mongoMigrationTest" + run_lane "security" "mongoSecurityIntegrationTest" + run_lane "failover" "mongoReplicaSetTest" + run_lane "failover" "mongoFailoverTest" + run_lane "performance" "mongoPerformanceTest" +else + reason="MONGODB_DOCKER!=1 or Docker unavailable" + skip_lane "compatibility" "mongoCompatibilityTest" "${reason}" + skip_lane "migration" "mongoMigrationTest" "${reason}" + skip_lane "security" "mongoSecurityIntegrationTest" "${reason}" + skip_lane "failover" "mongoReplicaSetTest" "${reason}" + skip_lane "failover" "mongoFailoverTest" "${reason}" + skip_lane "performance" "mongoPerformanceTest" "${reason}" +fi + +# --- Architecture-wide gates ----------------------------------------------------------------- +echo "" +echo "=== [architecture] repository-wide verification" +if (cd "${GRADLE_DIR}" \ + && "${GRADLE[@]}" verifyCleanArchitectureDependencies \ + && "${GRADLE[@]}" :app-bootstrap:test --tests '*CleanArchitectureTest'); then + RAN+=("architecture:repository-wide") +else + FAILED+=("architecture:repository-wide") +fi + +# --- Report ------------------------------------------------------------------------------------ +echo "" +echo "---------------------------------------------------------------" +echo "ran: ${#RAN[@]}" +for entry in "${RAN[@]:-}"; do [[ -n "${entry}" ]] && echo " + ${entry}"; done +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 +echo "---------------------------------------------------------------" + +if (( ${#FAILED[@]} > 0 )); then + echo "STABLE GATE: FAILED" + exit 1 +fi + +if (( ${#SKIPPED[@]} > 0 )); then + echo "STABLE GATE: INCOMPLETE -- lanes above were not run, so their evidence categories are absent." + echo "A release requires every category. Re-run with MONGODB_DOCKER=1 on a host with Docker." + exit 2 +fi + +echo "STABLE GATE: PASSED -- every evidence category produced." diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackMvcSecurityConfiguration.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackMvcSecurityConfiguration.java new file mode 100644 index 00000000..74a10d7c --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackMvcSecurityConfiguration.java @@ -0,0 +1,38 @@ +package dev.caskeleton.adapter.inbound.web.notification.platform.callback; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; + +/** + * Security chain for the provider callback endpoints. + * + *

Callbacks authenticate with a provider signature, not with a user session, so they get their + * own chain: CSRF and session creation are off, and the ordinary user chain never sees them. + * Putting them on the user chain would either break every provider or force the user chain to be + * permissive. + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnProperty( + prefix = "ca-skeleton.notification.platform.callbacks", + name = "enabled", + havingValue = "true") +public class CallbackMvcSecurityConfiguration { + + /** Dedicated, ordered-first chain for the callback path. */ + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE + 10) + public SecurityFilterChain notificationCallbackFilterChain(HttpSecurity http) throws Exception { + return http.securityMatcher("/internal/notification/callbacks/**") + .csrf(csrf -> csrf.disable()) + .sessionManagement( + session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(requests -> requests.anyRequest().permitAll()) + .build(); + } +} 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 new file mode 100644 index 00000000..5d237b1d --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackRequestFactory.java @@ -0,0 +1,75 @@ +package dev.caskeleton.adapter.inbound.web.notification.platform.callback; + +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; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Builds the transport-neutral callback request. + * + *

Both the servlet and reactive endpoints use this, so signature verification sees exactly the + * same canonical bytes and URL regardless of which stack received the call. + */ +public final class CallbackRequestFactory { + + private final ExternalRequestUrlResolver urlResolver; + private final Clock clock; + + public CallbackRequestFactory(ExternalRequestUrlResolver urlResolver, Clock clock) { + this.urlResolver = Objects.requireNonNull(urlResolver, "urlResolver"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + /** Build from a servlet request plus the already-read raw body. */ + public CallbackRequest create( + String provider, String profile, HttpServletRequest request, byte[] body) { + Objects.requireNonNull(provider, "provider"); + Objects.requireNonNull(profile, "profile"); + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(body, "body"); + + Map> headers = new LinkedHashMap<>(); + for (String name : Collections.list(request.getHeaderNames())) { + headers.put(name, new ArrayList<>(Collections.list(request.getHeaders(name)))); + } + + return new CallbackRequest( + new ProviderId(provider), + new ProviderProfileId(profile), + urlResolver.resolve(request), + request.getMethod(), + Optional.ofNullable(request.getContentType()), + headers, + body, + clock.instant()); + } + + /** Build from an already-resolved external URL, used by the reactive endpoint. */ + public CallbackRequest create( + String provider, + String profile, + String externalUrl, + String method, + Optional contentType, + Map> headers, + byte[] body) { + return new CallbackRequest( + new ProviderId(provider), + new ProviderProfileId(profile), + externalUrl, + method, + contentType, + headers, + body, + clock.instant()); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/ExternalRequestUrlResolver.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/ExternalRequestUrlResolver.java new file mode 100644 index 00000000..c59a022a --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/ExternalRequestUrlResolver.java @@ -0,0 +1,71 @@ +package dev.caskeleton.adapter.inbound.web.notification.platform.callback; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +/** + * Reconstructs the URL the provider actually called. + * + *

Several providers sign the request URL, so getting this wrong turns every valid webhook into a + * signature failure. Forwarded headers are only honoured when the immediate peer is a configured + * trusted proxy: trusting them unconditionally would let any caller choose the URL that gets + * verified, which defeats the signature entirely. + */ +public final class ExternalRequestUrlResolver { + + private final Set trustedProxies; + + public ExternalRequestUrlResolver(Set trustedProxies) { + this.trustedProxies = Set.copyOf(Objects.requireNonNull(trustedProxies, "trustedProxies")); + } + + /** External URL of a request. */ + public String resolve(HttpServletRequest request) { + Objects.requireNonNull(request, "request"); + String scheme = request.getScheme(); + String host = request.getServerName(); + int port = request.getServerPort(); + + if (trustedProxies.contains(request.getRemoteAddr())) { + String forwarded = request.getHeader("Forwarded"); + if (forwarded != null) { + for (String element : forwarded.split(";", -1)) { + String trimmed = element.trim().toLowerCase(Locale.ROOT); + if (trimmed.startsWith("proto=")) { + scheme = trimmed.substring("proto=".length()); + } else if (trimmed.startsWith("host=")) { + host = element.trim().substring("host=".length()); + port = -1; + } + } + } else { + String protoHeader = request.getHeader("X-Forwarded-Proto"); + String hostHeader = request.getHeader("X-Forwarded-Host"); + if (protoHeader != null) { + scheme = protoHeader; + } + if (hostHeader != null) { + host = hostHeader; + port = -1; + } + } + } + + StringBuilder url = new StringBuilder(scheme).append("://").append(host); + boolean defaultPort = + port < 0 + || ("https".equalsIgnoreCase(scheme) && port == 443) + || ("http".equalsIgnoreCase(scheme) && port == 80); + if (!defaultPort) { + url.append(':').append(port); + } + url.append(request.getRequestURI()); + String query = request.getQueryString(); + if (query != null && !query.isBlank()) { + url.append('?').append(query); + } + return url.toString(); + } +} 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 new file mode 100644 index 00000000..bf72ee64 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcController.java @@ -0,0 +1,75 @@ +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 jakarta.servlet.http.HttpServletRequest; +import java.util.Objects; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Servlet callback endpoint. + * + *

The body arrives as raw bytes, never as a parsed form. Providers sign the exact octets, and + * letting the container parse and re-encode them is the most common cause of a valid webhook + * failing verification. + * + *

The response is a bare {@code 204}: no body, no diagnostics. A provider only needs to know the + * event is recorded, and an error body would be a channel for leaking what the platform knows. + * + *

Registered only in a servlet application and only when callbacks are enabled. An annotated + * controller is also honoured by WebFlux, so without the servlet condition a reactive deployment + * would map both this and the functional router onto the same path — and a provider signature would + * then be verified twice against two different canonical URLs. + */ +@RestController +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@ConditionalOnProperty( + prefix = "ca-skeleton.notification.platform.callbacks", + name = "enabled", + havingValue = "true") +@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; + + private final ProviderCallbackIngestionService ingestion; + private final CallbackRequestFactory requestFactory; + + public NotificationCallbackMvcController( + ProviderCallbackIngestionService ingestion, CallbackRequestFactory requestFactory) { + this.ingestion = Objects.requireNonNull(ingestion, "ingestion"); + this.requestFactory = Objects.requireNonNull(requestFactory, "requestFactory"); + } + + /** Receive one provider callback. */ + @PostMapping(path = "/{provider}/{profile}") + public ResponseEntity callback( + @PathVariable String provider, + @PathVariable String profile, + HttpServletRequest request, + @RequestBody byte[] body) { + if (body.length > MAX_BODY_BYTES) { + return ResponseEntity.status(HttpStatus.CONTENT_TOO_LARGE).build(); + } + // A duplicate answers 204 exactly like a first delivery. The provider did its job either way, + // and any other status would make it retry an event that is already recorded. + ingestion.ingest(requestFactory.create(provider, profile, request, body)); + return ResponseEntity.noContent().build(); + } + + /** A rejected callback never reveals why beyond the status code. */ + @ExceptionHandler(CallbackValidationException.class) + public ResponseEntity onValidationFailure(CallbackValidationException failure) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/BoundedCallbackBodyReader.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/BoundedCallbackBodyReader.java new file mode 100644 index 00000000..f62c1744 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/BoundedCallbackBodyReader.java @@ -0,0 +1,48 @@ +package dev.caskeleton.adapter.inbound.web.notification.platform.callback.reactive; + +import java.util.Objects; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.web.reactive.function.server.ServerRequest; +import reactor.core.publisher.Mono; + +/** + * Reads the raw body with a hard ceiling and no buffer leaks. + * + *

Every {@link DataBuffer} is released on success, on error and on cancellation. A reactive + * endpoint that forgets the cancellation path leaks native memory exactly when it is under the load + * that caused the cancellation. + */ +public final class BoundedCallbackBodyReader { + + private final int maxBytes; + + public BoundedCallbackBodyReader(int maxBytes) { + if (maxBytes < 1) { + throw new IllegalArgumentException("maxBytes"); + } + this.maxBytes = maxBytes; + } + + /** Read at most the configured number of bytes. */ + public Mono read(ServerRequest request) { + Objects.requireNonNull(request, "request"); + return DataBufferUtils.join(request.bodyToFlux(DataBuffer.class), maxBytes) + .map( + buffer -> { + try { + byte[] bytes = new byte[buffer.readableByteCount()]; + buffer.read(bytes); + return bytes; + } finally { + DataBufferUtils.release(buffer); + } + }) + .defaultIfEmpty(new byte[0]); + } + + /** Configured ceiling. */ + public int maxBytes() { + return maxBytes; + } +} 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 new file mode 100644 index 00000000..f0f00546 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/CallbackWebFluxConfiguration.java @@ -0,0 +1,58 @@ +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 org.springframework.beans.factory.annotation.Value; +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.RouterFunction; +import org.springframework.web.reactive.function.server.ServerResponse; + +/** + * Registers the reactive callback transport, and only it. + * + *

This configuration is {@code REACTIVE}-only and the servlet controller carries the matching + * {@code SERVLET} condition, so exactly one of the two is ever registered — by construction rather + * than by convention. Both on the same path would mean a provider signature is verified twice + * against two different canonical URLs, a failure that shows up only in production and only for + * signed providers, and reads like a credential problem. + * + *

The body ceiling is read as a property rather than through the platform settings type: that + * type belongs to the outbound notification adapter, which this inbound adapter must not depend on. + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) +@ConditionalOnProperty( + prefix = "ca-skeleton.notification.platform.callbacks", + name = "enabled", + havingValue = "true") +public class CallbackWebFluxConfiguration { + + /** Bounded body reader; the ceiling applies before any provider adapter is consulted. */ + @Bean + @ConditionalOnMissingBean + public BoundedCallbackBodyReader notificationCallbackBodyReader( + @Value("${ca-skeleton.notification.platform.callbacks.max-body-bytes:65536}") int maxBytes) { + return new BoundedCallbackBodyReader(maxBytes); + } + + /** Reactive handler. */ + @Bean + @ConditionalOnMissingBean + public NotificationCallbackWebFluxHandler notificationCallbackWebFluxHandler( + ProviderCallbackIngestionService ingestion, + CallbackRequestFactory requestFactory, + BoundedCallbackBodyReader bodyReader) { + return new NotificationCallbackWebFluxHandler(ingestion, requestFactory, bodyReader); + } + + /** Functional route for the callback path. */ + @Bean + public RouterFunction notificationCallbackRoutes( + NotificationCallbackWebFluxHandler handler) { + return new CallbackWebFluxRouter(handler).routes(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/CallbackWebFluxRouter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/CallbackWebFluxRouter.java new file mode 100644 index 00000000..cdabab9d --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/CallbackWebFluxRouter.java @@ -0,0 +1,30 @@ +package dev.caskeleton.adapter.inbound.web.notification.platform.callback.reactive; + +import java.util.Objects; +import org.springframework.web.reactive.function.server.RequestPredicates; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; + +/** + * Routes the reactive callback path. + * + *

Kept separate from the servlet controller so that only one of the two is ever registered; two + * endpoints on the same path would mean a provider's signature is verified twice against two + * different canonical URLs. + */ +public final class CallbackWebFluxRouter { + + private final NotificationCallbackWebFluxHandler handler; + + public CallbackWebFluxRouter(NotificationCallbackWebFluxHandler handler) { + this.handler = Objects.requireNonNull(handler, "handler"); + } + + /** Router function for the callback path. */ + public RouterFunction routes() { + return RouterFunctions.route( + RequestPredicates.POST("/internal/notification/callbacks/{provider}/{profile}"), + handler::handle); + } +} 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 new file mode 100644 index 00000000..bdddbb36 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/NotificationCallbackWebFluxHandler.java @@ -0,0 +1,85 @@ +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.api.error.CallbackValidationException; +import dev.caskeleton.application.notification.platform.callback.ProviderCallbackIngestionService; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.springframework.core.io.buffer.DataBufferLimitException; +import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +/** + * Reactive callback endpoint. + * + *

Ingestion is blocking — it writes to the database — so it runs on {@code boundedElastic} and + * never on the event loop. Running it inline would stall every other connection the loop is + * serving. + * + *

It shares the canonicalisation and the ingestion service with the servlet endpoint, so a + * deployment can switch web stacks without changing what a provider signature is checked against. + */ +public final class NotificationCallbackWebFluxHandler { + + private final ProviderCallbackIngestionService ingestion; + private final CallbackRequestFactory requestFactory; + private final BoundedCallbackBodyReader bodyReader; + + public NotificationCallbackWebFluxHandler( + ProviderCallbackIngestionService ingestion, + CallbackRequestFactory requestFactory, + BoundedCallbackBodyReader bodyReader) { + this.ingestion = Objects.requireNonNull(ingestion, "ingestion"); + this.requestFactory = Objects.requireNonNull(requestFactory, "requestFactory"); + this.bodyReader = Objects.requireNonNull(bodyReader, "bodyReader"); + } + + /** Handle one callback. */ + public Mono handle(ServerRequest request) { + String provider = request.pathVariable("provider"); + String profile = request.pathVariable("profile"); + + return bodyReader + .read(request) + .flatMap( + body -> + Mono.fromCallable( + () -> + ingestion.ingest( + requestFactory.create( + provider, + profile, + request.uri().toString(), + request.method().name(), + request.headers().contentType().map(Object::toString), + headers(request), + body))) + .subscribeOn(Schedulers.boundedElastic())) + .then(ServerResponse.noContent().build()) + .onErrorResume( + DataBufferLimitException.class, + failure -> ServerResponse.status(HttpStatus.CONTENT_TOO_LARGE).build()) + .onErrorResume( + CallbackValidationException.class, + failure -> ServerResponse.status(HttpStatus.BAD_REQUEST).build()); + } + + private static Map> headers(ServerRequest request) { + Map> headers = new java.util.LinkedHashMap<>(); + request + .headers() + .asHttpHeaders() + .forEach((name, values) -> headers.put(name, List.copyOf(values))); + return Map.copyOf(headers); + } + + /** Content type of a request, if declared. */ + public static Optional contentType(ServerRequest request) { + return request.headers().contentType().map(Object::toString); + } +} 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 new file mode 100644 index 00000000..0e4d3d48 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcControllerTest.java @@ -0,0 +1,396 @@ +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.DeliveryAttemptId; +import dev.caskeleton.application.notification.platform.api.ProviderId; +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.api.error.CallbackValidationException; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +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.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; +import dev.caskeleton.application.notification.platform.callback.VerifiedProviderEvent; +import dev.caskeleton.application.notification.platform.observation.NotificationMetricsPort; +import dev.caskeleton.application.notification.platform.observation.NotificationSecurityAuditPort; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletRequest; + +/** + * What the servlet transport is responsible for handing the callback pipeline. + * + *

The pipeline itself belongs to application-core and is tested there. What is only testable + * here is the translation: the exact received octets, the externally-visible URL, and headers that + * survive the servlet container's own casing. Each is a common cause of a valid webhook failing + * verification, and none is visible from a unit test of the provider adapter. + * + *

The capture point is the provider adapter's {@code verify}, which is the first thing in the + * pipeline to see the whole request. It rejects, so the test never needs a ledger. + */ +class NotificationCallbackMvcControllerTest { + + private static final Clock CLOCK = + Clock.fixed(Instant.parse("2026-08-14T00:00:00Z"), ZoneOffset.UTC); + private static final String TRUSTED_PROXY = "10.0.0.1"; + + private final List verified = new ArrayList<>(); + private final List rejections = new ArrayList<>(); + + private final NotificationCallbackMvcController controller = + new NotificationCallbackMvcController( + new ProviderCallbackIngestionService( + 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()), + new UnusedPayloadProtection(), + new RecordingSecurityAudit(), + new DiscardingMetrics(), + CLOCK), + new CallbackRequestFactory(new ExternalRequestUrlResolver(Set.of(TRUSTED_PROXY)), CLOCK)); + + @Test + void theExactReceivedOctetsReachTheAdapterUnparsed() { + byte[] body = + "MessageSid=SM1&MessageStatus=delivered&Signed=a+b%2Fc".getBytes(StandardCharsets.UTF_8); + + assertThatThrownBy( + () -> + controller.callback( + "twilio", "twilio-primary", request("application/x-www-form-urlencoded"), body)) + .isInstanceOf(CallbackValidationException.class); + + // Byte for byte, including the percent-encoding a form parse would have consumed and re-encoded + // differently — which is the single most common cause of a valid webhook failing its signature. + assertThat(verified).hasSize(1); + assertThat(verified.get(0).body()).isEqualTo(body); + assertThat(verified.get(0).contentType()).contains("application/x-www-form-urlencoded"); + assertThat(verified.get(0).httpMethod()).isEqualTo("POST"); + } + + @Test + void aForwardedHostFromAnUntrustedPeerIsIgnored() { + var request = request("application/json"); + request.setRemoteAddr("203.0.113.9"); + request.addHeader("X-Forwarded-Proto", "https"); + request.addHeader("X-Forwarded-Host", "attacker.example.com"); + + assertThatThrownBy( + () -> + controller.callback( + "twilio", "twilio-primary", request, "{}".getBytes(StandardCharsets.UTF_8))) + .isInstanceOf(CallbackValidationException.class); + + // Honouring the header unconditionally would let any caller choose the URL that gets verified, + // which defeats the signature entirely. + assertThat(verified.get(0).externalUrl()).doesNotContain("attacker.example.com"); + } + + @Test + void aForwardedHostFromATrustedProxyBecomesTheCanonicalUrl() { + var request = request("application/json"); + request.setRemoteAddr(TRUSTED_PROXY); + request.addHeader("X-Forwarded-Proto", "https"); + request.addHeader("X-Forwarded-Host", "callback.example.com"); + + assertThatThrownBy( + () -> + controller.callback( + "twilio", "twilio-primary", request, "{}".getBytes(StandardCharsets.UTF_8))) + .isInstanceOf(CallbackValidationException.class); + + assertThat(verified.get(0).externalUrl()) + .isEqualTo( + "https://callback.example.com/internal/notification/callbacks/twilio/twilio-primary"); + } + + @Test + void headersSurviveTheContainersCasingAndStayAddressableEitherWay() { + var request = request("application/json"); + request.addHeader("X-Twilio-Signature", "abc123"); + + assertThatThrownBy( + () -> + controller.callback( + "twilio", "twilio-primary", request, "{}".getBytes(StandardCharsets.UTF_8))) + .isInstanceOf(CallbackValidationException.class); + + assertThat(verified.get(0).header("x-twilio-signature")).contains("abc123"); + assertThat(verified.get(0).header("X-TWILIO-SIGNATURE")).contains("abc123"); + } + + @Test + void aBodyOverTheTransportCeilingIsRefusedBeforeAnyAdapterIsConsulted() { + byte[] oversized = new byte[NotificationCallbackMvcController.MAX_BODY_BYTES + 1]; + + var response = + controller.callback("twilio", "twilio-primary", request("application/json"), oversized); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONTENT_TOO_LARGE); + // Nothing downstream sees it, so no signature check ever runs over an attacker-sized payload. + assertThat(verified).isEmpty(); + } + + @Test + void aRejectedCallbackRevealsNothingBeyondTheStatusCode() { + var response = + controller.onValidationFailure( + new CallbackValidationException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.CALLBACK_SIGNATURE_INVALID, + FailureCategory.CALLBACK_VALIDATION_FAILURE))); + + // The endpoint is unauthenticated by design — the signature is the authentication — so an error + // body is a free oracle for whoever is probing it. + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNull(); + } + + @Test + void aRejectedSignatureIsRecordedAsASecurityEventRatherThanADeliveryEvent() { + assertThatThrownBy( + () -> + controller.callback( + "twilio", + "twilio-primary", + request("application/json"), + "{}".getBytes(StandardCharsets.UTF_8))) + .isInstanceOf(CallbackValidationException.class); + + // Writing it to the ledger would let anyone who can reach the endpoint fill a recipient's + // delivery history with noise. + assertThat(rejections).containsExactly("SIGNATURE_MISMATCH"); + } + + private static MockHttpServletRequest request(String contentType) { + var request = + new MockHttpServletRequest( + "POST", "/internal/notification/callbacks/twilio/twilio-primary"); + request.setContentType(contentType); + return request; + } + + /** Registry whose adapter records the request and then refuses it. */ + private final class CapturingRegistry implements ProviderCallbackAdapterRegistry { + + @Override + public ProviderCallbackAdapter require(ProviderProfileId profileId) { + return new ProviderCallbackAdapter() { + + @Override + public ProviderId providerId() { + return new ProviderId("twilio"); + } + + @Override + public CallbackVerificationResult verify(CallbackRequest request) { + verified.add(request); + return CallbackVerificationResult.invalid("SIGNATURE_MISMATCH"); + } + + @Override + public List normalize(VerifiedCallback callback) { + throw new UnsupportedOperationException("verification always fails in this fixture"); + } + }; + } + + @Override + public CallbackLimits limitsFor(ProviderProfileId profileId) { + return new CallbackLimits( + 65_536L, Set.of("application/json", "application/x-www-form-urlencoded")); + } + } + + /** Security audit that keeps the rejection reason. */ + private final class RecordingSecurityAudit implements NotificationSecurityAuditPort { + + @Override + public void callbackSignatureRejected(ProviderProfileId profileId, String reasonCode) { + rejections.add(reasonCode); + } + + @Override + public void callbackRejectedByLimit(ProviderProfileId profileId, String reasonCode) { + rejections.add(reasonCode); + } + } + + /** Metrics are exercised elsewhere; discarding them keeps this test about the transport. */ + private static final class DiscardingMetrics implements NotificationMetricsPort { + + @Override + public void increment(String metricName, Map tags) { + // Intentionally empty. + } + + @Override + public void record(String metricName, Map tags, Duration value) { + // Intentionally empty. + } + + @Override + public void gauge(String metricName, Map tags, double value) { + // Intentionally empty. + } + } + + /** Never reached: attempt correlation happens only for an accepted callback. */ + private static final class UnusedAttemptResolver + implements dev.caskeleton.application.notification.platform.callback + .DeliveryAttemptResolverPort { + + @Override + public java.util.Optional< + dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot> + byAttemptId(DeliveryAttemptId attemptId) { + return java.util.Optional.empty(); + } + + @Override + public java.util.Optional< + dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot> + byProviderRequestId(ProviderProfileId profileId, String providerRequestIdHash) { + return java.util.Optional.empty(); + } + } + + /** Never reached: projection runs only after a signature has been accepted. */ + private static final class UnusedProjectionStore + implements dev.caskeleton.application.notification.platform.callback + .DeliveryProjectionStorePort { + + @Override + public dev.caskeleton.application.notification.platform.callback.DeliveryProjection load( + DeliveryAttemptId attemptId) { + throw new UnsupportedOperationException(); + } + + @Override + public void save( + DeliveryAttemptId attemptId, + dev.caskeleton.application.notification.platform.callback.DeliveryProjection projection) { + throw new UnsupportedOperationException(); + } + } + + /** Never reached: nothing in this fixture gets as far as a transaction. */ + private static final class UnusedTransactions + implements dev.caskeleton.application.transaction.TransactionPort { + + @Override + public T inWrite(java.util.function.Supplier action) { + throw new UnsupportedOperationException(); + } + + @Override + public T inRootWrite(java.util.function.Supplier action) { + throw new UnsupportedOperationException(); + } + + @Override + public T inRead(java.util.function.Supplier action) { + throw new UnsupportedOperationException(); + } + + @Override + public T inNew(java.util.function.Supplier action) { + throw new UnsupportedOperationException(); + } + } + + /** Never reached: every request in this fixture is rejected before the payload is retained. */ + private static final class UnusedPayloadProtection + implements dev.caskeleton.application.notification.platform.callback + .CallbackPayloadProtectionPort { + + @Override + public byte[] protectRawPayload(byte[] rawBody) { + throw new UnsupportedOperationException(); + } + + @Override + public String digest(byte[] rawBody) { + throw new UnsupportedOperationException(); + } + + @Override + public String fingerprint( + ProviderProfileId profileId, NormalizedProviderEvent event, String rawPayloadDigest) { + throw new UnsupportedOperationException(); + } + } + + /** Never reached: every request in this fixture is rejected before the append. */ + private static final class UnusedLedger implements ProviderEventLedger { + + @Override + public AppendEventResult append(VerifiedProviderEvent event) { + throw new UnsupportedOperationException(); + } + + @Override + public AppendEventResult appendAll(List events) { + throw new UnsupportedOperationException(); + } + + @Override + public List pendingProjection(int limit) { + throw new UnsupportedOperationException(); + } + + @Override + public void markApplied(ProviderEventRecordId eventId, ProjectionResult result) { + throw new UnsupportedOperationException(); + } + + @Override + public void markFailed(ProviderEventRecordId eventId, String errorCode) { + throw new UnsupportedOperationException(); + } + + @Override + public List unmatched(int limit) { + throw new UnsupportedOperationException(); + } + + @Override + public List eventsForAttempt(DeliveryAttemptId attemptId) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/src/adapter/outbound/notification/build.gradle b/src/adapter/outbound/notification/build.gradle index 555e34ed..01671d8d 100644 --- a/src/adapter/outbound/notification/build.gradle +++ b/src/adapter/outbound/notification/build.gradle @@ -6,6 +6,34 @@ dependencies { implementation 'org.springframework.boot:spring-boot-autoconfigure' implementation 'org.springframework:spring-web' // Slack webhook client (RestClient) implementation 'org.slf4j:slf4j-api' + + // Notification Delivery Platform. + // - mail: the SMTP provider adapter is built on JavaMailSender/MimeMessageHelper, which is where + // multipart/alternative, inline resources and header validation already live. Rebuilding MIME + // by hand to avoid one dependency would be the more dangerous choice. + // - jackson-databind: provider payloads, callback bodies and the canonical variables payload are + // JSON. It stays inside this adapter; application-core never sees a JSON type. + // - reactor-core: only the optional Reactor facade uses it. The core async type stays + // CompletionStage, so nothing else on this classpath depends on Reactor. + implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-json' + implementation 'io.projectreactor:reactor-core' + // JSON Schema 2020-12 validation of template variables, using the same validator and version the + // messaging adapter already depends on rather than a second implementation of the same spec. + // The YAML dataformat is excluded: schemas are supplied as JSON strings, so pulling a YAML + // parser onto the runtime classpath would add attack surface for a format nothing reads. + // Thymeleaf is the reference HTML renderer, added as the engine only — not the Spring + // starter, which would drag a view resolver and a servlet integration onto an outbound + // adapter that renders strings and never serves a request. + implementation 'org.thymeleaf:thymeleaf' + + implementation('com.networknt:json-schema-validator:3.0.2') { + exclude group: 'tools.jackson.dataformat', module: 'jackson-dataformat-yaml' + exclude group: 'com.fasterxml.jackson.dataformat', module: 'jackson-dataformat-yaml' + } + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + + testImplementation 'io.projectreactor:reactor-test' } tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' } diff --git a/src/adapter/outbound/notification/gradle.lockfile b/src/adapter/outbound/notification/gradle.lockfile index 518fe132..d59942f4 100644 --- a/src/adapter/outbound/notification/gradle.lockfile +++ b/src/adapter/outbound/notification/gradle.lockfile @@ -1,23 +1,24 @@ # 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=testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath +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.ethlo.time:itu:1.14.0=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 com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath +com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath 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,spotbugs,testCompileClasspath +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath 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=testCompileClasspath +com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath 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 @@ -32,6 +33,7 @@ com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnno com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.networknt:json-schema-validator:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle @@ -43,8 +45,11 @@ io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnota io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath +io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.projectreactor:reactor-test:3.8.0=testCompileClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.mail:jakarta.mail-api:2.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs @@ -53,6 +58,7 @@ net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +ognl:ognl:3.3.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.antlr:antlr4-runtime:4.13.2=checkstyle org.apache.bcel:bcel:6.12.0=spotbugs org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs @@ -60,9 +66,9 @@ 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=spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,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 @@ -73,14 +79,18 @@ org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,test 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 +org.attoparser:attoparser:2.0.7.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle 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.eclipse.angus:angus-activation:2.0.3=runtimeClasspath,testRuntimeClasspath +org.eclipse.angus:angus-mail:2.0.5=runtimeClasspath,testRuntimeClasspath org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle +org.javassist:javassist:3.29.0-GA=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath @@ -95,10 +105,10 @@ org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeCla 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=testCompileClasspath -org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath -org.osgi:org.osgi.resource:1.0.0=testCompileClasspath -org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath +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.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 @@ -106,28 +116,32 @@ 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.reflections:reflections:0.10.2=checkstyle org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=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: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-configuration-processor:4.0.0=annotationProcessor org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-mail:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,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-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-json: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-mail:4.0.0=compileClasspath,runtimeClasspath,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=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat: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=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,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=testCompileClasspath,testRuntimeClasspath @@ -137,16 +151,19 @@ org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRunti org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,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-support: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-test:7.0.1=testCompileClasspath,testRuntimeClasspath org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath +org.thymeleaf:thymeleaf:3.1.3.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.unbescape:unbescape:1.1.6.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=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 empty= diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/admin/AdminAuthorizationGuard.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/admin/AdminAuthorizationGuard.java new file mode 100644 index 00000000..38949c30 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/admin/AdminAuthorizationGuard.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.outbound.notification.platform.admin; + +import dev.caskeleton.application.notification.platform.admin.AdminAccessDeniedException; +import dev.caskeleton.application.notification.platform.admin.AdminActor; +import dev.caskeleton.application.notification.platform.admin.NotificationAdminAuthority; +import dev.caskeleton.application.notification.platform.api.TenantId; +import java.util.Objects; +import java.util.Optional; + +/** + * Operator authority check. + * + *

Application authority never grants an operator authority. The two planes are separated so that + * a compromised application credential cannot redrive a message or lift a suppression — the actions + * whose whole purpose is to override the platform's own safety decisions. + */ +public final class AdminAuthorizationGuard { + + /** Require an authority, or refuse. */ + public void require(AdminActor actor, NotificationAdminAuthority authority) { + Objects.requireNonNull(actor, "actor"); + Objects.requireNonNull(authority, "authority"); + if (!actor.holds(authority)) { + throw new AdminAccessDeniedException(authority); + } + } + + /** + * Require that the actor may act on a tenant. + * + *

An actor with no tenant is a global operator; one bound to a tenant may only act inside it. + */ + public void requireTenant(AdminActor actor, TenantId tenantId) { + Objects.requireNonNull(actor, "actor"); + Objects.requireNonNull(tenantId, "tenantId"); + Optional scope = actor.tenantId(); + if (scope.isPresent() && !scope.get().equals(tenantId)) { + throw new AdminAccessDeniedException(NotificationAdminAuthority.SUPPRESS); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/admin/DuplicateRiskGuard.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/admin/DuplicateRiskGuard.java new file mode 100644 index 00000000..4a728134 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/admin/DuplicateRiskGuard.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.outbound.notification.platform.admin; + +import dev.caskeleton.application.notification.platform.admin.DuplicateRiskApprovalRequiredException; +import dev.caskeleton.application.notification.platform.api.delivery.AttemptConfirmation; +import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot; +import java.util.Objects; + +/** + * Blocks an unapproved redrive of an ambiguous attempt. + * + *

The platform cannot tell whether the first submission reached the user, so re-sending is a + * decision with a real cost that only a human can accept. Requiring the approval flag makes that + * acceptance an explicit, audited act rather than a default. + */ +public final class DuplicateRiskGuard { + + /** Verify the operator accepted the duplicate risk when one exists. */ + public void verify(DeliveryAttemptSnapshot attempt, boolean approved) { + Objects.requireNonNull(attempt, "attempt"); + boolean risky = + attempt.confirmation() == AttemptConfirmation.AMBIGUOUS + || attempt.submissionOutcome() + == dev.caskeleton.application.notification.platform.api.delivery.SubmissionOutcome + .CONFIRMED_ACCEPTED; + if (risky && !approved) { + throw new DuplicateRiskApprovalRequiredException(); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/admin/NotificationAdminServiceImpl.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/admin/NotificationAdminServiceImpl.java new file mode 100644 index 00000000..9e072277 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/admin/NotificationAdminServiceImpl.java @@ -0,0 +1,328 @@ +package dev.caskeleton.adapter.outbound.notification.platform.admin; + +import dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderRuntimeRegistry; +import dev.caskeleton.application.notification.platform.admin.AdminActor; +import dev.caskeleton.application.notification.platform.admin.AdminOperationResult; +import dev.caskeleton.application.notification.platform.admin.AdminOperationStorePort; +import dev.caskeleton.application.notification.platform.admin.NotificationAdminAuthority; +import dev.caskeleton.application.notification.platform.admin.NotificationAdminService; +import dev.caskeleton.application.notification.platform.admin.ReconcileCommand; +import dev.caskeleton.application.notification.platform.admin.RedriveCommand; +import dev.caskeleton.application.notification.platform.admin.SetProviderStateCommand; +import dev.caskeleton.application.notification.platform.admin.SuppressCommand; +import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId; +import dev.caskeleton.application.notification.platform.api.delivery.RecipientDeliveryState; +import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot; +import dev.caskeleton.application.notification.platform.dispatch.DeliveryAttemptStorePort; +import dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryStorePort; +import dev.caskeleton.application.notification.platform.dispatch.ReconciliationService; +import dev.caskeleton.application.notification.platform.observation.NotificationAuditEvent; +import dev.caskeleton.application.notification.platform.observation.NotificationAuditPort; +import dev.caskeleton.application.notification.platform.policy.SuppressionEntry; +import dev.caskeleton.application.notification.platform.policy.SuppressionId; +import dev.caskeleton.application.notification.platform.policy.SuppressionSource; +import dev.caskeleton.application.notification.platform.policy.SuppressionStorePort; +import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState; +import dev.caskeleton.application.transaction.TransactionPort; +import java.time.Clock; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +/** + * N4 operator plane. + * + *

Four properties hold for every operation: a separate authority, an idempotent operation id, a + * recorded reason, and an audit row. The idempotency matters more than it looks — an operator + * retrying a redrive after a timeout must not send the message twice, which is exactly the failure + * the operation is trying to repair. + * + *

A dry run reads and reports but writes nothing, so an operator can see the blast radius of a + * bulk action before committing to it. + */ +public final class NotificationAdminServiceImpl implements NotificationAdminService { + + private final AdminAuthorizationGuard authorization; + private final DuplicateRiskGuard duplicateRiskGuard; + private final DeliveryAttemptStorePort attempts; + private final RecipientDeliveryStorePort recipients; + private final ReconciliationService reconciliation; + private final SuppressionStorePort suppressions; + private final ProviderRuntimeRegistry runtimes; + private final AdminOperationStorePort operations; + private final NotificationAuditPort audit; + private final TransactionPort transactions; + private final Clock clock; + + public NotificationAdminServiceImpl( + AdminAuthorizationGuard authorization, + DuplicateRiskGuard duplicateRiskGuard, + DeliveryAttemptStorePort attempts, + RecipientDeliveryStorePort recipients, + ReconciliationService reconciliation, + SuppressionStorePort suppressions, + ProviderRuntimeRegistry runtimes, + AdminOperationStorePort operations, + NotificationAuditPort audit, + TransactionPort transactions, + Clock clock) { + this.authorization = Objects.requireNonNull(authorization, "authorization"); + this.duplicateRiskGuard = Objects.requireNonNull(duplicateRiskGuard, "duplicateRiskGuard"); + this.attempts = Objects.requireNonNull(attempts, "attempts"); + this.recipients = Objects.requireNonNull(recipients, "recipients"); + this.reconciliation = Objects.requireNonNull(reconciliation, "reconciliation"); + this.suppressions = Objects.requireNonNull(suppressions, "suppressions"); + this.runtimes = Objects.requireNonNull(runtimes, "runtimes"); + this.operations = Objects.requireNonNull(operations, "operations"); + this.audit = Objects.requireNonNull(audit, "audit"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + @Override + public AdminOperationResult redrive(RedriveCommand command, AdminActor actor) { + Objects.requireNonNull(command, "command"); + authorization.require(actor, NotificationAdminAuthority.REDRIVE); + + Optional replayed = operations.findByOperationId(command.operationId()); + if (replayed.isPresent()) { + return replayed.get(); + } + + DeliveryAttemptSnapshot original = + attempts + .snapshot(command.attemptId()) + .orElseThrow(() -> new IllegalStateException("delivery attempt is not available")); + authorization.requireTenant(actor, original.tenantId()); + duplicateRiskGuard.verify(original, command.approveDuplicateRisk()); + + if (command.dryRun()) { + return new AdminOperationResult( + command.operationId(), + true, + 1, + Optional.of(original.notificationId()), + Optional.of(original.recipientDeliveryId()), + Optional.empty(), + List.of("DRY_RUN")); + } + + return transactions.inWrite( + () -> { + // The logical identities are preserved and only the attempt is new, so the history stays + // one story rather than becoming two unrelated notifications. + recipients.transition( + original.recipientDeliveryId(), + RecipientDeliveryState.READY_TO_DISPATCH, + Optional.of(clock.instant())); + + AdminOperationResult result = + new AdminOperationResult( + command.operationId(), + false, + 1, + Optional.of(original.notificationId()), + Optional.of(original.recipientDeliveryId()), + Optional.empty(), + List.of(command.reason())); + audit.record( + new NotificationAuditEvent( + "ADMIN_REDRIVE", + actor.actorRef(), + Optional.of(command.reason()), + Optional.of(command.operationId()), + clock.instant(), + Map.of( + "provider", original.providerId().value(), + "channel", original.channel().name()))); + return operations.save(result, actor, "ADMIN_REDRIVE"); + }); + } + + @Override + public AdminOperationResult reconcile(ReconcileCommand command, AdminActor actor) { + Objects.requireNonNull(command, "command"); + authorization.require(actor, NotificationAdminAuthority.RECONCILE); + + Optional replayed = operations.findByOperationId(command.operationId()); + if (replayed.isPresent()) { + return replayed.get(); + } + if (command.dryRun()) { + return new AdminOperationResult( + command.operationId(), + true, + command.attemptIds().size(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of("DRY_RUN")); + } + + List reasons = new ArrayList<>(); + int reconciled = 0; + for (DeliveryAttemptId attemptId : command.attemptIds()) { + reconciliation.reconcile(attemptId); + reconciled++; + } + reasons.add(command.reason()); + + AdminOperationResult result = + new AdminOperationResult( + command.operationId(), + false, + reconciled, + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.copyOf(reasons)); + audit.record( + new NotificationAuditEvent( + "ADMIN_RECONCILE", + actor.actorRef(), + Optional.of(command.reason()), + Optional.of(command.operationId()), + clock.instant(), + Map.of())); + return operations.save(result, actor, "ADMIN_RECONCILE"); + } + + @Override + public AdminOperationResult suppress(SuppressCommand command, AdminActor actor) { + Objects.requireNonNull(command, "command"); + authorization.require(actor, NotificationAdminAuthority.SUPPRESS); + authorization.requireTenant(actor, command.tenantId()); + + Optional replayed = operations.findByOperationId(command.operationId()); + if (replayed.isPresent()) { + return replayed.get(); + } + if (command.dryRun()) { + return new AdminOperationResult( + command.operationId(), + true, + 1, + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of("DRY_RUN")); + } + + return transactions.inWrite( + () -> { + int affected; + if (command.remove()) { + // Removal is by fingerprint match rather than by id, because an operator lifting a + // suppression knows the target, not the row identifier the platform assigned. + affected = + suppressions + .activeFor( + command.tenantId(), command.targetFingerprint(), clock.instant()) + .stream() + .map(entry -> suppressions.remove(command.tenantId(), entry.id())) + .filter(Optional::isPresent) + .count() + > 0 + ? 1 + : 0; + } else { + suppressions.upsert( + new SuppressionEntry( + new SuppressionId(UUID.randomUUID()), + command.tenantId(), + command.scope(), + command.reason(), + command.targetFingerprint(), + Optional.empty(), + clock.instant(), + command.expiresAt(), + SuppressionSource.ADMIN)); + affected = 1; + } + + AdminOperationResult result = + new AdminOperationResult( + command.operationId(), + false, + affected, + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of(command.reasonText())); + audit.record( + new NotificationAuditEvent( + command.remove() ? "ADMIN_SUPPRESSION_REMOVED" : "ADMIN_SUPPRESSION_ADDED", + actor.actorRef(), + Optional.of(command.reason().name()), + Optional.of(command.operationId()), + clock.instant(), + Map.of())); + return operations.save( + result, actor, command.remove() ? "ADMIN_SUPPRESS_REMOVE" : "ADMIN_SUPPRESS_ADD"); + }); + } + + @Override + public AdminOperationResult setProviderState(SetProviderStateCommand command, AdminActor actor) { + Objects.requireNonNull(command, "command"); + authorization.require(actor, NotificationAdminAuthority.PROVIDER_CONTROL); + + Optional replayed = operations.findByOperationId(command.operationId()); + if (replayed.isPresent()) { + return replayed.get(); + } + if (command.dryRun()) { + return new AdminOperationResult( + command.operationId(), + true, + 1, + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of("DRY_RUN")); + } + + var runtime = runtimes.current(command.profileId()); + switch (command.desiredState()) { + case DISABLED -> runtime.markDisabled(); + case DRAINING -> runtime.markDraining(); + case HEALTHY -> runtime.markHealthy(); + case DEGRADED -> runtime.markDegraded(command.reason()); + case THROTTLED -> runtime.markThrottled(); + case AUTHENTICATION_FAILED -> runtime.markAuthenticationFailed(command.reason()); + // 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"); + } + + AdminOperationResult result = + new AdminOperationResult( + command.operationId(), + false, + 1, + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of(command.reason())); + audit.record( + new NotificationAuditEvent( + "ADMIN_PROVIDER_STATE", + actor.actorRef(), + Optional.of(command.reason()), + Optional.of(command.operationId()), + clock.instant(), + Map.of( + "providerProfile", command.profileId().value(), + "status", command.desiredState().name()))); + return operations.save(result, actor, "ADMIN_PROVIDER_STATE"); + } + + /** Current state of a provider runtime, for the health endpoint. */ + public ProviderRuntimeState providerState( + dev.caskeleton.application.notification.platform.api.ProviderProfileId profileId) { + return runtimes.state(profileId); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformAutoConfiguration.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformAutoConfiguration.java new file mode 100644 index 00000000..4e1c2642 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformAutoConfiguration.java @@ -0,0 +1,105 @@ +package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector; +import dev.caskeleton.adapter.outbound.notification.platform.template.JacksonNotificationVariablesCodec; +import dev.caskeleton.adapter.outbound.notification.platform.template.JsonSchemaVariableValidator; +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationTemplateEngine; +import dev.caskeleton.adapter.outbound.notification.platform.template.PlaceholderTemplateEngine; +import dev.caskeleton.adapter.outbound.notification.platform.template.Sha256MessageDigestAdapter; +import dev.caskeleton.adapter.outbound.notification.platform.template.ThymeleafStringTemplateEngine; +import dev.caskeleton.application.notification.platform.dispatch.MessageDigestPort; +import dev.caskeleton.application.notification.platform.dispatch.NotificationVariablesCodecPort; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.template.TemplateVariableValidator; +import java.time.Duration; +import org.springframework.beans.factory.annotation.Value; +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.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Notification platform wiring. + * + *

Everything is opt-in and conditional. The platform contributes no beans unless it is enabled, + * and the contact point protector only appears once a secret provider exists — because a protector + * without keys would fail on the first delivery instead of at startup. + */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(NotificationPlatformSettings.class) +@ConditionalOnProperty( + prefix = "ca-skeleton.notification.platform", + name = "enabled", + havingValue = "true") +public class NotificationPlatformAutoConfiguration { + + /** Canonical variables codec. */ + @Bean + @ConditionalOnMissingBean + public NotificationVariablesCodecPort notificationVariablesCodec() { + return new JacksonNotificationVariablesCodec(); + } + + /** Request fingerprint hashing. */ + @Bean + @ConditionalOnMissingBean + public MessageDigestPort notificationMessageDigest() { + return new Sha256MessageDigestAdapter(); + } + + /** JSON Schema 2020-12 variable validation. */ + @Bean + @ConditionalOnMissingBean + public TemplateVariableValidator notificationTemplateVariableValidator() { + return new JsonSchemaVariableValidator(); + } + + /** + * Template engine, defaulting to the deterministic placeholder substitution. + * + *

Thymeleaf is the opt-in alternative: it escapes by default, which matters for HTML email + * bodies built from application input. The default stays the placeholder engine because it has no + * expression evaluator at all, and an unknown engine name fails the boot rather than quietly + * falling back — a deployment that thought it had escaping and did not is the worse outcome. + */ + @Bean + @ConditionalOnMissingBean + public NotificationTemplateEngine notificationTemplateEngine( + @Value("${ca-skeleton.notification.platform.template.engine:placeholder}") String engine) { + return switch (engine.toLowerCase(java.util.Locale.ROOT)) { + case "placeholder" -> new PlaceholderTemplateEngine(); + case "thymeleaf" -> new ThymeleafStringTemplateEngine(); + default -> + throw new IllegalArgumentException( + "ca-skeleton.notification.platform.template.engine must be" + + " 'placeholder' or 'thymeleaf', not '" + + engine + + "'"); + }; + } + + /** Contact point protection, only once key material is available. */ + @Bean + @ConditionalOnBean(SecretMaterialProvider.class) + @ConditionalOnMissingBean + public ContactPointProtector notificationContactPointProtector(SecretMaterialProvider secrets) { + return new AesGcmContactPointProtector(secrets); + } + + /** + * Default provider transport. + * + *

Replaced in the composition root when the HTTP Client Platform is bound, which is the + * supported way to reuse its TLS, circuit-breaker and SSRF policy. + */ + @Bean + @ConditionalOnMissingBean + public NotificationHttpGateway notificationHttpGateway() { + return new JdkNotificationHttpGateway(Duration.ofSeconds(2)); + } +} 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 new file mode 100644 index 00000000..51147faf --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformSettings.java @@ -0,0 +1,154 @@ +package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure; + +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Bound notification platform configuration. + * + *

Validation happens in the constructor, so a misconfiguration fails the boot rather than + * surfacing as a delivery incident hours later. Everything is bounded: there is no property whose + * value may be "unlimited", because an unbounded queue or payload is a resource failure waiting for + * the first burst. + */ +@ConfigurationProperties("ca-skeleton.notification.platform") +public record NotificationPlatformSettings( + boolean enabled, Dispatch dispatch, Callbacks callbacks, Map providers) { + + public NotificationPlatformSettings { + dispatch = dispatch == null ? Dispatch.defaults() : dispatch; + callbacks = callbacks == null ? Callbacks.defaults() : callbacks; + providers = providers == null ? Map.of() : Map.copyOf(providers); + providers.forEach((id, provider) -> provider.validate(id)); + } + + /** Dispatch runtime bounds. */ + public record Dispatch( + int claimBatchSize, + Duration leaseDuration, + Duration pollInterval, + int maxGlobalConcurrency, + int maxAdditionalAttempts, + Duration maxQueueAge, + boolean allowAmbiguousFallback) { + + private static final int MAX_CLAIM_BATCH = 1000; + + public Dispatch { + Objects.requireNonNull(leaseDuration, "leaseDuration"); + Objects.requireNonNull(pollInterval, "pollInterval"); + Objects.requireNonNull(maxQueueAge, "maxQueueAge"); + if (claimBatchSize < 1 || claimBatchSize > MAX_CLAIM_BATCH) { + throw new IllegalArgumentException( + "ca-skeleton.notification.platform.dispatch.claim-batch-size must be 1.." + + MAX_CLAIM_BATCH); + } + if (maxGlobalConcurrency < 1) { + throw new IllegalArgumentException("max-global-concurrency must be positive"); + } + if (maxAdditionalAttempts < 0) { + throw new IllegalArgumentException("max-additional-attempts must not be negative"); + } + if (leaseDuration.isNegative() || leaseDuration.isZero()) { + throw new IllegalArgumentException("lease-duration must be positive and finite"); + } + if (leaseDuration.compareTo(pollInterval) <= 0) { + throw new IllegalArgumentException("lease-duration must exceed poll-interval"); + } + if (allowAmbiguousFallback) { + // Refused outright rather than warned about: automatic fallback after an ambiguous + // submission is the configuration that turns an unknown into a guaranteed duplicate. + throw new IllegalArgumentException( + "allow-ambiguous-fallback is not a supported configuration"); + } + } + + /** Conservative defaults. */ + public static Dispatch defaults() { + return new Dispatch( + 100, Duration.ofSeconds(30), Duration.ofMillis(250), 128, 3, Duration.ofHours(24), false); + } + } + + /** Callback endpoint bounds. */ + public record Callbacks(boolean enabled, long maxBodyBytes, Duration replaySkew) { + + private static final long MAX_BODY_CEILING = 1_048_576L; + + 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); + } + if (replaySkew.isNegative()) { + throw new IllegalArgumentException("replay-skew must not be negative"); + } + } + + /** Conservative defaults. */ + public static Callbacks defaults() { + return new Callbacks(false, 65_536L, Duration.ofMinutes(5)); + } + } + + /** One provider profile. */ + public record Provider( + String type, + boolean enabled, + String environment, + String credentialProfile, + String topic, + String vapidPublicKey, + String callbackSigningSecretRef, + Duration timeout, + int maxConcurrency, + int ratePerSecond) { + + /** Fail the boot when a profile cannot possibly work. */ + public void validate(String profileId) { + Objects.requireNonNull(profileId, "profileId"); + if (!enabled) { + return; + } + require(type != null && !type.isBlank(), profileId, "type is required"); + require(environment != null && !environment.isBlank(), profileId, "environment is required"); + require( + credentialProfile != null && !credentialProfile.isBlank(), + profileId, + "credential-profile is required"); + require( + timeout != null && !timeout.isNegative() && !timeout.isZero(), + profileId, + "timeout must be positive and finite"); + 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)) { + case "APNS" -> + require(topic != null && !topic.isBlank(), profileId, "APNs profiles require a topic"); + case "WEB_PUSH" -> + require( + vapidPublicKey != null && !vapidPublicKey.isBlank(), + profileId, + "Web Push profiles require a VAPID key"); + case "TWILIO", "SES" -> + require( + callbackSigningSecretRef != null && !callbackSigningSecretRef.isBlank(), + profileId, + "callback-capable profiles require a callback signing secret reference"); + default -> { + // Providers without extra requirements are already covered by the common checks. + } + } + } + + private static void require(boolean condition, String profileId, String message) { + if (!condition) { + throw new IllegalArgumentException( + "notification provider profile '" + profileId + "': " + message); + } + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/AttemptPermit.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/AttemptPermit.java new file mode 100644 index 00000000..d9725875 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/AttemptPermit.java @@ -0,0 +1,17 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +/** + * A held concurrency slot for one provider attempt. + * + *

Closing it is what releases the slot, so every call site uses try-with-resources. The permit + * also carries the credential generation the attempt ran under, which is what makes a rotation + * auditable after the fact. + */ +public interface AttemptPermit extends AutoCloseable { + + /** Credential generation this attempt is bound to. */ + long generation(); + + @Override + void close(); +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/CapabilityReconciliationGateway.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/CapabilityReconciliationGateway.java new file mode 100644 index 00000000..e565164a --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/CapabilityReconciliationGateway.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot; +import dev.caskeleton.application.notification.platform.dispatch.ReconciliationGatewayPort; +import dev.caskeleton.application.notification.platform.provider.ReconciliationCapability; +import dev.caskeleton.application.notification.platform.provider.ReconciliationResult; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Routes a reconciliation to the capability that owns the provider. + * + *

A profile with no registered capability reports {@code Unsupported} rather than falling back + * to a guess. Inventing a final status for a provider that cannot be queried is precisely the + * behaviour the ambiguity model exists to prevent. + */ +public final class CapabilityReconciliationGateway implements ReconciliationGatewayPort { + + private final Map capabilities; + private final ProviderRuntimeRegistry runtimes; + + public CapabilityReconciliationGateway( + Map capabilities, + ProviderRuntimeRegistry runtimes) { + this.capabilities = Map.copyOf(Objects.requireNonNull(capabilities, "capabilities")); + this.runtimes = Objects.requireNonNull(runtimes, "runtimes"); + } + + @Override + public boolean supports(ProviderProfileId profileId) { + Objects.requireNonNull(profileId, "profileId"); + return Optional.ofNullable(capabilities.get(profileId)) + .map(capability -> capability.supports(runtimes.current(profileId).profile())) + .orElse(false); + } + + @Override + public ReconciliationResult reconcile(DeliveryAttemptSnapshot attempt) { + Objects.requireNonNull(attempt, "attempt"); + ReconciliationCapability capability = capabilities.get(attempt.providerProfileId()); + if (capability == null) { + return new ReconciliationResult.Unsupported(); + } + // The permit is taken so a reconciliation backlog cannot become a second load source during the + // incident that produced it. + try (AttemptPermit permit = runtimes.current(attempt.providerProfileId()).acquireAttempt()) { + return capability.reconcile(attempt).toCompletableFuture().join(); + } + } +} 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 new file mode 100644 index 00000000..3a2b8261 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ConfiguredRoutePlanner.java @@ -0,0 +1,72 @@ +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/CredentialProbe.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/CredentialProbe.java new file mode 100644 index 00000000..b41729af --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/CredentialProbe.java @@ -0,0 +1,9 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +/** Verifies a candidate generation before it becomes the current one. */ +@FunctionalInterface +public interface CredentialProbe { + + /** Return false when the candidate credential is not usable. */ + boolean isUsable(ProviderRuntime candidate); +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/CredentialValidationException.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/CredentialValidationException.java new file mode 100644 index 00000000..20c658fd --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/CredentialValidationException.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationException; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; + +/** Raised when a candidate credential generation fails its probe before any cutover. */ +public class CredentialValidationException extends NotificationException { + + private static final long serialVersionUID = 1L; + + public CredentialValidationException() { + super( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.PROVIDER_CONFIGURATION_INVALID, + FailureCategory.AUTHENTICATION)); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/JacksonRoutingPlanCodec.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/JacksonRoutingPlanCodec.java new file mode 100644 index 00000000..6693cd0c --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/JacksonRoutingPlanCodec.java @@ -0,0 +1,61 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; +import dev.caskeleton.application.notification.platform.api.ContactPointId; +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.dispatch.NotificationRoutingPlanCodecPort; +import dev.caskeleton.application.notification.platform.policy.RouteCandidate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import tools.jackson.core.type.TypeReference; + +/** + * Route plan encoding. + * + *

The plan is frozen at submit time, so this is a snapshot format rather than a view: it stores + * exactly what was decided, including which routes were usable then, and never recomputes. + */ +public final class JacksonRoutingPlanCodec implements NotificationRoutingPlanCodecPort { + + @Override + public String encode(List routes) { + Objects.requireNonNull(routes, "routes"); + List> encoded = new ArrayList<>(routes.size()); + for (RouteCandidate route : routes) { + Map entry = new LinkedHashMap<>(); + entry.put("routeIndex", route.routeIndex()); + entry.put("channel", route.channel().name()); + entry.put("contactPointId", route.contactPointId().value().toString()); + entry.put("providerProfileId", route.providerProfileId().value()); + entry.put("contactPointActive", route.contactPointActive()); + entry.put("providerEnabled", route.providerEnabled()); + encoded.add(entry); + } + return NotificationJsonMapper.mapper().writeValueAsString(encoded); + } + + @Override + public List decode(String payload) { + Objects.requireNonNull(payload, "payload"); + List> raw = + NotificationJsonMapper.mapper() + .readValue(payload, new TypeReference>>() {}); + List routes = new ArrayList<>(raw.size()); + for (Map entry : raw) { + routes.add( + new RouteCandidate( + ((Number) entry.get("routeIndex")).intValue(), + Channel.valueOf(String.valueOf(entry.get("channel"))), + new ContactPointId(UUID.fromString(String.valueOf(entry.get("contactPointId")))), + new ProviderProfileId(String.valueOf(entry.get("providerProfileId"))), + Boolean.TRUE.equals(entry.get("contactPointActive")), + Boolean.TRUE.equals(entry.get("providerEnabled")))); + } + 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 new file mode 100644 index 00000000..171269ff --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/LeaseRecoveryService.java @@ -0,0 +1,61 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId; +import dev.caskeleton.application.notification.platform.dispatch.DeliveryAttemptStorePort; +import dev.caskeleton.application.notification.platform.dispatch.RecipientLeaseStorePort; +import dev.caskeleton.application.notification.platform.dispatch.ReconciliationService; +import java.time.Duration; +import java.util.List; +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. + */ +public final class LeaseRecoveryService { + + private final RecipientLeaseStorePort leases; + private final DeliveryAttemptStorePort attempts; + private final ReconciliationService reconciliation; + private final Duration staleAfter; + private final int batchSize; + + public LeaseRecoveryService( + RecipientLeaseStorePort leases, + DeliveryAttemptStorePort attempts, + ReconciliationService reconciliation, + Duration staleAfter, + int batchSize) { + this.leases = Objects.requireNonNull(leases, "leases"); + this.attempts = Objects.requireNonNull(attempts, "attempts"); + this.reconciliation = Objects.requireNonNull(reconciliation, "reconciliation"); + this.staleAfter = Objects.requireNonNull(staleAfter, "staleAfter"); + this.batchSize = batchSize; + if (batchSize < 1) { + throw new IllegalArgumentException("batchSize"); + } + if (staleAfter.isNegative() || staleAfter.isZero()) { + throw new IllegalArgumentException("staleAfter must be positive and finite"); + } + } + + /** Recover one batch of abandoned deliveries; returns how many were handled. */ + public int recoverOnce() { + List abandoned = + leases.expiredDispatching(batchSize, staleAfter); + int handled = 0; + for (var recipientDeliveryId : abandoned) { + for (var attempt : attempts.attemptsOf(recipientDeliveryId)) { + if (attempt.completedAt().isEmpty()) { + DeliveryAttemptId attemptId = attempt.id(); + reconciliation.reconcile(attemptId); + handled++; + } + } + } + return handled; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/LoggingInboxSignalPublisher.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/LoggingInboxSignalPublisher.java new file mode 100644 index 00000000..24e24f3c --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/LoggingInboxSignalPublisher.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.inbox.InboxItemCreated; +import dev.caskeleton.application.notification.platform.inbox.NotificationInboxSignalPort; +import java.util.Objects; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Default inbox signal sink. + * + *

Emits identifiers only, never content. A deployment with a WebSocket or messaging relay + * replaces it; until then the inbox is still complete, because the row — not the signal — is the + * source of truth. + */ +public final class LoggingInboxSignalPublisher implements NotificationInboxSignalPort { + + private static final Logger log = LoggerFactory.getLogger("notification.inbox.signal"); + + @Override + public void publish(InboxItemCreated event) { + Objects.requireNonNull(event, "event"); + log.info( + "event=inbox_item_created itemId={} tenant={} category={}", + event.itemId().value(), + event.principal().tenantId().value(), + event.category()); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/MapTemplateRendererRegistry.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/MapTemplateRendererRegistry.java new file mode 100644 index 00000000..b2e3d8db --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/MapTemplateRendererRegistry.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.dispatch.TemplateRendererRegistry; +import dev.caskeleton.application.notification.platform.template.NotificationTemplateRenderer; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** Channel-to-renderer lookup built at wiring time. */ +public final class MapTemplateRendererRegistry implements TemplateRendererRegistry { + + private final Map renderers; + + public MapTemplateRendererRegistry(List renderers) { + Objects.requireNonNull(renderers, "renderers"); + Map byChannel = new EnumMap<>(Channel.class); + renderers.forEach(renderer -> byChannel.put(renderer.channel(), renderer)); + this.renderers = Map.copyOf(byChannel); + } + + @Override + public NotificationTemplateRenderer rendererFor(Channel channel) { + NotificationTemplateRenderer renderer = renderers.get(channel); + if (renderer == null) { + throw new IllegalStateException("no renderer registered for the channel"); + } + return renderer; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/NotificationDispatchProperties.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/NotificationDispatchProperties.java new file mode 100644 index 00000000..63ff2c75 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/NotificationDispatchProperties.java @@ -0,0 +1,55 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import java.time.Duration; +import java.util.Objects; + +/** + * Dispatch runtime bounds. + * + *

Every field is bounded and validated at construction. "Unlimited" is never an accepted value: + * an unbounded claim batch or queue is how a burst becomes an out-of-memory failure instead of + * backpressure. + */ +public record NotificationDispatchProperties( + int claimBatchSize, + Duration leaseDuration, + Duration pollInterval, + int maxGlobalConcurrency, + int maxAdditionalAttempts, + Duration estimatedDispatchDuration, + Duration maxQueueAge, + Duration shutdownGrace) { + + private static final int MAX_CLAIM_BATCH = 1000; + + public NotificationDispatchProperties { + Objects.requireNonNull(leaseDuration, "leaseDuration"); + Objects.requireNonNull(pollInterval, "pollInterval"); + Objects.requireNonNull(estimatedDispatchDuration, "estimatedDispatchDuration"); + Objects.requireNonNull(maxQueueAge, "maxQueueAge"); + Objects.requireNonNull(shutdownGrace, "shutdownGrace"); + if (claimBatchSize < 1 || claimBatchSize > MAX_CLAIM_BATCH) { + throw new IllegalArgumentException("claimBatchSize must be 1.." + MAX_CLAIM_BATCH); + } + if (maxGlobalConcurrency < 1) { + throw new IllegalArgumentException("maxGlobalConcurrency"); + } + if (maxAdditionalAttempts < 0) { + throw new IllegalArgumentException("maxAdditionalAttempts"); + } + requirePositive(leaseDuration, "leaseDuration"); + requirePositive(pollInterval, "pollInterval"); + requirePositive(maxQueueAge, "maxQueueAge"); + if (leaseDuration.compareTo(pollInterval) <= 0) { + // A lease shorter than the poll interval expires before the worker can renew it, so two + // workers would routinely claim the same job. + throw new IllegalArgumentException("leaseDuration must exceed pollInterval"); + } + } + + private static void requirePositive(Duration value, String name) { + if (value.isNegative() || value.isZero()) { + throw new IllegalArgumentException(name + " must be positive and finite"); + } + } +} 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 new file mode 100644 index 00000000..6212a8cb --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/NotificationSchedulerWorker.java @@ -0,0 +1,137 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.dispatch.NotificationDispatchService; +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 java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Claims due deliveries and hands them to the dispatcher. + * + *

The worker never calls a provider itself. It claims, submits to a bounded executor, and stops + * claiming the moment shutdown begins — so a rolling restart drains rather than abandoning leases + * that then have to time out. + * + *

Claiming is bounded twice over: by the claim batch size and by a global concurrency permit. + * The second bound matters because a slow provider would otherwise let the queue depth become the + * thread count. + */ +public final class NotificationSchedulerWorker implements AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(NotificationSchedulerWorker.class); + + private final RecipientLeaseStorePort leases; + private final NotificationDispatchService dispatcher; + private final NotificationMetricsPort metrics; + private final NotificationDispatchProperties properties; + private final String workerId; + private final ExecutorService dispatchExecutor; + private final Semaphore globalConcurrency; + private final AtomicBoolean running = new AtomicBoolean(); + private final AtomicBoolean shuttingDown = new AtomicBoolean(); + + public NotificationSchedulerWorker( + RecipientLeaseStorePort leases, + NotificationDispatchService dispatcher, + NotificationMetricsPort metrics, + NotificationDispatchProperties properties, + 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.workerId = Objects.requireNonNull(workerId, "workerId"); + this.dispatchExecutor = Executors.newVirtualThreadPerTaskExecutor(); + this.globalConcurrency = new Semaphore(properties.maxGlobalConcurrency()); + } + + /** Claim and dispatch one batch. Returns how many deliveries were claimed. */ + public int runOnce() { + if (shuttingDown.get()) { + return 0; + } + List claimed = + leases.claim(workerId, properties.claimBatchSize(), properties.leaseDuration()); + metrics.gauge(NotificationMetricName.QUEUE_DEPTH, Map.of(), claimed.size()); + + for (RecipientLease lease : claimed) { + globalConcurrency.acquireUninterruptibly(); + dispatchExecutor.execute( + () -> { + try { + dispatcher.dispatch(lease); + } catch (RuntimeException failure) { + // The lease is left to expire rather than being released optimistically: a worker + // that + // failed mid-dispatch cannot prove what the provider did. + log.warn( + "notification dispatch failed worker={} reason={}", + workerId, + failure.getClass().getSimpleName()); + } finally { + globalConcurrency.release(); + } + }); + } + return claimed.size(); + } + + /** Start the polling loop on a dedicated thread. */ + public void start() { + 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()); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } catch (RuntimeException failure) { + log.warn( + "notification scheduler tick failed worker={} reason={}", + workerId, + failure.getClass().getSimpleName()); + } + } + }); + } + + @Override + public void close() { + shuttingDown.set(true); + running.set(false); + dispatchExecutor.shutdown(); + try { + if (!dispatchExecutor.awaitTermination( + properties.shutdownGrace().toMillis(), TimeUnit.MILLISECONDS)) { + dispatchExecutor.shutdownNow(); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + dispatchExecutor.shutdownNow(); + } + } + + /** Whether the worker has stopped claiming new work. */ + public boolean shuttingDown() { + return shuttingDown.get(); + } +} 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 new file mode 100644 index 00000000..8ab2e030 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderAttemptLimiter.java @@ -0,0 +1,73 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; +import dev.caskeleton.application.notification.platform.api.error.ProviderUnavailableException; +import java.time.Clock; +import java.util.Objects; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Per-provider rate and concurrency guard. + * + *

Tokens are spent on real attempts only. A delivery waiting out its backoff holds no permit, + * because a provider outage would otherwise pin the whole concurrency budget on deliveries that are + * not doing anything. + */ +public final class ProviderAttemptLimiter { + + private final Semaphore concurrency; + private final int maxConcurrency; + private final int ratePerSecond; + private final Clock clock; + private final AtomicLong windowStartSecond = new AtomicLong(); + private final AtomicLong issuedInWindow = new AtomicLong(); + + public ProviderAttemptLimiter(int maxConcurrency, int ratePerSecond, Clock clock) { + if (maxConcurrency < 1) { + throw new IllegalArgumentException("maxConcurrency"); + } + if (ratePerSecond < 1) { + throw new IllegalArgumentException("ratePerSecond"); + } + this.concurrency = new Semaphore(maxConcurrency); + this.maxConcurrency = maxConcurrency; + this.ratePerSecond = ratePerSecond; + this.clock = Objects.requireNonNull(clock, "clock"); + this.windowStartSecond.set(clock.instant().getEpochSecond()); + } + + /** 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) { + throw unavailable(); + } + if (!concurrency.tryAcquire()) { + issuedInWindow.decrementAndGet(); + throw unavailable(); + } + } + + /** Release a previously acquired slot. */ + public void release() { + concurrency.release(); + } + + /** Slots currently held. */ + public int activeAttempts() { + return maxConcurrency - concurrency.availablePermits(); + } + + private static ProviderUnavailableException unavailable() { + return new ProviderUnavailableException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.PROVIDER_UNAVAILABLE, FailureCategory.CAPACITY_REJECTED)); + } +} 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 new file mode 100644 index 00000000..4703ecff --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntime.java @@ -0,0 +1,136 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; +import dev.caskeleton.application.notification.platform.api.error.ProviderUnavailableException; +import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter; +import dev.caskeleton.application.notification.platform.provider.ProviderProfileSnapshot; +import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +/** + * One immutable credential generation of a provider. + * + *

Generations are replaced, never mutated. Rotating a key by editing a live client would leave + * in-flight calls half-way between two credentials; replacing the whole runtime and letting the old + * one drain keeps every attempt attributable to exactly one generation. + * + *

An authentication failure moves the whole runtime, not the message. One expired credential + * multiplied by a queue of notifications is a self-inflicted outage, so the route opens once and + * raises an operational alert instead. + */ +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<>(); + + public ProviderRuntime( + ProviderProfileSnapshot profile, + NotificationProviderAdapter adapter, + ProviderAttemptLimiter limiter) { + this.profile = Objects.requireNonNull(profile, "profile"); + this.adapter = Objects.requireNonNull(adapter, "adapter"); + this.limiter = Objects.requireNonNull(limiter, "limiter"); + this.state = new AtomicReference<>(ProviderRuntimeState.HEALTHY); + } + + /** Profile snapshot including the credential generation. */ + public ProviderProfileSnapshot profile() { + return profile; + } + + /** Credential generation of this runtime. */ + public long generation() { + return profile.credentialGeneration(); + } + + /** Provider adapter bound to this generation. */ + public NotificationProviderAdapter adapter() { + return adapter; + } + + /** Current health. */ + public ProviderRuntimeState state() { + return state.get(); + } + + /** Why the runtime is unhealthy, if it is. */ + public Optional unhealthyReason() { + return Optional.ofNullable(unhealthyReason.get()); + } + + /** Attempts currently in flight on this generation. */ + public int activeAttempts() { + return limiter.activeAttempts(); + } + + /** + * Acquire a permit for one attempt. + * + *

The health check happens before the limiter, so a disabled or failed provider never consumes + * a token it cannot use. + */ + public AttemptPermit acquireAttempt() { + ProviderRuntimeState current = state.get(); + if (!current.admitsNewAttempts()) { + throw new ProviderUnavailableException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.PROVIDER_UNAVAILABLE, + current == ProviderRuntimeState.AUTHENTICATION_FAILED + ? FailureCategory.AUTHENTICATION + : FailureCategory.CAPACITY_REJECTED)); + } + limiter.acquire(); + return new LimiterPermit(profile.credentialGeneration(), limiter); + } + + /** Mark the credential as rejected by the provider. */ + public void markAuthenticationFailed(String reasonCode) { + unhealthyReason.set(Objects.requireNonNull(reasonCode, "reasonCode")); + state.set(ProviderRuntimeState.AUTHENTICATION_FAILED); + } + + /** Mark the provider as rate limited. */ + public void markThrottled() { + state.compareAndSet(ProviderRuntimeState.HEALTHY, ProviderRuntimeState.THROTTLED); + } + + /** Mark the provider as degraded but still usable. */ + public void markDegraded(String reasonCode) { + unhealthyReason.set(reasonCode); + state.compareAndSet(ProviderRuntimeState.HEALTHY, 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); + } + + /** Stop admitting new attempts; in-flight attempts finish. */ + public void markDraining() { + state.set(ProviderRuntimeState.DRAINING); + } + + /** Operator disable. */ + public void markDisabled() { + state.set(ProviderRuntimeState.DISABLED); + } + + /** A permit that releases exactly one limiter slot. */ + private record LimiterPermit(long generation, ProviderAttemptLimiter limiter) + implements AttemptPermit { + + @Override + public void close() { + 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 new file mode 100644 index 00000000..a5d0145e --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRegistry.java @@ -0,0 +1,78 @@ +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.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** 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. */ + public void register(ProviderRuntime runtime) { + Objects.requireNonNull(runtime, "runtime"); + current.put(runtime.profile().profileId(), runtime); + } + + /** Current generation, or a configuration failure when the profile is unknown. */ + public ProviderRuntime current(ProviderProfileId profileId) { + ProviderRuntime runtime = current.get(profileId); + if (runtime == null) { + throw new IllegalStateException("no provider runtime registered for the profile"); + } + return runtime; + } + + /** Current generation if registered. */ + public Optional find(ProviderProfileId profileId) { + return Optional.ofNullable(current.get(profileId)); + } + + /** + * Swap in a new generation and start draining the old one. + * + *

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. + */ + 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); + } + + /** 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<>())); + } + + /** Health of the current generation. */ + public ProviderRuntimeState state(ProviderProfileId profileId) { + return current(profileId).state(); + } + + private void forgetIfDrained(ProviderProfileId profileId) { + CopyOnWriteArrayList generations = draining.get(profileId); + if (generations == null) { + return; + } + generations.removeIf(runtime -> runtime.activeAttempts() == 0); + if (generations.isEmpty()) { + draining.remove(profileId); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRotator.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRotator.java new file mode 100644 index 00000000..c9cd5ec3 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRotator.java @@ -0,0 +1,69 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.observation.NotificationAuditEvent; +import dev.caskeleton.application.notification.platform.observation.NotificationAuditPort; +import java.time.Clock; +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Credential and certificate rotation. + * + *

The candidate is probed before the swap. Validating after cutover would mean a typo + * in a rotated secret takes the provider down and only then tells anyone; validating first makes a + * bad candidate a no-op that leaves the working generation in place. + * + *

Only the generation and key id reach the audit trail — never the credential material itself. + */ +public final class ProviderRuntimeRotator { + + private final ProviderRuntimeRegistry registry; + private final CredentialProbe probe; + private final RuntimeDrainCoordinator drainCoordinator; + private final NotificationAuditPort audit; + private final Clock clock; + private final Duration drainTimeout; + + public ProviderRuntimeRotator( + ProviderRuntimeRegistry registry, + CredentialProbe probe, + RuntimeDrainCoordinator drainCoordinator, + NotificationAuditPort audit, + Clock clock, + Duration drainTimeout) { + this.registry = Objects.requireNonNull(registry, "registry"); + this.probe = Objects.requireNonNull(probe, "probe"); + this.drainCoordinator = Objects.requireNonNull(drainCoordinator, "drainCoordinator"); + this.audit = Objects.requireNonNull(audit, "audit"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.drainTimeout = Objects.requireNonNull(drainTimeout, "drainTimeout"); + } + + /** Cut over to a new credential generation. */ + public void rotate(ProviderProfileId profileId, ProviderRuntime candidate) { + Objects.requireNonNull(profileId, "profileId"); + Objects.requireNonNull(candidate, "candidate"); + if (!candidate.profile().profileId().equals(profileId)) { + throw new IllegalArgumentException("candidate belongs to a different profile"); + } + if (!probe.isUsable(candidate)) { + throw new CredentialValidationException(); + } + + Optional previous = registry.replace(candidate); + audit.record( + new NotificationAuditEvent( + "PROVIDER_CREDENTIAL_ROTATION", + "system", + Optional.of("ROTATION"), + Optional.empty(), + clock.instant(), + Map.of( + "providerProfile", profileId.value(), + "generation", Long.toString(candidate.generation())))); + previous.ifPresent(runtime -> drainCoordinator.drain(runtime, drainTimeout)); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/RegistryProviderDispatchGateway.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/RegistryProviderDispatchGateway.java new file mode 100644 index 00000000..9249058d --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/RegistryProviderDispatchGateway.java @@ -0,0 +1,76 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.dispatch.ProviderDispatchGatewayPort; +import dev.caskeleton.application.notification.platform.provider.ProviderProfileSnapshot; +import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult; +import java.util.Objects; +import java.util.concurrent.CompletionException; + +/** + * The single outbound call, wrapped in a permit. + * + *

The permit is acquired before the call and released in a finally, so a provider that hangs + * consumes exactly one slot and a burst queues rather than exhausting the pool. + * + *

A credential rejection is promoted to a runtime state change here rather than being left as a + * per-message failure — one expired key must open the route once, not produce one retry per queued + * notification. + */ +public final class RegistryProviderDispatchGateway implements ProviderDispatchGatewayPort { + + private final ProviderRuntimeRegistry runtimes; + + public RegistryProviderDispatchGateway(ProviderRuntimeRegistry runtimes) { + this.runtimes = Objects.requireNonNull(runtimes, "runtimes"); + } + + @Override + public ProviderProfileSnapshot profile(ProviderProfileId profileId) { + return runtimes.current(profileId).profile(); + } + + @Override + public ProviderRuntimeState state(ProviderProfileId profileId) { + return runtimes.state(profileId); + } + + @Override + public ProviderSubmissionResult submit(ProviderSubmission submission) { + Objects.requireNonNull(submission, "submission"); + ProviderRuntime runtime = runtimes.current(submission.profile().profileId()); + + try (AttemptPermit permit = runtime.acquireAttempt()) { + ProviderSubmissionResult result = + runtime.adapter().submit(submission).toCompletableFuture().join(); + applyHealth(runtime, result); + return result; + } catch (CompletionException failure) { + // Unwrapped so the dispatcher classifies the real cause rather than the future's wrapper. + Throwable cause = failure.getCause() == null ? failure : failure.getCause(); + throw cause instanceof RuntimeException runtimeFailure + ? runtimeFailure + : new IllegalStateException("provider submission failed", cause); + } + } + + private static void applyHealth(ProviderRuntime runtime, ProviderSubmissionResult result) { + result + .failure() + .ifPresentOrElse( + failure -> { + switch (failure.category()) { + case AUTHENTICATION, AUTHORIZATION -> + runtime.markAuthenticationFailed(failure.code()); + case THROTTLED -> runtime.markThrottled(); + case TRANSIENT_PROVIDER -> runtime.markDegraded(failure.code()); + default -> { + // A message-level failure says nothing about the provider's health. + } + } + }, + runtime::markHealthy); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/RuntimeDrainCoordinator.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/RuntimeDrainCoordinator.java new file mode 100644 index 00000000..8db03d07 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/RuntimeDrainCoordinator.java @@ -0,0 +1,46 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import java.time.Duration; +import java.util.Objects; + +/** + * Waits for a replaced generation to finish its in-flight attempts. + * + *

The deadline comes from {@link System#nanoTime()}, not from the injectable clock. A drain + * timeout is a real elapsed-time budget: driving it from a test clock that never advances turns the + * loop into a hang, and driving it from a wall clock makes it sensitive to time adjustments. + * + *

Draining is bounded on purpose. A provider that never answers must not hold a credential + * rotation open forever, so after the timeout the generation is abandoned and its attempts follow + * the normal ambiguity and reconciliation path rather than being cancelled mid-flight. + */ +public final class RuntimeDrainCoordinator { + + private final Duration pollInterval; + + public RuntimeDrainCoordinator(Duration pollInterval) { + this.pollInterval = Objects.requireNonNull(pollInterval, "pollInterval"); + if (pollInterval.isNegative() || pollInterval.isZero()) { + throw new IllegalArgumentException("pollInterval"); + } + } + + /** Drain a generation, returning whether it finished within the timeout. */ + public boolean drain(ProviderRuntime runtime, Duration timeout) { + Objects.requireNonNull(runtime, "runtime"); + Objects.requireNonNull(timeout, "timeout"); + long deadlineNanos = System.nanoTime() + timeout.toNanos(); + while (runtime.activeAttempts() > 0) { + if (System.nanoTime() - deadlineNanos >= 0) { + return false; + } + try { + Thread.sleep(pollInterval.toMillis()); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return false; + } + } + return true; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/SingleTenantContext.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/SingleTenantContext.java new file mode 100644 index 00000000..4b0fae79 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/SingleTenantContext.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.api.TenantId; +import dev.caskeleton.application.notification.platform.dispatch.TenantContextPort; +import java.util.Objects; + +/** + * Tenant context for a single-tenant deployment. + * + *

A multi-tenant deployment replaces this with a request-scoped implementation. It exists so + * that a single-tenant application still goes through the tenant boundary rather than around it — + * the store queries take a tenant either way, and a deployment that later becomes multi-tenant does + * not have to find every unscoped query. + */ +public final class SingleTenantContext implements TenantContextPort { + + private final TenantId tenantId; + + public SingleTenantContext(String tenantId) { + this.tenantId = new TenantId(Objects.requireNonNull(tenantId, "tenantId")); + } + + @Override + public TenantId currentTenant() { + return tenantId; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/UuidV7Generator.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/UuidV7Generator.java new file mode 100644 index 00000000..938430f0 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/UuidV7Generator.java @@ -0,0 +1,54 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import dev.caskeleton.application.notification.platform.dispatch.NotificationIdGeneratorPort; +import java.security.SecureRandom; +import java.time.Clock; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicLong; + +/** + * RFC 9562 UUIDv7. + * + *

Time-ordered rather than random because these identifiers are primary keys: a random UUID + * scatters inserts across the whole index, and a notification table takes the highest insert rate + * in the platform. + * + *

The monotonic counter guards the case two identifiers are requested inside the same + * millisecond, so ordering holds even under a burst. + */ +public final class UuidV7Generator implements NotificationIdGeneratorPort { + + private static final long VERSION_7 = 0x7000L; + private static final long VARIANT_RFC = 0x8000000000000000L; + + private final Clock clock; + private final SecureRandom random; + private final AtomicLong lastMillis = new AtomicLong(); + private final AtomicLong sequence = new AtomicLong(); + + public UuidV7Generator(Clock clock) { + this(clock, new SecureRandom()); + } + + UuidV7Generator(Clock clock, SecureRandom random) { + this.clock = Objects.requireNonNull(clock, "clock"); + this.random = Objects.requireNonNull(random, "random"); + } + + @Override + public UUID nextId() { + long millis = clock.millis(); + long previous = lastMillis.getAndSet(millis); + long counter = millis == previous ? sequence.incrementAndGet() : sequence.updateAndGet(x -> 0L); + + long high = (millis & 0xFFFFFFFFFFFFL) << 16; + high |= VERSION_7; + high |= counter & 0x0FFFL; + + long low = random.nextLong(); + low &= 0x3FFFFFFFFFFFFFFFL; + low |= VARIANT_RFC; + return new UUID(high, low); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/LoggingNotificationAudit.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/LoggingNotificationAudit.java new file mode 100644 index 00000000..10a762d6 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/LoggingNotificationAudit.java @@ -0,0 +1,60 @@ +package dev.caskeleton.adapter.outbound.notification.platform.observation; + +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.observation.NotificationAuditEvent; +import dev.caskeleton.application.notification.platform.observation.NotificationAuditPort; +import dev.caskeleton.application.notification.platform.observation.NotificationSecurityAuditPort; +import java.util.Objects; +import java.util.TreeMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Audit sink on a dedicated logger. + * + *

Separate from the metrics logger because audit has different retention: a metric may be + * sampled away, while "who lifted this suppression, and why" has to survive. + * + *

A rejected callback signature is a security event, not a provider event, so it is recorded + * here and never in the ledger — otherwise anyone who can reach the endpoint could fill a delivery + * history with noise. + */ +public final class LoggingNotificationAudit + implements NotificationAuditPort, NotificationSecurityAuditPort { + + private static final Logger AUDIT = LoggerFactory.getLogger("notification.audit"); + private static final Logger SECURITY = LoggerFactory.getLogger("notification.security"); + + @Override + public void record(NotificationAuditEvent event) { + Objects.requireNonNull(event, "event"); + AUDIT.info( + "action={} actor={} reason={} operationId={} occurredAt={} attributes={}", + event.action(), + event.actorRef(), + event.reasonCode().orElse("-"), + event.operationId().orElse("-"), + event.occurredAt(), + new TreeMap<>(event.boundedAttributes())); + } + + @Override + public void callbackSignatureRejected(ProviderProfileId profileId, String reasonCode) { + Objects.requireNonNull(profileId, "profileId"); + // The payload is deliberately absent: a forged callback must not get its content into the log + // just by being rejected. + SECURITY.warn( + "event=callback_signature_rejected providerProfile={} reason={}", + profileId.value(), + reasonCode); + } + + @Override + public void callbackRejectedByLimit(ProviderProfileId profileId, String reasonCode) { + Objects.requireNonNull(profileId, "profileId"); + SECURITY.warn( + "event=callback_rejected_by_limit providerProfile={} reason={}", + profileId.value(), + reasonCode); + } +} 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 new file mode 100644 index 00000000..2398c7f8 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/LoggingNotificationMetrics.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.outbound.notification.platform.observation; + +import dev.caskeleton.application.notification.platform.observation.CardinalityGuard; +import dev.caskeleton.application.notification.platform.observation.NotificationMetricsPort; +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Structured-log metrics sink. + * + *

Every tag map passes the cardinality guard before it is emitted, so a stray notification id + * fails here rather than after it has already multiplied a time series into millions of them. + * + *

A Micrometer-backed implementation belongs in the composition root, which owns the registry; + * this one keeps the platform usable — and its tag discipline enforced — without one. + */ +public final class LoggingNotificationMetrics implements NotificationMetricsPort { + + private static final Logger log = LoggerFactory.getLogger("notification.metrics"); + + private final CardinalityGuard guard; + + public LoggingNotificationMetrics(CardinalityGuard guard) { + this.guard = Objects.requireNonNull(guard, "guard"); + } + + @Override + public void increment(String metricName, Map tags) { + guard.validate(tags); + log.info("metric={} kind=counter tags={}", metricName, ordered(tags)); + } + + @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)); + } + + @Override + public void gauge(String metricName, Map tags, double value) { + guard.validate(tags); + log.info("metric={} kind=gauge value={} tags={}", metricName, value, ordered(tags)); + } + + private static Map ordered(Map tags) { + return new TreeMap<>(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 new file mode 100644 index 00000000..26d9777f --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/NotificationHealthReporter.java @@ -0,0 +1,57 @@ +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.provider.ProviderRuntimeState; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Builds the operational snapshot. + * + *

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. + */ +public final class NotificationHealthReporter { + + private final ProviderRuntimeRegistry runtimes; + private final List monitoredProfiles; + + public NotificationHealthReporter( + ProviderRuntimeRegistry runtimes, List monitoredProfiles) { + this.runtimes = Objects.requireNonNull(runtimes, "runtimes"); + this.monitoredProfiles = List.copyOf(Objects.requireNonNull(monitoredProfiles, "profiles")); + } + + /** Current snapshot. */ + public NotificationHealthSnapshot snapshot() { + List providers = new ArrayList<>(); + boolean healthy = true; + + for (ProviderProfileId profileId : monitoredProfiles) { + var runtime = runtimes.find(profileId); + if (runtime.isEmpty()) { + healthy = false; + providers.add( + new NotificationHealthSnapshot.ProviderHealth(profileId.value(), "UNREGISTERED", 0, 0)); + continue; + } + ProviderRuntimeState state = runtime.get().state(); + if (state == ProviderRuntimeState.AUTHENTICATION_FAILED + || state == ProviderRuntimeState.DISABLED) { + healthy = false; + } + providers.add( + new NotificationHealthSnapshot.ProviderHealth( + profileId.value(), + state.name(), + runtime.get().generation(), + runtime.get().activeAttempts())); + } + + return new NotificationHealthSnapshot(healthy, providers, Map.of()); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/NotificationHealthSnapshot.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/NotificationHealthSnapshot.java new file mode 100644 index 00000000..a41a5704 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/NotificationHealthSnapshot.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.outbound.notification.platform.observation; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Operational view of the platform. + * + *

Provider states, credential generations and queue age — nothing else. A health endpoint is one + * of the least protected surfaces an application exposes, so a sender address or a credential + * reference appearing here would be a leak with a wide audience. + */ +public record NotificationHealthSnapshot( + boolean healthy, List providers, Map queue) { + + public NotificationHealthSnapshot { + providers = List.copyOf(Objects.requireNonNull(providers, "providers")); + queue = Map.copyOf(Objects.requireNonNull(queue, "queue")); + } + + /** One provider runtime's state. */ + public record ProviderHealth( + String profileId, String state, long credentialGeneration, int activeAttempts) { + + public ProviderHealth { + Objects.requireNonNull(profileId, "profileId"); + Objects.requireNonNull(state, "state"); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ProviderResults.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ProviderResults.java new file mode 100644 index 00000000..3536d27b --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ProviderResults.java @@ -0,0 +1,100 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.provider.ProviderExecutionEvidence; +import dev.caskeleton.application.notification.platform.provider.ProviderFailure; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult; +import java.time.Duration; +import java.util.Optional; + +/** + * Shared translation from a transport failure into an evidence-carrying result. + * + *

Every HTTP provider adapter routes its transport failures through here, so the rule that + * "committed body plus no response equals ambiguous" is written once rather than re-derived per + * provider. + */ +public final class ProviderResults { + + private ProviderResults() {} + + /** Classify a transport failure. */ + public static ProviderSubmissionResult fromTransport( + NotificationHttpTransportException failure, Duration elapsed) { + if (failure.requestBodyCommitted()) { + return ProviderSubmissionResult.ambiguous( + new ProviderFailure( + NotificationFailureCode.PROVIDER_RESPONSE_LOST, + FailureCategory.AMBIGUOUS_SUBMISSION, + false, + Optional.empty(), + Optional.of(failure.reasonCode())), + ProviderExecutionEvidence.responseLost(), + elapsed); + } + return ProviderSubmissionResult.notSubmitted( + new ProviderFailure( + NotificationFailureCode.PROVIDER_TRANSIENT_FAILURE, + FailureCategory.TRANSIENT_PROVIDER, + true, + Optional.empty(), + Optional.of(failure.reasonCode())), + elapsed); + } + + /** Classify an HTTP status that is not provider-specific. */ + public static ProviderFailure fromStatus(int statusCode, Optional retryAfter) { + if (statusCode == 429) { + return new ProviderFailure( + NotificationFailureCode.PROVIDER_THROTTLED, + FailureCategory.THROTTLED, + true, + retryAfter, + Optional.of(Integer.toString(statusCode))); + } + if (statusCode == 401) { + return new ProviderFailure( + NotificationFailureCode.PROVIDER_AUTHENTICATION_FAILED, + FailureCategory.AUTHENTICATION, + false, + Optional.empty(), + Optional.of("401")); + } + if (statusCode == 403) { + return new ProviderFailure( + NotificationFailureCode.PROVIDER_AUTHORIZATION_FAILED, + FailureCategory.AUTHORIZATION, + false, + Optional.empty(), + Optional.of("403")); + } + if (statusCode >= 500) { + return new ProviderFailure( + NotificationFailureCode.PROVIDER_TRANSIENT_FAILURE, + FailureCategory.TRANSIENT_PROVIDER, + true, + retryAfter, + Optional.of(Integer.toString(statusCode))); + } + return new ProviderFailure( + NotificationFailureCode.PROVIDER_PERMANENT_FAILURE, + FailureCategory.PERMANENT_PROVIDER, + false, + Optional.empty(), + Optional.of(Integer.toString(statusCode))); + } + + /** Parse a {@code Retry-After} header expressed in seconds. */ + public static Optional retryAfter(Optional headerValue) { + return headerValue.flatMap( + value -> { + try { + return Optional.of(Duration.ofSeconds(Long.parseLong(value.trim()))); + } catch (NumberFormatException notSeconds) { + return Optional.empty(); + } + }); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/UnconfiguredAttachmentResolver.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/UnconfiguredAttachmentResolver.java new file mode 100644 index 00000000..579bf96a --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/UnconfiguredAttachmentResolver.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider; + +import dev.caskeleton.application.notification.platform.api.content.AttachmentRef; +import dev.caskeleton.application.notification.platform.api.error.AttachmentUnavailableException; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; +import dev.caskeleton.application.notification.platform.provider.AttachmentAccessContext; +import dev.caskeleton.application.notification.platform.provider.AttachmentResolver; +import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment; + +/** + * The resolver used when no attachment source is wired. + * + *

It refuses rather than returning an empty stream. Sending a mail whose attachment is silently + * missing is worse than not sending it: the recipient is told something is attached and it is not. + * + *

The composition root replaces this with a file-server or object-storage backed resolver; both + * leaves are visible there, and neither is reachable from this one. + */ +public final class UnconfiguredAttachmentResolver implements AttachmentResolver { + + @Override + public ResolvedAttachment resolve(AttachmentRef reference, AttachmentAccessContext context) { + throw new AttachmentUnavailableException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.ATTACHMENT_UNAVAILABLE, FailureCategory.INVALID_PAYLOAD)); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsFailureClassifier.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsFailureClassifier.java new file mode 100644 index 00000000..c23f207d --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsFailureClassifier.java @@ -0,0 +1,67 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.apns; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse; +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.provider.ProviderFailure; +import java.util.Optional; +import java.util.Set; + +/** Maps APNs reason strings onto the stable failure vocabulary. */ +public final class ApnsFailureClassifier { + + private static final Set INVALID_TOKEN_REASONS = + Set.of("BadDeviceToken", "Unregistered", "DeviceTokenNotForTopic"); + private static final Set CONFIGURATION_REASONS = + Set.of("BadTopic", "TopicDisallowed", "BadCertificateEnvironment", "InvalidPushType"); + + /** Classify a non-2xx APNs response. */ + public ProviderFailure classify(NotificationHttpResponse response) { + Optional reason = reason(response); + if (reason.filter(INVALID_TOKEN_REASONS::contains).isPresent()) { + return new ProviderFailure( + NotificationFailureCode.CONTACT_POINT_INVALID, + FailureCategory.INVALID_RECIPIENT, + false, + Optional.empty(), + reason); + } + if (reason.filter(CONFIGURATION_REASONS::contains).isPresent()) { + return new ProviderFailure( + NotificationFailureCode.PROVIDER_CONFIGURATION_INVALID, + FailureCategory.AUTHORIZATION, + false, + Optional.empty(), + reason); + } + if (reason.filter("ExpiredProviderToken"::equals).isPresent()) { + return new ProviderFailure( + NotificationFailureCode.PROVIDER_AUTHENTICATION_FAILED, + FailureCategory.AUTHENTICATION, + false, + Optional.empty(), + reason); + } + if (reason.filter("TooManyRequests"::equals).isPresent()) { + return new ProviderFailure( + NotificationFailureCode.PROVIDER_THROTTLED, + FailureCategory.THROTTLED, + true, + Optional.empty(), + reason); + } + return ProviderResults.fromStatus(response.statusCode(), Optional.empty()); + } + + private static Optional reason(NotificationHttpResponse response) { + try { + var node = NotificationJsonMapper.mapper().readTree(response.bodyAsString()); + var reason = node.get("reason"); + return reason == null || reason.isNull() ? Optional.empty() : Optional.of(reason.asString()); + } catch (RuntimeException unparseable) { + return Optional.empty(); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsNotificationProviderAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsNotificationProviderAdapter.java new file mode 100644 index 00000000..b16d78e1 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsNotificationProviderAdapter.java @@ -0,0 +1,100 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.apns; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException; +import dev.caskeleton.application.notification.platform.api.ProviderId; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter; +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.security.AccessContext; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import java.time.Duration; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.function.Supplier; + +/** + * APNs adapter. + * + *

A 2xx is acceptance. Apple documents that an accepted notification may be delivered, stored or + * discarded, and that ordering is not guaranteed, so this adapter never produces a delivery outcome + * and the platform never uses APNs as an ordered event transport. + */ +public final class ApnsNotificationProviderAdapter implements NotificationProviderAdapter { + + private static final ProviderId PROVIDER_ID = new ProviderId("apns"); + + private final NotificationHttpGateway gateway; + private final ApnsRequestMapper mapper; + private final ApnsFailureClassifier classifier; + private final ContactPointProtector protector; + private final Supplier authorizationSupplier; + + public ApnsNotificationProviderAdapter( + NotificationHttpGateway gateway, + ApnsRequestMapper mapper, + ApnsFailureClassifier classifier, + ContactPointProtector protector, + Supplier authorizationSupplier, + ApnsProviderProperties properties) { + // The profile is required at construction so a missing topic or environment fails at wiring + // time, but it is never exposed: a public accessor would leak an adapter type across the port. + Objects.requireNonNull(properties, "properties"); + this.gateway = Objects.requireNonNull(gateway, "gateway"); + this.mapper = Objects.requireNonNull(mapper, "mapper"); + this.classifier = Objects.requireNonNull(classifier, "classifier"); + this.protector = Objects.requireNonNull(protector, "protector"); + this.authorizationSupplier = + Objects.requireNonNull(authorizationSupplier, "authorizationSupplier"); + } + + @Override + public ProviderId providerId() { + return PROVIDER_ID; + } + + @Override + public Set channels() { + return Set.of(Channel.PUSH); + } + + @Override + public ProviderCapabilities capabilities() { + return new ProviderCapabilities( + false, false, false, false, false, false, false, true, 1, 4096L, Duration.ofDays(30)); + } + + @Override + public CompletionStage submit(ProviderSubmission submission) { + Objects.requireNonNull(submission, "submission"); + return CompletableFuture.completedFuture(send(submission)); + } + + private ProviderSubmissionResult send(ProviderSubmission submission) { + long startedNanos = System.nanoTime(); + var contactPoint = + protector.reveal( + submission.contactPoint(), + AccessContext.dispatch(submission.profile().profileId().value())); + var request = mapper.map(submission, contactPoint, authorizationSupplier.get()); + + try { + NotificationHttpResponse response = gateway.exchange(request); + Duration elapsed = Duration.ofNanos(System.nanoTime() - startedNanos); + if (response.isSuccessful()) { + return ProviderSubmissionResult.accepted( + response.header("apns-id").orElse(null), "Accepted", elapsed); + } + return ProviderSubmissionResult.rejected(classifier.classify(response), elapsed); + } catch (NotificationHttpTransportException transportFailure) { + return ProviderResults.fromTransport( + transportFailure, Duration.ofNanos(System.nanoTime() - startedNanos)); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsProviderProperties.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsProviderProperties.java new file mode 100644 index 00000000..3f357179 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsProviderProperties.java @@ -0,0 +1,38 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.apns; + +import dev.caskeleton.application.notification.platform.contact.ApnsEnvironment; +import java.net.URI; +import java.time.Duration; +import java.util.Objects; +import java.util.Set; + +/** + * APNs profile. + * + *

Environment and topic are required. A sandbox token sent to the production host is a silent + * non-delivery, so the pairing is checked before the call rather than diagnosed afterwards. + */ +public record ApnsProviderProperties( + URI endpoint, + String topic, + ApnsEnvironment environment, + Set allowedPushTypes, + Duration timeout) { + + public ApnsProviderProperties { + Objects.requireNonNull(endpoint, "endpoint"); + Objects.requireNonNull(topic, "topic"); + Objects.requireNonNull(environment, "environment"); + allowedPushTypes = Set.copyOf(Objects.requireNonNull(allowedPushTypes, "allowedPushTypes")); + Objects.requireNonNull(timeout, "timeout"); + if (topic.isBlank()) { + throw new IllegalArgumentException("topic"); + } + if (allowedPushTypes.isEmpty()) { + throw new IllegalArgumentException("allowedPushTypes must not be empty"); + } + if (timeout.isNegative() || timeout.isZero()) { + throw new IllegalArgumentException("timeout must be positive and finite"); + } + } +} 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 new file mode 100644 index 00000000..4a87e0cc --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsRequestMapper.java @@ -0,0 +1,96 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.apns; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest; +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; +import dev.caskeleton.application.notification.platform.api.content.MobilePushContent; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; +import dev.caskeleton.application.notification.platform.api.error.ProviderConfigurationException; +import dev.caskeleton.application.notification.platform.contact.ApnsDeviceToken; +import dev.caskeleton.application.notification.platform.contact.ContactPointValue; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** Builds the APNs HTTP/2 request headers and payload. */ +public final class ApnsRequestMapper { + + private static final String DEFAULT_PUSH_TYPE = "alert"; + + private final ApnsProviderProperties properties; + private final Clock clock; + + public ApnsRequestMapper(ApnsProviderProperties properties, Clock clock) { + this.properties = Objects.requireNonNull(properties, "properties"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + /** Map one submission, rejecting an environment or push-type mismatch first. */ + public NotificationHttpRequest map( + ProviderSubmission submission, ContactPointValue contactPoint, String authorization) { + Objects.requireNonNull(submission, "submission"); + Objects.requireNonNull(authorization, "authorization"); + if (!(contactPoint instanceof ApnsDeviceToken token)) { + throw new IllegalArgumentException("APNs requires an APNs device token"); + } + if (token.environment() != properties.environment()) { + throw configurationFailure(); + } + if (!(submission.content().content() instanceof MobilePushContent push)) { + throw new IllegalArgumentException("APNs requires mobile push content"); + } + String pushType = DEFAULT_PUSH_TYPE; + if (!properties.allowedPushTypes().contains(pushType)) { + throw configurationFailure(); + } + + Map aps = new LinkedHashMap<>(); + aps.put("alert", Map.of("title", push.title(), "body", push.body())); + push.presentation().sound().ifPresent(sound -> aps.put("sound", sound)); + push.presentation().badge().ifPresent(badge -> aps.put("badge", badge)); + + Map payload = new LinkedHashMap<>(); + payload.put("aps", aps); + payload.putAll(push.data()); + + Map headers = new LinkedHashMap<>(); + headers.put("authorization", authorization); + headers.put("apns-topic", properties.topic()); + headers.put("apns-push-type", pushType); + headers.put("apns-priority", "10"); + headers.put("apns-id", submission.attemptId().value().toString()); + submission + .expiresAt() + .ifPresent( + expiry -> headers.put("apns-expiration", Long.toString(expiry.getEpochSecond()))); + submission.collapse().ifPresent(spec -> headers.put("apns-collapse-id", spec.key())); + + byte[] body = + NotificationJsonMapper.mapper() + .writeValueAsString(payload) + .getBytes(StandardCharsets.UTF_8); + return new NotificationHttpRequest( + "POST", + URI.create(properties.endpoint() + "/3/device/" + token.value()), + JdkNotificationHttpGateway.headers(headers), + body, + properties.timeout()); + } + + /** Current time, exposed so expiry mapping stays testable. */ + public java.time.Instant now() { + return clock.instant(); + } + + private static ProviderConfigurationException configurationFailure() { + return new ProviderConfigurationException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.PROVIDER_CONFIGURATION_INVALID, FailureCategory.AUTHORIZATION)); + } +} 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 new file mode 100644 index 00000000..2b270870 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmBatchCoordinator.java @@ -0,0 +1,89 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm; + +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; +import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException; +import dev.caskeleton.application.notification.platform.contact.ContactPointValue; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult; +import dev.caskeleton.application.notification.platform.security.AccessContext; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +/** + * Batch submission that keeps per-recipient identity. + * + *

One transport call, many attempts. FCM returns a positional result per input, so a partial + * failure is decomposed back to the recipient that owns it; collapsing a batch into one shared + * outcome would mark four delivered recipients as failed because the fifth token was stale. + */ +public final class FcmBatchCoordinator { + + private final FcmGateway gateway; + private final FcmMessageMapper messageMapper; + private final FcmTargetMapper targetMapper; + private final FcmFailureClassifier classifier; + private final ContactPointProtector protector; + private final FcmProviderProperties properties; + + public FcmBatchCoordinator( + FcmGateway gateway, + FcmMessageMapper messageMapper, + FcmTargetMapper targetMapper, + FcmFailureClassifier classifier, + ContactPointProtector protector, + FcmProviderProperties properties) { + this.gateway = Objects.requireNonNull(gateway, "gateway"); + this.messageMapper = Objects.requireNonNull(messageMapper, "messageMapper"); + this.targetMapper = Objects.requireNonNull(targetMapper, "targetMapper"); + this.classifier = Objects.requireNonNull(classifier, "classifier"); + this.protector = Objects.requireNonNull(protector, "protector"); + this.properties = Objects.requireNonNull(properties, "properties"); + } + + /** Submit a batch and return one result per input, in input order. */ + public CompletionStage> submit( + List submissions) { + Objects.requireNonNull(submissions, "submissions"); + if (submissions.isEmpty()) { + return CompletableFuture.completedFuture(List.of()); + } + if (submissions.size() > properties.maxBatchSize()) { + throw new ProviderPayloadLimitException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.PROVIDER_PAYLOAD_LIMIT, FailureCategory.INVALID_PAYLOAD)); + } + + long startedNanos = System.nanoTime(); + List> messages = new ArrayList<>(submissions.size()); + for (ProviderSubmission submission : submissions) { + ContactPointValue value = + protector.reveal( + submission.contactPoint(), + AccessContext.dispatch(submission.profile().profileId().value())); + messages.add(messageMapper.map(submission, targetMapper.map(value))); + } + + FcmBatchResult batch = gateway.sendBatch(messages); + if (batch.items().size() != submissions.size()) { + throw new IllegalStateException("FCM returned a result count that does not match the input"); + } + + Duration elapsed = Duration.ofNanos(System.nanoTime() - startedNanos); + List results = new ArrayList<>(submissions.size()); + for (FcmBatchResult.Item item : batch.items()) { + results.add( + item.success() + ? ProviderSubmissionResult.accepted(item.messageId().orElse(null), "SUCCESS", elapsed) + : classifier.classify(item.errorCode().orElseThrow(), elapsed)); + } + return CompletableFuture.completedFuture(List.copyOf(results)); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmBatchResult.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmBatchResult.java new file mode 100644 index 00000000..2a8a4b1c --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmBatchResult.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** Positional result of one FCM multicast call. */ +public record FcmBatchResult(List items) { + + public FcmBatchResult { + items = List.copyOf(Objects.requireNonNull(items, "items")); + } + + /** One item result, aligned with the input index. */ + public record Item(boolean success, Optional messageId, Optional errorCode) { + + public Item { + Objects.requireNonNull(messageId, "messageId"); + Objects.requireNonNull(errorCode, "errorCode"); + if (success == errorCode.isPresent()) { + throw new IllegalArgumentException("an item is either a success or an error, never both"); + } + } + + /** Successful item. */ + public static Item success(String messageId) { + return new Item(true, Optional.ofNullable(messageId), Optional.empty()); + } + + /** Failed item. */ + public static Item failure(String errorCode) { + return new Item(false, Optional.empty(), Optional.of(errorCode)); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmContactPointUpdater.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmContactPointUpdater.java new file mode 100644 index 00000000..5c21bd71 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmContactPointUpdater.java @@ -0,0 +1,39 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm; + +import dev.caskeleton.application.notification.platform.api.ContactPointId; +import dev.caskeleton.application.notification.platform.api.TenantId; +import dev.caskeleton.application.notification.platform.contact.ContactPointStatus; +import dev.caskeleton.application.notification.platform.dispatch.ContactPointStorePort; +import java.util.Objects; + +/** + * Applies FCM target lifecycle changes. + * + *

An {@code UNREGISTERED} response is the provider telling us the target no longer exists. Not + * acting on it means every future notification to that user spends a provider call to learn the + * same thing again. + */ +public final class FcmContactPointUpdater { + + private final ContactPointStorePort contactPoints; + private final FcmFailureClassifier classifier; + + public FcmContactPointUpdater( + ContactPointStorePort contactPoints, FcmFailureClassifier classifier) { + this.contactPoints = Objects.requireNonNull(contactPoints, "contactPoints"); + this.classifier = Objects.requireNonNull(classifier, "classifier"); + } + + /** Invalidate the contact point when the error code says the target is gone. */ + public boolean apply(TenantId tenantId, ContactPointId contactPointId, String errorCode) { + Objects.requireNonNull(tenantId, "tenantId"); + Objects.requireNonNull(contactPointId, "contactPointId"); + Objects.requireNonNull(errorCode, "errorCode"); + if (!classifier.invalidatesContactPoint(errorCode)) { + return false; + } + contactPoints.updateStatus( + tenantId, contactPointId, ContactPointStatus.INVALID, "FCM_" + errorCode); + return true; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmFailureClassifier.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmFailureClassifier.java new file mode 100644 index 00000000..d6bf2e8b --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmFailureClassifier.java @@ -0,0 +1,76 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm; + +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.provider.ProviderFailure; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult; +import java.time.Duration; +import java.util.Optional; + +/** + * FCM error codes to the stable failure vocabulary. + * + *

{@code UNREGISTERED} is the one that must never be retried: the target is gone, and repeating + * the call cannot bring it back. It invalidates the contact point and lets routing fall back. + */ +public final class FcmFailureClassifier { + + /** Classify one FCM error code. */ + public ProviderSubmissionResult classify(String errorCode, Duration elapsed) { + return ProviderSubmissionResult.rejected(failure(errorCode), elapsed); + } + + /** Failure for one FCM error code. */ + public ProviderFailure failure(String errorCode) { + return switch (errorCode) { + case "UNREGISTERED", "INVALID_TOKEN" -> + ProviderFailure.of( + NotificationFailureCode.CONTACT_POINT_INVALID, + FailureCategory.INVALID_RECIPIENT, + false); + case "QUOTA_EXCEEDED" -> + ProviderFailure.of( + NotificationFailureCode.PROVIDER_THROTTLED, FailureCategory.THROTTLED, true); + case "UNAVAILABLE", "INTERNAL" -> + ProviderFailure.of( + NotificationFailureCode.PROVIDER_TRANSIENT_FAILURE, + FailureCategory.TRANSIENT_PROVIDER, + true); + case "INVALID_ARGUMENT" -> + ProviderFailure.of( + NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD, false); + case "THIRD_PARTY_AUTH_ERROR", "UNAUTHENTICATED" -> + ProviderFailure.of( + NotificationFailureCode.PROVIDER_AUTHENTICATION_FAILED, + FailureCategory.AUTHENTICATION, + false); + case "SENDER_ID_MISMATCH" -> + ProviderFailure.of( + NotificationFailureCode.PROVIDER_AUTHORIZATION_FAILED, + FailureCategory.AUTHORIZATION, + false); + default -> + ProviderFailure.of( + NotificationFailureCode.PROVIDER_PERMANENT_FAILURE, + FailureCategory.PERMANENT_PROVIDER, + false); + }; + } + + /** Whether an error code means the contact point should be invalidated. */ + public boolean invalidatesContactPoint(String errorCode) { + return failure(errorCode).category() == FailureCategory.INVALID_RECIPIENT; + } + + /** Retry hint, where FCM supplies one. */ + public Optional retryAfter(Optional headerValue) { + return headerValue.flatMap( + value -> { + try { + return Optional.of(Duration.ofSeconds(Long.parseLong(value.trim()))); + } catch (NumberFormatException notSeconds) { + return Optional.empty(); + } + }); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmGateway.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmGateway.java new file mode 100644 index 00000000..79c6cb09 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmGateway.java @@ -0,0 +1,12 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm; + +import java.util.List; +import java.util.Map; + +/** The FCM transport seam, so batch decomposition can be tested without a live project. */ +@FunctionalInterface +public interface FcmGateway { + + /** Send a batch and return one positional result per message. */ + FcmBatchResult sendBatch(List> 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 new file mode 100644 index 00000000..d65001b2 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmMessageMapper.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm; + +import dev.caskeleton.application.notification.platform.api.content.MobilePushContent; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import java.time.Clock; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Builds the FCM message body. + * + *

TTL is the minimum of the remaining delivery deadline and the provider maximum. Sending the + * provider maximum when the notification expires in ninety seconds would let FCM keep retrying a + * message the platform has already given up on. + */ +public final class FcmMessageMapper { + + private static final int MAX_PAYLOAD_BYTES = 4096; + + private final FcmProviderProperties properties; + private final Clock clock; + + public FcmMessageMapper(FcmProviderProperties properties, Clock clock) { + this.properties = Objects.requireNonNull(properties, "properties"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + /** Message body for one submission. */ + public Map map(ProviderSubmission submission, FcmWireTarget target) { + Objects.requireNonNull(submission, "submission"); + Objects.requireNonNull(target, "target"); + if (!(submission.content().content() instanceof MobilePushContent push)) { + throw new IllegalArgumentException("FCM requires mobile push content"); + } + + Map message = new LinkedHashMap<>(); + if ("FID".equals(target.kind())) { + message.put("installation_id", target.value()); + } else { + message.put("token", target.value()); + } + message.put("notification", Map.of("title", push.title(), "body", push.body())); + if (!push.data().isEmpty()) { + message.put("data", push.data()); + } + + Map android = new LinkedHashMap<>(); + android.put("ttl", ttl(submission).toSeconds() + "s"); + submission.collapse().ifPresent(spec -> android.put("collapse_key", spec.key())); + message.put("android", android); + + return Map.of("message", message); + } + + /** Effective TTL for a submission. */ + 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()); + } + + /** Payload ceiling enforced before the provider call. */ + public int maxPayloadBytes() { + return MAX_PAYLOAD_BYTES; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmNotificationProviderAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmNotificationProviderAdapter.java new file mode 100644 index 00000000..1d3722c1 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmNotificationProviderAdapter.java @@ -0,0 +1,73 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm; + +import dev.caskeleton.application.notification.platform.api.ProviderId; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.provider.BatchNotificationProviderAdapter; +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 java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletionStage; + +/** + * FCM adapter. + * + *

A successful send means FCM took the message. Firebase describes its own failures as handoff + * failures, which is the clearest statement that success is a handoff and not a device delivery, so + * the strongest evidence this adapter ever produces is {@code PROVIDER_ACCEPTED}. + */ +public final class FcmNotificationProviderAdapter implements BatchNotificationProviderAdapter { + + private static final ProviderId PROVIDER_ID = new ProviderId("fcm"); + + private final FcmBatchCoordinator coordinator; + private final FcmProviderProperties properties; + + public FcmNotificationProviderAdapter( + FcmBatchCoordinator coordinator, FcmProviderProperties properties) { + this.coordinator = Objects.requireNonNull(coordinator, "coordinator"); + this.properties = Objects.requireNonNull(properties, "properties"); + } + + @Override + public ProviderId providerId() { + return PROVIDER_ID; + } + + @Override + public Set channels() { + return Set.of(Channel.PUSH); + } + + @Override + public ProviderCapabilities capabilities() { + // deliveryReceipt is false: FCM has no server-side delivery receipt for ordinary sends, and + // claiming one would let the runtime plan a reconciliation that can never succeed. + return new ProviderCapabilities( + true, + false, + false, + false, + false, + false, + false, + true, + properties.maxBatchSize(), + 4096L, + properties.maxTtl()); + } + + @Override + public CompletionStage submit(ProviderSubmission submission) { + Objects.requireNonNull(submission, "submission"); + return coordinator.submit(List.of(submission)).thenApply(results -> results.get(0)); + } + + @Override + public CompletionStage> submitBatch( + List submissions) { + return coordinator.submit(submissions); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmProviderProperties.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmProviderProperties.java new file mode 100644 index 00000000..a6bd37f1 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmProviderProperties.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm; + +import java.net.URI; +import java.time.Duration; +import java.util.Objects; + +/** FCM profile. Project and application identity are pinned so a target cannot cross projects. */ +public record FcmProviderProperties( + URI endpoint, + String projectId, + String applicationId, + int maxBatchSize, + Duration maxTtl, + Duration timeout) { + + /** The Admin SDK multicast ceiling. */ + public static final int MAX_SUPPORTED_BATCH = 500; + + public FcmProviderProperties { + Objects.requireNonNull(endpoint, "endpoint"); + Objects.requireNonNull(projectId, "projectId"); + Objects.requireNonNull(applicationId, "applicationId"); + Objects.requireNonNull(maxTtl, "maxTtl"); + Objects.requireNonNull(timeout, "timeout"); + if (projectId.isBlank() || applicationId.isBlank()) { + throw new IllegalArgumentException("projectId and applicationId must not be blank"); + } + if (maxBatchSize < 1 || maxBatchSize > MAX_SUPPORTED_BATCH) { + throw new IllegalArgumentException("maxBatchSize must be 1.." + MAX_SUPPORTED_BATCH); + } + if (timeout.isNegative() || timeout.isZero()) { + throw new IllegalArgumentException("timeout must be positive and finite"); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmTargetMapper.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmTargetMapper.java new file mode 100644 index 00000000..aa8fe01c --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmTargetMapper.java @@ -0,0 +1,18 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm; + +import dev.caskeleton.application.notification.platform.contact.ContactPointValue; +import dev.caskeleton.application.notification.platform.contact.FcmInstallationId; +import dev.caskeleton.application.notification.platform.contact.LegacyFcmRegistrationToken; + +/** Maps typed push targets to their FCM wire representation. */ +public final class FcmTargetMapper { + + /** Wire target for a contact point value. */ + public FcmWireTarget map(ContactPointValue value) { + return switch (value) { + case FcmInstallationId fid -> new FcmWireTarget("FID", fid.value()); + case LegacyFcmRegistrationToken token -> new FcmWireTarget("LEGACY_TOKEN", token.value()); + default -> throw new IllegalArgumentException("FCM requires an FCM target"); + }; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmWireTarget.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmWireTarget.java new file mode 100644 index 00000000..878fbf7f --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmWireTarget.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm; + +import java.util.Objects; + +/** + * A target in its wire form, with the kind kept explicit. + * + *

The kind is not cosmetic: an installation id and a legacy registration token go to different + * request fields, and flattening them would make a migration a runtime guess. + */ +public record FcmWireTarget(String kind, String value) { + + public FcmWireTarget { + Objects.requireNonNull(kind, "kind"); + Objects.requireNonNull(value, "value"); + if (value.isBlank()) { + throw new IllegalArgumentException("value"); + } + } +} 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 new file mode 100644 index 00000000..e72be549 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/JdkNotificationHttpGateway.java @@ -0,0 +1,103 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.http; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Default gateway on the JDK HTTP client. + * + *

Redirects are never followed. A provider redirect would move a signed, credential-bearing + * request to a host the profile never approved. + * + *

Timeout and connection-reset failures are translated into an explicit statement about whether + * the body was committed, because that single bit is what separates a safe retry from a duplicate. + */ +public final class JdkNotificationHttpGateway implements NotificationHttpGateway { + + // Restricted headers the JDK client refuses to let a caller set. + private static final Set RESTRICTED = + Set.of("connection", "content-length", "expect", "host", "upgrade"); + + private final HttpClient client; + + public JdkNotificationHttpGateway(Duration connectTimeout) { + this( + HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NEVER) + .connectTimeout(Objects.requireNonNull(connectTimeout, "connectTimeout")) + .build()); + } + + public JdkNotificationHttpGateway(HttpClient client) { + this.client = Objects.requireNonNull(client, "client"); + } + + @Override + public NotificationHttpResponse exchange(NotificationHttpRequest request) { + Objects.requireNonNull(request, "request"); + HttpRequest.Builder builder = + HttpRequest.newBuilder(request.uri()) + .timeout(request.timeout()) + .method(request.method(), HttpRequest.BodyPublishers.ofByteArray(request.body())); + request + .headers() + .forEach( + (name, values) -> { + if (!RESTRICTED.contains(name)) { + values.forEach(value -> builder.header(name, value)); + } + }); + + try { + HttpResponse response = + client.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray()); + return new NotificationHttpResponse( + response.statusCode(), Map.copyOf(response.headers().map()), response.body()); + } catch (HttpTimeoutException timeout) { + // The request timed out after the body was published, so the provider may well have it. + throw new NotificationHttpTransportException("RESPONSE_TIMEOUT", true, timeout); + } catch (IOException failure) { + throw new NotificationHttpTransportException( + "TRANSPORT_FAILURE", bodyWasLikelyCommitted(failure), failure); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new NotificationHttpTransportException("INTERRUPTED", true, interrupted); + } + } + + /** + * A connect failure happens before anything is written; anything else may have written the body. + * + *

The default is deliberately the pessimistic one: guessing "not committed" would turn an + * unknown into an automatic resend. + */ + private static boolean bodyWasLikelyCommitted(IOException failure) { + String message = failure.getMessage(); + if (message == null) { + return true; + } + 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; + } + + /** Header map helper for adapters. */ + public static Map> headers(Map singleValued) { + return singleValued.entrySet().stream() + .collect( + java.util.stream.Collectors.toUnmodifiableMap( + Map.Entry::getKey, entry -> List.of(entry.getValue()))); + } +} 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 new file mode 100644 index 00000000..9bc735bc --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationEndpoints.java @@ -0,0 +1,43 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.http; + +import java.net.URI; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +/** Endpoint validation shared by the provider profiles. */ +public final class NotificationEndpoints { + + private static final Set LOOPBACK_HOSTS = Set.of("127.0.0.1", "::1", "localhost"); + + private NotificationEndpoints() {} + + /** + * Require TLS, except on the loopback interface. + * + *

The exception is narrow on purpose. A plaintext provider endpoint on a routable host exposes + * credentials and message bodies to anything on the path, which is why it is refused outright. A + * loopback endpoint never leaves the machine, so the same reasoning does not apply — and without + * this the contract suite could not exercise a real socket at all, which would mean the ambiguity + * behaviour it exists to prove went untested. + */ + public static URI requireSecureOrLoopback(URI endpoint, String name) { + Objects.requireNonNull(endpoint, name); + String scheme = + endpoint.getScheme() == null ? "" : endpoint.getScheme().toLowerCase(Locale.ROOT); + if ("https".equals(scheme)) { + return endpoint; + } + String host = endpoint.getHost() == null ? "" : endpoint.getHost().toLowerCase(Locale.ROOT); + if ("http".equals(scheme) && LOOPBACK_HOSTS.contains(host)) { + return endpoint; + } + throw new IllegalArgumentException(name + " must use https outside the loopback interface"); + } + + /** Whether an endpoint is on the loopback interface. */ + public static boolean isLoopback(URI endpoint) { + String host = endpoint.getHost() == null ? "" : endpoint.getHost().toLowerCase(Locale.ROOT); + return LOOPBACK_HOSTS.contains(host); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationHttpGateway.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationHttpGateway.java new file mode 100644 index 00000000..a0c19265 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationHttpGateway.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.http; + +/** + * The only way a provider adapter in this leaf reaches the network. + * + *

It exists as a port because the registry does not permit {@code adapter-outbound-notification + * → adapter-outbound-httpclient}. The composition root sees both leaves and is the supported place + * to substitute an implementation backed by the HTTP Client Platform, which brings its own TLS, + * circuit breaker, SSRF and dynamic-target policy. + */ +public interface NotificationHttpGateway { + + /** + * Execute one request. + * + * @throws NotificationHttpTransportException when no response could be read; the exception states + * whether the request body was already committed + */ + NotificationHttpResponse exchange(NotificationHttpRequest request); +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationHttpRequest.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationHttpRequest.java new file mode 100644 index 00000000..1123dfbf --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationHttpRequest.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.http; + +import java.net.URI; +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +/** One outbound provider HTTP request. */ +@SuppressWarnings("ArrayRecordComponent") // defensive copies on construction and on every accessor +public record NotificationHttpRequest( + String method, URI uri, Map> headers, byte[] body, Duration timeout) { + + public NotificationHttpRequest { + Objects.requireNonNull(method, "method"); + Objects.requireNonNull(uri, "uri"); + Objects.requireNonNull(headers, "headers"); + Objects.requireNonNull(body, "body"); + Objects.requireNonNull(timeout, "timeout"); + if (timeout.isNegative() || timeout.isZero()) { + throw new IllegalArgumentException("timeout must be finite and positive"); + } + headers = + headers.entrySet().stream() + .collect( + java.util.stream.Collectors.toUnmodifiableMap( + entry -> entry.getKey().toLowerCase(Locale.ROOT), + entry -> List.copyOf(entry.getValue()))); + body = body.clone(); + } + + @Override + public byte[] body() { + return body.clone(); + } + + @Override + public boolean equals(Object other) { + return other instanceof NotificationHttpRequest request + && method.equals(request.method) + && uri.equals(request.uri) + && headers.equals(request.headers) + && Arrays.equals(body, request.body) + && timeout.equals(request.timeout); + } + + @Override + public int hashCode() { + return Objects.hash(method, uri, headers, Arrays.hashCode(body), timeout); + } + + @Override + public String toString() { + // The URI is redacted because a Web Push endpoint is a capability URL and the request body may + // be a rendered message. + return "NotificationHttpRequest[method=" + + method + + ", uri=redacted, bytes=" + + body.length + + "]"; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationHttpResponse.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationHttpResponse.java new file mode 100644 index 00000000..de43312c --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationHttpResponse.java @@ -0,0 +1,66 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.http; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** One provider HTTP response. */ +@SuppressWarnings("ArrayRecordComponent") // defensive copies on construction and on every accessor +public record NotificationHttpResponse( + int statusCode, Map> headers, byte[] body) { + + public NotificationHttpResponse { + Objects.requireNonNull(headers, "headers"); + Objects.requireNonNull(body, "body"); + headers = + headers.entrySet().stream() + .collect( + java.util.stream.Collectors.toUnmodifiableMap( + entry -> entry.getKey().toLowerCase(Locale.ROOT), + entry -> List.copyOf(entry.getValue()))); + body = body.clone(); + } + + @Override + public byte[] body() { + return body.clone(); + } + + /** Body decoded as UTF-8. */ + public String bodyAsString() { + return new String(body, StandardCharsets.UTF_8); + } + + /** First value of a header, matched case-insensitively. */ + public Optional header(String name) { + List values = headers.get(name.toLowerCase(Locale.ROOT)); + return values == null || values.isEmpty() ? Optional.empty() : Optional.of(values.get(0)); + } + + /** Whether the status is 2xx. */ + public boolean isSuccessful() { + return statusCode >= 200 && statusCode < 300; + } + + @Override + public boolean equals(Object other) { + return other instanceof NotificationHttpResponse response + && statusCode == response.statusCode + && headers.equals(response.headers) + && Arrays.equals(body, response.body); + } + + @Override + public int hashCode() { + return Objects.hash(statusCode, headers, Arrays.hashCode(body)); + } + + @Override + public String toString() { + return "NotificationHttpResponse[status=" + statusCode + ", bytes=" + body.length + "]"; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationHttpTransportException.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationHttpTransportException.java new file mode 100644 index 00000000..750d6511 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationHttpTransportException.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.http; + +import java.util.Objects; + +/** + * A provider HTTP call that produced no usable response. + * + *

{@code requestBodyCommitted} is the field that decides everything downstream: a connection + * that failed before the body was written is a safe retry, while one that failed after it was + * written is an ambiguous submission that must not be resent automatically. + */ +public class NotificationHttpTransportException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final boolean requestBodyCommitted; + private final String reasonCode; + + public NotificationHttpTransportException( + String reasonCode, boolean requestBodyCommitted, Throwable cause) { + super(reasonCode, cause); + this.reasonCode = Objects.requireNonNull(reasonCode, "reasonCode"); + this.requestBodyCommitted = requestBodyCommitted; + } + + /** Whether the request body reached the provider before the failure. */ + public boolean requestBodyCommitted() { + return requestBodyCommitted; + } + + /** Bounded reason code, safe for logs and metrics. */ + public String reasonCode() { + return reasonCode; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/AwsSignatureV4Signer.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/AwsSignatureV4Signer.java new file mode 100644 index 00000000..91f5f16d --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/AwsSignatureV4Signer.java @@ -0,0 +1,147 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.ses; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.HexFormat; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * AWS Signature Version 4. + * + *

Implemented here rather than pulled in with an SDK because the SDK would also bring its own + * HTTP client, retry policy and credential chain — three things this platform already owns and + * whose duplication would quietly move retry ownership out of the notification retry policy. + */ +public final class AwsSignatureV4Signer { + + private static final String ALGORITHM = "AWS4-HMAC-SHA256"; + private static final DateTimeFormatter AMZ_DATE = + DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").withZone(ZoneOffset.UTC); + private static final DateTimeFormatter DATE_STAMP = + DateTimeFormatter.ofPattern("yyyyMMdd").withZone(ZoneOffset.UTC); + + /** Signed headers to add to a request. */ + public record SignedHeaders(String authorization, String amzDate, String contentSha256) { + + public SignedHeaders { + Objects.requireNonNull(authorization, "authorization"); + Objects.requireNonNull(amzDate, "amzDate"); + Objects.requireNonNull(contentSha256, "contentSha256"); + } + } + + /** Sign one request. */ + public SignedHeaders sign( + String method, + String canonicalUri, + String canonicalQuery, + Map headers, + byte[] body, + String accessKeyId, + byte[] secretAccessKey, + String region, + String service, + Instant signedAt) { + Objects.requireNonNull(method, "method"); + Objects.requireNonNull(canonicalUri, "canonicalUri"); + Objects.requireNonNull(canonicalQuery, "canonicalQuery"); + Objects.requireNonNull(headers, "headers"); + Objects.requireNonNull(body, "body"); + Objects.requireNonNull(signedAt, "signedAt"); + + String amzDate = AMZ_DATE.format(signedAt); + String dateStamp = DATE_STAMP.format(signedAt); + String payloadHash = hex(sha256(body)); + + TreeMap canonicalHeaders = new TreeMap<>(); + headers.forEach( + (name, value) -> canonicalHeaders.put(name.toLowerCase(Locale.ROOT), value.trim())); + canonicalHeaders.put("x-amz-date", amzDate); + canonicalHeaders.put("x-amz-content-sha256", payloadHash); + + StringBuilder canonicalHeaderBlock = new StringBuilder(); + canonicalHeaders.forEach( + (name, value) -> canonicalHeaderBlock.append(name).append(':').append(value).append('\n')); + String signedHeaderNames = String.join(";", canonicalHeaders.keySet()); + + String canonicalRequest = + method + + '\n' + + canonicalUri + + '\n' + + canonicalQuery + + '\n' + + canonicalHeaderBlock + + '\n' + + signedHeaderNames + + '\n' + + payloadHash; + + String credentialScope = dateStamp + "/" + region + "/" + service + "/aws4_request"; + String stringToSign = + ALGORITHM + + '\n' + + amzDate + + '\n' + + credentialScope + + '\n' + + hex(sha256(canonicalRequest.getBytes(StandardCharsets.UTF_8))); + + byte[] signingKey = signingKey(secretAccessKey, dateStamp, region, service); + String signature = hex(hmac(signingKey, stringToSign.getBytes(StandardCharsets.UTF_8))); + + String authorization = + ALGORITHM + + " Credential=" + + accessKeyId + + "/" + + credentialScope + + ", SignedHeaders=" + + signedHeaderNames + + ", Signature=" + + signature; + return new SignedHeaders(authorization, amzDate, payloadHash); + } + + private static byte[] signingKey( + byte[] secretAccessKey, String dateStamp, String region, String service) { + byte[] key = + ("AWS4" + new String(secretAccessKey, StandardCharsets.UTF_8)) + .getBytes(StandardCharsets.UTF_8); + byte[] dateKey = hmac(key, dateStamp.getBytes(StandardCharsets.UTF_8)); + byte[] regionKey = hmac(dateKey, region.getBytes(StandardCharsets.UTF_8)); + byte[] serviceKey = hmac(regionKey, service.getBytes(StandardCharsets.UTF_8)); + return hmac(serviceKey, "aws4_request".getBytes(StandardCharsets.UTF_8)); + } + + private static byte[] hmac(byte[] key, byte[] data) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + return mac.doFinal(data); + } catch (java.security.GeneralSecurityException failure) { + throw new IllegalStateException("HmacSHA256 is required by the Java platform", failure); + } + } + + private static byte[] sha256(byte[] value) { + try { + return MessageDigest.getInstance("SHA-256").digest(value); + } catch (NoSuchAlgorithmException failure) { + throw new IllegalStateException("SHA-256 is required by the Java platform", failure); + } + } + + private static String hex(byte[] value) { + return HexFormat.of().formatHex(value); + } +} 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 new file mode 100644 index 00000000..61494c7e --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesCallbackAdapter.java @@ -0,0 +1,82 @@ +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.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; +import dev.caskeleton.application.notification.platform.callback.VerifiedCallback; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import tools.jackson.databind.JsonNode; + +/** + * SES event ingestion over SNS. + * + *

A subscription confirmation is verified like any other message but produces no provider event: + * confirming a topic is an operational act, and letting it into the ledger would mean an unverified + * caller could add rows just by claiming to be SNS. + */ +public final class SesCallbackAdapter implements ProviderCallbackAdapter { + + private static final ProviderId PROVIDER_ID = new ProviderId("ses"); + + private final SnsSignatureVerifier verifier; + private final SesEventNormalizer normalizer; + + public SesCallbackAdapter(SnsSignatureVerifier verifier, SesEventNormalizer normalizer) { + this.verifier = Objects.requireNonNull(verifier, "verifier"); + this.normalizer = Objects.requireNonNull(normalizer, "normalizer"); + } + + @Override + public ProviderId providerId() { + return PROVIDER_ID; + } + + @Override + public CallbackVerificationResult verify(CallbackRequest request) { + Objects.requireNonNull(request, "request"); + Map envelope; + try { + envelope = flatten(new String(request.body(), StandardCharsets.UTF_8)); + } catch (RuntimeException unparseable) { + return CallbackVerificationResult.invalid("SNS_ENVELOPE_UNPARSEABLE"); + } + if (!verifier.isValid(envelope)) { + return CallbackVerificationResult.invalid("SNS_SIGNATURE_MISMATCH"); + } + return CallbackVerificationResult.valid(new VerifiedCallback(request, envelope)); + } + + @Override + public List normalize(VerifiedCallback callback) { + Objects.requireNonNull(callback, "callback"); + Map envelope = callback.canonicalParameters(); + String type = envelope.getOrDefault("Type", "Notification"); + if (!"Notification".equals(type)) { + // Confirmations and unsubscribes are handled by operations, not by the delivery ledger. + return List.of(); + } + String message = envelope.getOrDefault("Message", "{}"); + return normalizer.normalize(message); + } + + private static Map flatten(String body) { + JsonNode root = NotificationJsonMapper.mapper().readTree(body); + Map envelope = new LinkedHashMap<>(); + root.properties() + .forEach( + property -> { + JsonNode value = property.getValue(); + if (value != null && value.isValueNode()) { + envelope.put(property.getKey(), value.asString()); + } + }); + return envelope; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesDeliveryProjector.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesDeliveryProjector.java new file mode 100644 index 00000000..e0c7ac2c --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesDeliveryProjector.java @@ -0,0 +1,18 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.ses; + +import dev.caskeleton.application.notification.platform.api.ProviderId; +import dev.caskeleton.application.notification.platform.callback.StandardDeliveryProjector; + +/** + * SES projector. + * + *

SES adds no transition of its own: delivery, bounce and complaint all obey the shared table, + * and the interesting SES-specific behaviour — a complaint arriving after a delivery — is exactly + * what the shared table already gets right. + */ +public final class SesDeliveryProjector extends StandardDeliveryProjector { + + public SesDeliveryProjector() { + super(new ProviderId("ses")); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesEventNormalizer.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesEventNormalizer.java new file mode 100644 index 00000000..eba30d54 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesEventNormalizer.java @@ -0,0 +1,101 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.ses; + +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; +import dev.caskeleton.application.notification.platform.callback.NormalizedEventType; +import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import tools.jackson.databind.JsonNode; + +/** + * SES event publishing to the stable vocabulary. + * + *

Bounce type decides the suppression consequence: a permanent bounce invalidates the address, + * while a transient one is a retry input. Collapsing both into one reason would remove an address + * because a mailbox was briefly full. + */ +public final class SesEventNormalizer { + + /** Normalize one SES notification payload. */ + public List normalize(String payload) { + JsonNode root = NotificationJsonMapper.mapper().readTree(payload); + String type = text(root, "eventType").orElse(text(root, "notificationType").orElse("Unknown")); + Optional messageId = + Optional.ofNullable(root.get("mail")).flatMap(mail -> text(mail, "messageId")); + Optional occurredAt = timestamp(root, type); + + List events = new ArrayList<>(1); + events.add( + switch (type) { + case "Send" -> + event(NormalizedEventType.PROVIDER_ACCEPTED, type, messageId, occurredAt, Map.of()); + case "Delivery" -> + event(NormalizedEventType.DELIVERY_CONFIRMED, type, messageId, occurredAt, Map.of()); + case "DeliveryDelay" -> + event(NormalizedEventType.DELIVERY_DELAYED, type, messageId, occurredAt, Map.of()); + case "Bounce" -> bounce(root, type, messageId, occurredAt); + case "Complaint" -> + event(NormalizedEventType.COMPLAINT, type, messageId, occurredAt, Map.of()); + case "Reject" -> + event(NormalizedEventType.PROVIDER_REJECTED, type, messageId, occurredAt, Map.of()); + case "RenderingFailure" -> + event(NormalizedEventType.TEMPLATE_FAILURE, type, messageId, occurredAt, Map.of()); + case "Open" -> event(NormalizedEventType.OPENED, type, messageId, occurredAt, Map.of()); + case "Click" -> event(NormalizedEventType.CLICKED, type, messageId, occurredAt, Map.of()); + default -> event(NormalizedEventType.UNKNOWN, type, messageId, occurredAt, Map.of()); + }); + return List.copyOf(events); + } + + private static NormalizedProviderEvent bounce( + JsonNode root, String type, Optional messageId, Optional occurredAt) { + String bounceType = + Optional.ofNullable(root.get("bounce")) + .flatMap(bounce -> text(bounce, "bounceType")) + .orElse("Undetermined"); + NormalizedEventType normalized = + "Permanent".equals(bounceType) + ? NormalizedEventType.BOUNCED_HARD + : NormalizedEventType.BOUNCED_SOFT; + return event( + normalized, + type + "/" + bounceType, + messageId, + occurredAt, + Map.of("bounceType", bounceType)); + } + + private static NormalizedProviderEvent event( + NormalizedEventType type, + String nativeType, + Optional messageId, + Optional occurredAt, + Map attributes) { + return new NormalizedProviderEvent( + type, nativeType, Optional.empty(), messageId, occurredAt, attributes); + } + + private static Optional text(JsonNode node, String field) { + JsonNode value = node.get(field); + return value == null || value.isNull() ? Optional.empty() : Optional.of(value.asString()); + } + + private static Optional timestamp(JsonNode root, String type) { + JsonNode section = root.get(type.toLowerCase(java.util.Locale.ROOT)); + if (section == null) { + return Optional.empty(); + } + return text(section, "timestamp") + .flatMap( + value -> { + try { + return Optional.of(Instant.parse(value)); + } catch (java.time.format.DateTimeParseException unparseable) { + return Optional.empty(); + } + }); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesFailureClassifier.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesFailureClassifier.java new file mode 100644 index 00000000..cfe49e50 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesFailureClassifier.java @@ -0,0 +1,51 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.ses; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.provider.ProviderFailure; +import java.util.Optional; + +/** Maps SES error responses onto the stable failure vocabulary. */ +public final class SesFailureClassifier { + + /** Classify a non-2xx SES response. */ + public ProviderFailure classify(NotificationHttpResponse response) { + String body = response.bodyAsString(); + if (body.contains("MessageRejected")) { + return new ProviderFailure( + NotificationFailureCode.PROVIDER_REJECTED, + FailureCategory.PERMANENT_PROVIDER, + false, + Optional.empty(), + Optional.of("MessageRejected")); + } + if (body.contains("MailFromDomainNotVerified") || body.contains("SendingPausedException")) { + return new ProviderFailure( + NotificationFailureCode.PROVIDER_CONFIGURATION_INVALID, + FailureCategory.AUTHORIZATION, + false, + Optional.empty(), + Optional.of("SenderIdentityNotReady")); + } + if (body.contains("TooManyRequestsException") || response.statusCode() == 429) { + return new ProviderFailure( + NotificationFailureCode.PROVIDER_THROTTLED, + FailureCategory.THROTTLED, + true, + ProviderResults.retryAfter(response.header("retry-after")), + Optional.of("TooManyRequests")); + } + if (body.contains("AccountSuspendedException")) { + return new ProviderFailure( + NotificationFailureCode.PROVIDER_AUTHORIZATION_FAILED, + FailureCategory.AUTHORIZATION, + false, + Optional.empty(), + Optional.of("AccountSuspended")); + } + return ProviderResults.fromStatus( + response.statusCode(), ProviderResults.retryAfter(response.header("retry-after"))); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesNotificationProviderAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesNotificationProviderAdapter.java new file mode 100644 index 00000000..82874047 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesNotificationProviderAdapter.java @@ -0,0 +1,134 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.ses; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException; +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; +import dev.caskeleton.application.notification.platform.api.ProviderId; +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; +import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter; +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.security.AccessContext; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.time.Clock; +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +/** + * Amazon SES submission. + * + *

{@code MessageId} is stored as the provider request id and mapped to {@code + * PROVIDER_ACCEPTED}. SES documents that it can accept a request and still decline to send — for a + * virus finding or a bad personalisation — so promoting acceptance to delivery would be wrong by + * the provider's own contract, not merely conservative. + * + *

Retry ownership stays with the notification retry policy: the gateway performs no blind retry, + * because a resend after a lost response is exactly the decision the evidence model must make. + */ +public final class SesNotificationProviderAdapter implements NotificationProviderAdapter { + + private static final ProviderId PROVIDER_ID = new ProviderId("ses"); + + private final NotificationHttpGateway gateway; + private final SesRequestMapper mapper; + private final SesFailureClassifier classifier; + private final ContactPointProtector protector; + private final SecretMaterialProvider secrets; + private final String accessKeyId; + private final Clock clock; + + public SesNotificationProviderAdapter( + NotificationHttpGateway gateway, + SesRequestMapper mapper, + SesFailureClassifier classifier, + ContactPointProtector protector, + SecretMaterialProvider secrets, + String accessKeyId, + Clock clock) { + this.gateway = Objects.requireNonNull(gateway, "gateway"); + this.mapper = Objects.requireNonNull(mapper, "mapper"); + this.classifier = Objects.requireNonNull(classifier, "classifier"); + this.protector = Objects.requireNonNull(protector, "protector"); + this.secrets = Objects.requireNonNull(secrets, "secrets"); + this.accessKeyId = Objects.requireNonNull(accessKeyId, "accessKeyId"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + @Override + public ProviderId providerId() { + return PROVIDER_ID; + } + + @Override + public Set channels() { + return Set.of(Channel.EMAIL); + } + + @Override + public ProviderCapabilities capabilities() { + return new ProviderCapabilities( + false, false, true, false, false, false, false, false, 1, 10_000_000L, Duration.ofDays(1)); + } + + @Override + public CompletionStage submit(ProviderSubmission submission) { + Objects.requireNonNull(submission, "submission"); + return CompletableFuture.completedFuture(send(submission)); + } + + private ProviderSubmissionResult send(ProviderSubmission submission) { + long startedNanos = System.nanoTime(); + ContactPointValue value = + protector.reveal( + submission.contactPoint(), + AccessContext.dispatch(submission.profile().profileId().value())); + if (!(value instanceof EmailAddress address)) { + throw new IllegalArgumentException("SES requires an email contact point"); + } + + var request = + mapper.map( + submission, + address.normalized(), + accessKeyId, + secrets.activeKey(SecretPurpose.PROVIDER_CREDENTIAL).material(), + clock.instant()); + + try { + NotificationHttpResponse response = gateway.exchange(request); + Duration elapsed = elapsedSince(startedNanos); + if (response.isSuccessful()) { + return ProviderSubmissionResult.accepted(messageId(response), "Accepted", elapsed); + } + return ProviderSubmissionResult.rejected(classifier.classify(response), elapsed); + } catch (NotificationHttpTransportException transportFailure) { + return ProviderResults.fromTransport(transportFailure, elapsedSince(startedNanos)); + } + } + + private static String messageId(NotificationHttpResponse response) { + try { + var node = NotificationJsonMapper.mapper().readTree(response.bodyAsString()); + return Optional.ofNullable(node.get("MessageId")).map(value -> value.asString()).orElse(null); + } catch (RuntimeException unparseable) { + // A 2xx without a parseable body is still acceptance; the platform simply has no provider + // request id to reconcile against later. + return null; + } + } + + private static Duration elapsedSince(long startedNanos) { + return Duration.ofNanos(System.nanoTime() - startedNanos); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesProviderProperties.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesProviderProperties.java new file mode 100644 index 00000000..8c354419 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesProviderProperties.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.ses; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationEndpoints; +import java.net.URI; +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; + +/** SES profile. Configuration sets are approved N3 options, not free-form provider parameters. */ +public record SesProviderProperties( + URI endpoint, + String region, + String senderIdentity, + Optional configurationSet, + Duration timeout) { + + public SesProviderProperties { + Objects.requireNonNull(endpoint, "endpoint"); + Objects.requireNonNull(region, "region"); + Objects.requireNonNull(senderIdentity, "senderIdentity"); + Objects.requireNonNull(configurationSet, "configurationSet"); + Objects.requireNonNull(timeout, "timeout"); + NotificationEndpoints.requireSecureOrLoopback(endpoint, "SES endpoint"); + if (region.isBlank() || senderIdentity.isBlank()) { + throw new IllegalArgumentException("region and senderIdentity must not be blank"); + } + if (timeout.isNegative() || timeout.isZero()) { + throw new IllegalArgumentException("timeout must be positive and finite"); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesRequestMapper.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesRequestMapper.java new file mode 100644 index 00000000..42ee9811 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesRequestMapper.java @@ -0,0 +1,86 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.ses; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest; +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; +import dev.caskeleton.application.notification.platform.api.content.EmailContent; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** Builds the signed SES v2 send request. */ +public final class SesRequestMapper { + + private static final String PATH = "/v2/email/outbound-emails"; + + private final SesProviderProperties properties; + private final AwsSignatureV4Signer signer; + + public SesRequestMapper(SesProviderProperties properties, AwsSignatureV4Signer signer) { + this.properties = Objects.requireNonNull(properties, "properties"); + this.signer = Objects.requireNonNull(signer, "signer"); + } + + /** Map one submission into a signed request. */ + public NotificationHttpRequest map( + ProviderSubmission submission, + String recipientAddress, + String accessKeyId, + byte[] secretAccessKey, + Instant signedAt) { + Objects.requireNonNull(submission, "submission"); + if (!(submission.content().content() instanceof EmailContent email)) { + throw new IllegalArgumentException("SES requires email content"); + } + + Map simple = new LinkedHashMap<>(); + simple.put("Subject", Map.of("Data", email.subject(), "Charset", "UTF-8")); + Map bodyParts = new LinkedHashMap<>(); + bodyParts.put("Text", Map.of("Data", email.textBody(), "Charset", "UTF-8")); + email + .htmlBody() + .ifPresent(html -> bodyParts.put("Html", Map.of("Data", html, "Charset", "UTF-8"))); + simple.put("Body", bodyParts); + + Map payload = new LinkedHashMap<>(); + payload.put("FromEmailAddress", properties.senderIdentity()); + payload.put("Destination", Map.of("ToAddresses", java.util.List.of(recipientAddress))); + payload.put("Content", Map.of("Simple", simple)); + properties.configurationSet().ifPresent(name -> payload.put("ConfigurationSetName", name)); + + byte[] body = + NotificationJsonMapper.mapper() + .writeValueAsString(payload) + .getBytes(StandardCharsets.UTF_8); + String host = properties.endpoint().getHost(); + + var signed = + signer.sign( + "POST", + PATH, + "", + Map.of("host", host, "content-type", "application/json"), + body, + accessKeyId, + secretAccessKey, + properties.region(), + "ses", + signedAt); + + return new NotificationHttpRequest( + "POST", + URI.create(properties.endpoint().toString() + PATH), + JdkNotificationHttpGateway.headers( + Map.of( + "content-type", "application/json", + "x-amz-date", signed.amzDate(), + "x-amz-content-sha256", signed.contentSha256(), + "authorization", signed.authorization())), + body, + properties.timeout()); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesSuppressionUpdater.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesSuppressionUpdater.java new file mode 100644 index 00000000..9a3d02df --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesSuppressionUpdater.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.ses; + +import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot; +import dev.caskeleton.application.notification.platform.callback.NormalizedEventType; +import dev.caskeleton.application.notification.platform.callback.NotificationSideEffectPort; +import dev.caskeleton.application.notification.platform.callback.SuppressionFacts; +import java.util.Objects; + +/** + * Turns an SES event into the suppression fact it implies. + * + *

A permanent bounce and a transient one map to different facts on purpose: treating a full + * mailbox like a dead address removes a recipient who would have received the next message fine. + */ +public final class SesSuppressionUpdater { + + private final NotificationSideEffectPort sideEffects; + + public SesSuppressionUpdater(NotificationSideEffectPort sideEffects) { + this.sideEffects = Objects.requireNonNull(sideEffects, "sideEffects"); + } + + /** Apply the suppression consequence of one normalized event. */ + public boolean apply(DeliveryAttemptSnapshot attempt, NormalizedEventType type) { + Objects.requireNonNull(attempt, "attempt"); + Objects.requireNonNull(type, "type"); + + SuppressionFacts facts = + switch (type) { + case BOUNCED_HARD -> SuppressionFacts.NONE.withHardBounce().withInvalidTarget(); + case COMPLAINT -> SuppressionFacts.NONE.withComplaint(); + case INVALID_RECIPIENT -> SuppressionFacts.NONE.withInvalidTarget(); + // A soft bounce is explicitly not a suppression: it is a retry input. + default -> SuppressionFacts.NONE; + }; + if (!facts.requiresSuppression()) { + return false; + } + sideEffects.apply(attempt, facts); + return true; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SnsCertificateProvider.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SnsCertificateProvider.java new file mode 100644 index 00000000..7791eda1 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SnsCertificateProvider.java @@ -0,0 +1,16 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.ses; + +import java.net.URI; +import java.security.PublicKey; + +/** + * Supplies the public key of an SNS signing certificate. + * + *

A port so the fetch, its cache and its TLS policy stay outside the verifier — and so a + * contract test can verify signatures without reaching the network. + */ +public interface SnsCertificateProvider { + + /** Public key of the certificate at a URL that has already been host-checked. */ + PublicKey publicKeyFor(URI certificateUrl); +} 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 new file mode 100644 index 00000000..6d6b640c --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SnsSignatureVerifier.java @@ -0,0 +1,108 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.ses; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.PublicKey; +import java.security.Signature; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * SNS message signature verification. + * + *

Two checks, and both are load-bearing. The signing certificate URL is constrained to an + * Amazon-owned host before it is fetched, because a message that names its own certificate host is + * otherwise self-signed by whoever sent it. The canonical string is then rebuilt from the fields + * SNS specifies, in its order, because signing a re-serialised body would verify our own JSON + * writer rather than the message. + */ +public final class SnsSignatureVerifier { + + private static final Set NOTIFICATION_FIELDS = + Set.of("Message", "MessageId", "Subject", "Timestamp", "TopicArn", "Type"); + private static final Set SUBSCRIPTION_FIELDS = + Set.of("Message", "MessageId", "SubscribeURL", "Timestamp", "Token", "TopicArn", "Type"); + + private final SnsCertificateProvider certificates; + private final String certificateHostSuffix; + + public SnsSignatureVerifier(SnsCertificateProvider certificates, String certificateHostSuffix) { + this.certificates = Objects.requireNonNull(certificates, "certificates"); + this.certificateHostSuffix = + Objects.requireNonNull(certificateHostSuffix, "certificateHostSuffix"); + if (certificateHostSuffix.isBlank()) { + throw new IllegalArgumentException("certificateHostSuffix"); + } + } + + /** Verify one SNS envelope. */ + public boolean isValid(Map envelope) { + Objects.requireNonNull(envelope, "envelope"); + String certificateUrl = envelope.get("SigningCertURL"); + String signature = envelope.get("Signature"); + String version = envelope.getOrDefault("SignatureVersion", "1"); + if (certificateUrl == null || signature == null) { + return false; + } + if (!isTrustedCertificateUrl(certificateUrl)) { + return false; + } + + try { + PublicKey key = certificates.publicKeyFor(URI.create(certificateUrl)); + Signature verifier = + Signature.getInstance("2".equals(version) ? "SHA256withRSA" : "SHA1withRSA"); + verifier.initVerify(key); + verifier.update(canonicalString(envelope).getBytes(StandardCharsets.UTF_8)); + return verifier.verify(Base64.getDecoder().decode(signature)); + } catch (GeneralSecurityException | IllegalArgumentException failure) { + return false; + } + } + + /** Whether the certificate URL is on an Amazon host over TLS. */ + public boolean isTrustedCertificateUrl(String certificateUrl) { + try { + URI uri = URI.create(certificateUrl); + String host = uri.getHost() == null ? "" : uri.getHost().toLowerCase(Locale.ROOT); + return "https".equalsIgnoreCase(uri.getScheme()) && host.endsWith(certificateHostSuffix); + } catch (IllegalArgumentException malformed) { + return false; + } + } + + /** The exact field-name/value sequence SNS signs. */ + public static String canonicalString(Map envelope) { + Set fields = + "SubscriptionConfirmation".equals(envelope.get("Type")) + || "UnsubscribeConfirmation".equals(envelope.get("Type")) + ? SUBSCRIPTION_FIELDS + : NOTIFICATION_FIELDS; + + Map ordered = new LinkedHashMap<>(); + List.of( + "Message", + "MessageId", + "Subject", + "SubscribeURL", + "Timestamp", + "Token", + "TopicArn", + "Type") + .stream() + .filter(fields::contains) + .filter(envelope::containsKey) + .forEach(name -> ordered.put(name, envelope.get(name))); + + StringBuilder canonical = new StringBuilder(); + ordered.forEach( + (name, value) -> canonical.append(name).append('\n').append(value).append('\n')); + return canonical.toString(); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpDispatch.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpDispatch.java new file mode 100644 index 00000000..d68bd1c0 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpDispatch.java @@ -0,0 +1,21 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp; + +import jakarta.mail.internet.MimeMessage; + +/** + * The single SMTP send operation. + * + *

Extracted behind an interface so the adapter's classification rules can be exercised against + * every SMTP outcome — including a connection lost after {@code DATA} — without a live relay. + */ +@FunctionalInterface +public interface SmtpDispatch { + + /** + * Send one message. + * + * @throws SmtpDispatchException with the reply code, or with the fact that the body was already + * committed when the connection dropped + */ + void send(MimeMessage message); +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpDispatchException.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpDispatchException.java new file mode 100644 index 00000000..919be2cf --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpDispatchException.java @@ -0,0 +1,30 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp; + +import java.util.Objects; +import java.util.Optional; + +/** An SMTP send that did not complete with a final acceptance. */ +public class SmtpDispatchException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient Optional replyCode; + private final boolean dataCommitted; + + public SmtpDispatchException( + String reasonCode, Optional replyCode, boolean dataCommitted, Throwable cause) { + super(reasonCode, cause); + this.replyCode = Objects.requireNonNull(replyCode, "replyCode"); + this.dataCommitted = dataCommitted; + } + + /** SMTP reply code, when the server answered at all. */ + public Optional replyCode() { + return replyCode; + } + + /** Whether the message body had already been transmitted when the failure happened. */ + public boolean dataCommitted() { + return dataCommitted; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpFailureClassifier.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpFailureClassifier.java new file mode 100644 index 00000000..4aacc57a --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpFailureClassifier.java @@ -0,0 +1,81 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp; + +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.provider.ProviderExecutionEvidence; +import dev.caskeleton.application.notification.platform.provider.ProviderFailure; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult; +import java.time.Duration; +import java.util.Optional; +import java.util.Set; + +/** + * RFC 5321 reply-code classification. + * + *

4yz is a temporary failure the client may repeat; 5yz is permanent and must not be repeated + * unchanged. The interesting case is neither: a connection lost after {@code DATA} means the relay + * may already hold the message, so {@code SMTP_SEND_FAILED = safe retry} is exactly the + * simplification this classifier exists to prevent. + */ +public final class SmtpFailureClassifier { + + /** Reply codes that identify the recipient rather than the transaction as the problem. */ + private static final Set INVALID_RECIPIENT_CODES = Set.of(550, 551, 553, 511); + + /** Classify a failed send. */ + public ProviderSubmissionResult classify(SmtpDispatchException failure, Duration elapsed) { + Optional replyCode = failure.replyCode(); + + if (replyCode.isEmpty()) { + if (failure.dataCommitted()) { + return ProviderSubmissionResult.ambiguous( + new ProviderFailure( + NotificationFailureCode.PROVIDER_RESPONSE_LOST, + FailureCategory.AMBIGUOUS_SUBMISSION, + false, + Optional.empty(), + Optional.of(failure.getMessage())), + ProviderExecutionEvidence.responseLost(), + elapsed); + } + return ProviderSubmissionResult.notSubmitted( + new ProviderFailure( + NotificationFailureCode.PROVIDER_TRANSIENT_FAILURE, + FailureCategory.TRANSIENT_PROVIDER, + true, + Optional.empty(), + Optional.of(failure.getMessage())), + elapsed); + } + + int code = replyCode.get(); + if (code >= 400 && code < 500) { + return ProviderSubmissionResult.rejected( + new ProviderFailure( + NotificationFailureCode.PROVIDER_TRANSIENT_FAILURE, + FailureCategory.TRANSIENT_PROVIDER, + true, + Optional.empty(), + Optional.of(Integer.toString(code))), + elapsed); + } + if (INVALID_RECIPIENT_CODES.contains(code)) { + return ProviderSubmissionResult.rejected( + new ProviderFailure( + NotificationFailureCode.CONTACT_POINT_INVALID, + FailureCategory.INVALID_RECIPIENT, + false, + Optional.empty(), + Optional.of(Integer.toString(code))), + elapsed); + } + return ProviderSubmissionResult.rejected( + new ProviderFailure( + NotificationFailureCode.PROVIDER_PERMANENT_FAILURE, + FailureCategory.PERMANENT_PROVIDER, + false, + Optional.empty(), + Optional.of(Integer.toString(code))), + elapsed); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpMimeMessageFactory.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpMimeMessageFactory.java new file mode 100644 index 00000000..89574384 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpMimeMessageFactory.java @@ -0,0 +1,95 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp; + +import dev.caskeleton.application.notification.platform.api.content.EmailContent; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; +import dev.caskeleton.application.notification.platform.api.error.NotificationValidationException; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment; +import jakarta.mail.MessagingException; +import jakarta.mail.Session; +import jakarta.mail.internet.MimeMessage; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Objects; +import org.springframework.mail.javamail.MimeMessageHelper; + +/** + * Builds the MIME message. + * + *

Text and HTML are assembled as {@code multipart/alternative} and everything is UTF-8. Header + * values containing CR or LF are rejected before the message is built: header injection is the one + * email failure that turns a notification into someone else's mail. + * + *

A MIME construction failure is a non-retryable rejection, and it happens before any relay is + * contacted. + */ +public final class SmtpMimeMessageFactory { + + private final Session session; + + public SmtpMimeMessageFactory(Session session) { + this.session = Objects.requireNonNull(session, "session"); + } + + /** Build a message for one submission. */ + public MimeMessage create( + ProviderSubmission submission, + String recipientAddress, + String fromAddress, + List attachments) { + Objects.requireNonNull(submission, "submission"); + Objects.requireNonNull(recipientAddress, "recipientAddress"); + Objects.requireNonNull(fromAddress, "fromAddress"); + Objects.requireNonNull(attachments, "attachments"); + + if (!(submission.content().content() instanceof EmailContent email)) { + throw rejection(); + } + requireHeaderSafe(recipientAddress); + requireHeaderSafe(fromAddress); + requireHeaderSafe(email.subject()); + + try { + MimeMessage message = new MimeMessage(session); + MimeMessageHelper helper = + new MimeMessageHelper( + message, + !attachments.isEmpty() || email.htmlBody().isPresent(), + StandardCharsets.UTF_8.name()); + helper.setFrom(fromAddress); + helper.setTo(recipientAddress); + helper.setSubject(email.subject()); + if (email.htmlBody().isPresent()) { + helper.setText(email.textBody(), email.htmlBody().get()); + } else { + helper.setText(email.textBody(), false); + } + for (ResolvedAttachment attachment : attachments) { + helper.addAttachment( + attachment.displayName(), () -> attachment.content(), attachment.contentType()); + } + for (var header : email.options().approvedHeaders().entrySet()) { + requireHeaderSafe(header.getKey()); + requireHeaderSafe(header.getValue()); + message.setHeader(header.getKey(), header.getValue()); + } + return message; + } catch (MessagingException failure) { + throw rejection(); + } + } + + private static void requireHeaderSafe(String value) { + if (value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\0') >= 0) { + throw rejection(); + } + } + + private static NotificationValidationException rejection() { + return new NotificationValidationException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD)); + } +} 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 new file mode 100644 index 00000000..5176fa40 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpNotificationProviderAdapter.java @@ -0,0 +1,102 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp; + +import dev.caskeleton.application.notification.platform.api.ProviderId; +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; +import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter; +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.security.AccessContext; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Executor; + +/** + * SMTP email adapter. + * + *

A final {@code 250} is provider acceptance and nothing more. The relay has taken + * responsibility for the message; whether it reaches an inbox is a separate question this adapter + * cannot answer, so the result carries {@code PROVIDER_ACCEPTED} and a delivery outcome of {@code + * UNKNOWN}. + */ +public final class SmtpNotificationProviderAdapter implements NotificationProviderAdapter { + + private static final ProviderId PROVIDER_ID = new ProviderId("smtp"); + + private final SmtpDispatch dispatch; + private final SmtpMimeMessageFactory mimeFactory; + private final SmtpFailureClassifier classifier; + private final ContactPointProtector protector; + private final SmtpProviderProperties properties; + private final Executor executor; + + public SmtpNotificationProviderAdapter( + SmtpDispatch dispatch, + SmtpMimeMessageFactory mimeFactory, + SmtpFailureClassifier classifier, + ContactPointProtector protector, + SmtpProviderProperties properties, + Executor executor) { + 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"); + } + + @Override + public ProviderId providerId() { + return PROVIDER_ID; + } + + @Override + public Set channels() { + return Set.of(Channel.EMAIL); + } + + @Override + public ProviderCapabilities capabilities() { + // SMTP offers no status callback, no status query and no provider-side idempotency, so the + // runtime must never plan a reconciliation for it. + return new ProviderCapabilities( + false, false, false, false, false, false, false, false, 1, 25_000_000L, Duration.ofDays(1)); + } + + @Override + public CompletionStage submit(ProviderSubmission submission) { + Objects.requireNonNull(submission, "submission"); + return CompletableFuture.supplyAsync(() -> send(submission), executor); + } + + private ProviderSubmissionResult send(ProviderSubmission submission) { + long startedNanos = System.nanoTime(); + ContactPointValue value = + protector.reveal( + submission.contactPoint(), + AccessContext.dispatch(submission.profile().profileId().value())); + if (!(value instanceof EmailAddress address)) { + throw new IllegalArgumentException("SMTP requires an email contact point"); + } + + try { + dispatch.send( + mimeFactory.create( + submission, address.normalized(), properties.senderIdentity(), List.of())); + return ProviderSubmissionResult.accepted(null, "250", elapsedSince(startedNanos)); + } catch (SmtpDispatchException failure) { + return classifier.classify(failure, elapsedSince(startedNanos)); + } + } + + private static Duration elapsedSince(long startedNanos) { + return Duration.ofNanos(System.nanoTime() - startedNanos); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpProviderProperties.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpProviderProperties.java new file mode 100644 index 00000000..5a499e7d --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpProviderProperties.java @@ -0,0 +1,57 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp; + +import java.time.Duration; +import java.util.Objects; + +/** + * SMTP profile. + * + *

Every timeout is required and finite. An unbounded SMTP read timeout is how one unresponsive + * relay turns into an exhausted dispatch pool. + */ +public record SmtpProviderProperties( + String host, + int port, + TlsMode tlsMode, + String senderIdentity, + Duration connectTimeout, + Duration readTimeout, + Duration writeTimeout, + int maxConcurrency) { + + /** Transport security of the SMTP session. */ + public enum TlsMode { + STARTTLS_REQUIRED, + IMPLICIT_TLS + } + + public SmtpProviderProperties { + Objects.requireNonNull(host, "host"); + Objects.requireNonNull(tlsMode, "tlsMode"); + Objects.requireNonNull(senderIdentity, "senderIdentity"); + requireFinite(connectTimeout, "connectTimeout"); + requireFinite(readTimeout, "readTimeout"); + requireFinite(writeTimeout, "writeTimeout"); + if (host.isBlank()) { + throw new IllegalArgumentException("host"); + } + if (port < 1 || port > 65535) { + throw new IllegalArgumentException("port"); + } + if (maxConcurrency < 1) { + throw new IllegalArgumentException("maxConcurrency"); + } + if (tlsMode == TlsMode.STARTTLS_REQUIRED && port == 25) { + // Port 25 with opportunistic STARTTLS is the classic silent-downgrade path; the profile has + // to say which it means. + throw new IllegalArgumentException("STARTTLS on port 25 must be declared explicitly"); + } + } + + private static void requireFinite(Duration timeout, String name) { + Objects.requireNonNull(timeout, name); + if (timeout.isNegative() || timeout.isZero()) { + throw new IllegalArgumentException(name + " must be positive and finite"); + } + } +} 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 new file mode 100644 index 00000000..5fde0cfa --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackAdapter.java @@ -0,0 +1,89 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio; + +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; +import dev.caskeleton.application.notification.platform.callback.VerifiedCallback; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** Twilio status callback verification and normalization. */ +public final class TwilioCallbackAdapter implements ProviderCallbackAdapter { + + private static final ProviderId PROVIDER_ID = new ProviderId("twilio"); + + private final TwilioSignatureValidator validator; + private final TwilioStatusNormalizer normalizer; + private final TwilioProviderProperties properties; + private final SecretMaterialProvider secrets; + + public TwilioCallbackAdapter( + TwilioSignatureValidator validator, + TwilioStatusNormalizer normalizer, + TwilioProviderProperties properties, + SecretMaterialProvider secrets) { + this.validator = Objects.requireNonNull(validator, "validator"); + this.normalizer = Objects.requireNonNull(normalizer, "normalizer"); + this.properties = Objects.requireNonNull(properties, "properties"); + this.secrets = Objects.requireNonNull(secrets, "secrets"); + } + + @Override + public ProviderId providerId() { + return PROVIDER_ID; + } + + @Override + public CallbackVerificationResult verify(CallbackRequest request) { + Objects.requireNonNull(request, "request"); + Map parameters = parseForm(request.body()); + boolean valid = + validator.isValid( + properties.canonicalCallbackUrl(), + parameters, + request.header("x-twilio-signature").orElse(null), + secrets.activeKey(SecretPurpose.CALLBACK_SIGNING).material()); + return valid + ? CallbackVerificationResult.valid(new VerifiedCallback(request, parameters)) + : CallbackVerificationResult.invalid("TWILIO_SIGNATURE_MISMATCH"); + } + + @Override + public List normalize(VerifiedCallback callback) { + Objects.requireNonNull(callback, "callback"); + Optional occurredAt = Optional.of(callback.request().receivedAt()); + return List.of(normalizer.normalize(callback.canonicalParameters(), occurredAt)); + } + + private static Map parseForm(byte[] body) { + Map parameters = new LinkedHashMap<>(); + String raw = new String(body, StandardCharsets.UTF_8); + if (raw.isBlank()) { + return parameters; + } + for (String pair : java.util.regex.Pattern.compile("&").split(raw, -1)) { + if (pair.isEmpty()) { + continue; + } + int separator = pair.indexOf('='); + if (separator < 0) { + parameters.put(URLDecoder.decode(pair, StandardCharsets.UTF_8), ""); + } else { + parameters.put( + URLDecoder.decode(pair.substring(0, separator), StandardCharsets.UTF_8), + URLDecoder.decode(pair.substring(separator + 1), StandardCharsets.UTF_8)); + } + } + return parameters; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioDeliveryProjector.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioDeliveryProjector.java new file mode 100644 index 00000000..cb5edccc --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioDeliveryProjector.java @@ -0,0 +1,18 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio; + +import dev.caskeleton.application.notification.platform.api.ProviderId; +import dev.caskeleton.application.notification.platform.callback.StandardDeliveryProjector; + +/** + * Twilio projector. + * + *

No provider-specific transitions are needed: the shared table already ignores a {@code sent} + * that follows a {@code delivered}, which is the exact Twilio behaviour this projector has to + * survive. + */ +public final class TwilioDeliveryProjector extends StandardDeliveryProjector { + + public TwilioDeliveryProjector() { + super(new ProviderId("twilio")); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioFailureClassifier.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioFailureClassifier.java new file mode 100644 index 00000000..cb9314c3 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioFailureClassifier.java @@ -0,0 +1,51 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse; +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.provider.ProviderFailure; +import java.util.Optional; +import java.util.Set; + +/** Maps Twilio error codes onto the stable failure vocabulary. */ +public final class TwilioFailureClassifier { + + /** Twilio error codes that identify the destination number rather than the request. */ + private static final Set INVALID_NUMBER_CODES = + Set.of(21211, 21214, 21610, 21612, 21614); + + /** Classify a non-2xx Twilio response. */ + public ProviderFailure classify(NotificationHttpResponse response) { + Optional code = errorCode(response); + if (code.filter(INVALID_NUMBER_CODES::contains).isPresent()) { + return new ProviderFailure( + NotificationFailureCode.CONTACT_POINT_INVALID, + FailureCategory.INVALID_RECIPIENT, + false, + Optional.empty(), + code.map(String::valueOf)); + } + if (response.statusCode() == 429) { + return new ProviderFailure( + NotificationFailureCode.PROVIDER_THROTTLED, + FailureCategory.THROTTLED, + true, + ProviderResults.retryAfter(response.header("retry-after")), + code.map(String::valueOf)); + } + return ProviderResults.fromStatus( + response.statusCode(), ProviderResults.retryAfter(response.header("retry-after"))); + } + + private static Optional errorCode(NotificationHttpResponse response) { + try { + var node = NotificationJsonMapper.mapper().readTree(response.bodyAsString()); + var code = node.get("code"); + return code == null || code.isNull() ? Optional.empty() : Optional.of(code.asInt()); + } catch (RuntimeException unparseable) { + return Optional.empty(); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioProviderProperties.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioProviderProperties.java new file mode 100644 index 00000000..8903e6a3 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioProviderProperties.java @@ -0,0 +1,44 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio; + +import java.net.URI; +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; + +/** + * Twilio profile. + * + *

{@code canonicalCallbackUrl} is pinned here rather than reconstructed from the incoming + * request. Twilio signs the URL it called, and a reverse proxy that rewrites scheme or host makes a + * server-side reconstruction disagree with the signature — the most common cause of "valid webhook, + * failed verification". + */ +public record TwilioProviderProperties( + URI endpoint, + String accountSid, + Optional messagingServiceSid, + Optional fromNumber, + String canonicalCallbackUrl, + Duration timeout, + Duration maxReconciliationAge) { + + public TwilioProviderProperties { + Objects.requireNonNull(endpoint, "endpoint"); + Objects.requireNonNull(accountSid, "accountSid"); + Objects.requireNonNull(messagingServiceSid, "messagingServiceSid"); + Objects.requireNonNull(fromNumber, "fromNumber"); + Objects.requireNonNull(canonicalCallbackUrl, "canonicalCallbackUrl"); + Objects.requireNonNull(timeout, "timeout"); + Objects.requireNonNull(maxReconciliationAge, "maxReconciliationAge"); + if (accountSid.isBlank()) { + throw new IllegalArgumentException("accountSid"); + } + if (messagingServiceSid.isEmpty() == fromNumber.isEmpty()) { + throw new IllegalArgumentException( + "exactly one of messagingServiceSid or fromNumber must be configured"); + } + if (timeout.isNegative() || timeout.isZero()) { + throw new IllegalArgumentException("timeout must be positive and finite"); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioReconciliationCapability.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioReconciliationCapability.java new file mode 100644 index 00000000..692e8282 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioReconciliationCapability.java @@ -0,0 +1,138 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException; +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; +import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot; +import dev.caskeleton.application.notification.platform.provider.ProviderProfileSnapshot; +import dev.caskeleton.application.notification.platform.provider.ReconciliationCapability; +import dev.caskeleton.application.notification.platform.provider.ReconciliationResult; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.util.Base64; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +/** + * Twilio message status polling. + * + *

Callbacks go missing. Twilio itself recommends polling when a status has not moved, so an + * attempt whose callback never arrived is corrected here rather than left ambiguous forever. + * + *

Two bounds keep the correction from becoming a second incident: an attempt older than the + * configured maximum is abandoned rather than polled indefinitely, and the query runs through the + * same gateway — and therefore the same provider rate budget — as dispatch. + */ +public final class TwilioReconciliationCapability implements ReconciliationCapability { + + private final NotificationHttpGateway gateway; + private final TwilioProviderProperties properties; + private final TwilioStatusNormalizer normalizer; + private final SecretMaterialProvider secrets; + private final Clock clock; + + public TwilioReconciliationCapability( + NotificationHttpGateway gateway, + TwilioProviderProperties properties, + TwilioStatusNormalizer normalizer, + SecretMaterialProvider secrets, + Clock clock) { + this.gateway = Objects.requireNonNull(gateway, "gateway"); + this.properties = Objects.requireNonNull(properties, "properties"); + this.normalizer = Objects.requireNonNull(normalizer, "normalizer"); + this.secrets = Objects.requireNonNull(secrets, "secrets"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + @Override + public boolean supports(ProviderProfileSnapshot profile) { + Objects.requireNonNull(profile, "profile"); + return profile.capabilities().statusQuery(); + } + + @Override + public CompletionStage reconcile(DeliveryAttemptSnapshot attempt) { + Objects.requireNonNull(attempt, "attempt"); + + Optional messageSid = attempt.providerRequestId(); + if (messageSid.isEmpty()) { + // Without a provider identifier there is nothing to ask about. This is the honest outcome of + // an ambiguous submission that never produced a SID, not a failure to try. + return CompletableFuture.completedFuture(new ReconciliationResult.Unsupported()); + } + if (isTooOld(attempt)) { + return CompletableFuture.completedFuture( + new ReconciliationResult.Failed("RECONCILIATION_WINDOW_EXPIRED", false)); + } + + try { + NotificationHttpResponse response = gateway.exchange(statusRequest(messageSid.get())); + if (!response.isSuccessful()) { + return CompletableFuture.completedFuture( + new ReconciliationResult.Failed( + "STATUS_QUERY_" + response.statusCode(), response.statusCode() >= 500)); + } + var node = NotificationJsonMapper.mapper().readTree(response.bodyAsString()); + String status = + Optional.ofNullable(node.get("status")).map(value -> value.asString()).orElse("unknown"); + + if (isPending(status)) { + return CompletableFuture.completedFuture( + new ReconciliationResult.StillUnknown(clock.instant().plusSeconds(300))); + } + return CompletableFuture.completedFuture( + new ReconciliationResult.Confirmed( + normalizer.normalize( + Map.of("MessageSid", messageSid.get(), "MessageStatus", status), + Optional.of(clock.instant())))); + } catch (NotificationHttpTransportException transportFailure) { + return CompletableFuture.completedFuture( + new ReconciliationResult.Failed("STATUS_QUERY_TRANSPORT", true)); + } + } + + private boolean isTooOld(DeliveryAttemptSnapshot attempt) { + return attempt.startedAt().plus(properties.maxReconciliationAge()).isBefore(clock.instant()); + } + + private static boolean isPending(String status) { + return switch (status) { + case "accepted", "queued", "sending", "scheduled" -> true; + default -> false; + }; + } + + private NotificationHttpRequest statusRequest(String messageSid) { + String credentials = + Base64.getEncoder() + .encodeToString( + (properties.accountSid() + + ":" + + new String( + secrets.activeKey(SecretPurpose.PROVIDER_CREDENTIAL).material(), + StandardCharsets.UTF_8)) + .getBytes(StandardCharsets.UTF_8)); + + return new NotificationHttpRequest( + "GET", + URI.create( + properties.endpoint() + + "/2010-04-01/Accounts/" + + properties.accountSid() + + "/Messages/" + + messageSid + + ".json"), + JdkNotificationHttpGateway.headers(Map.of("authorization", "Basic " + credentials)), + new byte[0], + properties.timeout()); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioRequestMapper.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioRequestMapper.java new file mode 100644 index 00000000..70267d28 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioRequestMapper.java @@ -0,0 +1,73 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest; +import dev.caskeleton.application.notification.platform.api.content.SmsContent; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +/** Builds the Twilio {@code Messages.json} form request. */ +public final class TwilioRequestMapper { + + private final TwilioProviderProperties properties; + + public TwilioRequestMapper(TwilioProviderProperties properties) { + this.properties = Objects.requireNonNull(properties, "properties"); + } + + /** Map one submission into a form-encoded request. */ + public NotificationHttpRequest map( + ProviderSubmission submission, String recipientE164, byte[] authToken) { + Objects.requireNonNull(submission, "submission"); + if (!(submission.content().content() instanceof SmsContent sms)) { + throw new IllegalArgumentException("Twilio requires SMS content"); + } + + Map form = new LinkedHashMap<>(); + form.put("To", recipientE164); + properties.messagingServiceSid().ifPresent(sid -> form.put("MessagingServiceSid", sid)); + properties.fromNumber().ifPresent(from -> form.put("From", from)); + form.put("Body", sms.text()); + form.put("StatusCallback", properties.canonicalCallbackUrl()); + + byte[] body = encode(form).getBytes(StandardCharsets.UTF_8); + String credentials = + Base64.getEncoder() + .encodeToString( + (properties.accountSid() + ":" + new String(authToken, StandardCharsets.UTF_8)) + .getBytes(StandardCharsets.UTF_8)); + + return new NotificationHttpRequest( + "POST", + URI.create( + properties.endpoint() + + "/2010-04-01/Accounts/" + + properties.accountSid() + + "/Messages.json"), + JdkNotificationHttpGateway.headers( + Map.of( + "content-type", + "application/x-www-form-urlencoded", + "authorization", + "Basic " + credentials)), + body, + properties.timeout()); + } + + private static String encode(Map form) { + return form.entrySet().stream() + .map( + entry -> + URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8) + + "=" + + URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)) + .collect(Collectors.joining("&")); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSignatureValidator.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSignatureValidator.java new file mode 100644 index 00000000..e6d44946 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSignatureValidator.java @@ -0,0 +1,49 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.Map; +import java.util.TreeMap; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * {@code X-Twilio-Signature} validation. + * + *

The signature covers the full external URL followed by every POST parameter in sorted key + * order, concatenated as {@code key + value}. The URL is the one Twilio called, which is why the + * profile pins it rather than the adapter rebuilding it from proxy headers. + */ +public final class TwilioSignatureValidator { + + /** Whether the presented signature matches. */ + public boolean isValid( + String canonicalUrl, + Map parameters, + String presentedSignature, + byte[] authToken) { + if (presentedSignature == null || presentedSignature.isBlank()) { + return false; + } + StringBuilder payload = new StringBuilder(canonicalUrl); + new TreeMap<>(parameters) + .forEach((key, value) -> payload.append(key).append(value == null ? "" : value)); + + try { + Mac mac = Mac.getInstance("HmacSHA1"); + mac.init(new SecretKeySpec(authToken, "HmacSHA1")); + String expected = + Base64.getEncoder() + .encodeToString(mac.doFinal(payload.toString().getBytes(StandardCharsets.UTF_8))); + // Constant-time comparison: a timing oracle on a webhook signature is a slow but real forgery + // path. + return MessageDigest.isEqual( + expected.getBytes(StandardCharsets.UTF_8), + presentedSignature.getBytes(StandardCharsets.UTF_8)); + } catch (GeneralSecurityException failure) { + return false; + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSmsProviderAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSmsProviderAdapter.java new file mode 100644 index 00000000..ad1cebda --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSmsProviderAdapter.java @@ -0,0 +1,139 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException; +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; +import dev.caskeleton.application.notification.platform.api.ProviderId; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.contact.ContactPointValue; +import dev.caskeleton.application.notification.platform.contact.PhoneNumber; +import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter; +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.security.AccessContext; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +/** + * Twilio Programmable Messaging submission. + * + *

{@code accepted}, {@code queued} and {@code sending} are all acceptance and nothing more. + * Twilio itself models {@code sent} and {@code delivered} as later, separate events, so this + * adapter never returns a delivery outcome — those arrive through status callbacks and + * reconciliation. + */ +public final class TwilioSmsProviderAdapter implements NotificationProviderAdapter { + + private static final ProviderId PROVIDER_ID = new ProviderId("twilio"); + + private final NotificationHttpGateway gateway; + private final TwilioRequestMapper mapper; + private final TwilioFailureClassifier classifier; + private final ContactPointProtector protector; + private final SecretMaterialProvider secrets; + + public TwilioSmsProviderAdapter( + NotificationHttpGateway gateway, + TwilioRequestMapper mapper, + TwilioFailureClassifier classifier, + ContactPointProtector protector, + SecretMaterialProvider secrets) { + this.gateway = Objects.requireNonNull(gateway, "gateway"); + this.mapper = Objects.requireNonNull(mapper, "mapper"); + this.classifier = Objects.requireNonNull(classifier, "classifier"); + this.protector = Objects.requireNonNull(protector, "protector"); + this.secrets = Objects.requireNonNull(secrets, "secrets"); + } + + @Override + public ProviderId providerId() { + return PROVIDER_ID; + } + + @Override + public Set channels() { + return Set.of(Channel.SMS); + } + + @Override + public ProviderCapabilities capabilities() { + // statusCallback and statusQuery are both true: Twilio delivers status callbacks and also lets + // the platform poll, which is what makes missing-callback reconciliation possible. + return new ProviderCapabilities( + false, false, true, true, true, false, false, false, 1, 1_600L, Duration.ofHours(4)); + } + + @Override + public CompletionStage submit(ProviderSubmission submission) { + Objects.requireNonNull(submission, "submission"); + return CompletableFuture.completedFuture(send(submission)); + } + + private ProviderSubmissionResult send(ProviderSubmission submission) { + long startedNanos = System.nanoTime(); + ContactPointValue value = + protector.reveal( + submission.contactPoint(), + AccessContext.dispatch(submission.profile().profileId().value())); + if (!(value instanceof PhoneNumber phone)) { + throw new IllegalArgumentException("Twilio requires a phone contact point"); + } + + var request = + mapper.map( + submission, + phone.e164(), + secrets.activeKey(SecretPurpose.PROVIDER_CREDENTIAL).material()); + + try { + NotificationHttpResponse response = gateway.exchange(request); + Duration elapsed = elapsedSince(startedNanos); + if (!response.isSuccessful()) { + return ProviderSubmissionResult.rejected(classifier.classify(response), elapsed); + } + var parsed = parse(response); + return switch (parsed.status()) { + case "accepted", "queued", "sending", "scheduled" -> + ProviderSubmissionResult.accepted(parsed.sid(), parsed.status(), elapsed); + case "failed", "undelivered" -> + ProviderSubmissionResult.rejected(classifier.classify(response), elapsed); + default -> + // An unrecognised status is preserved verbatim rather than guessed at; the native value + // reaches the ledger and the stable vocabulary stays closed. + ProviderSubmissionResult.accepted(parsed.sid(), parsed.status(), elapsed); + }; + } catch (NotificationHttpTransportException transportFailure) { + return ProviderResults.fromTransport(transportFailure, elapsedSince(startedNanos)); + } + } + + private static TwilioMessage parse(NotificationHttpResponse response) { + try { + var node = NotificationJsonMapper.mapper().readTree(response.bodyAsString()); + return new TwilioMessage( + Optional.ofNullable(node.get("sid")).map(value -> value.asString()).orElse(null), + Optional.ofNullable(node.get("status")) + .map(value -> value.asString()) + .orElse("accepted")); + } catch (RuntimeException unparseable) { + return new TwilioMessage(null, "accepted"); + } + } + + private static Duration elapsedSince(long startedNanos) { + return Duration.ofNanos(System.nanoTime() - startedNanos); + } + + /** Minimal projection of the Twilio message resource. */ + private record TwilioMessage(String sid, String status) {} +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioStatusNormalizer.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioStatusNormalizer.java new file mode 100644 index 00000000..4c816e7f --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioStatusNormalizer.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio; + +import dev.caskeleton.application.notification.platform.callback.NormalizedEventType; +import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; + +/** + * Twilio status to stable event. + * + *

The mapping is by status name, never by arrival time. Twilio does not guarantee callback + * ordering, so a {@code sent} that arrives after {@code delivered} has to be recognisable as the + * weaker fact it is. + */ +public final class TwilioStatusNormalizer { + + /** Normalize one status callback. */ + public NormalizedProviderEvent normalize( + Map parameters, Optional occurredAt) { + String status = parameters.getOrDefault("MessageStatus", "unknown"); + Optional sid = Optional.ofNullable(parameters.get("MessageSid")); + + NormalizedEventType type = + switch (status) { + case "accepted", "queued", "scheduled", "sending" -> + NormalizedEventType.PROVIDER_ACCEPTED; + case "sent" -> NormalizedEventType.SENT; + case "delivered" -> NormalizedEventType.DELIVERY_CONFIRMED; + case "undelivered" -> NormalizedEventType.UNDELIVERED; + case "failed" -> NormalizedEventType.PROVIDER_REJECTED; + default -> NormalizedEventType.UNKNOWN; + }; + + Map attributes = + parameters.containsKey("ErrorCode") + ? Map.of("errorCode", parameters.get("ErrorCode")) + : Map.of(); + return new NormalizedProviderEvent(type, status, Optional.empty(), sid, occurredAt, attributes); + } +} 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 new file mode 100644 index 00000000..93486df6 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookNotificationProviderAdapter.java @@ -0,0 +1,188 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webhook; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException; +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; +import dev.caskeleton.application.notification.platform.api.ProviderId; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter; +import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities; +import dev.caskeleton.application.notification.platform.provider.ProviderFailure; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult; +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; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.function.Function; + +/** + * Webhook extension. + * + *

Two gateways, chosen by whether the destination is operator configured or user supplied. A + * dynamic target never receives an {@code Authorization} or {@code Cookie} header, because a + * webhook pointed at an attacker's host would otherwise hand over whatever credential the trusted + * path uses. + * + *

An accepted body with no response is ambiguous here for the same reason as everywhere else: + * the receiver may already have acted on it. + */ +public final class WebhookNotificationProviderAdapter implements NotificationProviderAdapter { + + private static final ProviderId PROVIDER_ID = new ProviderId("webhook"); + private static final int MAX_DIAGNOSTIC_BODY = 512; + + private final NotificationHttpGateway trustedGateway; + private final NotificationHttpGateway dynamicGateway; + private final WebhookSignatureStrategy signatures; + private final SecretMaterialProvider secrets; + private final Function subscriptionResolver; + private final Duration timeout; + private final Clock clock; + + public WebhookNotificationProviderAdapter( + NotificationHttpGateway trustedGateway, + NotificationHttpGateway dynamicGateway, + WebhookSignatureStrategy signatures, + SecretMaterialProvider secrets, + Function subscriptionResolver, + Duration timeout, + Clock clock) { + this.trustedGateway = Objects.requireNonNull(trustedGateway, "trustedGateway"); + this.dynamicGateway = Objects.requireNonNull(dynamicGateway, "dynamicGateway"); + this.signatures = Objects.requireNonNull(signatures, "signatures"); + this.secrets = Objects.requireNonNull(secrets, "secrets"); + this.subscriptionResolver = + Objects.requireNonNull(subscriptionResolver, "subscriptionResolver"); + this.timeout = Objects.requireNonNull(timeout, "timeout"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + @Override + public ProviderId providerId() { + return PROVIDER_ID; + } + + @Override + public Set channels() { + return Set.of(Channel.WEBHOOK); + } + + @Override + public ProviderCapabilities capabilities() { + return new ProviderCapabilities( + false, false, false, false, false, false, false, false, 1, 1_000_000L, Duration.ofHours(1)); + } + + @Override + public CompletionStage submit(ProviderSubmission submission) { + Objects.requireNonNull(submission, "submission"); + return CompletableFuture.completedFuture(send(submission)); + } + + private ProviderSubmissionResult send(ProviderSubmission submission) { + long startedNanos = System.nanoTime(); + WebhookSubscription subscription = subscriptionResolver.apply(submission); + + byte[] body = + NotificationJsonMapper.mapper() + .writeValueAsString( + Map.of( + "attemptId", submission.attemptId().value().toString(), + "contentDigest", submission.content().contentDigest())) + .getBytes(StandardCharsets.UTF_8); + + Map headers = new LinkedHashMap<>(); + headers.put("content-type", "application/json"); + if (subscription.trusted() && subscription.signingKeyRef().isPresent()) { + var timestamp = clock.instant(); + headers.put( + WebhookSignatureStrategy.TIMESTAMP_HEADER, Long.toString(timestamp.getEpochSecond())); + headers.put( + WebhookSignatureStrategy.SIGNATURE_HEADER, + signatures.sign( + body, timestamp, secrets.activeKey(SecretPurpose.CALLBACK_SIGNING).material())); + } + + NotificationHttpRequest request = + new NotificationHttpRequest( + "POST", + subscription.target(), + JdkNotificationHttpGateway.headers(headers), + body, + timeout); + + NotificationHttpGateway gateway = subscription.trusted() ? trustedGateway : dynamicGateway; + try { + NotificationHttpResponse response = gateway.exchange(request); + Duration elapsed = Duration.ofNanos(System.nanoTime() - startedNanos); + if (response.isSuccessful()) { + return ProviderSubmissionResult.accepted( + null, Integer.toString(response.statusCode()), elapsed); + } + // 429 is the receiver saying "later", not "no". Classifying it as permanent would drop a + // notification a working receiver explicitly asked us to resend, and no later evidence can + // tell that apart from a genuine rejection. + boolean throttled = response.statusCode() == 429; + boolean transientFailure = throttled || response.statusCode() >= 500; + return ProviderSubmissionResult.rejected( + new ProviderFailure( + NotificationFailureCode.PROVIDER_REJECTED, + throttled + ? FailureCategory.THROTTLED + : transientFailure + ? FailureCategory.TRANSIENT_PROVIDER + : FailureCategory.PERMANENT_PROVIDER, + transientFailure, + retryAfter(response), + Optional.of(boundedDiagnostic(response))), + elapsed); + } catch (NotificationHttpTransportException transportFailure) { + return ProviderResults.fromTransport( + transportFailure, Duration.ofNanos(System.nanoTime() - startedNanos)); + } + } + + /** + * The receiver's own backoff hint, when it sent a usable one. + * + *

Only the delta-seconds form is honoured. RFC 9110 also allows an HTTP-date, but a receiver + * whose clock disagrees with ours would then dictate a wait computed from the difference — which + * is how one misconfigured subscriber stalls a queue. + */ + private static Optional retryAfter(NotificationHttpResponse response) { + return response + .header("retry-after") + .flatMap( + value -> { + try { + long seconds = Long.parseLong(value.trim()); + return seconds > 0 ? Optional.of(Duration.ofSeconds(seconds)) : Optional.empty(); + } catch (NumberFormatException notDeltaSeconds) { + return Optional.empty(); + } + }); + } + + /** Only a bounded slice of the response is kept; a receiver's body is not our log. */ + private static String boundedDiagnostic(NotificationHttpResponse response) { + String body = response.bodyAsString(); + return response.statusCode() + + ":" + + body.substring(0, Math.min(body.length(), MAX_DIAGNOSTIC_BODY)); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookSignatureStrategy.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookSignatureStrategy.java new file mode 100644 index 00000000..80e29c40 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookSignatureStrategy.java @@ -0,0 +1,40 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webhook; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.time.Instant; +import java.util.HexFormat; +import java.util.Objects; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * Request signing for trusted webhook subscriptions. + * + *

The timestamp is inside the signed payload, so a captured request cannot be replayed later + * without the receiver noticing the skew. + */ +public final class WebhookSignatureStrategy { + + /** Header carrying the signature. */ + public static final String SIGNATURE_HEADER = "x-notification-signature"; + + /** Header carrying the signed timestamp. */ + public static final String TIMESTAMP_HEADER = "x-notification-timestamp"; + + /** Compute the signature over timestamp and body. */ + public String sign(byte[] body, Instant timestamp, byte[] signingKey) { + Objects.requireNonNull(body, "body"); + Objects.requireNonNull(timestamp, "timestamp"); + Objects.requireNonNull(signingKey, "signingKey"); + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(signingKey, "HmacSHA256")); + mac.update(Long.toString(timestamp.getEpochSecond()).getBytes(StandardCharsets.US_ASCII)); + mac.update((byte) '.'); + return "v1=" + HexFormat.of().formatHex(mac.doFinal(body)); + } catch (GeneralSecurityException failure) { + throw new IllegalStateException("webhook signing failed", failure); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookSubscription.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookSubscription.java new file mode 100644 index 00000000..ea4911c4 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookSubscription.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webhook; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationEndpoints; +import java.net.URI; +import java.util.Objects; +import java.util.Optional; + +/** + * A webhook destination. + * + *

{@code trusted} decides which gateway carries the call. A trusted subscription is operator + * configured and may use platform credentials; a dynamic one comes from user input and must not + * inherit anything, because that is how a webhook feature becomes an SSRF credential-relay. + */ +public record WebhookSubscription( + String subscriptionId, URI target, boolean trusted, Optional signingKeyRef) { + + public WebhookSubscription { + Objects.requireNonNull(subscriptionId, "subscriptionId"); + Objects.requireNonNull(target, "target"); + Objects.requireNonNull(signingKeyRef, "signingKeyRef"); + if (subscriptionId.isBlank()) { + throw new IllegalArgumentException("subscriptionId"); + } + NotificationEndpoints.requireSecureOrLoopback(target, "webhook target"); + if (!trusted && signingKeyRef.isPresent()) { + throw new IllegalArgumentException( + "a dynamic target may not be paired with a platform signing key"); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/EncryptedWebPushPayload.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/EncryptedWebPushPayload.java new file mode 100644 index 00000000..359456fc --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/EncryptedWebPushPayload.java @@ -0,0 +1,37 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush; + +import java.util.Arrays; +import java.util.Objects; + +/** An RFC 8291 {@code aes128gcm} record ready to be sent as the request body. */ +@SuppressWarnings("ArrayRecordComponent") // defensive copies on construction and on every accessor +public record EncryptedWebPushPayload(byte[] body, String contentEncoding) { + + public EncryptedWebPushPayload { + Objects.requireNonNull(body, "body"); + Objects.requireNonNull(contentEncoding, "contentEncoding"); + body = body.clone(); + } + + @Override + public byte[] body() { + return body.clone(); + } + + @Override + public boolean equals(Object other) { + return other instanceof EncryptedWebPushPayload payload + && Arrays.equals(body, payload.body) + && contentEncoding.equals(payload.contentEncoding); + } + + @Override + public int hashCode() { + return Objects.hash(Arrays.hashCode(body), contentEncoding); + } + + @Override + public String toString() { + return "EncryptedWebPushPayload[encoding=" + contentEncoding + ", bytes=" + body.length + "]"; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/Rfc8291Aes128GcmEncryptor.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/Rfc8291Aes128GcmEncryptor.java new file mode 100644 index 00000000..14777489 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/Rfc8291Aes128GcmEncryptor.java @@ -0,0 +1,193 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush; + +import dev.caskeleton.application.notification.platform.contact.WebPushSubscriptionValue; +import java.io.ByteArrayOutputStream; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.AlgorithmParameters; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.interfaces.ECPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.security.spec.ECParameterSpec; +import java.security.spec.ECPoint; +import java.security.spec.ECPublicKeySpec; +import java.util.Objects; +import javax.crypto.Cipher; +import javax.crypto.KeyAgreement; +import javax.crypto.Mac; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * RFC 8291 Web Push payload encryption. + * + *

The subscription's public key and auth secret are the only inputs the application server has, + * and the sequence — ECDH, then HKDF keyed by the auth secret, then HKDF keyed by a fresh salt — is + * what binds the ciphertext to that one subscriber. A fresh ephemeral key pair per message is not + * an optimisation choice: reusing one would let two messages to the same subscriber share a key + * stream. + */ +public final class Rfc8291Aes128GcmEncryptor { + + private static final int SALT_BYTES = 16; + private static final int KEY_BYTES = 16; + private static final int NONCE_BYTES = 12; + private static final int RECORD_SIZE = 4096; + private static final int TAG_BITS = 128; + private static final byte PADDING_DELIMITER = 0x02; + + private final SecureRandom random; + + public Rfc8291Aes128GcmEncryptor() { + this(new SecureRandom()); + } + + public Rfc8291Aes128GcmEncryptor(SecureRandom random) { + this.random = Objects.requireNonNull(random, "random"); + } + + /** Encrypt one payload for one subscription. */ + public EncryptedWebPushPayload encrypt(WebPushSubscriptionValue subscription, byte[] plaintext) { + Objects.requireNonNull(subscription, "subscription"); + Objects.requireNonNull(plaintext, "plaintext"); + if (plaintext.length + 1 > RECORD_SIZE - 16 - 5 - 65 - 16) { + throw new IllegalArgumentException("payload exceeds the Web Push record size"); + } + + try { + byte[] userAgentPublic = subscription.p256dh(); + byte[] authSecret = subscription.authSecret(); + + KeyPair ephemeral = generateP256KeyPair(); + byte[] applicationServerPublic = encodePoint((ECPublicKey) ephemeral.getPublic()); + + byte[] sharedSecret = agree(ephemeral, decodePoint(userAgentPublic)); + byte[] ikm = + hkdf( + authSecret, + sharedSecret, + concat( + "WebPush: info\0".getBytes(StandardCharsets.US_ASCII), + userAgentPublic, + applicationServerPublic), + 32); + + byte[] salt = new byte[SALT_BYTES]; + random.nextBytes(salt); + byte[] contentEncryptionKey = + hkdf( + salt, + ikm, + "Content-Encoding: aes128gcm\0".getBytes(StandardCharsets.US_ASCII), + KEY_BYTES); + byte[] nonce = + hkdf( + salt, + ikm, + "Content-Encoding: nonce\0".getBytes(StandardCharsets.US_ASCII), + NONCE_BYTES); + + byte[] padded = concat(plaintext, new byte[] {PADDING_DELIMITER}); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init( + Cipher.ENCRYPT_MODE, + new SecretKeySpec(contentEncryptionKey, "AES"), + new GCMParameterSpec(TAG_BITS, nonce)); + byte[] ciphertext = cipher.doFinal(padded); + + ByteArrayOutputStream body = new ByteArrayOutputStream(); + body.writeBytes(salt); + body.writeBytes(ByteBuffer.allocate(4).putInt(RECORD_SIZE).array()); + body.write(applicationServerPublic.length); + body.writeBytes(applicationServerPublic); + body.writeBytes(ciphertext); + return new EncryptedWebPushPayload(body.toByteArray(), "aes128gcm"); + } catch (GeneralSecurityException failure) { + throw new IllegalStateException("Web Push payload encryption failed", failure); + } + } + + /** HKDF with SHA-256, as used throughout RFC 8291. */ + public static byte[] hkdf(byte[] salt, byte[] ikm, byte[] info, int length) { + try { + Mac extract = Mac.getInstance("HmacSHA256"); + extract.init(new SecretKeySpec(salt, "HmacSHA256")); + byte[] prk = extract.doFinal(ikm); + + Mac expand = Mac.getInstance("HmacSHA256"); + expand.init(new SecretKeySpec(prk, "HmacSHA256")); + expand.update(info); + expand.update((byte) 1); + byte[] okm = expand.doFinal(); + return java.util.Arrays.copyOf(okm, length); + } catch (GeneralSecurityException failure) { + throw new IllegalStateException("HKDF failed", failure); + } + } + + private static KeyPair generateP256KeyPair() throws GeneralSecurityException { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new ECGenParameterSpec("secp256r1")); + return generator.generateKeyPair(); + } + + private static byte[] agree(KeyPair ephemeral, PublicKey peer) throws GeneralSecurityException { + KeyAgreement agreement = KeyAgreement.getInstance("ECDH"); + agreement.init(ephemeral.getPrivate()); + agreement.doPhase(peer, true); + return agreement.generateSecret(); + } + + /** Uncompressed SEC1 encoding of a P-256 public key. */ + public static byte[] encodePoint(ECPublicKey key) { + byte[] x = unsigned(key.getW().getAffineX(), 32); + byte[] y = unsigned(key.getW().getAffineY(), 32); + byte[] encoded = new byte[65]; + encoded[0] = 0x04; + System.arraycopy(x, 0, encoded, 1, 32); + System.arraycopy(y, 0, encoded, 33, 32); + return encoded; + } + + /** Decode an uncompressed SEC1 P-256 point. */ + public static PublicKey decodePoint(byte[] encoded) throws GeneralSecurityException { + if (encoded.length != 65 || encoded[0] != 0x04) { + throw new GeneralSecurityException("expected an uncompressed P-256 point"); + } + BigInteger x = new BigInteger(1, java.util.Arrays.copyOfRange(encoded, 1, 33)); + BigInteger y = new BigInteger(1, java.util.Arrays.copyOfRange(encoded, 33, 65)); + AlgorithmParameters parameters = AlgorithmParameters.getInstance("EC"); + parameters.init(new ECGenParameterSpec("secp256r1")); + ECParameterSpec spec = parameters.getParameterSpec(ECParameterSpec.class); + return KeyFactory.getInstance("EC") + .generatePublic(new ECPublicKeySpec(new ECPoint(x, y), spec)); + } + + private static byte[] unsigned(BigInteger value, int length) { + byte[] raw = value.toByteArray(); + if (raw.length == length) { + return raw; + } + byte[] fixed = new byte[length]; + if (raw.length > length) { + System.arraycopy(raw, raw.length - length, fixed, 0, length); + } else { + System.arraycopy(raw, 0, fixed, length - raw.length, raw.length); + } + return fixed; + } + + private static byte[] concat(byte[]... parts) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (byte[] part : parts) { + out.writeBytes(part); + } + return out.toByteArray(); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/VapidAuthorizationProvider.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/VapidAuthorizationProvider.java new file mode 100644 index 00000000..87ef2155 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/VapidAuthorizationProvider.java @@ -0,0 +1,17 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush; + +import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial; +import java.net.URI; + +/** + * Supplies the {@code Authorization} header for a Web Push request. + * + *

An interface rather than the signer itself because VAPID is one identification scheme among + * several a push service may accept, and because the transport behaviour — TTL, urgency, status + * mapping — has to be testable without a real EC private key. + */ +public interface VapidAuthorizationProvider { + + /** Header value for one endpoint. */ + String authorization(URI endpoint, SecretKeyMaterial signingKey, String publicKeyBase64Url); +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/VapidJwtSigner.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/VapidJwtSigner.java new file mode 100644 index 00000000..7ba2272b --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/VapidJwtSigner.java @@ -0,0 +1,122 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush; + +import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Clock; +import java.time.Duration; +import java.util.Base64; +import java.util.Objects; + +/** + * RFC 8292 VAPID JWT. + * + *

The audience is the origin of the endpoint the request is going to, not a configured constant. + * A token minted for one push service and replayed at another is exactly what audience binding + * prevents. + * + *

The signature is converted from the JVM's DER encoding to the 64-byte JOSE form, because ES256 + * in JWT is fixed-width {@code r || s}. + */ +public final class VapidJwtSigner implements VapidAuthorizationProvider { + + private static final Duration MAX_LIFETIME = Duration.ofHours(12); + + private final Clock clock; + private final String subject; + + public VapidJwtSigner(Clock clock, String subject) { + this.clock = Objects.requireNonNull(clock, "clock"); + this.subject = Objects.requireNonNull(subject, "subject"); + if (!subject.startsWith("mailto:") && !subject.startsWith("https://")) { + throw new IllegalArgumentException("VAPID subject must be a mailto: or https: URI"); + } + } + + /** Sign a token for one endpoint. */ + public String sign(URI endpoint, SecretKeyMaterial signingKey, Duration lifetime) { + Objects.requireNonNull(endpoint, "endpoint"); + Objects.requireNonNull(signingKey, "signingKey"); + Objects.requireNonNull(lifetime, "lifetime"); + if (lifetime.compareTo(MAX_LIFETIME) > 0) { + throw new IllegalArgumentException("VAPID token lifetime must not exceed 12 hours"); + } + + String audience = endpoint.getScheme() + "://" + endpoint.getHost(); + String header = base64Url("{\"typ\":\"JWT\",\"alg\":\"ES256\"}"); + String payload = + base64Url( + "{\"aud\":\"" + + audience + + "\",\"exp\":" + + clock.instant().plus(lifetime).getEpochSecond() + + ",\"sub\":\"" + + subject + + "\"}"); + String signingInput = header + "." + payload; + + try { + PrivateKey privateKey = + KeyFactory.getInstance("EC") + .generatePrivate(new PKCS8EncodedKeySpec(signingKey.material())); + Signature signature = Signature.getInstance("SHA256withECDSA"); + signature.initSign(privateKey); + signature.update(signingInput.getBytes(StandardCharsets.US_ASCII)); + byte[] jose = derToJose(signature.sign()); + return signingInput + "." + Base64.getUrlEncoder().withoutPadding().encodeToString(jose); + } catch (GeneralSecurityException failure) { + throw new IllegalStateException("VAPID signing failed", failure); + } + } + + /** Full {@code Authorization} header value for a request. */ + @Override + public String authorization( + URI endpoint, SecretKeyMaterial signingKey, String publicKeyBase64Url) { + return "vapid t=" + + sign(endpoint, signingKey, Duration.ofHours(1)) + + ", k=" + + publicKeyBase64Url; + } + + private static String base64Url(String json) { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(json.getBytes(StandardCharsets.UTF_8)); + } + + /** DER {@code SEQUENCE{INTEGER r, INTEGER s}} to fixed-width {@code r || s}. */ + private static byte[] derToJose(byte[] der) throws GeneralSecurityException { + if (der.length < 8 || der[0] != 0x30) { + throw new GeneralSecurityException("unexpected ECDSA signature encoding"); + } + int offset = der[1] == (byte) 0x81 ? 3 : 2; + if (der[offset] != 0x02) { + throw new GeneralSecurityException("unexpected ECDSA signature encoding"); + } + int rLength = der[offset + 1]; + int rStart = offset + 2; + int sLengthOffset = rStart + rLength; + if (der[sLengthOffset] != 0x02) { + throw new GeneralSecurityException("unexpected ECDSA signature encoding"); + } + int sLength = der[sLengthOffset + 1]; + int sStart = sLengthOffset + 2; + + byte[] jose = new byte[64]; + copyFixed(der, rStart, rLength, jose, 0); + copyFixed(der, sStart, sLength, jose, 32); + return jose; + } + + private static void copyFixed(byte[] source, int start, int length, byte[] target, int offset) { + int copyLength = Math.min(length, 32); + int sourceStart = start + length - copyLength; + System.arraycopy(source, sourceStart, target, offset + 32 - copyLength, copyLength); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/VapidKeyRegistry.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/VapidKeyRegistry.java new file mode 100644 index 00000000..3bfc6694 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/VapidKeyRegistry.java @@ -0,0 +1,88 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush; + +import dev.caskeleton.application.notification.platform.contact.WebPushSubscriptionValue; +import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.util.Map; +import java.util.Objects; + +/** + * The application server keys a push service will accept, keyed by VAPID key id. + * + *

Under RFC 8292 a subscription is created against one application server key. The user agent + * remembers it, and a push signed by a different key is rejected — so a VAPID rotation is not a + * server-side credential swap, it is a client migration that only completes when every subscriber's + * user agent re-subscribes. + * + *

That is why this registry keeps every key that still has live subscriptions rather + * than only the current one, and why {@link #requiresSubscriptionMigration} exists: the state has + * to be visible to operators, because the only way out of it is to prompt users to re-subscribe. + * Silently signing with the new key would look like a successful rotation and deliver nothing. + */ +public final class VapidKeyRegistry { + + private final SecretMaterialProvider secrets; + private final String activeKeyId; + private final Map publicKeysByKeyId; + + /** + * @param secrets source of the EC private keys; never a config property or a file + * @param activeKeyId key id new subscriptions are created against + * @param publicKeysByKeyId base64url-encoded uncompressed P-256 public keys, per key id + */ + public VapidKeyRegistry( + SecretMaterialProvider secrets, String activeKeyId, Map publicKeysByKeyId) { + this.secrets = Objects.requireNonNull(secrets, "secrets"); + this.activeKeyId = Objects.requireNonNull(activeKeyId, "activeKeyId"); + this.publicKeysByKeyId = Map.copyOf(Objects.requireNonNull(publicKeysByKeyId, "publicKeys")); + if (!this.publicKeysByKeyId.containsKey(activeKeyId)) { + throw new IllegalArgumentException("no public key registered for the active VAPID key id"); + } + } + + /** Key id new subscriptions should be created against. */ + public String activeKeyId() { + return activeKeyId; + } + + /** Base64url public key advertised to the user agent for a key id. */ + public String publicKey(String keyId) { + String publicKey = publicKeysByKeyId.get(Objects.requireNonNull(keyId, "keyId")); + if (publicKey == null) { + throw new IllegalStateException("unknown VAPID key id"); + } + return publicKey; + } + + /** + * Private key for the id a subscription was created with — never the active key. + * + *

Falling back to the active key here is the tempting shortcut and the wrong one: the push + * service would reject the token, and the failure would look like an invalid subscription rather + * than a misconfigured rotation. + */ + public SecretKeyMaterial signingKeyFor(WebPushSubscriptionValue subscription) { + Objects.requireNonNull(subscription, "subscription"); + SecretKeyMaterial key = secrets.keyById(subscription.vapidKeyId()); + if (key.purpose() != SecretPurpose.VAPID_SIGNING) { + throw new IllegalStateException("key is not a VAPID signing key"); + } + return key; + } + + /** Public key belonging to the subscription's own key id. */ + public String publicKeyFor(WebPushSubscriptionValue subscription) { + return publicKey(Objects.requireNonNull(subscription, "subscription").vapidKeyId()); + } + + /** + * True when this subscription is still bound to a superseded key. + * + *

Sends keep working — they are signed with the old key, which is why it is still registered — + * but the subscription cannot be considered migrated until the user agent re-subscribes. + */ + public boolean requiresSubscriptionMigration(WebPushSubscriptionValue subscription) { + return !activeKeyId.equals(Objects.requireNonNull(subscription, "subscription").vapidKeyId()); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushFailureClassifier.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushFailureClassifier.java new file mode 100644 index 00000000..eb580fee --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushFailureClassifier.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.provider.ProviderFailure; +import java.util.Optional; + +/** + * Web Push status classification. + * + *

RFC 8030 defines {@code 404} for an expired subscription. Several push services return {@code + * 410} instead, so both are treated as invalidation — hard-coding only the one a particular browser + * happens to send is how subscriptions accumulate forever. + */ +public final class WebPushFailureClassifier { + + /** Classify a non-2xx push-service response. */ + public ProviderFailure classify(NotificationHttpResponse response) { + int status = response.statusCode(); + if (status == 404 || status == 410) { + return new ProviderFailure( + NotificationFailureCode.CONTACT_POINT_INVALID, + FailureCategory.INVALID_RECIPIENT, + false, + Optional.empty(), + Optional.of(Integer.toString(status))); + } + if (status == 413) { + return new ProviderFailure( + NotificationFailureCode.PROVIDER_PAYLOAD_LIMIT, + FailureCategory.INVALID_PAYLOAD, + false, + Optional.empty(), + Optional.of("413")); + } + return ProviderResults.fromStatus( + status, ProviderResults.retryAfter(response.header("retry-after"))); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushNotificationProviderAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushNotificationProviderAdapter.java new file mode 100644 index 00000000..41675cc7 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushNotificationProviderAdapter.java @@ -0,0 +1,111 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException; +import dev.caskeleton.application.notification.platform.api.ProviderId; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.contact.ContactPointValue; +import dev.caskeleton.application.notification.platform.contact.WebPushSubscriptionValue; +import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter; +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.security.AccessContext; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import java.time.Duration; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +/** + * Web Push transport. + * + *

A {@code 201} is push-service acceptance. RFC 8030 keeps user-agent acknowledgement in a + * separate receipt mechanism, so this adapter only reports {@code DEVICE_DELIVERED} where the + * profile declares that the service actually offers receipts. + */ +public final class WebPushNotificationProviderAdapter implements NotificationProviderAdapter { + + private static final ProviderId PROVIDER_ID = new ProviderId("webpush"); + + private final NotificationHttpGateway gateway; + private final WebPushRequestMapper mapper; + private final WebPushFailureClassifier classifier; + private final ContactPointProtector protector; + private final WebPushProviderProperties properties; + + public WebPushNotificationProviderAdapter( + NotificationHttpGateway gateway, + WebPushRequestMapper mapper, + WebPushFailureClassifier classifier, + ContactPointProtector protector, + WebPushProviderProperties properties) { + this.gateway = Objects.requireNonNull(gateway, "gateway"); + this.mapper = Objects.requireNonNull(mapper, "mapper"); + this.classifier = Objects.requireNonNull(classifier, "classifier"); + this.protector = Objects.requireNonNull(protector, "protector"); + this.properties = Objects.requireNonNull(properties, "properties"); + } + + @Override + public ProviderId providerId() { + return PROVIDER_ID; + } + + @Override + public Set channels() { + return Set.of(Channel.WEB_PUSH); + } + + @Override + public ProviderCapabilities capabilities() { + return new ProviderCapabilities( + false, + false, + properties.receiptsSupported(), + false, + properties.receiptsSupported(), + false, + false, + true, + 1, + properties.maxPayloadBytes(), + properties.maxTtl()); + } + + @Override + public CompletionStage submit(ProviderSubmission submission) { + Objects.requireNonNull(submission, "submission"); + return CompletableFuture.completedFuture(send(submission)); + } + + private ProviderSubmissionResult send(ProviderSubmission submission) { + long startedNanos = System.nanoTime(); + ContactPointValue value = + protector.reveal( + submission.contactPoint(), + AccessContext.dispatch(submission.profile().profileId().value())); + if (!(value instanceof WebPushSubscriptionValue subscription)) { + throw new IllegalArgumentException("Web Push requires a subscription contact point"); + } + + var request = mapper.map(submission, subscription); + try { + NotificationHttpResponse response = gateway.exchange(request); + Duration elapsed = Duration.ofNanos(System.nanoTime() - startedNanos); + if (response.isSuccessful()) { + return ProviderSubmissionResult.accepted( + response.header("location").orElse(null), + Integer.toString(response.statusCode()), + elapsed); + } + return ProviderSubmissionResult.rejected(classifier.classify(response), elapsed); + } catch (NotificationHttpTransportException transportFailure) { + return ProviderResults.fromTransport( + transportFailure, Duration.ofNanos(System.nanoTime() - startedNanos)); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushProviderProperties.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushProviderProperties.java new file mode 100644 index 00000000..066b705c --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushProviderProperties.java @@ -0,0 +1,37 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush; + +import java.time.Duration; +import java.util.Objects; + +/** + * Web Push profile. + * + *

{@code receiptsSupported} defaults to false. RFC 8030 defines delivery receipts, but not every + * push service implements them, and assuming one exists would mean waiting forever for a receipt + * that is never coming. + */ +public record WebPushProviderProperties( + String vapidPublicKeyBase64Url, + Duration maxTtl, + long maxPayloadBytes, + boolean receiptsSupported, + Duration timeout) { + + /** RFC 8291 does not require a push service to accept more than this. */ + public static final long RFC_8291_MAX_BODY_BYTES = 4096L; + + public WebPushProviderProperties { + Objects.requireNonNull(vapidPublicKeyBase64Url, "vapidPublicKeyBase64Url"); + Objects.requireNonNull(maxTtl, "maxTtl"); + Objects.requireNonNull(timeout, "timeout"); + if (vapidPublicKeyBase64Url.isBlank()) { + throw new IllegalArgumentException("vapidPublicKeyBase64Url"); + } + if (maxPayloadBytes < 1 || maxPayloadBytes > RFC_8291_MAX_BODY_BYTES) { + throw new IllegalArgumentException("maxPayloadBytes must be 1.." + RFC_8291_MAX_BODY_BYTES); + } + if (maxTtl.isNegative() || maxTtl.isZero() || timeout.isNegative() || timeout.isZero()) { + throw new IllegalArgumentException("maxTtl and timeout must be positive and finite"); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushReceiptCapability.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushReceiptCapability.java new file mode 100644 index 00000000..36f00d5c --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushReceiptCapability.java @@ -0,0 +1,97 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush; + +import java.net.URI; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; + +/** + * RFC 8030 §8 delivery receipts, treated as optional because they are. + * + *

A receipt subscription is the only way Web Push can report {@code DEVICE_DELIVERED} rather + * than {@code PROVIDER_ACCEPTED}. But RFC 8030 does not require a push service to implement it, and + * the major ones largely do not — so the capability has to be declared per profile and confirmed by + * the response, never assumed. Assuming it would leave deliveries parked forever waiting on a + * receipt that is not coming, which is worse than honestly reporting acceptance. + */ +public final class WebPushReceiptCapability { + + /** Link relation a push service uses to hand back a receipt subscription. */ + public static final String RECEIPT_LINK_RELATION = "urn:ietf:params:push:receipt"; + + private final boolean requested; + + private WebPushReceiptCapability(boolean requested) { + this.requested = requested; + } + + /** Capability derived from the profile's declared support. */ + public static WebPushReceiptCapability forProfile(WebPushProviderProperties properties) { + return new WebPushReceiptCapability( + Objects.requireNonNull(properties, "properties").receiptsSupported()); + } + + /** Never request receipts. */ + public static WebPushReceiptCapability unsupported() { + return new WebPushReceiptCapability(false); + } + + /** True when a receipt should be requested for this profile. */ + public boolean requested() { + return requested; + } + + /** + * The {@code Prefer} header value, if any. + * + *

Empty rather than a no-op header: sending {@code Prefer: respond-async} to a service that + * does not implement receipts invites a 4xx from strict implementations for no benefit. + */ + public Optional preferHeader() { + return requested ? Optional.of("respond-async") : Optional.empty(); + } + + /** + * Receipt subscription URI advertised by the push service, if it advertised one. + * + *

A response that carries no receipt link is the normal case, not an error. It means the send + * was accepted and delivery evidence will never rise above acceptance for this message. + * + * @param linkHeaders raw {@code Link} response header values + */ + public Optional receiptSubscription(List linkHeaders) { + Objects.requireNonNull(linkHeaders, "linkHeaders"); + if (!requested) { + return Optional.empty(); + } + for (String header : linkHeaders) { + for (String link : header.split(",", -1)) { + Optional receipt = parseReceiptLink(link); + if (receipt.isPresent()) { + return receipt; + } + } + } + return Optional.empty(); + } + + private static Optional parseReceiptLink(String link) { + String candidate = link.trim(); + int start = candidate.indexOf('<'); + int end = candidate.indexOf('>'); + if (start < 0 || end <= start) { + return Optional.empty(); + } + String parameters = candidate.substring(end + 1).toLowerCase(Locale.ROOT).replace("\"", ""); + if (!parameters.contains("rel=" + RECEIPT_LINK_RELATION)) { + return Optional.empty(); + } + try { + return Optional.of(URI.create(candidate.substring(start + 1, end).trim())); + } catch (IllegalArgumentException malformed) { + // A malformed link is not worth failing an accepted send over; it only costs the receipt. + return Optional.empty(); + } + } +} 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 new file mode 100644 index 00000000..48e6a304 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushRequestMapper.java @@ -0,0 +1,117 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest; +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper; +import dev.caskeleton.application.notification.platform.api.content.WebPushContent; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationExpiredException; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; +import dev.caskeleton.application.notification.platform.api.error.ProviderConfigurationException; +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; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Builds the RFC 8030 request. + * + *

{@code TTL} is mandatory by protocol, and a submission with no expiry cannot produce one. That + * is a configuration failure rather than a silent default, because a guessed TTL would decide how + * long a push service keeps a message the platform has no opinion about. + */ +public final class WebPushRequestMapper { + + private final Rfc8291Aes128GcmEncryptor encryptor; + private final VapidAuthorizationProvider signer; + private final SecretMaterialProvider secrets; + private final WebPushProviderProperties properties; + private final Clock clock; + + public WebPushRequestMapper( + Rfc8291Aes128GcmEncryptor encryptor, + VapidAuthorizationProvider signer, + SecretMaterialProvider secrets, + WebPushProviderProperties properties, + Clock clock) { + this.encryptor = Objects.requireNonNull(encryptor, "encryptor"); + this.signer = Objects.requireNonNull(signer, "signer"); + this.secrets = Objects.requireNonNull(secrets, "secrets"); + this.properties = Objects.requireNonNull(properties, "properties"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + /** Map one submission into a Web Push request. */ + public NotificationHttpRequest map( + ProviderSubmission submission, WebPushSubscriptionValue subscription) { + Objects.requireNonNull(submission, "submission"); + Objects.requireNonNull(subscription, "subscription"); + + if (submission.expiresAt().isEmpty()) { + throw new ProviderConfigurationException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.PROVIDER_CONFIGURATION_INVALID, + FailureCategory.INVALID_PAYLOAD)); + } + Duration ttl = Duration.between(clock.instant(), submission.expiresAt().get()); + if (ttl.isNegative() || ttl.isZero()) { + throw new NotificationExpiredException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.NOTIFICATION_EXPIRED, FailureCategory.EXPIRED)); + } + if (ttl.compareTo(properties.maxTtl()) > 0) { + ttl = properties.maxTtl(); + } + + if (!(submission.content().content() instanceof WebPushContent content)) { + throw new IllegalArgumentException("Web Push requires Web Push content"); + } + Map payload = new LinkedHashMap<>(); + payload.put("title", content.title()); + payload.put("body", content.body()); + content.deepLink().ifPresent(link -> payload.put("deepLink", link.toString())); + if (!content.data().isEmpty()) { + payload.put("data", content.data()); + } + + var encrypted = + encryptor.encrypt( + subscription, + NotificationJsonMapper.mapper() + .writeValueAsString(payload) + .getBytes(StandardCharsets.UTF_8)); + if (encrypted.body().length > properties.maxPayloadBytes()) { + throw new ProviderPayloadLimitException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.PROVIDER_PAYLOAD_LIMIT, FailureCategory.INVALID_PAYLOAD)); + } + + Map headers = new LinkedHashMap<>(); + headers.put("ttl", Long.toString(ttl.toSeconds())); + headers.put("content-encoding", encrypted.contentEncoding()); + headers.put("content-type", "application/octet-stream"); + headers.put("urgency", content.options().urgency().headerValue()); + content.options().topic().ifPresent(topic -> headers.put("topic", topic)); + headers.put( + "authorization", + signer.authorization( + subscription.endpoint(), + secrets.activeKey(SecretPurpose.VAPID_SIGNING), + properties.vapidPublicKeyBase64Url())); + + return new NotificationHttpRequest( + "POST", + subscription.endpoint(), + JdkNotificationHttpGateway.headers(headers), + encrypted.body(), + properties.timeout()); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/reactor/ReactiveNotificationOrchestrator.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/reactor/ReactiveNotificationOrchestrator.java new file mode 100644 index 00000000..bc130299 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/reactor/ReactiveNotificationOrchestrator.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.outbound.notification.platform.reactor; + +import dev.caskeleton.application.notification.platform.api.NotificationId; +import dev.caskeleton.application.notification.platform.api.NotificationPlan; +import dev.caskeleton.application.notification.platform.api.NotificationReceipt; +import dev.caskeleton.application.notification.platform.api.NotificationSnapshot; +import java.time.Instant; +import reactor.core.publisher.Mono; + +/** + * Optional Reactor facade. + * + *

It exists as a separate type so that Reactor stays out of the core contract: the platform's + * asynchronous type is {@code CompletionStage}, and an application that does not use Reactor never + * sees it. + */ +public interface ReactiveNotificationOrchestrator { + + /** Accept a plan. */ + Mono submit(NotificationPlan plan); + + /** Accept a scheduled plan. */ + Mono schedule(NotificationPlan plan, Instant scheduleAt); + + /** Read the projection of a notification. */ + Mono get(NotificationId notificationId); +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/reactor/ReactorContextBridge.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/reactor/ReactorContextBridge.java new file mode 100644 index 00000000..4be68bc2 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/reactor/ReactorContextBridge.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.outbound.notification.platform.reactor; + +import java.util.Objects; +import java.util.Optional; +import java.util.function.Supplier; +import reactor.util.context.ContextView; + +/** + * Carries selected Reactor context values across the blocking boundary. + * + *

Only the correlation id crosses. A general context copy would let request-scoped state leak + * into a worker thread that outlives the request. + */ +public final class ReactorContextBridge { + + /** Reactor context key for the correlation id. */ + public static final String CORRELATION_ID = "correlationId"; + + private final ThreadLocal currentCorrelationId = new ThreadLocal<>(); + + /** Run an action with the context's correlation id bound to the calling thread. */ + public T withContext(ContextView context, Supplier action) { + Objects.requireNonNull(context, "context"); + Objects.requireNonNull(action, "action"); + Optional correlationId = + context.hasKey(CORRELATION_ID) + ? Optional.of(String.valueOf(context.get(CORRELATION_ID))) + : Optional.empty(); + correlationId.ifPresent(currentCorrelationId::set); + try { + return action.get(); + } finally { + currentCorrelationId.remove(); + } + } + + /** Correlation id bound to the current thread, if any. */ + public Optional currentCorrelationId() { + return Optional.ofNullable(currentCorrelationId.get()); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/reactor/ReactorNotificationOrchestrator.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/reactor/ReactorNotificationOrchestrator.java new file mode 100644 index 00000000..45e34dc3 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/reactor/ReactorNotificationOrchestrator.java @@ -0,0 +1,74 @@ +package dev.caskeleton.adapter.outbound.notification.platform.reactor; + +import dev.caskeleton.application.notification.platform.api.NotificationId; +import dev.caskeleton.application.notification.platform.api.NotificationOrchestrator; +import dev.caskeleton.application.notification.platform.api.NotificationPlan; +import dev.caskeleton.application.notification.platform.api.NotificationReceipt; +import dev.caskeleton.application.notification.platform.api.NotificationSnapshot; +import java.time.Instant; +import java.util.Objects; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; + +/** + * Reactor facade over the synchronous durable API. + * + *

The blocking submit runs on {@code boundedElastic}, never on an event loop, and the facade + * itself never calls {@code block()}. + * + *

Cancelling the {@code Mono} stops the caller waiting; it does not delete a notification that + * has already been committed. Undoing a durable acceptance because a subscriber went away would + * make the receipt meaningless. + */ +public final class ReactorNotificationOrchestrator implements ReactiveNotificationOrchestrator { + + private final NotificationOrchestrator delegate; + private final ReactorContextBridge contextBridge; + private final Scheduler scheduler; + + public ReactorNotificationOrchestrator( + NotificationOrchestrator delegate, ReactorContextBridge contextBridge) { + this(delegate, contextBridge, Schedulers.boundedElastic()); + } + + public ReactorNotificationOrchestrator( + NotificationOrchestrator delegate, ReactorContextBridge contextBridge, Scheduler scheduler) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + this.contextBridge = Objects.requireNonNull(contextBridge, "contextBridge"); + this.scheduler = Objects.requireNonNull(scheduler, "scheduler"); + } + + @Override + public Mono submit(NotificationPlan plan) { + Objects.requireNonNull(plan, "plan"); + return Mono.deferContextual( + context -> + Mono.fromSupplier( + () -> contextBridge.withContext(context, () -> delegate.submit(plan)))) + .subscribeOn(scheduler); + } + + @Override + public Mono schedule(NotificationPlan plan, Instant scheduleAt) { + Objects.requireNonNull(plan, "plan"); + Objects.requireNonNull(scheduleAt, "scheduleAt"); + return Mono.deferContextual( + context -> + Mono.fromSupplier( + () -> + contextBridge.withContext( + context, () -> delegate.schedule(plan, scheduleAt)))) + .subscribeOn(scheduler); + } + + @Override + public Mono get(NotificationId notificationId) { + Objects.requireNonNull(notificationId, "notificationId"); + return Mono.deferContextual( + context -> + Mono.fromSupplier( + () -> contextBridge.withContext(context, () -> delegate.get(notificationId)))) + .subscribeOn(scheduler); + } +} 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 new file mode 100644 index 00000000..8443cc0c --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmCallbackPayloadProtection.java @@ -0,0 +1,118 @@ +package dev.caskeleton.adapter.outbound.notification.platform.security; + +import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationDigest; +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.callback.CallbackPayloadProtectionPort; +import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.HexFormat; +import java.util.Objects; +import java.util.TreeMap; +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * Bounded, encrypted retention of raw callback payloads. + * + *

The raw payload is kept because a normalization bug is only diagnosable against what the + * provider actually sent — but it routinely contains addresses and message metadata, so it is + * encrypted and truncated rather than stored as received. + * + *

The nonce is prefixed to the ciphertext so a rotation does not need a second column, and the + * fingerprint is keyed so that providers without an event id still get collision-resistant, + * non-enumerable duplicate detection. + */ +public final class AesGcmCallbackPayloadProtection implements CallbackPayloadProtectionPort { + + private static final int NONCE_BYTES = 12; + private static final int TAG_BITS = 128; + + private final SecretMaterialProvider secrets; + private final SecureRandom random; + private final int maxRetainedBytes; + + public AesGcmCallbackPayloadProtection(SecretMaterialProvider secrets, int maxRetainedBytes) { + this(secrets, new SecureRandom(), maxRetainedBytes); + } + + AesGcmCallbackPayloadProtection( + 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"); + } + } + + @Override + public byte[] protectRawPayload(byte[] rawBody) { + Objects.requireNonNull(rawBody, "rawBody"); + byte[] bounded = + rawBody.length <= maxRetainedBytes ? rawBody : Arrays.copyOf(rawBody, maxRetainedBytes); + byte[] nonce = new byte[NONCE_BYTES]; + random.nextBytes(nonce); + try { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init( + Cipher.ENCRYPT_MODE, + new SecretKeySpec(secrets.activeKey(SecretPurpose.PAYLOAD_ENCRYPTION).material(), "AES"), + new GCMParameterSpec(TAG_BITS, nonce)); + byte[] ciphertext = cipher.doFinal(bounded); + byte[] stored = new byte[nonce.length + ciphertext.length]; + System.arraycopy(nonce, 0, stored, 0, nonce.length); + System.arraycopy(ciphertext, 0, stored, nonce.length, ciphertext.length); + return stored; + } catch (GeneralSecurityException failure) { + throw new IllegalStateException("callback payload encryption failed", failure); + } + } + + @Override + public String digest(byte[] rawBody) { + Objects.requireNonNull(rawBody, "rawBody"); + return NotificationDigest.hex(rawBody); + } + + @Override + public String fingerprint( + ProviderProfileId profileId, NormalizedProviderEvent event, String rawPayloadDigest) { + Objects.requireNonNull(profileId, "profileId"); + Objects.requireNonNull(event, "event"); + Objects.requireNonNull(rawPayloadDigest, "rawPayloadDigest"); + + // Everything that distinguishes two genuinely different events goes into the input; arrival + // time deliberately does not, or a redelivery would look like a new event. + String seed = + profileId.value() + + '\u001f' + + event.type().name() + + '\u001f' + + event.providerNativeType() + + '\u001f' + + event.providerRequestId().orElse("-") + + '\u001f' + + event.providerOccurredAt().map(Object::toString).orElse("-") + + '\u001f' + + new TreeMap<>(event.attributes()) + + '\u001f' + + rawPayloadDigest; + + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init( + new SecretKeySpec( + secrets.activeKey(SecretPurpose.CONTACT_LOOKUP_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 new file mode 100644 index 00000000..ebe5e8f0 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmContactPointProtector.java @@ -0,0 +1,204 @@ +package dev.caskeleton.adapter.outbound.notification.platform.security; + +import dev.caskeleton.application.notification.platform.contact.ApnsDeviceToken; +import dev.caskeleton.application.notification.platform.contact.ApnsEnvironment; +import dev.caskeleton.application.notification.platform.contact.ContactPointType; +import dev.caskeleton.application.notification.platform.contact.ContactPointValue; +import dev.caskeleton.application.notification.platform.contact.EmailAddress; +import dev.caskeleton.application.notification.platform.contact.FcmInstallationId; +import dev.caskeleton.application.notification.platform.contact.InAppRecipientRef; +import dev.caskeleton.application.notification.platform.contact.LegacyFcmRegistrationToken; +import dev.caskeleton.application.notification.platform.contact.PhoneNumber; +import dev.caskeleton.application.notification.platform.contact.WebPushSubscriptionValue; +import dev.caskeleton.application.notification.platform.security.AccessContext; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import dev.caskeleton.application.notification.platform.security.ProtectedContactPoint; +import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.HexFormat; +import java.util.Objects; +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * AES-256-GCM encryption with a separate HMAC-SHA-256 lookup fingerprint. + * + *

Two keys, not one. The ciphertext must be non-deterministic so that two records of the same + * address are not visibly identical, while equality lookup must be deterministic — those are + * opposite requirements, and one key cannot serve both without leaking one of them. + * + *

The fingerprint is keyed rather than a plain digest because email addresses and phone numbers + * come from a small, guessable space: an unkeyed hash of a phone number is recoverable by + * enumeration in seconds. + */ +public final class AesGcmContactPointProtector implements ContactPointProtector { + + private static final String CIPHER = "AES/GCM/NoPadding"; + private static final String HMAC = "HmacSHA256"; + private static final String KEY_ALGORITHM = "AES"; + private static final int NONCE_BYTES = 12; + private static final int TAG_BITS = 128; + private static final int REQUIRED_KEY_BITS = 256; + + private final SecretMaterialProvider keys; + private final SecureRandom random; + + public AesGcmContactPointProtector(SecretMaterialProvider keys) { + this(keys, new SecureRandom()); + } + + AesGcmContactPointProtector(SecretMaterialProvider keys, SecureRandom random) { + this.keys = Objects.requireNonNull(keys, "keys"); + this.random = Objects.requireNonNull(random, "random"); + } + + @Override + public ProtectedContactPoint protect(ContactPointValue value) { + Objects.requireNonNull(value, "value"); + SecretKeyMaterial encryption = requireEncryptionKey(); + SecretKeyMaterial lookup = keys.activeKey(SecretPurpose.CONTACT_LOOKUP_HMAC); + requireDistinctKeys(encryption, lookup); + + byte[] nonce = new byte[NONCE_BYTES]; + random.nextBytes(nonce); + byte[] plaintext = value.normalized().getBytes(StandardCharsets.UTF_8); + byte[] ciphertext = encrypt(encryption, nonce, associatedData(value.type()), plaintext); + + return new ProtectedContactPoint( + value.type(), + encryption.keyId(), + nonce, + ciphertext, + fingerprint(lookup, value.type(), value.normalized())); + } + + @Override + public ContactPointValue reveal(ProtectedContactPoint protectedValue, AccessContext context) { + Objects.requireNonNull(protectedValue, "protectedValue"); + Objects.requireNonNull(context, "context"); + SecretKeyMaterial key = keys.keyById(protectedValue.keyId()); + byte[] plaintext = + decrypt( + key, + protectedValue.nonce(), + associatedData(protectedValue.type()), + protectedValue.ciphertext()); + return parse(protectedValue.type(), new String(plaintext, StandardCharsets.UTF_8)); + } + + @Override + public String fingerprint(ContactPointValue value) { + Objects.requireNonNull(value, "value"); + return fingerprint( + keys.activeKey(SecretPurpose.CONTACT_LOOKUP_HMAC), value.type(), value.normalized()); + } + + private SecretKeyMaterial requireEncryptionKey() { + SecretKeyMaterial encryption = keys.activeKey(SecretPurpose.CONTACT_ENCRYPTION); + if (encryption.lengthBits() != REQUIRED_KEY_BITS) { + throw new IllegalArgumentException( + "contact encryption key must be " + REQUIRED_KEY_BITS + " bits"); + } + return encryption; + } + + private static void requireDistinctKeys(SecretKeyMaterial encryption, SecretKeyMaterial lookup) { + if (encryption.keyId().equals(lookup.keyId()) + || java.security.MessageDigest.isEqual(encryption.material(), lookup.material())) { + throw new IllegalArgumentException("encryption and HMAC keys must differ"); + } + } + + private static byte[] associatedData(ContactPointType type) { + // Binding the type into the AAD means a ciphertext cannot be moved between contact point kinds + // without the tag check failing. + return ("contact-point:" + type.name()).getBytes(StandardCharsets.UTF_8); + } + + private static byte[] encrypt( + SecretKeyMaterial key, byte[] nonce, byte[] associatedData, byte[] plaintext) { + try { + Cipher cipher = Cipher.getInstance(CIPHER); + cipher.init( + Cipher.ENCRYPT_MODE, + new SecretKeySpec(key.material(), KEY_ALGORITHM), + new GCMParameterSpec(TAG_BITS, nonce)); + cipher.updateAAD(associatedData); + return cipher.doFinal(plaintext); + } catch (GeneralSecurityException failure) { + // The message deliberately carries no plaintext and no key material. + throw new IllegalStateException("contact point encryption failed", failure); + } + } + + private static byte[] decrypt( + SecretKeyMaterial key, byte[] nonce, byte[] associatedData, byte[] ciphertext) { + try { + Cipher cipher = Cipher.getInstance(CIPHER); + cipher.init( + Cipher.DECRYPT_MODE, + new SecretKeySpec(key.material(), KEY_ALGORITHM), + new GCMParameterSpec(TAG_BITS, nonce)); + cipher.updateAAD(associatedData); + return cipher.doFinal(ciphertext); + } catch (GeneralSecurityException failure) { + throw new IllegalStateException("contact point decryption failed", failure); + } + } + + private static String fingerprint( + SecretKeyMaterial key, ContactPointType type, String normalized) { + try { + Mac mac = Mac.getInstance(HMAC); + mac.init(new SecretKeySpec(key.material(), HMAC)); + mac.update((type.name() + ":").getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(mac.doFinal(normalized.getBytes(StandardCharsets.UTF_8))); + } catch (GeneralSecurityException failure) { + throw new IllegalStateException("contact point fingerprinting failed", failure); + } + } + + private static ContactPointValue parse(ContactPointType type, String normalized) { + 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); + }; + } + + private static ApnsDeviceToken parseApns(String normalized) { + int separator = normalized.indexOf(':'); + if (separator <= 0) { + throw new IllegalStateException("stored APNs token is missing its environment"); + } + return new ApnsDeviceToken( + normalized.substring(separator + 1), + 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"); + } + // 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. + return new WebPushSubscriptionValue( + URI.create(normalized.substring(0, separator)), + Base64.getUrlDecoder().decode(normalized.substring(separator + 1)), + new byte[16], + "restored"); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/CredentialGeneration.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/CredentialGeneration.java new file mode 100644 index 00000000..5a214c56 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/CredentialGeneration.java @@ -0,0 +1,69 @@ +package dev.caskeleton.adapter.outbound.notification.platform.security; + +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** + * One numbered credential generation of a provider profile. + * + *

This is a reference to credential material, not the material. The generation number + * and the key id are the only two facts that may appear in an audit record or a metric tag; the + * bytes stay behind {@link dev.caskeleton.application.notification.platform.security + * .SecretMaterialProvider} and are fetched at use time. That split is what lets a rotation be fully + * auditable without the audit trail itself becoming a place secrets accumulate. + * + *

Generations are strictly increasing per profile. A rotation that reused or decreased the + * number would make an attempt record ambiguous about which credential actually signed it, which is + * exactly the question an incident asks first. + * + * @param profileId profile this generation belongs to + * @param generation strictly increasing generation number, starting at 1 + * @param keyId secret-manager key id backing this generation + * @param activatedAt when the generation was cut over, absent for a candidate + */ +public record CredentialGeneration( + ProviderProfileId profileId, long generation, String keyId, Optional activatedAt) { + + public CredentialGeneration { + Objects.requireNonNull(profileId, "profileId"); + Objects.requireNonNull(keyId, "keyId"); + Objects.requireNonNull(activatedAt, "activatedAt"); + if (generation < 1) { + throw new IllegalArgumentException("generation"); + } + if (keyId.isBlank()) { + throw new IllegalArgumentException("keyId"); + } + } + + /** A candidate generation that has not been activated yet. */ + public static CredentialGeneration candidate( + ProviderProfileId profileId, long generation, String keyId) { + return new CredentialGeneration(profileId, generation, keyId, Optional.empty()); + } + + /** The same generation, marked active as of {@code at}. */ + public CredentialGeneration activatedAt(Instant at) { + return new CredentialGeneration( + profileId, generation, keyId, Optional.of(Objects.requireNonNull(at, "at"))); + } + + /** True when this generation supersedes {@code other}. */ + public boolean supersedes(CredentialGeneration other) { + Objects.requireNonNull(other, "other"); + return profileId.equals(other.profileId) && generation > other.generation; + } + + /** + * Bounded audit form. + * + *

Deliberately excludes everything except the profile, the number and the key id — a key id is + * a handle, not a secret, and it is the field an operator needs to correlate a rotation with the + * secret manager's own log. + */ + public String auditForm() { + return profileId.value() + "#" + generation + "/" + keyId; + } +} 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 new file mode 100644 index 00000000..355169d1 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/HmacProviderRequestIdHasher.java @@ -0,0 +1,47 @@ +package dev.caskeleton.adapter.outbound.notification.platform.security; + +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.dispatch.ProviderRequestIdHasherPort; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.util.HexFormat; +import java.util.Objects; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * Keyed hash of a provider request id. + * + *

The profile is mixed into the input, so the same identifier issued by two providers hashes + * differently and an event cannot attach itself to the wrong attempt. + * + *

Keyed rather than a plain digest: provider identifiers are short and structured, so an unkeyed + * hash of the whole table is reversible by anyone who obtains it. + */ +public final class HmacProviderRequestIdHasher implements ProviderRequestIdHasherPort { + + private final SecretMaterialProvider secrets; + + public HmacProviderRequestIdHasher(SecretMaterialProvider secrets) { + this.secrets = Objects.requireNonNull(secrets, "secrets"); + } + + @Override + public String hash(ProviderProfileId profileId, String providerRequestId) { + Objects.requireNonNull(profileId, "profileId"); + Objects.requireNonNull(providerRequestId, "providerRequestId"); + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init( + new SecretKeySpec( + secrets.activeKey(SecretPurpose.CONTACT_LOOKUP_HMAC).material(), "HmacSHA256")); + mac.update((profileId.value() + ":").getBytes(StandardCharsets.UTF_8)); + return HexFormat.of() + .formatHex(mac.doFinal(providerRequestId.getBytes(StandardCharsets.UTF_8))); + } catch (GeneralSecurityException failure) { + throw new IllegalStateException("provider request id hashing failed", failure); + } + } +} 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 new file mode 100644 index 00000000..4273da28 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/ProviderCredentialManager.java @@ -0,0 +1,104 @@ +package dev.caskeleton.adapter.outbound.notification.platform.security; + +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.time.Clock; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Tracks which credential generation is current for each provider profile. + * + *

Two rotations are deliberately not handled here, because treating them as ordinary + * credential swaps would silently lose data or delivery: + * + *

    + *
  • Contact-point encryption key rotation. Swapping the key does not re-encrypt the rows + * already written with the previous one; that needs a background re-encryption job which + * reads with the old key id and writes with the new. {@link #rejectManagedRotation} refuses + * the shortcut rather than leaving a table half-readable. + *
  • VAPID key rotation. Under RFC 8292 a push subscription is bound to the application server + * key it was created with, so a new VAPID key invalidates every existing subscription until + * the user agent re-subscribes. That is a client migration, not a server-side rotation. + *
+ */ +public final class ProviderCredentialManager { + + private final SecretMaterialProvider secrets; + private final Clock clock; + private final Map current = new ConcurrentHashMap<>(); + + public ProviderCredentialManager(SecretMaterialProvider secrets, Clock clock) { + this.secrets = Objects.requireNonNull(secrets, "secrets"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + /** Record the generation a profile starts on. */ + 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. + requireResolvable(generation); + CredentialGeneration activated = generation.activatedAt(clock.instant()); + current.put(activated.profileId(), activated); + return activated; + } + + /** Current generation of a profile. */ + public Optional current(ProviderProfileId profileId) { + return Optional.ofNullable(current.get(Objects.requireNonNull(profileId, "profileId"))); + } + + /** The next candidate number for a profile. */ + public long nextGenerationNumber(ProviderProfileId profileId) { + return current(profileId).map(CredentialGeneration::generation).orElse(0L) + 1; + } + + /** + * Credential material for a generation. + * + *

Resolved per call rather than cached in a field. A cached credential outlives the rotation + * that replaced it, and the resulting attempt is attributed to a generation that is no longer + * current. + */ + public SecretKeyMaterial material(CredentialGeneration generation) { + Objects.requireNonNull(generation, "generation"); + SecretKeyMaterial key = secrets.keyById(generation.keyId()); + if (key.purpose() != SecretPurpose.PROVIDER_CREDENTIAL) { + throw new IllegalStateException("key is not a provider credential"); + } + return key; + } + + /** + * Refuse a rotation that this manager must not perform. + * + * @throws IllegalArgumentException always, naming the required migration instead + */ + public static void rejectManagedRotation(SecretPurpose purpose) { + Objects.requireNonNull(purpose, "purpose"); + throw new IllegalArgumentException( + switch (purpose) { + case CONTACT_ENCRYPTION -> + "contact encryption key rotation requires a background re-encryption job"; + case VAPID_SIGNING -> + "VAPID key rotation requires subscription migration by the user agent"; + default -> "purpose is not rotated through the provider credential manager: " + purpose; + }); + } + + private void requireResolvable(CredentialGeneration generation) { + SecretKeyMaterial key = secrets.keyById(generation.keyId()); + if (key.purpose() != SecretPurpose.PROVIDER_CREDENTIAL) { + throw new IllegalArgumentException("key is not a provider credential"); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/SettingsSecretMaterialProvider.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/SettingsSecretMaterialProvider.java new file mode 100644 index 00000000..ab92af84 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/SettingsSecretMaterialProvider.java @@ -0,0 +1,49 @@ +package dev.caskeleton.adapter.outbound.notification.platform.security; + +import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.util.Map; +import java.util.Objects; + +/** + * Secret provider backed by material supplied at composition time. + * + *

Values arrive from the environment or a secret manager through the composition root. Nothing + * here reads a file or a configuration property directly, so rotating a key never means editing a + * deployed artifact. + */ +public final class SettingsSecretMaterialProvider implements SecretMaterialProvider { + + private final Map active; + private final Map byKeyId; + + public SettingsSecretMaterialProvider( + Map active, Map historical) { + this.active = Map.copyOf(Objects.requireNonNull(active, "active")); + Objects.requireNonNull(historical, "historical"); + java.util.Map all = new java.util.HashMap<>(historical); + active.values().forEach(key -> all.put(key.keyId(), key)); + this.byKeyId = Map.copyOf(all); + } + + @Override + public SecretKeyMaterial activeKey(SecretPurpose purpose) { + SecretKeyMaterial key = active.get(purpose); + if (key == null) { + throw new IllegalStateException("no active key configured for purpose " + purpose); + } + return key; + } + + @Override + public SecretKeyMaterial keyById(String keyId) { + SecretKeyMaterial key = byKeyId.get(keyId); + if (key == null) { + // Refusing here is what makes key rotation safe: silently falling back to the current key + // would turn every historical row into an authentication-tag failure at read time. + throw new IllegalStateException("unknown key id"); + } + return key; + } +} 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 new file mode 100644 index 00000000..e5a673b9 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/CanonicalNotificationRenderer.java @@ -0,0 +1,166 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import dev.caskeleton.application.notification.platform.api.content.EmailContent; +import dev.caskeleton.application.notification.platform.api.content.EmailOptions; +import dev.caskeleton.application.notification.platform.api.content.InAppContent; +import dev.caskeleton.application.notification.platform.api.content.MobilePushContent; +import dev.caskeleton.application.notification.platform.api.content.NotificationContent; +import dev.caskeleton.application.notification.platform.api.content.PushPresentation; +import dev.caskeleton.application.notification.platform.api.content.SmsContent; +import dev.caskeleton.application.notification.platform.api.content.SmsOptions; +import dev.caskeleton.application.notification.platform.api.content.WebPushContent; +import dev.caskeleton.application.notification.platform.api.content.WebPushOptions; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.template.NotificationTemplateRenderer; +import dev.caskeleton.application.notification.platform.template.NotificationTemplateVersion; +import dev.caskeleton.application.notification.platform.template.RenderCommand; +import dev.caskeleton.application.notification.platform.template.RenderedNotificationContent; +import dev.caskeleton.application.notification.platform.template.TemplateRegistry; +import dev.caskeleton.application.notification.platform.template.TemplateSlot; +import dev.caskeleton.application.notification.platform.template.TemplateVariableValidator; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * The reference renderer for every stable channel. + * + *

Rendering is deterministic and the digest covers channel, template coordinate, resolved locale + * and every rendered field. That digest is stored on the attempt, which is what lets a retry prove + * it sent the same content and lets a redrive re-execute the original notification rather than a + * newly rendered one. + * + *

Variables are validated first, so a schema violation costs nothing at the provider. + */ +public final class CanonicalNotificationRenderer implements NotificationTemplateRenderer { + + private final Channel channel; + private final TemplateRegistry templates; + private final TemplateVariableValidator validator; + private final NotificationTemplateEngine engine; + + public CanonicalNotificationRenderer( + Channel channel, + TemplateRegistry templates, + TemplateVariableValidator validator, + NotificationTemplateEngine engine) { + this.channel = Objects.requireNonNull(channel, "channel"); + this.templates = Objects.requireNonNull(templates, "templates"); + this.validator = Objects.requireNonNull(validator, "validator"); + this.engine = Objects.requireNonNull(engine, "engine"); + } + + @Override + public Channel channel() { + return channel; + } + + @Override + public RenderedNotificationContent render(RenderCommand command) { + Objects.requireNonNull(command, "command"); + + NotificationTemplateVersion template = + templates.resolve( + command.selection().templateId(), + command.selection().version(), + command.channel(), + command.requestedLocale()); + validator.validate(template.variableSchema(), command.variables()); + + NotificationContent content = buildContent(template, command.variables()); + String digest = + NotificationDigest.hex( + command.channel().name() + + '\u001f' + + template.templateId() + + '\u001f' + + template.version() + + '\u001f' + + template.locale().toLanguageTag() + + '\u001f' + + canonicalForm(content)); + return new RenderedNotificationContent(content, digest, command.selection(), template.locale()); + } + + private NotificationContent buildContent( + NotificationTemplateVersion template, Map variables) { + return switch (channel) { + case EMAIL -> + new EmailContent( + slot(template, TemplateSlot.SUBJECT, variables), + slot(template, TemplateSlot.TEXT_BODY, variables), + optionalSlot(template, TemplateSlot.HTML_BODY, variables), + List.of(), + EmailOptions.DEFAULT); + case SMS -> + new SmsContent(slot(template, TemplateSlot.TEXT_BODY, variables), SmsOptions.DEFAULT); + case PUSH -> + new MobilePushContent( + slot(template, TemplateSlot.TITLE, variables), + slot(template, TemplateSlot.BODY, variables), + optionalSlot(template, TemplateSlot.DEEP_LINK, variables).map(URI::create), + Map.of(), + PushPresentation.DEFAULT); + case WEB_PUSH -> + new WebPushContent( + slot(template, TemplateSlot.TITLE, variables), + slot(template, TemplateSlot.BODY, variables), + optionalSlot(template, TemplateSlot.DEEP_LINK, variables).map(URI::create), + Map.of(), + WebPushOptions.DEFAULT); + case IN_APP, WEBHOOK -> + new InAppContent( + slot(template, TemplateSlot.TITLE, variables), + slot(template, TemplateSlot.BODY, variables), + optionalSlot(template, TemplateSlot.DEEP_LINK, variables).map(URI::create), + List.of(), + optionalSlot(template, TemplateSlot.CATEGORY, variables).orElse("general")); + }; + } + + private String slot( + NotificationTemplateVersion template, TemplateSlot slot, Map variables) { + return engine.render(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)); + } + + private static String canonicalForm(NotificationContent content) { + return switch (content) { + case EmailContent email -> + "subject=" + + email.subject() + + "\u001ftext=" + + email.textBody() + + "\u001fhtml=" + + email.htmlBody().orElse(""); + case SmsContent sms -> "text=" + sms.text(); + case MobilePushContent push -> + "title=" + + push.title() + + "\u001fbody=" + + push.body() + + "\u001flink=" + + push.deepLink().map(URI::toString).orElse(""); + case WebPushContent webPush -> + "title=" + + webPush.title() + + "\u001fbody=" + + webPush.body() + + "\u001flink=" + + webPush.deepLink().map(URI::toString).orElse(""); + case InAppContent inApp -> + "title=" + + inApp.title() + + "\u001fbody=" + + inApp.body() + + "\u001fcategory=" + + inApp.category(); + }; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/JacksonInboxContentCodec.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/JacksonInboxContentCodec.java new file mode 100644 index 00000000..de60cca7 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/JacksonInboxContentCodec.java @@ -0,0 +1,78 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import dev.caskeleton.application.notification.platform.api.content.InAppAction; +import dev.caskeleton.application.notification.platform.api.content.InAppContent; +import dev.caskeleton.application.notification.platform.inbox.InboxContentCodecPort; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import tools.jackson.core.type.TypeReference; + +/** + * Inbox content encoding. + * + *

Lives here rather than in the persistence adapter so that the persistence leaf needs no JSON + * library, and so the stored shape has exactly one owner. + */ +public final class JacksonInboxContentCodec implements InboxContentCodecPort { + + @Override + public String encode(InAppContent content) { + Objects.requireNonNull(content, "content"); + return NotificationJsonMapper.mapper() + .writeValueAsString( + Map.of( + "title", content.title(), + "body", content.body(), + "deepLink", content.deepLink().map(URI::toString).orElse(""), + "actions", + content.actions().stream() + .map( + action -> + Map.of( + "actionId", action.actionId(), + "label", action.label(), + "deepLink", action.deepLink().map(URI::toString).orElse(""))) + .toList())); + } + + @Override + public InAppContent decode(String payload, String category) { + Objects.requireNonNull(payload, "payload"); + Objects.requireNonNull(category, "category"); + Map fields = + NotificationJsonMapper.mapper() + .readValue(payload, new TypeReference>() {}); + + return new InAppContent( + String.valueOf(fields.getOrDefault("title", "")), + String.valueOf(fields.getOrDefault("body", "")), + optionalUri(fields.get("deepLink")), + actions(fields.get("actions")), + category); + } + + private static Optional optionalUri(Object value) { + String text = value == null ? "" : String.valueOf(value); + return text.isBlank() ? Optional.empty() : Optional.of(URI.create(text)); + } + + @SuppressWarnings("unchecked") + private static List actions(Object value) { + if (!(value instanceof List raw)) { + return List.of(); + } + return raw.stream() + .filter(Map.class::isInstance) + .map(entry -> (Map) entry) + .map( + entry -> + new InAppAction( + String.valueOf(entry.getOrDefault("actionId", "")), + String.valueOf(entry.getOrDefault("label", "")), + optionalUri(entry.get("deepLink")))) + .toList(); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/JacksonNotificationVariablesCodec.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/JacksonNotificationVariablesCodec.java new file mode 100644 index 00000000..5ea04a15 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/JacksonNotificationVariablesCodec.java @@ -0,0 +1,51 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; +import dev.caskeleton.application.notification.platform.api.error.NotificationValidationException; +import dev.caskeleton.application.notification.platform.dispatch.NotificationVariablesCodecPort; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; + +/** + * Canonical JSON encoding of template variables. + * + *

Keys are sorted before writing, so the stored payload and the request fingerprint derived from + * it stay stable across callers and across restarts. + * + *

Failures never carry the payload: a rejected variables map routinely contains the recovery + * code or the amount the notification is about. + */ +public final class JacksonNotificationVariablesCodec implements NotificationVariablesCodecPort { + + @Override + public String encode(Map variables) { + Objects.requireNonNull(variables, "variables"); + try { + return NotificationJsonMapper.mapper().writeValueAsString(new TreeMap<>(variables)); + } catch (JacksonException failure) { + throw rejection(); + } + } + + @Override + public Map decode(String payload) { + Objects.requireNonNull(payload, "payload"); + try { + return NotificationJsonMapper.mapper() + .readValue(payload, new TypeReference>() {}); + } catch (JacksonException failure) { + throw rejection(); + } + } + + private static NotificationValidationException rejection() { + return new NotificationValidationException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD)); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/JacksonTemplateContentCodec.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/JacksonTemplateContentCodec.java new file mode 100644 index 00000000..a3bac7bc --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/JacksonTemplateContentCodec.java @@ -0,0 +1,34 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import dev.caskeleton.application.notification.platform.template.TemplateContentCodecPort; +import dev.caskeleton.application.notification.platform.template.TemplateContentDefinition; +import dev.caskeleton.application.notification.platform.template.TemplateSlot; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import tools.jackson.core.type.TypeReference; + +/** Template content encoding. Slot names are stored, so a renamed enum constant fails loudly. */ +public final class JacksonTemplateContentCodec implements TemplateContentCodecPort { + + @Override + public String encode(TemplateContentDefinition content) { + Objects.requireNonNull(content, "content"); + Map slots = new TreeMap<>(); + content.slots().forEach((slot, source) -> slots.put(slot.name(), source)); + return NotificationJsonMapper.mapper().writeValueAsString(slots); + } + + @Override + public TemplateContentDefinition decode(String payload) { + Objects.requireNonNull(payload, "payload"); + Map raw = + NotificationJsonMapper.mapper() + .readValue(payload, new TypeReference>() {}); + Map slots = new EnumMap<>(TemplateSlot.class); + raw.forEach((name, source) -> slots.put(TemplateSlot.valueOf(name), source)); + return new TemplateContentDefinition(slots); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/JsonSchemaVariableValidator.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/JsonSchemaVariableValidator.java new file mode 100644 index 00000000..46b2a550 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/JsonSchemaVariableValidator.java @@ -0,0 +1,63 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import com.networknt.schema.Error; +import com.networknt.schema.Schema; +import com.networknt.schema.SchemaRegistry; +import com.networknt.schema.SpecificationVersion; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; +import dev.caskeleton.application.notification.platform.api.error.TemplateVariableValidationException; +import dev.caskeleton.application.notification.platform.template.TemplateVariableValidator; +import dev.caskeleton.application.notification.platform.template.VariableSchema; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.concurrent.ConcurrentHashMap; +import tools.jackson.databind.JsonNode; + +/** + * JSON Schema 2020-12 validation of template variables. + * + *

Validation runs before any provider call, so a missing or mistyped variable is a fast, + * non-retryable rejection rather than a message the recipient receives with a blank in it. + * + *

Validator messages are deliberately dropped rather than attached to the exception. A message + * such as {@code $.recoveryCode: must be at least 6 characters} echoes the instance, and the + * instance is exactly the secret-classified value the redaction rules exist to keep out of logs. + */ +public final class JsonSchemaVariableValidator implements TemplateVariableValidator { + + private final SchemaRegistry registry = + SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_2020_12); + private final Map compiled = new ConcurrentHashMap<>(); + + @Override + public void validate(VariableSchema schema, Map variables) { + Objects.requireNonNull(schema, "schema"); + Objects.requireNonNull(variables, "variables"); + + for (String required : schema.requiredVariables()) { + if (variables.get(required) == null) { + throw rejection(); + } + } + + JsonNode instance = NotificationJsonMapper.mapper().valueToTree(new TreeMap<>(variables)); + List errors = compiledSchema(schema).validate(instance); + if (!errors.isEmpty()) { + throw rejection(); + } + } + + private Schema compiledSchema(VariableSchema schema) { + return compiled.computeIfAbsent(schema.schemaJson(), registry::getSchema); + } + + private static TemplateVariableValidationException rejection() { + return new TemplateVariableValidationException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.TEMPLATE_VARIABLES_INVALID, FailureCategory.TEMPLATE_FAILURE)); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/NotificationDigest.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/NotificationDigest.java new file mode 100644 index 00000000..df06a261 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/NotificationDigest.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +/** SHA-256 helper shared by rendering, fingerprinting and payload protection. */ +public final class NotificationDigest { + + private NotificationDigest() {} + + /** Hex SHA-256 of a UTF-8 string. */ + public static String hex(String value) { + return hex(value.getBytes(StandardCharsets.UTF_8)); + } + + /** Hex SHA-256 of raw bytes. */ + public static String hex(byte[] value) { + return HexFormat.of().formatHex(sha256(value)); + } + + /** Raw SHA-256 of bytes. */ + public static byte[] sha256(byte[] value) { + try { + return MessageDigest.getInstance("SHA-256").digest(value); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 is required by the Java platform", impossible); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/NotificationJsonMapper.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/NotificationJsonMapper.java new file mode 100644 index 00000000..84940d4c --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/NotificationJsonMapper.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.SerializationFeature; +import tools.jackson.databind.json.JsonMapper; + +/** + * Shared JSON mapper for the notification adapter. + * + *

Map entries are written in key order. Without that, the canonical variables payload — and the + * request fingerprint computed from it — would depend on which map implementation the caller + * happened to pass, which is exactly the kind of instability idempotency cannot tolerate. + */ +public final class NotificationJsonMapper { + + private static final ObjectMapper MAPPER = + JsonMapper.builder().enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).build(); + + private NotificationJsonMapper() {} + + /** The shared, deterministically configured mapper. */ + public static ObjectMapper mapper() { + return MAPPER; + } +} 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 new file mode 100644 index 00000000..1d27132a --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/NotificationTemplateEngine.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import java.util.Map; + +/** + * Renders one template slot. + * + *

The seam exists so the renderer's contract — validate first, assemble the channel content, + * digest the result — is shared by every engine, and only the substitution differs. Duplicating the + * renderer per engine is how two implementations end up computing different digests for the same + * template, which silently breaks the retry equality the digest exists to prove. + */ +@FunctionalInterface +public interface NotificationTemplateEngine { + + /** + * Render one slot. + * + * @throws dev.caskeleton.application.notification.platform.api.error.TemplateRenderingException + * when a referenced variable is absent — never rendered as an empty string + */ + String render(String source, Map 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 new file mode 100644 index 00000000..192facd0 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/PlaceholderTemplateEngine.java @@ -0,0 +1,47 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; +import dev.caskeleton.application.notification.platform.api.error.TemplateRenderingException; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Deterministic {@code {name}} placeholder substitution. + * + *

Deliberately not a general expression language. A renderer that can evaluate arbitrary + * expressions over caller-supplied variables is a server-side template injection surface, and + * notification variables come from application input by definition. + * + *

An unresolved placeholder fails rendering instead of rendering an empty string: a password + * reset mail that says "your code is " is worse than one that was never sent. + */ +public final class PlaceholderTemplateEngine implements NotificationTemplateEngine { + + private static final Pattern PLACEHOLDER = Pattern.compile("\\{([a-zA-Z0-9_.-]{1,64})\\}"); + + /** Render one slot. */ + @Override + public String render(String source, Map variables) { + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(variables, "variables"); + + Matcher matcher = PLACEHOLDER.matcher(source); + StringBuilder rendered = new StringBuilder(source.length()); + while (matcher.find()) { + Object value = variables.get(matcher.group(1)); + if (value == null) { + throw new TemplateRenderingException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.TEMPLATE_RENDERING_FAILED, + FailureCategory.TEMPLATE_FAILURE)); + } + matcher.appendReplacement(rendered, Matcher.quoteReplacement(String.valueOf(value))); + } + matcher.appendTail(rendered); + return rendered.toString(); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/Sha256MessageDigestAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/Sha256MessageDigestAdapter.java new file mode 100644 index 00000000..15a9409b --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/Sha256MessageDigestAdapter.java @@ -0,0 +1,12 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import dev.caskeleton.application.notification.platform.dispatch.MessageDigestPort; + +/** Hashing adapter for the canonical request fingerprint. */ +public final class Sha256MessageDigestAdapter implements MessageDigestPort { + + @Override + public byte[] sha256(byte[] input) { + return NotificationDigest.sha256(input); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/ThymeleafNotificationRenderer.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/ThymeleafNotificationRenderer.java new file mode 100644 index 00000000..b7e6a77d --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/ThymeleafNotificationRenderer.java @@ -0,0 +1,50 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.template.NotificationTemplateRenderer; +import dev.caskeleton.application.notification.platform.template.RenderCommand; +import dev.caskeleton.application.notification.platform.template.RenderedNotificationContent; +import dev.caskeleton.application.notification.platform.template.TemplateRegistry; +import dev.caskeleton.application.notification.platform.template.TemplateVariableValidator; +import java.util.Objects; + +/** + * Reference renderer backed by Thymeleaf. + * + *

Composition rather than a parallel implementation: validation order, channel content assembly + * and the content digest are the renderer's contract, and the engine is the only thing that + * differs. A second hand-written renderer is how two of them end up computing different digests for + * the same template — which quietly breaks the retry-sent-the-same-content guarantee the digest + * exists for. + * + *

No Thymeleaf type appears on this class's signature, so the application-side template contract + * stays engine-free and a deployment can swap engines without recompiling anything above the + * adapter. + */ +public final class ThymeleafNotificationRenderer implements NotificationTemplateRenderer { + + private final CanonicalNotificationRenderer delegate; + + public ThymeleafNotificationRenderer( + Channel channel, + TemplateRegistry templates, + TemplateVariableValidator validator, + ThymeleafStringTemplateEngine engine) { + this.delegate = + new CanonicalNotificationRenderer( + Objects.requireNonNull(channel, "channel"), + Objects.requireNonNull(templates, "templates"), + Objects.requireNonNull(validator, "validator"), + Objects.requireNonNull(engine, "engine")); + } + + @Override + public Channel channel() { + return delegate.channel(); + } + + @Override + public RenderedNotificationContent render(RenderCommand command) { + return delegate.render(command); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/ThymeleafStringTemplateEngine.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/ThymeleafStringTemplateEngine.java new file mode 100644 index 00000000..e119b706 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/ThymeleafStringTemplateEngine.java @@ -0,0 +1,108 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode; +import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor; +import dev.caskeleton.application.notification.platform.api.error.TemplateRenderingException; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; +import org.thymeleaf.templatemode.TemplateMode; +import org.thymeleaf.templateresolver.StringTemplateResolver; + +/** + * Thymeleaf as the reference HTML engine. + * + *

Thymeleaf earns its place on one axis the placeholder engine cannot cover: it escapes by + * default in HTML mode. A notification variable is application input, and {@code } rendering a name containing markup into an HTML email is the difference between an + * escaped string and an injected one. + * + *

Two things are deliberately turned off. + * + *

Template cache. Sources come from the template registry, not from the classpath, and a + * cache keyed by source text on a per-tenant registry is an unbounded map keyed by attacker-visible + * content. Rendering is not the bottleneck a notification platform has. + * + *

Caller input never becomes template source. Thymeleaf does evaluate expressions — OGNL + * is its default engine and is on this classpath. What keeps that from being a server-side template + * injection surface is the direction of the data: the source comes from the operator-owned + * template registry, and caller-supplied variables only ever enter as context values, which are + * data to the evaluator rather than program text. Rendering a caller-supplied string as a template + * would break that and must not be added. The plain {@code org.thymeleaf:thymeleaf} artifact is + * used rather than the Spring starter so no SpringEL evaluation context, bean resolution or view + * resolver reaches an outbound adapter that only ever renders strings. + */ +public final class ThymeleafStringTemplateEngine implements NotificationTemplateEngine { + + /** Root identifier of a {@code ${...}} or {@code *{...}} expression. */ + private static final Pattern EXPRESSION_ROOT = + Pattern.compile("[$*]\\{\\s*([a-zA-Z_][a-zA-Z0-9_]*)"); + + private final TemplateEngine engine; + + /** HTML-escaping engine, which is the safe default for email bodies. */ + public ThymeleafStringTemplateEngine() { + this(TemplateMode.HTML); + } + + /** + * @param mode {@link TemplateMode#HTML} to escape, {@link TemplateMode#TEXT} for plain-text slots + */ + public ThymeleafStringTemplateEngine(TemplateMode mode) { + Objects.requireNonNull(mode, "mode"); + StringTemplateResolver resolver = new StringTemplateResolver(); + resolver.setTemplateMode(mode); + resolver.setCacheable(false); + TemplateEngine created = new TemplateEngine(); + created.setTemplateResolver(resolver); + this.engine = created; + } + + @Override + public String render(String source, Map variables) { + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(variables, "variables"); + requireEveryReferencedVariable(source, variables); + + Context context = new Context(); + variables.forEach(context::setVariable); + try { + return engine.process(source, context); + } catch (RuntimeException failure) { + // The message is dropped on purpose. Thymeleaf reports the offending expression, and a + // template expression contains the variable it failed on — which for this platform is a + // one-time code or a recipient name. + throw new TemplateRenderingException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.TEMPLATE_RENDERING_FAILED, FailureCategory.TEMPLATE_FAILURE)); + } + } + + /** + * Fail on an absent variable instead of rendering it away. + * + *

Thymeleaf resolves a missing variable to null and writes an empty string. That default is + * right for a web page with an optional section and wrong for a notification: "your code is " + * reaches the recipient, looks delivered on every metric, and is worse than a notification that + * was never sent. Checked here rather than after rendering, because an empty rendered slot is + * indistinguishable from a legitimately empty one. + * + *

Only the root of each expression is required — {@code ${user.name}} needs {@code user} — + * since anything deeper is the schema validator's job. + */ + private static void requireEveryReferencedVariable(String source, Map variables) { + Matcher references = EXPRESSION_ROOT.matcher(source); + while (references.find()) { + if (!variables.containsKey(references.group(1))) { + throw new TemplateRenderingException( + NotificationFailureDescriptor.preDispatch( + NotificationFailureCode.TEMPLATE_RENDERING_FAILED, + FailureCategory.TEMPLATE_FAILURE)); + } + } + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformSettingsTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformSettingsTest.java new file mode 100644 index 00000000..48d6fe39 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformSettingsTest.java @@ -0,0 +1,152 @@ +package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure; + +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.time.Duration; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class NotificationPlatformSettingsTest { + + @Test + void productionWebPushWithoutVapidKeyFailsStartup() { + assertThatThrownBy(() -> properties(webPushWithoutVapid())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("VAPID"); + } + + @Test + void apnsWithoutTopicFailsStartup() { + assertThatThrownBy(() -> properties(apnsWithoutTopic())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("topic"); + } + + @Test + void aCallbackCapableProviderRequiresASigningSecret() { + assertThatThrownBy(() -> properties(twilioWithoutSigningSecret())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("signing secret"); + } + + @Test + void aDisabledProfileIsNotValidated() { + assertThatCode(() -> properties(disabled(webPushWithoutVapid()))).doesNotThrowAnyException(); + } + + @Test + void ambiguousFallbackCannotBeEnabled() { + assertThatThrownBy( + () -> + new NotificationPlatformSettings.Dispatch( + 100, + Duration.ofSeconds(30), + Duration.ofMillis(250), + 128, + 3, + Duration.ofHours(24), + true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("allow-ambiguous-fallback"); + } + + @Test + void anUnboundedClaimBatchIsRefused() { + assertThatThrownBy( + () -> + new NotificationPlatformSettings.Dispatch( + 100_000, + Duration.ofSeconds(30), + Duration.ofMillis(250), + 128, + 3, + Duration.ofHours(24), + false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("claim-batch-size"); + } + + @Test + void aLeaseShorterThanThePollIntervalIsRefused() { + assertThatThrownBy( + () -> + new NotificationPlatformSettings.Dispatch( + 100, + Duration.ofMillis(100), + Duration.ofSeconds(1), + 128, + 3, + Duration.ofHours(24), + false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("lease-duration"); + } + + @Test + void defaultsAreUsableAndConservative() { + var defaults = NotificationPlatformSettings.Dispatch.defaults(); + + assertThat(defaults.allowAmbiguousFallback()).isFalse(); + assertThat(defaults.claimBatchSize()).isPositive(); + assertThat(NotificationPlatformSettings.Callbacks.defaults().enabled()).isFalse(); + } + + private static NotificationPlatformSettings properties( + NotificationPlatformSettings.Provider provider) { + return new NotificationPlatformSettings( + true, + NotificationPlatformSettings.Dispatch.defaults(), + NotificationPlatformSettings.Callbacks.defaults(), + Map.of("profile", provider)); + } + + private static NotificationPlatformSettings.Provider webPushWithoutVapid() { + return new NotificationPlatformSettings.Provider( + "WEB_PUSH", + true, + "PRODUCTION", + "webpush-main", + null, + null, + null, + Duration.ofSeconds(3), + 8, + 20); + } + + private static NotificationPlatformSettings.Provider apnsWithoutTopic() { + return new NotificationPlatformSettings.Provider( + "APNS", true, "PRODUCTION", "apns-main", null, null, null, Duration.ofSeconds(3), 8, 20); + } + + private static NotificationPlatformSettings.Provider twilioWithoutSigningSecret() { + return new NotificationPlatformSettings.Provider( + "TWILIO", + true, + "PRODUCTION", + "twilio-main", + null, + null, + null, + Duration.ofSeconds(3), + 8, + 20); + } + + private static NotificationPlatformSettings.Provider disabled( + NotificationPlatformSettings.Provider provider) { + return new NotificationPlatformSettings.Provider( + provider.type(), + false, + provider.environment(), + provider.credentialProfile(), + provider.topic(), + provider.vapidPublicKey(), + provider.callbackSigningSecretRef(), + provider.timeout(), + provider.maxConcurrency(), + provider.ratePerSecond()); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationReleaseGateTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationReleaseGateTest.java new file mode 100644 index 00000000..5f8df14b --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationReleaseGateTest.java @@ -0,0 +1,63 @@ +package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; + +/** + * Release gate. + * + *

The documentation is part of the contract, not a nicety: an operator handling an ambiguous + * attempt at 3am needs the runbook to exist, and a support matrix that quietly starts promising + * guaranteed delivery is a defect even though no code changed. + */ +class NotificationReleaseGateTest { + + private static final Path DOCS = Path.of("..", "..", "..", "..", "docs", "notification"); + + @Test + void requiredDocumentationAndAdrsExist() { + assertThat(DOCS.resolve("delivery-evidence.md")).exists(); + assertThat(DOCS.resolve("callback-reconciliation.md")).exists(); + assertThat(DOCS.resolve("support-matrix.md")).exists(); + assertThat(DOCS.resolve("security-privacy.md")).exists(); + assertThat(DOCS.resolve("operations.md")).exists(); + assertThat(DOCS.resolve("provider-runbooks.md")).exists(); + assertThat(DOCS.resolve("configuration-reference.md")).exists(); + assertThat(DOCS.resolve("migration-guide.md")).exists(); + assertThat(DOCS.resolve("module-mapping.md")).exists(); + assertThat(DOCS.resolve("adr/NOTIF-ADR-001-durable-acceptance.md")).exists(); + assertThat(DOCS.resolve("adr/NOTIF-ADR-002-event-ledger-projection.md")).exists(); + assertThat(DOCS.resolve("adr/NOTIF-ADR-003-ambiguous-submission.md")).exists(); + assertThat(DOCS.resolve("adr/NOTIF-ADR-004-fcm-fid-primary.md")).exists(); + } + + @Test + void supportMatrixDoesNotClaimGuaranteedDelivery() throws IOException { + String text = Files.readString(DOCS.resolve("support-matrix.md")); + + // The terms must appear, but only under the refusal heading. Asserting they are absent + // altogether would be the wrong test: a matrix that never mentions guaranteed delivery leaves + // the reader to assume it, which is exactly the assumption this document exists to remove. + int refusalHeading = text.indexOf("## Not supported"); + assertThat(refusalHeading).as("the matrix must state what it refuses to claim").isPositive(); + + String claims = text.substring(0, refusalHeading); + String refusals = text.substring(refusalHeading); + + assertThat(claims).doesNotContain("guaranteed delivery", "guaranteed read", "exactly-once"); + assertThat(refusals).contains("guaranteed delivery", "guaranteed read", "exactly-once"); + assertThat(text).contains("PROVIDER_ACCEPTED", "AMBIGUOUS", "FCM_FID"); + } + + @Test + void theEvidenceDocumentStatesTheForbiddenPromotions() throws IOException { + String text = Files.readString(DOCS.resolve("support-matrix.md")); + + assertThat(text).contains("is not `DEVICE_DELIVERED`"); + assertThat(text).contains("is not `DELIVERED`"); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRegistryTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRegistryTest.java new file mode 100644 index 00000000..46a8ac0f --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRegistryTest.java @@ -0,0 +1,192 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.notification.platform.api.ProviderId; +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.api.error.ProviderUnavailableException; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.observation.NotificationAuditEvent; +import dev.caskeleton.application.notification.platform.observation.NotificationAuditPort; +import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter; +import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities; +import dev.caskeleton.application.notification.platform.provider.ProviderProfileSnapshot; +import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import org.junit.jupiter.api.Test; + +class ProviderRuntimeRegistryTest { + + private static final ProviderProfileId PROFILE = new ProviderProfileId("apns-main"); + private static final Clock CLOCK = + Clock.fixed(Instant.parse("2026-08-10T00:00:00Z"), ZoneOffset.UTC); + + @Test + void authenticationFailureRejectsNewAttemptsWithoutConsumingPermit() { + var runtime = runtime(1); + runtime.markAuthenticationFailed("INVALID_CREDENTIAL"); + + assertThatThrownBy(runtime::acquireAttempt).isInstanceOf(ProviderUnavailableException.class); + assertThat(runtime.activeAttempts()).isZero(); + assertThat(runtime.state()).isEqualTo(ProviderRuntimeState.AUTHENTICATION_FAILED); + } + + @Test + void replacementKeepsOldRuntimeDrainingUntilItsAttemptsFinish() { + var registry = new ProviderRuntimeRegistry(); + var first = runtime(1); + registry.register(first); + + var permit = first.acquireAttempt(); + registry.replace(runtime(2)); + + assertThat(registry.current(PROFILE).generation()).isEqualTo(2); + assertThat(first.state()).isEqualTo(ProviderRuntimeState.DRAINING); + assertThat(registry.drainingGenerations(PROFILE)).hasSize(1); + + permit.close(); + assertThat(registry.drainingGenerations(PROFILE)).isEmpty(); + } + + @Test + void newAttemptUsesNewGenerationWhileOldAttemptDrains() { + var registry = new ProviderRuntimeRegistry(); + registry.register(runtime(1)); + var oldPermit = registry.current(PROFILE).acquireAttempt(); + + rotator(registry, candidate -> true).rotate(PROFILE, runtime(2)); + var newPermit = registry.current(PROFILE).acquireAttempt(); + + assertThat(oldPermit.generation()).isEqualTo(1); + assertThat(newPermit.generation()).isEqualTo(2); + oldPermit.close(); + newPermit.close(); + assertThat(registry.drainingGenerations(PROFILE)).isEmpty(); + } + + @Test + void failedNewCredentialKeepsOldRuntimeActive() { + var registry = new ProviderRuntimeRegistry(); + registry.register(runtime(1)); + + assertThatThrownBy(() -> rotator(registry, candidate -> false).rotate(PROFILE, runtime(2))) + .isInstanceOf(CredentialValidationException.class); + assertThat(registry.current(PROFILE).generation()).isEqualTo(1); + assertThat(registry.current(PROFILE).state()).isEqualTo(ProviderRuntimeState.HEALTHY); + } + + @Test + void concurrencyLimitFailsFastRatherThanQueueing() { + var runtime = runtime(1); + var permit = runtime.acquireAttempt(); + + assertThatThrownBy(runtime::acquireAttempt).isInstanceOf(ProviderUnavailableException.class); + permit.close(); + runtime.acquireAttempt().close(); + } + + @Test + void rotationAuditRecordsGenerationButNoCredentialMaterial() { + var registry = new ProviderRuntimeRegistry(); + registry.register(runtime(1)); + var audit = new RecordingAudit(); + + new ProviderRuntimeRotator( + registry, + candidate -> true, + new RuntimeDrainCoordinator(Duration.ofMillis(1)), + audit, + CLOCK, + Duration.ofMillis(1)) + .rotate(PROFILE, runtime(2)); + + assertThat(audit.events).hasSize(1); + assertThat(audit.events.get(0).boundedAttributes()).containsEntry("generation", "2"); + assertThat(audit.events.get(0).boundedAttributes().values()) + .noneMatch(value -> value.contains("key")); + } + + private static ProviderRuntimeRotator rotator( + ProviderRuntimeRegistry registry, CredentialProbe probe) { + return new ProviderRuntimeRotator( + registry, + probe, + new RuntimeDrainCoordinator(Duration.ofMillis(1)), + new RecordingAudit(), + CLOCK, + Duration.ofMillis(1)); + } + + private static ProviderRuntime runtime(long generation) { + return new ProviderRuntime( + new ProviderProfileSnapshot( + PROFILE, + new ProviderId("apns"), + Channel.PUSH, + "PRODUCTION", + generation, + new ProviderCapabilities( + false, + false, + false, + false, + false, + false, + false, + true, + 1, + 4096L, + Duration.ofHours(1)), + Map.of("topic", "com.example.app")), + new StubAdapter(), + new ProviderAttemptLimiter(1, 100, CLOCK)); + } + + /** Adapter that is never actually invoked by these runtime tests. */ + private static final class StubAdapter implements NotificationProviderAdapter { + + @Override + public ProviderId providerId() { + return new ProviderId("apns"); + } + + @Override + public Set channels() { + return Set.of(Channel.PUSH); + } + + @Override + public ProviderCapabilities capabilities() { + return new ProviderCapabilities( + false, false, false, false, false, false, false, true, 1, 4096L, Duration.ofHours(1)); + } + + @Override + public CompletionStage submit(ProviderSubmission submission) { + return CompletableFuture.failedFuture(new UnsupportedOperationException()); + } + } + + /** Audit port that keeps what it was told. */ + private static final class RecordingAudit implements NotificationAuditPort { + + private final List events = new ArrayList<>(); + + @Override + public void record(NotificationAuditEvent event) { + events.add(event); + } + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRotationTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRotationTest.java new file mode 100644 index 00000000..6fe877ab --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRotationTest.java @@ -0,0 +1,96 @@ +package dev.caskeleton.adapter.outbound.notification.platform.dispatch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.notification.platform.security.CredentialGeneration; +import dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager; +import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures; +import dev.caskeleton.application.notification.platform.api.ProviderProfileId; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import org.junit.jupiter.api.Test; + +/** + * Credential rotation as a numbered, auditable generation change. + * + *

The runtime-swap and drain half of this behaviour is certified by {@code + * ProviderRuntimeRegistryTest}; what is certified here is the bookkeeping that decides which + * generation a rotation is allowed to become, and the two rotations this manager must refuse rather + * than perform badly. + */ +class ProviderRuntimeRotationTest { + + private static final ProviderProfileId PROFILE = new ProviderProfileId("ses-primary"); + private static final Clock CLOCK = + Clock.fixed(Instant.parse("2026-08-14T00:00:00Z"), ZoneOffset.UTC); + + @Test + void activationRecordsTheGenerationAndItsKeyIdButNeverTheMaterial() { + var manager = new ProviderCredentialManager(SecurityFixtures.keys(), CLOCK); + + var activated = manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1")); + + assertThat(activated.generation()).isEqualTo(1); + assertThat(activated.activatedAt()).contains(CLOCK.instant()); + assertThat(activated.auditForm()).isEqualTo("ses-primary#1/cred-1"); + // The audit form is the only string this type renders; it names the key, never its bytes. + assertThat(activated.auditForm()).doesNotContain("DDDD"); + } + + @Test + void aGenerationThatDoesNotSupersedeTheActiveOneIsRefused() { + var manager = new ProviderCredentialManager(SecurityFixtures.keys(), CLOCK); + manager.activate(CredentialGeneration.candidate(PROFILE, 2, "cred-1")); + + // Re-using or lowering the number would make an attempt record ambiguous about which credential + // signed it, which is the first question an incident asks. + assertThatThrownBy(() -> manager.activate(CredentialGeneration.candidate(PROFILE, 2, "cred-1"))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1"))) + .isInstanceOf(IllegalArgumentException.class); + assertThat(manager.current(PROFILE).orElseThrow().generation()).isEqualTo(2); + assertThat(manager.nextGenerationNumber(PROFILE)).isEqualTo(3); + } + + @Test + void anUnresolvableKeyIdFailsTheRotationRatherThanTheFirstNotification() { + var manager = new ProviderCredentialManager(SecurityFixtures.keys(), CLOCK); + + assertThatThrownBy(() -> manager.activate(CredentialGeneration.candidate(PROFILE, 1, "absent"))) + .isInstanceOf(IllegalStateException.class); + assertThat(manager.current(PROFILE)).isEmpty(); + } + + @Test + void aKeyIssuedForAnotherPurposeCannotBecomeAProviderCredential() { + var manager = new ProviderCredentialManager(SecurityFixtures.keys(), CLOCK); + + assertThatThrownBy(() -> manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cb-1"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void contactEncryptionAndVapidRotationAreRefusedWithTheMigrationTheyActuallyNeed() { + assertThatThrownBy( + () -> ProviderCredentialManager.rejectManagedRotation(SecretPurpose.CONTACT_ENCRYPTION)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("re-encryption"); + assertThatThrownBy( + () -> ProviderCredentialManager.rejectManagedRotation(SecretPurpose.VAPID_SIGNING)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("subscription migration"); + } + + @Test + void materialIsResolvedPerCallSoARotationIsNotOutlivedByACachedCredential() { + var manager = new ProviderCredentialManager(SecurityFixtures.keys(), CLOCK); + var first = manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1")); + + assertThat(manager.material(first).keyId()).isEqualTo("cred-1"); + assertThat(manager.material(first).purpose()).isEqualTo(SecretPurpose.PROVIDER_CREDENTIAL); + assertThat(manager.material(first).toString()).contains("redacted"); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsNotificationProviderAdapterTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsNotificationProviderAdapterTest.java new file mode 100644 index 00000000..54cc57c9 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsNotificationProviderAdapterTest.java @@ -0,0 +1,149 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.apns; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector; +import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderAdapterContract; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFaultHarness; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures; +import dev.caskeleton.application.notification.platform.api.delivery.EvidenceLevel; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.ProviderConfigurationException; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.contact.ApnsDeviceToken; +import dev.caskeleton.application.notification.platform.contact.ApnsEnvironment; +import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class ApnsNotificationProviderAdapterTest extends ProviderAdapterContract { + + private static final Clock CLOCK = + Clock.fixed(Instant.parse("2026-08-14T00:00:00Z"), ZoneOffset.UTC); + + private final ProviderFaultHarness harness = new ProviderFaultHarness(); + private final ContactPointProtector protector = + new AesGcmContactPointProtector(SecurityFixtures.keys()); + + @AfterEach + void stopHarness() { + harness.close(); + } + + private ApnsProviderProperties properties(ApnsEnvironment environment) { + return new ApnsProviderProperties( + harness.baseUri(), "com.example.app", environment, Set.of("alert"), Duration.ofSeconds(3)); + } + + private NotificationProviderAdapter adapter(ApnsEnvironment environment) { + return new ApnsNotificationProviderAdapter( + new JdkNotificationHttpGateway(Duration.ofSeconds(2)), + new ApnsRequestMapper(properties(environment), CLOCK), + new ApnsFailureClassifier(), + protector, + () -> "bearer test-token", + properties(environment)); + } + + @Override + protected NotificationProviderAdapter adapter() { + return adapter(ApnsEnvironment.PRODUCTION); + } + + @Override + protected ProviderFaultHarness harness() { + return harness; + } + + @Override + protected ProviderSubmission submission() { + return submission(ApnsEnvironment.PRODUCTION); + } + + private ProviderSubmission submission(ApnsEnvironment environment) { + return ProviderFixtures.submission( + ProviderFixtures.profile("apns-main", "apns", Channel.PUSH), + Channel.PUSH, + ProviderFixtures.push(), + protector, + new ApnsDeviceToken("device-token-value", environment), + Optional.of(CLOCK.instant().plus(Duration.ofHours(1)))); + } + + @Override + protected String successBody() { + return ""; + } + + @Test + void http200IsProviderAcceptedNotDelivered() { + harness.respondWith(200, "", Map.of("apns-id", "apns-request-1")); + + var result = adapter().submit(submission()).toCompletableFuture().join(); + + assertThat(result.providerRequestId()).contains("apns-request-1"); + assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED); + assertThat(result.deliveryOutcome()) + .isEqualTo( + dev.caskeleton.application.notification.platform.api.delivery.DeliveryOutcome.UNKNOWN); + } + + @Test + void sandboxTokenCannotUseProductionProfile() { + harness.respondWith(200, "", Map.of()); + + assertThatThrownBy( + () -> + adapter(ApnsEnvironment.PRODUCTION) + .submit(submission(ApnsEnvironment.SANDBOX)) + .toCompletableFuture() + .join()) + .isInstanceOf(ProviderConfigurationException.class); + } + + @Test + void unregisteredTokenIsAnInvalidRecipient() { + harness.respondWith(410, "{\"reason\":\"Unregistered\"}", Map.of()); + + var result = adapter().submit(submission()).toCompletableFuture().join(); + + assertThat(result.failure().orElseThrow().category()) + .isEqualTo(FailureCategory.INVALID_RECIPIENT); + assertThat(result.failure().orElseThrow().retryable()).isFalse(); + } + + @Test + void requiredHeadersAreSentAndCarryNoDeviceTokenInTheQuery() { + harness.respondWith(200, "", Map.of()); + + adapter().submit(submission()).toCompletableFuture().join(); + + var recorded = harness.received().get(0); + assertThat(recorded.header("apns-topic")).contains("com.example.app"); + assertThat(recorded.header("apns-push-type")).contains("alert"); + assertThat(recorded.header("apns-expiration")).isPresent(); + assertThat(recorded.uri().getQuery()).isNull(); + } + + @Test + void anExpiredProviderTokenBecomesAnAuthenticationFailureNotAMessageRetry() { + harness.respondWith(403, "{\"reason\":\"ExpiredProviderToken\"}", Map.of()); + + var result = adapter().submit(submission()).toCompletableFuture().join(); + + assertThat(result.failure().orElseThrow().category()).isEqualTo(FailureCategory.AUTHENTICATION); + assertThat(result.failure().orElseThrow().retryable()).isFalse(); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmBatchAdapterTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmBatchAdapterTest.java new file mode 100644 index 00000000..0b08c721 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmBatchAdapterTest.java @@ -0,0 +1,189 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector; +import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures; +import dev.caskeleton.application.notification.platform.api.delivery.AttemptConfirmation; +import dev.caskeleton.application.notification.platform.api.delivery.EvidenceLevel; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.contact.FcmInstallationId; +import dev.caskeleton.application.notification.platform.contact.LegacyFcmRegistrationToken; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import java.net.URI; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; + +class FcmBatchAdapterTest { + + private static final Clock CLOCK = + Clock.fixed(Instant.parse("2026-08-14T00:00:00Z"), ZoneOffset.UTC); + + private final ContactPointProtector protector = + new AesGcmContactPointProtector(SecurityFixtures.keys()); + private final FcmProviderProperties properties = + new FcmProviderProperties( + URI.create("https://fcm.example"), + "example-prod", + "mobile-main", + 500, + Duration.ofHours(4), + Duration.ofSeconds(3)); + + @Test + void mapsPartialBatchResultToEachRecipientAttempt() { + var gateway = gatewayWith(true, false, true, false, true); + var results = coordinator(gateway).submit(submissions(5)).toCompletableFuture().join(); + + assertThat(results).hasSize(5); + assertThat(results.get(0).confirmation()).isEqualTo(AttemptConfirmation.CONFIRMED); + assertThat(results.get(0).evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED); + assertThat(results.get(1).confirmation()).isEqualTo(AttemptConfirmation.REJECTED); + assertThat(results.get(2).confirmation()).isEqualTo(AttemptConfirmation.CONFIRMED); + assertThat(results.get(4).confirmation()).isEqualTo(AttemptConfirmation.CONFIRMED); + } + + @Test + void rejectsBatchAboveTheProviderMaximum() { + assertThatThrownBy( + () -> + coordinator(gatewayWith(true)) + .submit(submissions(501)) + .toCompletableFuture() + .join()) + .isInstanceOf(ProviderPayloadLimitException.class); + } + + @Test + void fidAndLegacyTokenUseDistinctWireTargetKinds() { + var mapper = new FcmTargetMapper(); + + assertThat(mapper.map(new FcmInstallationId("fid-1")).kind()).isEqualTo("FID"); + assertThat(mapper.map(new LegacyFcmRegistrationToken("token-1")).kind()) + .isEqualTo("LEGACY_TOKEN"); + } + + @Test + void fidAndLegacyTokenLandInDifferentRequestFields() { + var messageMapper = new FcmMessageMapper(properties, CLOCK); + var targetMapper = new FcmTargetMapper(); + + var fid = + messageMapper.map( + submission(new FcmInstallationId("fid-1")), + targetMapper.map(new FcmInstallationId("fid-1"))); + var legacy = + messageMapper.map( + submission(new LegacyFcmRegistrationToken("token-1")), + targetMapper.map(new LegacyFcmRegistrationToken("token-1"))); + + assertThat(message(fid)).containsKey("installation_id"); + assertThat(message(legacy)).containsKey("token"); + } + + @Test + void ttlIsCappedByTheDeliveryExpiry() { + var mapper = new FcmMessageMapper(properties, CLOCK); + var submission = + ProviderFixtures.submission( + ProviderFixtures.profile("fcm-main", "fcm", Channel.PUSH), + Channel.PUSH, + ProviderFixtures.push(), + protector, + new FcmInstallationId("fid-1"), + Optional.of(CLOCK.instant().plusSeconds(90))); + + assertThat(mapper.ttl(submission)).isEqualTo(Duration.ofSeconds(90)); + } + + @Test + void ttlFallsBackToTheProviderMaximumWhenNoExpiryIsSet() { + var mapper = new FcmMessageMapper(properties, CLOCK); + + assertThat(mapper.ttl(submission(new FcmInstallationId("fid-1")))) + .isEqualTo(properties.maxTtl()); + } + + @Test + void unregisteredIsInvalidRecipientAndNeverRetried() { + var classifier = new FcmFailureClassifier(); + + var failure = classifier.failure("UNREGISTERED"); + + assertThat(failure.category()).isEqualTo(FailureCategory.INVALID_RECIPIENT); + assertThat(failure.retryable()).isFalse(); + assertThat(classifier.invalidatesContactPoint("UNREGISTERED")).isTrue(); + assertThat(classifier.invalidatesContactPoint("UNAVAILABLE")).isFalse(); + } + + @Test + void quotaAndUnavailableAreRetryableButAuthIsNot() { + var classifier = new FcmFailureClassifier(); + + assertThat(classifier.failure("QUOTA_EXCEEDED").category()) + .isEqualTo(FailureCategory.THROTTLED); + assertThat(classifier.failure("UNAVAILABLE").retryable()).isTrue(); + assertThat(classifier.failure("THIRD_PARTY_AUTH_ERROR").category()) + .isEqualTo(FailureCategory.AUTHENTICATION); + assertThat(classifier.failure("THIRD_PARTY_AUTH_ERROR").retryable()).isFalse(); + } + + @SuppressWarnings("unchecked") + private static Map message(Map request) { + return (Map) request.get("message"); + } + + private FcmBatchCoordinator coordinator(FcmGateway gateway) { + return new FcmBatchCoordinator( + gateway, + new FcmMessageMapper(properties, CLOCK), + new FcmTargetMapper(), + new FcmFailureClassifier(), + protector, + properties); + } + + private static FcmGateway gatewayWith(boolean... successes) { + return messages -> { + List items = new ArrayList<>(messages.size()); + for (int index = 0; index < messages.size(); index++) { + boolean success = index < successes.length ? successes[index] : true; + items.add( + success + ? FcmBatchResult.Item.success("message-" + index) + : FcmBatchResult.Item.failure("UNREGISTERED")); + } + return new FcmBatchResult(items); + }; + } + + private List submissions(int count) { + return IntStream.range(0, count) + .mapToObj(index -> submission(new FcmInstallationId("fid-" + index))) + .toList(); + } + + private ProviderSubmission submission( + dev.caskeleton.application.notification.platform.contact.ContactPointValue target) { + return ProviderFixtures.submission( + ProviderFixtures.profile("fcm-main", "fcm", Channel.PUSH), + Channel.PUSH, + ProviderFixtures.push(), + protector, + target, + Optional.empty()); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesNotificationProviderAdapterTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesNotificationProviderAdapterTest.java new file mode 100644 index 00000000..2abb0e53 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesNotificationProviderAdapterTest.java @@ -0,0 +1,104 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.ses; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector; +import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderAdapterContract; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFaultHarness; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures; +import dev.caskeleton.application.notification.platform.api.delivery.EvidenceLevel; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.contact.EmailAddress; +import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class SesNotificationProviderAdapterTest extends ProviderAdapterContract { + + private static final Clock CLOCK = + Clock.fixed(Instant.parse("2026-08-14T00:00:00Z"), ZoneOffset.UTC); + + private final ProviderFaultHarness harness = new ProviderFaultHarness(); + private final ContactPointProtector protector = + new AesGcmContactPointProtector(SecurityFixtures.keys()); + + @AfterEach + void stopHarness() { + harness.close(); + } + + @Override + protected NotificationProviderAdapter adapter() { + var properties = + new SesProviderProperties( + harness.baseUri(), + "ap-northeast-2", + "transactional@example.com", + Optional.empty(), + Duration.ofSeconds(3)); + return new SesNotificationProviderAdapter( + new JdkNotificationHttpGateway(Duration.ofSeconds(2)), + new SesRequestMapper(properties, new AwsSignatureV4Signer()), + new SesFailureClassifier(), + protector, + SecurityFixtures.keys(), + "AKIAEXAMPLE", + CLOCK); + } + + @Override + protected ProviderFaultHarness harness() { + return harness; + } + + @Override + protected ProviderSubmission submission() { + return ProviderFixtures.submission( + ProviderFixtures.profile("ses-primary", "ses", Channel.EMAIL), + Channel.EMAIL, + ProviderFixtures.email(), + protector, + EmailAddress.parse(ProviderFixtures.SECRET_EMAIL), + Optional.of(CLOCK.instant().plus(Duration.ofHours(1)))); + } + + @Override + protected String successBody() { + return "{\"MessageId\":\"ses-message-1\"}"; + } + + @Test + void messageIdIsAcceptedEvidenceOnly() { + harness.respondWith(200, successBody(), Map.of()); + + var result = adapter().submit(submission()).toCompletableFuture().join(); + + assertThat(result.providerRequestId()).contains("ses-message-1"); + assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED); + assertThat(result.deliveryOutcome()) + .isEqualTo( + dev.caskeleton.application.notification.platform.api.delivery.DeliveryOutcome.UNKNOWN); + } + + @Test + void everyRequestIsSignedAndCarriesNoRawRecipientInTheUrl() { + harness.respondWith(200, successBody(), Map.of()); + + adapter().submit(submission()).toCompletableFuture().join(); + + var recorded = harness.received().get(0); + assertThat(recorded.header("Authorization").orElseThrow()).startsWith("AWS4-HMAC-SHA256"); + assertThat(recorded.header("X-Amz-Content-Sha256")).isPresent(); + assertThat(recorded.uri().toString()).doesNotContain(ProviderFixtures.SECRET_EMAIL); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpNotificationProviderAdapterTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpNotificationProviderAdapterTest.java new file mode 100644 index 00000000..c9d25a39 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpNotificationProviderAdapterTest.java @@ -0,0 +1,141 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector; +import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures; +import dev.caskeleton.application.notification.platform.api.delivery.AttemptConfirmation; +import dev.caskeleton.application.notification.platform.api.delivery.DeliveryOutcome; +import dev.caskeleton.application.notification.platform.api.delivery.EvidenceLevel; +import dev.caskeleton.application.notification.platform.api.delivery.SubmissionOutcome; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.contact.EmailAddress; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import jakarta.mail.Session; +import java.time.Duration; +import java.util.Optional; +import java.util.Properties; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.Test; + +class SmtpNotificationProviderAdapterTest { + + private final ContactPointProtector protector = + new AesGcmContactPointProtector(SecurityFixtures.keys()); + + @Test + void finalTwoFiftyMeansProviderAcceptedNotDelivered() { + var result = adapter(message -> {}).submit(submission()).toCompletableFuture().join(); + + assertThat(result.submissionOutcome()).isEqualTo(SubmissionOutcome.CONFIRMED_ACCEPTED); + assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED); + assertThat(result.deliveryOutcome()).isEqualTo(DeliveryOutcome.UNKNOWN); + } + + @Test + void connectionLossAfterDataIsAmbiguous() { + var result = + adapter( + message -> { + throw new SmtpDispatchException( + "CONNECTION_RESET_AFTER_DATA", Optional.empty(), true, null); + }) + .submit(submission()) + .toCompletableFuture() + .join(); + + assertThat(result.confirmation()).isEqualTo(AttemptConfirmation.AMBIGUOUS); + assertThat(result.submissionOutcome()).isEqualTo(SubmissionOutcome.AMBIGUOUS); + assertThat(result.evidenceLevel()).isNotEqualTo(EvidenceLevel.PROVIDER_ACCEPTED); + assertThat(result.executionEvidence().requestBodyCommitted().value()).isTrue(); + } + + @Test + void aFailureBeforeDataIsASafeRetry() { + var result = + adapter( + message -> { + throw new SmtpDispatchException("CONNECT_FAILED", Optional.empty(), false, null); + }) + .submit(submission()) + .toCompletableFuture() + .join(); + + assertThat(result.submissionOutcome()).isEqualTo(SubmissionOutcome.NOT_SUBMITTED); + assertThat(result.failure().orElseThrow().retryable()).isTrue(); + } + + @Test + void fourYankeeZuluIsTransientAndFiveIsPermanent() { + var transientResult = + adapter( + message -> { + throw new SmtpDispatchException("BUSY", Optional.of(451), false, null); + }) + .submit(submission()) + .toCompletableFuture() + .join(); + var permanentResult = + adapter( + message -> { + throw new SmtpDispatchException("REFUSED", Optional.of(554), false, null); + }) + .submit(submission()) + .toCompletableFuture() + .join(); + + assertThat(transientResult.failure().orElseThrow().category()) + .isEqualTo(FailureCategory.TRANSIENT_PROVIDER); + assertThat(permanentResult.failure().orElseThrow().category()) + .isEqualTo(FailureCategory.PERMANENT_PROVIDER); + } + + @Test + void aRejectedMailboxInvalidatesTheRecipientRatherThanRetrying() { + var result = + adapter( + message -> { + throw new SmtpDispatchException("NO_SUCH_USER", Optional.of(550), false, null); + }) + .submit(submission()) + .toCompletableFuture() + .join(); + + assertThat(result.failure().orElseThrow().category()) + .isEqualTo(FailureCategory.INVALID_RECIPIENT); + assertThat(result.failure().orElseThrow().retryable()).isFalse(); + } + + private SmtpNotificationProviderAdapter adapter(SmtpDispatch dispatch) { + var properties = + new SmtpProviderProperties( + "smtp.example.com", + 587, + SmtpProviderProperties.TlsMode.STARTTLS_REQUIRED, + "noreply@example.com", + Duration.ofSeconds(1), + Duration.ofSeconds(3), + Duration.ofSeconds(3), + 4); + return new SmtpNotificationProviderAdapter( + dispatch, + new SmtpMimeMessageFactory(Session.getInstance(new Properties())), + new SmtpFailureClassifier(), + protector, + properties, + Executors.newSingleThreadExecutor()); + } + + private ProviderSubmission submission() { + return ProviderFixtures.submission( + ProviderFixtures.profile("smtp-primary", "smtp", Channel.EMAIL), + Channel.EMAIL, + ProviderFixtures.email(), + protector, + EmailAddress.parse(ProviderFixtures.SECRET_EMAIL), + Optional.empty()); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackAndProjectionTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackAndProjectionTest.java new file mode 100644 index 00000000..9ea49083 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackAndProjectionTest.java @@ -0,0 +1,133 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures; +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 dev.caskeleton.application.notification.platform.callback.NormalizedEventType; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; +import org.junit.jupiter.api.Test; + +class TwilioCallbackAndProjectionTest { + + private static final String CALLBACK_URL = + "https://callback.example.com/internal/notification/callbacks/twilio/twilio-primary"; + + private final TwilioProviderProperties properties = + new TwilioProviderProperties( + java.net.URI.create("https://api.twilio.example"), + "AC123", + Optional.of("MG123"), + Optional.empty(), + CALLBACK_URL, + java.time.Duration.ofSeconds(3), + java.time.Duration.ofHours(12)); + + private final TwilioCallbackAdapter adapter = + new TwilioCallbackAdapter( + new TwilioSignatureValidator(), + new TwilioStatusNormalizer(), + properties, + SecurityFixtures.keys()); + + @Test + void aValidSignatureIsAcceptedAndNormalized() { + Map parameters = + new TreeMap<>(Map.of("MessageSid", "SM1", "MessageStatus", "delivered")); + var request = callback(parameters, signature(parameters)); + + var verification = adapter.verify(request); + assertThat(verification.valid()).isTrue(); + + var events = adapter.normalize(verification.verifiedCallback().orElseThrow()); + assertThat(events).hasSize(1); + assertThat(events.get(0).type()).isEqualTo(NormalizedEventType.DELIVERY_CONFIRMED); + assertThat(events.get(0).providerRequestId()).contains("SM1"); + } + + @Test + void invalidSignatureIsRejected() { + Map parameters = + new TreeMap<>(Map.of("MessageSid", "SM1", "MessageStatus", "delivered")); + + var verification = adapter.verify(callback(parameters, "not-the-signature")); + + assertThat(verification.valid()).isFalse(); + assertThat(verification.reasonCode()).isEqualTo("TWILIO_SIGNATURE_MISMATCH"); + assertThat(verification.verifiedCallback()).isEmpty(); + } + + @Test + void aTamperedParameterInvalidatesTheSignature() { + Map signed = + new TreeMap<>(Map.of("MessageSid", "SM1", "MessageStatus", "delivered")); + Map tampered = + new TreeMap<>(Map.of("MessageSid", "SM1", "MessageStatus", "failed")); + + assertThat(adapter.verify(callback(tampered, signature(signed))).valid()).isFalse(); + } + + @Test + void statusNamesMapToTheStableVocabulary() { + var normalizer = new TwilioStatusNormalizer(); + Optional at = Optional.of(Instant.parse("2026-08-14T00:00:00Z")); + + assertThat(normalizer.normalize(Map.of("MessageStatus", "queued"), at).type()) + .isEqualTo(NormalizedEventType.PROVIDER_ACCEPTED); + assertThat(normalizer.normalize(Map.of("MessageStatus", "sent"), at).type()) + .isEqualTo(NormalizedEventType.SENT); + assertThat(normalizer.normalize(Map.of("MessageStatus", "undelivered"), at).type()) + .isEqualTo(NormalizedEventType.UNDELIVERED); + assertThat(normalizer.normalize(Map.of("MessageStatus", "brand-new-status"), at).type()) + .isEqualTo(NormalizedEventType.UNKNOWN); + } + + private String signature(Map parameters) { + var validator = new TwilioSignatureValidator(); + byte[] token = SecurityFixtures.keys().activeKey(SecretPurpose.CALLBACK_SIGNING).material(); + // Recompute the value the validator expects, which is also what Twilio would send. + StringBuilder payload = new StringBuilder(CALLBACK_URL); + new TreeMap<>(parameters).forEach((key, value) -> payload.append(key).append(value)); + try { + var mac = javax.crypto.Mac.getInstance("HmacSHA1"); + mac.init(new javax.crypto.spec.SecretKeySpec(token, "HmacSHA1")); + String computed = + java.util.Base64.getEncoder() + .encodeToString(mac.doFinal(payload.toString().getBytes(StandardCharsets.UTF_8))); + assertThat(validator.isValid(CALLBACK_URL, parameters, computed, token)).isTrue(); + return computed; + } catch (java.security.GeneralSecurityException failure) { + throw new IllegalStateException(failure); + } + } + + private static CallbackRequest callback(Map parameters, String signature) { + String form = + parameters.entrySet().stream() + .map( + entry -> + URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8) + + "=" + + URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)) + .reduce((left, right) -> left + "&" + right) + .orElse(""); + return new CallbackRequest( + new ProviderId("twilio"), + new ProviderProfileId("twilio-primary"), + CALLBACK_URL, + "POST", + Optional.of("application/x-www-form-urlencoded"), + Map.of("x-twilio-signature", List.of(signature)), + form.getBytes(StandardCharsets.UTF_8), + Instant.parse("2026-08-14T00:00:00Z")); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackContractTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackContractTest.java new file mode 100644 index 00000000..9b70a603 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackContractTest.java @@ -0,0 +1,103 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio; + +import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.CallbackContract; +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 dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapter; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; + +/** Twilio against the shared callback contract. */ +class TwilioCallbackContractTest extends CallbackContract { + + private static final String CALLBACK_URL = + "https://callback.example.com/internal/notification/callbacks/twilio/twilio-primary"; + + private final TwilioProviderProperties properties = + new TwilioProviderProperties( + java.net.URI.create("https://api.twilio.example"), + "AC123", + Optional.of("MG123"), + Optional.empty(), + CALLBACK_URL, + Duration.ofSeconds(3), + Duration.ofHours(12)); + + private final TwilioCallbackAdapter adapter = + new TwilioCallbackAdapter( + new TwilioSignatureValidator(), + new TwilioStatusNormalizer(), + properties, + SecurityFixtures.keys()); + + @Override + protected ProviderCallbackAdapter adapter() { + return adapter; + } + + @Override + protected CallbackRequest signedRequest() { + Map parameters = delivered(); + return callback(parameters, signature(parameters)); + } + + @Override + protected CallbackRequest tamperedRequest() { + Map failed = + new TreeMap<>(Map.of("MessageSid", "SM1", "MessageStatus", "failed")); + return callback(failed, signature(delivered())); + } + + @Override + protected CallbackRequest unsignedRequest() { + return callback(delivered(), ""); + } + + private static Map delivered() { + return new TreeMap<>(Map.of("MessageSid", "SM1", "MessageStatus", "delivered")); + } + + private static String signature(Map parameters) { + byte[] token = SecurityFixtures.keys().activeKey(SecretPurpose.CALLBACK_SIGNING).material(); + StringBuilder payload = new StringBuilder(CALLBACK_URL); + new TreeMap<>(parameters).forEach((key, value) -> payload.append(key).append(value)); + try { + var mac = javax.crypto.Mac.getInstance("HmacSHA1"); + mac.init(new javax.crypto.spec.SecretKeySpec(token, "HmacSHA1")); + return java.util.Base64.getEncoder() + .encodeToString(mac.doFinal(payload.toString().getBytes(StandardCharsets.UTF_8))); + } catch (java.security.GeneralSecurityException failure) { + throw new IllegalStateException(failure); + } + } + + private static CallbackRequest callback(Map parameters, String signature) { + String form = + parameters.entrySet().stream() + .map( + entry -> + URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8) + + "=" + + URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)) + .reduce((left, right) -> left + "&" + right) + .orElse(""); + return new CallbackRequest( + new ProviderId("twilio"), + new ProviderProfileId("twilio-primary"), + CALLBACK_URL, + "POST", + Optional.of("application/x-www-form-urlencoded"), + Map.of("x-twilio-signature", List.of(signature)), + form.getBytes(StandardCharsets.UTF_8), + Instant.parse("2026-08-14T00:00:00Z")); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSmsProviderAdapterTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSmsProviderAdapterTest.java new file mode 100644 index 00000000..6cda768d --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSmsProviderAdapterTest.java @@ -0,0 +1,121 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector; +import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderAdapterContract; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFaultHarness; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures; +import dev.caskeleton.application.notification.platform.api.delivery.EvidenceLevel; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.contact.PhoneNumber; +import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import java.time.Duration; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class TwilioSmsProviderAdapterTest extends ProviderAdapterContract { + + private final ProviderFaultHarness harness = new ProviderFaultHarness(); + private final ContactPointProtector protector = + new AesGcmContactPointProtector(SecurityFixtures.keys()); + + @AfterEach + void stopHarness() { + harness.close(); + } + + private TwilioProviderProperties properties() { + return new TwilioProviderProperties( + harness.baseUri(), + "AC123", + Optional.of("MG123"), + Optional.empty(), + "https://callback.example.com/internal/notification/callbacks/twilio/twilio-primary", + Duration.ofSeconds(3), + Duration.ofHours(12)); + } + + @Override + protected NotificationProviderAdapter adapter() { + return new TwilioSmsProviderAdapter( + new JdkNotificationHttpGateway(Duration.ofSeconds(2)), + new TwilioRequestMapper(properties()), + new TwilioFailureClassifier(), + protector, + SecurityFixtures.keys()); + } + + @Override + protected ProviderFaultHarness harness() { + return harness; + } + + @Override + protected ProviderSubmission submission() { + return ProviderFixtures.submission( + ProviderFixtures.profile("twilio-primary", "twilio", Channel.SMS), + Channel.SMS, + ProviderFixtures.sms(), + protector, + new PhoneNumber(ProviderFixtures.SECRET_PHONE), + Optional.empty()); + } + + @Override + protected String successBody() { + return "{\"sid\":\"SM1\",\"status\":\"accepted\"}"; + } + + @Test + void acceptedStatusIsProviderAcceptedOnly() { + harness.respondWith(201, successBody(), Map.of()); + + var result = adapter().submit(submission()).toCompletableFuture().join(); + + assertThat(result.providerRequestId()).contains("SM1"); + assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED); + assertThat(result.deliveryOutcome()) + .isEqualTo( + dev.caskeleton.application.notification.platform.api.delivery.DeliveryOutcome.UNKNOWN); + assertThat(result.nativeStatus()).contains("accepted"); + } + + @Test + void provider429IsThrottled() { + harness.respondWith(429, "{\"code\":20429}", Map.of("retry-after", "10")); + + var result = adapter().submit(submission()).toCompletableFuture().join(); + + assertThat(result.failure().orElseThrow().category()).isEqualTo(FailureCategory.THROTTLED); + assertThat(result.failure().orElseThrow().retryAfter()).contains(Duration.ofSeconds(10)); + } + + @Test + void anInvalidDestinationInvalidatesTheContactPointInsteadOfRetrying() { + harness.respondWith(400, "{\"code\":21211}", Map.of()); + + var result = adapter().submit(submission()).toCompletableFuture().join(); + + assertThat(result.failure().orElseThrow().category()) + .isEqualTo(FailureCategory.INVALID_RECIPIENT); + assertThat(result.failure().orElseThrow().retryable()).isFalse(); + } + + @Test + void theStatusCallbackUrlComesFromTheProfileNotTheRequest() { + harness.respondWith(201, successBody(), Map.of()); + + adapter().submit(submission()).toCompletableFuture().join(); + + assertThat(harness.received().get(0).bodyAsString()) + .contains("StatusCallback=https%3A%2F%2Fcallback.example.com"); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookNotificationProviderAdapterTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookNotificationProviderAdapterTest.java new file mode 100644 index 00000000..5e28ddcb --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookNotificationProviderAdapterTest.java @@ -0,0 +1,112 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webhook; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector; +import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFaultHarness; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures; +import dev.caskeleton.application.notification.platform.api.delivery.AttemptConfirmation; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.contact.InAppRecipientRef; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class WebhookNotificationProviderAdapterTest { + + private static final Clock CLOCK = + Clock.fixed(Instant.parse("2026-08-14T00:00:00Z"), ZoneOffset.UTC); + + private final ProviderFaultHarness harness = new ProviderFaultHarness(); + private final ContactPointProtector protector = + new AesGcmContactPointProtector(SecurityFixtures.keys()); + + @AfterEach + void stopHarness() { + harness.close(); + } + + @Test + void dynamicTargetNeverInheritsTrustedCredentials() { + harness.respondWith(200, "{}", Map.of()); + + adapter(dynamicSubscription()).submit(submission()).toCompletableFuture().join(); + + var recorded = harness.received().get(0); + assertThat(recorded.header("Authorization")).isEmpty(); + assertThat(recorded.header("Cookie")).isEmpty(); + assertThat(recorded.header(WebhookSignatureStrategy.SIGNATURE_HEADER)).isEmpty(); + } + + @Test + void trustedTargetIsSignedWithATimestamp() { + harness.respondWith(200, "{}", Map.of()); + + adapter(trustedSubscription()).submit(submission()).toCompletableFuture().join(); + + var recorded = harness.received().get(0); + assertThat(recorded.header(WebhookSignatureStrategy.SIGNATURE_HEADER).orElseThrow()) + .startsWith("v1="); + assertThat(recorded.header(WebhookSignatureStrategy.TIMESTAMP_HEADER)).isPresent(); + } + + @Test + void sentWithNoResponseMapsToAmbiguous() { + harness.acceptBodyThenDropConnection(); + + var result = adapter(trustedSubscription()).submit(submission()).toCompletableFuture().join(); + + assertThat(result.confirmation()).isEqualTo(AttemptConfirmation.AMBIGUOUS); + assertThat(result.executionEvidence().requestBodyCommitted().value()).isTrue(); + } + + @Test + void aReceiverErrorBodyIsBoundedInTheDiagnostic() { + harness.respondWith(500, "x".repeat(5000), Map.of()); + + var result = adapter(trustedSubscription()).submit(submission()).toCompletableFuture().join(); + + assertThat(result.failure().orElseThrow().nativeCode().orElseThrow().length()).isLessThan(1000); + } + + private WebhookNotificationProviderAdapter adapter(WebhookSubscription subscription) { + var gateway = new JdkNotificationHttpGateway(Duration.ofSeconds(2)); + return new WebhookNotificationProviderAdapter( + gateway, + gateway, + new WebhookSignatureStrategy(), + SecurityFixtures.keys(), + submission -> subscription, + Duration.ofSeconds(3), + CLOCK); + } + + private WebhookSubscription trustedSubscription() { + return new WebhookSubscription( + "sub-1", harness.baseUri().resolve("/hook"), true, Optional.of("callback-sign")); + } + + private WebhookSubscription dynamicSubscription() { + return new WebhookSubscription( + "sub-2", harness.baseUri().resolve("/hook"), false, Optional.empty()); + } + + private ProviderSubmission submission() { + return ProviderFixtures.submission( + ProviderFixtures.profile("webhook-main", "webhook", Channel.WEBHOOK), + Channel.WEBHOOK, + ProviderFixtures.webPush(), + protector, + new InAppRecipientRef("user-1"), + Optional.empty()); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushCryptoTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushCryptoTest.java new file mode 100644 index 00000000..5531598c --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushCryptoTest.java @@ -0,0 +1,273 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.notification.platform.contact.WebPushSubscriptionValue; +import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.SecureRandom; +import java.security.interfaces.ECPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import javax.crypto.Cipher; +import javax.crypto.KeyAgreement; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import org.junit.jupiter.api.Test; + +/** + * RFC 8291 encryption and RFC 8292 VAPID, verified by actually decrypting. + * + *

The subscriber side is reconstructed here from a real P-256 key pair rather than asserted + * against a recorded fixture. A recorded ciphertext would still match after a change that broke + * interoperability, because both sides of the comparison would be this code; a round trip through + * the user-agent half of the protocol will not. + */ +class WebPushCryptoTest { + + private static final Clock CLOCK = + Clock.fixed(Instant.parse("2026-08-14T00:00:00Z"), ZoneOffset.UTC); + private static final URI ENDPOINT = URI.create("https://push.example.com/send/abc123"); + // One instance: a fresh SecureRandom per call re-seeds from the OS each time, which is slower + // and, on a constrained CI runner, can block on entropy. + private static final SecureRandom RANDOM = new SecureRandom(); + + @Test + void payloadIsEncryptedForTheSubscriptionAndDecryptsBackToThePlaintext() throws Exception { + KeyPair userAgent = p256(); + byte[] authSecret = authSecret(); + var subscription = subscription(userAgent, authSecret); + byte[] plaintext = "hello".getBytes(StandardCharsets.UTF_8); + + var encrypted = new Rfc8291Aes128GcmEncryptor().encrypt(subscription, plaintext); + + assertThat(encrypted.contentEncoding()).isEqualTo("aes128gcm"); + assertThat(indexOf(encrypted.body(), plaintext)).isEqualTo(-1); + assertThat(decrypt(encrypted.body(), userAgent, authSecret)).isEqualTo(plaintext); + } + + @Test + void everyMessageUsesAFreshEphemeralKeySoTwoSendsNeverShareAKeyStream() { + KeyPair userAgent = p256(); + byte[] authSecret = authSecret(); + var subscription = subscription(userAgent, authSecret); + var encryptor = new Rfc8291Aes128GcmEncryptor(); + byte[] plaintext = "same message".getBytes(StandardCharsets.UTF_8); + + byte[] first = encryptor.encrypt(subscription, plaintext).body(); + byte[] second = encryptor.encrypt(subscription, plaintext).body(); + + assertThat(first).isNotEqualTo(second); + // Salt is the first 16 bytes; a repeated salt would mean a repeated content encryption key. + assertThat(Arrays.copyOf(first, 16)).isNotEqualTo(Arrays.copyOf(second, 16)); + } + + @Test + void aPayloadLargerThanTheRecordIsRefusedBeforeAnyProviderCall() { + var subscription = subscription(p256(), authSecret()); + + assertThatThrownBy(() -> new Rfc8291Aes128GcmEncryptor().encrypt(subscription, new byte[4096])) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void vapidAudienceIsTheEndpointOriginAndNotAConfiguredConstant() { + String jwt = + new VapidJwtSigner(CLOCK, "mailto:ops@example.com") + .sign(ENDPOINT, vapidKey(), Duration.ofHours(1)); + + String claims = new String(decodeSegment(jwt, 1), StandardCharsets.UTF_8); + assertThat(claims).contains("\"aud\":\"https://push.example.com\""); + assertThat(claims).contains("\"sub\":\"mailto:ops@example.com\""); + assertThat(claims) + .contains("\"exp\":" + CLOCK.instant().plus(Duration.ofHours(1)).getEpochSecond()); + // ES256 in JOSE is fixed-width r || s, never the JVM's variable-length DER encoding. + assertThat(decodeSegment(jwt, 2)).hasSize(64); + } + + @Test + void aTokenLifetimeBeyondTwelveHoursIsRefused() { + var signer = new VapidJwtSigner(CLOCK, "mailto:ops@example.com"); + + assertThatThrownBy(() -> signer.sign(ENDPOINT, vapidKey(), Duration.ofHours(13))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void aSubjectThatIsNotAMailtoOrHttpsUriIsRefused() { + assertThatThrownBy(() -> new VapidJwtSigner(CLOCK, "ops@example.com")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void authorizationHeaderCarriesBothTheTokenAndTheApplicationServerKey() { + String header = + new VapidJwtSigner(CLOCK, "mailto:ops@example.com") + .authorization(ENDPOINT, vapidKey(), "BPublicKey"); + + assertThat(header).startsWith("vapid t="); + assertThat(header).contains(", k=BPublicKey"); + } + + @Test + void aSubscriptionKeepsSigningWithItsOwnKeyIdAndIsReportedAsNeedingMigration() { + var registry = + new VapidKeyRegistry(vapidKeys(), "vapid-2", Map.of("vapid-1", "BOld", "vapid-2", "BNew")); + var subscription = subscription(p256(), authSecret(), "vapid-1"); + + assertThat(registry.signingKeyFor(subscription).keyId()).isEqualTo("vapid-1"); + assertThat(registry.publicKeyFor(subscription)).isEqualTo("BOld"); + assertThat(registry.requiresSubscriptionMigration(subscription)).isTrue(); + assertThat(registry.activeKeyId()).isEqualTo("vapid-2"); + } + + @Test + void aSubscriptionOnTheActiveKeyNeedsNoMigration() { + var registry = new VapidKeyRegistry(vapidKeys(), "vapid-1", Map.of("vapid-1", "BOld")); + + assertThat( + registry.requiresSubscriptionMigration(subscription(p256(), authSecret(), "vapid-1"))) + .isFalse(); + } + + @Test + void receiptsAreOnlyRequestedWhenTheProfileDeclaresThemAndTheServiceAdvertisesOne() { + var supported = + WebPushReceiptCapability.forProfile( + new WebPushProviderProperties( + "BKey", Duration.ofHours(1), 4096L, true, Duration.ofSeconds(5))); + var unsupported = WebPushReceiptCapability.unsupported(); + List links = + List.of("; rel=\"" + WebPushReceiptCapability.RECEIPT_LINK_RELATION + "\""); + + assertThat(supported.preferHeader()).contains("respond-async"); + assertThat(supported.receiptSubscription(links)).contains(URI.create("/receipts/9")); + assertThat(supported.receiptSubscription(List.of("; rel=\"urn:ietf:params:push\""))) + .isEmpty(); + assertThat(unsupported.preferHeader()).isEmpty(); + assertThat(unsupported.receiptSubscription(links)).isEmpty(); + } + + private static WebPushSubscriptionValue subscription(KeyPair userAgent, byte[] authSecret) { + return subscription(userAgent, authSecret, "vapid-1"); + } + + private static WebPushSubscriptionValue subscription( + KeyPair userAgent, byte[] authSecret, String vapidKeyId) { + return new WebPushSubscriptionValue( + ENDPOINT, + Rfc8291Aes128GcmEncryptor.encodePoint((ECPublicKey) userAgent.getPublic()), + authSecret, + vapidKeyId); + } + + /** + * The user-agent half of RFC 8291 §3.4, so the assertion is interoperability, not self-agreement. + */ + private static byte[] decrypt(byte[] body, KeyPair userAgent, byte[] authSecret) + throws Exception { + byte[] salt = Arrays.copyOf(body, 16); + int keyIdLength = body[20] & 0xFF; + byte[] applicationServerPublic = Arrays.copyOfRange(body, 21, 21 + keyIdLength); + byte[] ciphertext = Arrays.copyOfRange(body, 21 + keyIdLength, body.length); + + KeyAgreement agreement = KeyAgreement.getInstance("ECDH"); + agreement.init(userAgent.getPrivate()); + agreement.doPhase(Rfc8291Aes128GcmEncryptor.decodePoint(applicationServerPublic), true); + byte[] sharedSecret = agreement.generateSecret(); + + byte[] userAgentPublic = + Rfc8291Aes128GcmEncryptor.encodePoint((ECPublicKey) userAgent.getPublic()); + byte[] info = new byte[14 + 65 + 65]; + byte[] label = "WebPush: info\0".getBytes(StandardCharsets.US_ASCII); + System.arraycopy(label, 0, info, 0, label.length); + System.arraycopy(userAgentPublic, 0, info, label.length, 65); + System.arraycopy(applicationServerPublic, 0, info, label.length + 65, 65); + + byte[] ikm = Rfc8291Aes128GcmEncryptor.hkdf(authSecret, sharedSecret, info, 32); + byte[] key = + Rfc8291Aes128GcmEncryptor.hkdf( + salt, ikm, "Content-Encoding: aes128gcm\0".getBytes(StandardCharsets.US_ASCII), 16); + byte[] nonce = + Rfc8291Aes128GcmEncryptor.hkdf( + salt, ikm, "Content-Encoding: nonce\0".getBytes(StandardCharsets.US_ASCII), 12); + + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init( + Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, nonce)); + byte[] padded = cipher.doFinal(ciphertext); + return Arrays.copyOf(padded, padded.length - 1); + } + + private static KeyPair p256() { + try { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new ECGenParameterSpec("secp256r1")); + return generator.generateKeyPair(); + } catch (java.security.GeneralSecurityException failure) { + throw new IllegalStateException(failure); + } + } + + private static byte[] authSecret() { + byte[] secret = new byte[16]; + RANDOM.nextBytes(secret); + return secret; + } + + private static SecretKeyMaterial vapidKey() { + return new SecretKeyMaterial( + "vapid-1", SecretPurpose.VAPID_SIGNING, p256().getPrivate().getEncoded()); + } + + private static SecretMaterialProvider vapidKeys() { + SecretKeyMaterial first = + new SecretKeyMaterial( + "vapid-1", SecretPurpose.VAPID_SIGNING, p256().getPrivate().getEncoded()); + SecretKeyMaterial second = + new SecretKeyMaterial( + "vapid-2", SecretPurpose.VAPID_SIGNING, p256().getPrivate().getEncoded()); + return new SecretMaterialProvider() { + + @Override + public SecretKeyMaterial activeKey(SecretPurpose purpose) { + return second; + } + + @Override + public SecretKeyMaterial keyById(String keyId) { + return "vapid-1".equals(keyId) ? first : second; + } + }; + } + + private static byte[] decodeSegment(String jwt, int index) { + return Base64.getUrlDecoder().decode(jwt.split("\\.", -1)[index]); + } + + private static int indexOf(byte[] haystack, byte[] needle) { + outer: + for (int start = 0; start + needle.length <= haystack.length; start++) { + for (int offset = 0; offset < needle.length; offset++) { + if (haystack[start + offset] != needle[offset]) { + continue outer; + } + } + return start; + } + return -1; + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushProviderAdapterTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushProviderAdapterTest.java new file mode 100644 index 00000000..2e47fe18 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushProviderAdapterTest.java @@ -0,0 +1,180 @@ +package dev.caskeleton.adapter.outbound.notification.platform.provider.webpush; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway; +import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector; +import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFaultHarness; +import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures; +import dev.caskeleton.application.notification.platform.api.delivery.EvidenceLevel; +import dev.caskeleton.application.notification.platform.api.error.FailureCategory; +import dev.caskeleton.application.notification.platform.api.error.ProviderConfigurationException; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.contact.WebPushSubscriptionValue; +import dev.caskeleton.application.notification.platform.provider.ProviderSubmission; +import dev.caskeleton.application.notification.platform.security.ContactPointProtector; +import java.net.URI; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.SecureRandom; +import java.security.interfaces.ECPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class WebPushProviderAdapterTest { + + private static final Clock CLOCK = + Clock.fixed(Instant.parse("2026-08-14T00:00:00Z"), ZoneOffset.UTC); + + // One instance: a fresh SecureRandom per call re-seeds from the OS each time, which is slower + // and, on a constrained CI runner, can block on entropy. + private static final SecureRandom RANDOM = new SecureRandom(); + + private final ProviderFaultHarness harness = new ProviderFaultHarness(); + private final ContactPointProtector protector = + new AesGcmContactPointProtector(SecurityFixtures.keys()); + + @AfterEach + void stopHarness() { + harness.close(); + } + + @Test + void ttlHeaderIsRequiredAndAcceptanceIsNotDelivery() { + harness.respondWith(201, "", Map.of("location", "https://push.example/receipt/1")); + + var result = + adapter() + .submit(submission(Optional.of(CLOCK.instant().plusSeconds(60)))) + .toCompletableFuture() + .join(); + + assertThat(harness.received().get(0).header("ttl")).contains("60"); + assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED); + assertThat(result.deliveryOutcome()) + .isEqualTo( + dev.caskeleton.application.notification.platform.api.delivery.DeliveryOutcome.UNKNOWN); + } + + @Test + void expiredSubscriptionIsInvalidated() { + harness.respondWith(404, "", Map.of()); + + var result = + adapter() + .submit(submission(Optional.of(CLOCK.instant().plusSeconds(60)))) + .toCompletableFuture() + .join(); + + assertThat(result.failure().orElseThrow().category()) + .isEqualTo(FailureCategory.INVALID_RECIPIENT); + } + + @Test + void providerSpecific410IsAlsoAnInvalidation() { + harness.respondWith(410, "", Map.of()); + + var result = + adapter() + .submit(submission(Optional.of(CLOCK.instant().plusSeconds(60)))) + .toCompletableFuture() + .join(); + + assertThat(result.failure().orElseThrow().category()) + .isEqualTo(FailureCategory.INVALID_RECIPIENT); + } + + @Test + void missingExpiryCannotCreateAWebPushAttempt() { + harness.respondWith(201, "", Map.of()); + + assertThatThrownBy( + () -> adapter().submit(submission(Optional.empty())).toCompletableFuture().join()) + .isInstanceOf(ProviderConfigurationException.class); + } + + @Test + void thePayloadIsEncryptedAndCarriesTheRfcContentEncoding() { + harness.respondWith(201, "", Map.of()); + + adapter() + .submit(submission(Optional.of(CLOCK.instant().plusSeconds(60)))) + .toCompletableFuture() + .join(); + + var recorded = harness.received().get(0); + assertThat(recorded.header("content-encoding")).contains("aes128gcm"); + assertThat(recorded.header("authorization").orElseThrow()).startsWith("vapid t="); + assertThat(recorded.bodyAsString()).doesNotContain("Contract title"); + } + + @Test + void payloadEncryptionRoundTripsThroughTheSubscriptionKeys() { + var encryptor = new Rfc8291Aes128GcmEncryptor(RANDOM); + var subscription = subscription(); + + var encrypted = + encryptor.encrypt(subscription, "hello".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + + assertThat(encrypted.contentEncoding()).isEqualTo("aes128gcm"); + assertThat(new String(encrypted.body(), java.nio.charset.StandardCharsets.ISO_8859_1)) + .doesNotContain("hello"); + // salt(16) + rs(4) + idlen(1) + key(65) is the RFC 8188 record header. + assertThat(encrypted.body().length).isGreaterThan(16 + 4 + 1 + 65); + } + + private WebPushNotificationProviderAdapter adapter() { + var properties = + new WebPushProviderProperties( + "BFakePublicKeyForTests", Duration.ofHours(1), 4096L, false, Duration.ofSeconds(3)); + return new WebPushNotificationProviderAdapter( + new JdkNotificationHttpGateway(Duration.ofSeconds(2)), + new WebPushRequestMapper( + new Rfc8291Aes128GcmEncryptor(RANDOM), + // Signing needs a PKCS#8 EC key, which VapidJwtSignerTest covers; this keeps the + // transport test about TTL, encryption and status mapping. + (endpoint, signingKey, publicKey) -> "vapid t=stub-token, k=" + publicKey, + SecurityFixtures.keys(), + properties, + CLOCK), + new WebPushFailureClassifier(), + protector, + properties); + } + + private ProviderSubmission submission(Optional expiresAt) { + return ProviderFixtures.submission( + ProviderFixtures.profile("webpush-main", "webpush", Channel.WEB_PUSH), + Channel.WEB_PUSH, + ProviderFixtures.webPush(), + protector, + subscription(), + expiresAt); + } + + private WebPushSubscriptionValue subscription() { + try { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new ECGenParameterSpec("secp256r1")); + KeyPair pair = generator.generateKeyPair(); + byte[] authSecret = new byte[16]; + RANDOM.nextBytes(authSecret); + return new WebPushSubscriptionValue( + URI.create(harness.baseUri() + "/push/subscription-1"), + Rfc8291Aes128GcmEncryptor.encodePoint((ECPublicKey) pair.getPublic()), + authSecret, + "vapid-key-1"); + } catch (java.security.GeneralSecurityException failure) { + throw new IllegalStateException(failure); + } + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmContactPointProtectorTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmContactPointProtectorTest.java new file mode 100644 index 00000000..ca195f7f --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmContactPointProtectorTest.java @@ -0,0 +1,89 @@ +package dev.caskeleton.adapter.outbound.notification.platform.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.notification.platform.contact.ApnsDeviceToken; +import dev.caskeleton.application.notification.platform.contact.ApnsEnvironment; +import dev.caskeleton.application.notification.platform.contact.EmailAddress; +import dev.caskeleton.application.notification.platform.contact.PhoneNumber; +import dev.caskeleton.application.notification.platform.security.AccessContext; +import org.junit.jupiter.api.Test; + +class AesGcmContactPointProtectorTest { + + private final AesGcmContactPointProtector protector = + new AesGcmContactPointProtector(SecurityFixtures.keys()); + + @Test + void encryptsRoundTripAndProducesStableLookupFingerprint() { + var value = EmailAddress.parse("user@example.com"); + + var first = protector.protect(value); + var second = protector.protect(value); + + assertThat(first.ciphertext()).isNotEqualTo(second.ciphertext()); + assertThat(first.lookupHmac()).isEqualTo(second.lookupHmac()); + assertThat(protector.reveal(first, AccessContext.dispatch("ses-primary"))).isEqualTo(value); + } + + @Test + void encryptionAndHmacKeysMustDiffer() { + var sharedKeyProtector = + new AesGcmContactPointProtector(SecurityFixtures.keysWithSameMaterial()); + assertThatThrownBy(() -> sharedKeyProtector.protect(EmailAddress.parse("user@example.com"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void encryptionKeyMustBe256Bits() { + var weakProtector = new AesGcmContactPointProtector(SecurityFixtures.shortEncryptionKey()); + assertThatThrownBy(() -> weakProtector.protect(EmailAddress.parse("user@example.com"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("256"); + } + + @Test + void differentContactKindsWithTheSameTextGetDifferentFingerprints() { + var phone = protector.fingerprint(new PhoneNumber("+821012345678")); + var apns = + protector.fingerprint(new ApnsDeviceToken("+821012345678", ApnsEnvironment.PRODUCTION)); + assertThat(phone).isNotEqualTo(apns); + } + + @Test + void aCiphertextCannotBeReplayedUnderAnotherContactKind() { + var protectedEmail = protector.protect(EmailAddress.parse("user@example.com")); + var moved = + new dev.caskeleton.application.notification.platform.security.ProtectedContactPoint( + dev.caskeleton.application.notification.platform.contact.ContactPointType.PHONE, + protectedEmail.keyId(), + protectedEmail.nonce(), + protectedEmail.ciphertext(), + protectedEmail.lookupHmac()); + + assertThatThrownBy(() -> protector.reveal(moved, AccessContext.dispatch("ses-primary"))) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void protectedValuesNeverPrintTheirContents() { + var protectedEmail = protector.protect(EmailAddress.parse("user@example.com")); + assertThat(protectedEmail.toString()).doesNotContain("user@example.com").contains("redacted"); + } + + @Test + void anUnknownKeyIdIsRefusedInsteadOfSilentlyFallingBack() { + var protectedEmail = protector.protect(EmailAddress.parse("user@example.com")); + var rotated = + new dev.caskeleton.application.notification.platform.security.ProtectedContactPoint( + protectedEmail.type(), + "retired-key", + protectedEmail.nonce(), + protectedEmail.ciphertext(), + protectedEmail.lookupHmac()); + + assertThatThrownBy(() -> protector.reveal(rotated, AccessContext.dispatch("ses-primary"))) + .isInstanceOf(IllegalStateException.class); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/SecurityFixtures.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/SecurityFixtures.java new file mode 100644 index 00000000..4a89ca09 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/security/SecurityFixtures.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.outbound.notification.platform.security; + +import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial; +import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider; +import dev.caskeleton.application.notification.platform.security.SecretPurpose; +import java.util.Map; + +/** Deterministic key material shared by the protection and provider contract tests. */ +public final class SecurityFixtures { + + private SecurityFixtures() {} + + public static SecretMaterialProvider keys() { + return new SettingsSecretMaterialProvider( + Map.of( + SecretPurpose.CONTACT_ENCRYPTION, + new SecretKeyMaterial( + "enc-1", SecretPurpose.CONTACT_ENCRYPTION, filled((byte) 0x11, 32)), + SecretPurpose.CONTACT_LOOKUP_HMAC, + new SecretKeyMaterial( + "mac-1", SecretPurpose.CONTACT_LOOKUP_HMAC, filled((byte) 0x22, 32)), + SecretPurpose.CALLBACK_SIGNING, + new SecretKeyMaterial("cb-1", SecretPurpose.CALLBACK_SIGNING, filled((byte) 0x33, 32)), + SecretPurpose.PROVIDER_CREDENTIAL, + new SecretKeyMaterial( + "cred-1", SecretPurpose.PROVIDER_CREDENTIAL, filled((byte) 0x44, 32)), + SecretPurpose.PAYLOAD_ENCRYPTION, + new SecretKeyMaterial( + "payload-1", SecretPurpose.PAYLOAD_ENCRYPTION, filled((byte) 0x55, 32)), + SecretPurpose.VAPID_SIGNING, + new SecretKeyMaterial("vapid-1", SecretPurpose.VAPID_SIGNING, filled((byte) 0x66, 32))), + Map.of()); + } + + public static SecretMaterialProvider keysWithSameMaterial() { + return new SettingsSecretMaterialProvider( + Map.of( + SecretPurpose.CONTACT_ENCRYPTION, + new SecretKeyMaterial( + "enc-1", SecretPurpose.CONTACT_ENCRYPTION, filled((byte) 0x11, 32)), + SecretPurpose.CONTACT_LOOKUP_HMAC, + new SecretKeyMaterial( + "mac-1", SecretPurpose.CONTACT_LOOKUP_HMAC, filled((byte) 0x11, 32))), + Map.of()); + } + + public static SecretMaterialProvider shortEncryptionKey() { + return new SettingsSecretMaterialProvider( + Map.of( + SecretPurpose.CONTACT_ENCRYPTION, + new SecretKeyMaterial( + "enc-1", SecretPurpose.CONTACT_ENCRYPTION, filled((byte) 0x11, 16)), + SecretPurpose.CONTACT_LOOKUP_HMAC, + new SecretKeyMaterial( + "mac-1", SecretPurpose.CONTACT_LOOKUP_HMAC, filled((byte) 0x22, 32))), + Map.of()); + } + + private static byte[] filled(byte value, int length) { + byte[] material = new byte[length]; + java.util.Arrays.fill(material, value); + return material; + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/template/CanonicalNotificationRendererTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/template/CanonicalNotificationRendererTest.java new file mode 100644 index 00000000..6ba89f7f --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/template/CanonicalNotificationRendererTest.java @@ -0,0 +1,148 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.notification.platform.api.TemplateSelection; +import dev.caskeleton.application.notification.platform.api.content.EmailContent; +import dev.caskeleton.application.notification.platform.api.error.TemplateVariableValidationException; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.template.NotificationTemplateVersion; +import dev.caskeleton.application.notification.platform.template.RenderCommand; +import dev.caskeleton.application.notification.platform.template.TemplateContentDefinition; +import dev.caskeleton.application.notification.platform.template.TemplateRegistry; +import dev.caskeleton.application.notification.platform.template.TemplateSlot; +import dev.caskeleton.application.notification.platform.template.TemplateStatus; +import dev.caskeleton.application.notification.platform.template.VariableSchema; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class CanonicalNotificationRendererTest { + + private static final String REQUIRED_CODE_SCHEMA = + "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"," + + "\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\"," + + "\"minLength\":6}},\"required\":[\"code\"]}"; + + @Test + void rejectsMissingRequiredVariableBeforeProviderCall() { + var renderer = + renderer(new VariableSchema(REQUIRED_CODE_SCHEMA, Set.of("code"), Set.of("code"))); + + assertThatThrownBy(() -> renderer.render(command(Map.of()))) + .isInstanceOf(TemplateVariableValidationException.class); + } + + @Test + void rejectsAVariableThatViolatesTheSchema() { + var renderer = renderer(new VariableSchema(REQUIRED_CODE_SCHEMA, Set.of("code"), Set.of())); + + assertThatThrownBy(() -> renderer.render(command(Map.of("code", "123")))) + .isInstanceOf(TemplateVariableValidationException.class); + } + + @Test + void validationFailuresNeverEchoTheSecretVariable() { + var renderer = + renderer(new VariableSchema(REQUIRED_CODE_SCHEMA, Set.of("code"), Set.of("code"))); + + assertThatThrownBy(() -> renderer.render(command(Map.of("code", "12345")))) + .hasMessageNotContaining("12345"); + } + + @Test + void sameVersionAndVariablesProduceSameDigest() { + var renderer = renderer(VariableSchema.NONE); + var command = command(Map.of("code", "654321")); + + assertThat(renderer.render(command).contentDigest()) + .isEqualTo(renderer.render(command).contentDigest()); + } + + @Test + void differentVariablesProduceDifferentDigests() { + var renderer = renderer(VariableSchema.NONE); + + assertThat(renderer.render(command(Map.of("code", "111111"))).contentDigest()) + .isNotEqualTo(renderer.render(command(Map.of("code", "222222"))).contentDigest()); + } + + @Test + void rendersTheChannelContentType() { + var rendered = renderer(VariableSchema.NONE).render(command(Map.of("code", "654321"))); + + assertThat(rendered.content()).isInstanceOf(EmailContent.class); + assertThat(((EmailContent) rendered.content()).textBody()).contains("654321"); + assertThat(rendered.resolvedLocale()).isEqualTo(Locale.KOREAN); + } + + @Test + void anUnresolvedPlaceholderFailsInsteadOfRenderingABlank() { + var renderer = renderer(VariableSchema.NONE); + + assertThatThrownBy(() -> renderer.render(command(Map.of()))) + .isInstanceOf( + dev.caskeleton.application.notification.platform.api.error.TemplateRenderingException + .class); + } + + private static CanonicalNotificationRenderer renderer(VariableSchema schema) { + return new CanonicalNotificationRenderer( + Channel.EMAIL, + new FixedTemplateRegistry(schema), + new JsonSchemaVariableValidator(), + new PlaceholderTemplateEngine()); + } + + private static RenderCommand command(Map variables) { + return new RenderCommand( + new TemplateSelection("password-reset", 1, Locale.KOREAN), + Channel.EMAIL, + Locale.KOREAN, + variables, + Optional.empty()); + } + + /** Registry returning one pinned version. */ + private record FixedTemplateRegistry(VariableSchema schema) implements TemplateRegistry { + + @Override + public NotificationTemplateVersion get(TemplateSelection selection) { + return version(); + } + + @Override + public NotificationTemplateVersion resolve( + String templateId, long version, Channel channel, Locale requestedLocale) { + return version(); + } + + @Override + public void publish(NotificationTemplateVersion version) { + throw new UnsupportedOperationException(); + } + + @Override + public void disable(String templateId, long version) { + throw new UnsupportedOperationException(); + } + + private NotificationTemplateVersion version() { + return new NotificationTemplateVersion( + "password-reset", + 1, + Channel.EMAIL, + Locale.KOREAN, + Optional.empty(), + schema, + new TemplateContentDefinition( + Map.of( + TemplateSlot.SUBJECT, "비밀번호 재설정", TemplateSlot.TEXT_BODY, "인증번호는 {code} 입니다.")), + TemplateStatus.PUBLISHED, + "a".repeat(64)); + } + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/template/ThymeleafNotificationRendererTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/template/ThymeleafNotificationRendererTest.java new file mode 100644 index 00000000..de4160b1 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/platform/template/ThymeleafNotificationRendererTest.java @@ -0,0 +1,179 @@ +package dev.caskeleton.adapter.outbound.notification.platform.template; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.notification.platform.api.TemplateSelection; +import dev.caskeleton.application.notification.platform.api.content.EmailContent; +import dev.caskeleton.application.notification.platform.api.error.TemplateRenderingException; +import dev.caskeleton.application.notification.platform.api.error.TemplateVariableValidationException; +import dev.caskeleton.application.notification.platform.api.routing.Channel; +import dev.caskeleton.application.notification.platform.template.NotificationTemplateVersion; +import dev.caskeleton.application.notification.platform.template.RenderCommand; +import dev.caskeleton.application.notification.platform.template.TemplateContentDefinition; +import dev.caskeleton.application.notification.platform.template.TemplateRegistry; +import dev.caskeleton.application.notification.platform.template.TemplateSlot; +import dev.caskeleton.application.notification.platform.template.TemplateStatus; +import dev.caskeleton.application.notification.platform.template.VariableSchema; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.thymeleaf.templatemode.TemplateMode; + +/** + * The Thymeleaf reference renderer. + * + *

The renderer contract — validate before rendering, exact version selection, stable digest — is + * shared with the placeholder engine and asserted again here, because the whole point of the + * composition is that swapping the engine does not change it. + * + *

The escaping test is the reason this engine exists at all. + */ +class ThymeleafNotificationRendererTest { + + private static final String REQUIRED_CODE_SCHEMA = + "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"," + + "\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\"," + + "\"minLength\":6}},\"required\":[\"code\"]}"; + + @Test + void rejectsMissingRequiredVariableBeforeProviderCall() { + var renderer = + renderer(new VariableSchema(REQUIRED_CODE_SCHEMA, Set.of("code"), Set.of("code"))); + + assertThatThrownBy(() -> renderer.render(command(Map.of()))) + .isInstanceOf(TemplateVariableValidationException.class); + } + + @Test + void sameVersionAndVariablesProduceSameDigest() { + var renderer = renderer(VariableSchema.NONE); + var command = command(Map.of("code", "654321")); + + assertThat(renderer.render(command).contentDigest()) + .isEqualTo(renderer.render(command).contentDigest()); + } + + @Test + void differentVariablesProduceDifferentDigests() { + var renderer = renderer(VariableSchema.NONE); + + assertThat(renderer.render(command(Map.of("code", "111111"))).contentDigest()) + .isNotEqualTo(renderer.render(command(Map.of("code", "222222"))).contentDigest()); + } + + @Test + void rendersTheChannelContentAtTheExactSelectedVersion() { + var rendered = renderer(VariableSchema.NONE).render(command(Map.of("code", "654321"))); + + assertThat(rendered.content()).isInstanceOf(EmailContent.class); + assertThat(((EmailContent) rendered.content()).textBody()).contains("654321"); + assertThat(rendered.templateSelection().version()).isEqualTo(1); + assertThat(rendered.resolvedLocale()).isEqualTo(Locale.KOREAN); + } + + @Test + void markupInAVariableIsEscapedRatherThanInjectedIntoTheBody() { + var rendered = + renderer(VariableSchema.NONE).render(command(Map.of("code", ""))); + + // This is what Thymeleaf buys over plain substitution: a notification variable is application + // input, and an HTML email body is a rendering context an injected tag executes in. + String body = ((EmailContent) rendered.content()).textBody(); + assertThat(body).doesNotContain("