Compare commits

...
17 Commits
Author SHA1 Message Date
DongHyeonka 2f5d2fc219 feat: jpa, messaging, notification, mongo, graphql 어댑터터 구현체 추가 2026-08-15 13:01:58 +09:00
DongHyeonkaandClaude Opus 5 ac874e49e6 fix(notification): close the two pre-existing gates that broke check
`./gradlew check` failed on main before this change. Both failures came from the
notification platform and neither was reachable from the graphql merge.

- `application-core:checkstyleMain` — ProviderSubmissionResult switches over
  AttemptConfirmation and covers all three constants, so the switch is exhaustive,
  but checkstyle's MissingSwitchDefault does not model exhaustive arrow switches.
  Add the repository's existing idiom (`default -> throw new IllegalStateException`),
  the same shape SwitchNotificationWriterOwnershipCommand and DefaultCleanupService
  already use. The branch stays unreachable; it exists to satisfy the linter and to
  fail loudly if the enum ever grows a constant.

- `adapter:inbound:web:spotbugsMain` — SPRING_CSRF_PROTECTION_DISABLED x2 on
  CallbackMvcSecurityConfiguration. Provider callbacks are inbound webhooks: an
  external provider POSTs to /internal/notification/callbacks/**, so it can never
  carry a CSRF token, and the chain is SESSION-STATELESS with no ambient cookie auth
  for CSRF to protect. Authenticity comes from the provider signature the callback
  package verifies (failure -> CallbackValidationException -> 400), not from
  permitAll(). Register it in the spotbugs exclude filter scoped to that exact class,
  matching the existing narrow-exception style; every other CSRF disable stays
  reportable.

Verified on the merged tree by replaying the CI jobs locally:
- quality-gates: `check verifyPublicPathSnapshot verifyDependencyLocks
  --warning-mode=fail --no-daemon` -> BUILD SUCCESSFUL (13m47s)
- sample-off, redis-sdk, jpa-candidate-evidence, gate-matrix-lint,
  conditionalTransportQualification (graphql 8 / grpc 15 / websocket 5 /
  composition 1, zero skips)
- notification-platform architecture gate (CleanArchitectureTest +
  NotificationArchitectureTest)
- runtime: `:app-bootstrap:bootRun` against the compose-local database started and
  served GET /api/healthcheck 200; actuator/health and /api/worklogs answered 401,
  so the authentication boundary is live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 17:39:39 +09:00
DongHyeonkaandClaude Opus 5 c3043e530a docs(graphql): 43-leaf 레지스트리 사실에 맞게 sub-package 근거를 정정
main 통합으로 messaging 플랫폼이 24개 leaf 를 modules.json 에 등록한 것이 드러났다
(현재 총 43개 leaf). 따라서 "레지스트리는 정확히 19개로 고정되어 있고 확장하면 게이트가
깨진다"는 기존 서술은 사실과 다르다.

- 레지스트리는 확장 가능하며, 자매 플랫폼 messaging 은 정반대 패턴(leaf 등록)을 택했다.
- graphql 의 sub-package 매핑은 "레지스트리가 닫혀서"가 아니라 "GraphQL 표면은 하나의
  인바운드 전송 경계이고 그 내부 분할을 레포 전역 SSOT 까지 올리지 않는다"는 선택으로
  다시 서술한다.
- 두 패턴이 공존하므로 통일 여부는 미결 아키텍처 결정으로 명시한다. 모듈 레코드는 그대로
  leaf 명세로 승격 가능한 형태라 분해 비용은 낮게 유지된다.

루트 CLAUDE.md 도 여전히 "exactly 19 leaf identities" 라고 적혀 있으나 이는 messaging 머지에서
비롯된 선행 불일치이므로 이 커밋 범위 밖으로 두고 보고한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 16:20:07 +09:00
DongHyeonka 5c3c0e3de9 Merge branch 'main' into worktree-graphql-platform 2026-08-14 15:41:45 +09:00
DongHyeonkaandClaude Opus 5 b074c1494e feat(graphql): GraphQL API 실행 플랫폼 구현 (Stable 48 + Advanced 19 Task)
설계 문서(specs/2026-08-12-graphql-api-execution-platform-design.md)와 두 실행 계획서에
선언된 create path 전량을 adapter-inbound-graphql leaf 안에 구현한다.

- 계획서 main 클래스 322개 전량, Task별 테스트 클래스 67개(Stable 48 + Advanced 19) 전량.
- 설계서의 Stable 16 + Advanced 12 "Gradle 모듈"은 modules.json 이 19개 leaf 정체성을
  소유하므로 bounded sub-package 로 매핑한다(선례: httpclient leaf). 모듈 경계는 문서가
  아니라 GraphQlStableModule/GraphQlAdvancedModule 값 선언 + GraphQlModuleBoundaryTest 의
  실제 소스 스캔으로 기계 검증한다.
- architecture/ 규칙은 리플렉션 + 단순명 매칭으로 구현한다. 인바운드 어댑터가 자신이
  금지하는 jakarta.persistence/spring-tx 에 의존해야 검사할 수 있다면 본말전도이기 때문.
- 부분 실패는 HTTP 200 + partial data, 요청 실패는 4xx. GraphQL over HTTP 초안 status 294 는
  의도적으로 미채택(초안 변경이 클라이언트를 깨뜨리므로).
- 요청 단위 DB 트랜잭션을 열지 않는다. 커서는 HMAC 서명된 버전 있는 keyset(상수 시간 비교).
- DataLoader 는 요청 스코프, 캐시 키는 actor/tenant sha256 지문으로 격리.
- Advanced capability 는 전부 기본 비활성. EXPERIMENTAL 등급은 명시 승인 없이 production
  활성화가 거부된다.
- spring-webflux 는 compileOnly(runtimeClasspath 제외) — MVC 배치가 WebFlux 런타임을
  물려받지 않도록. lockfile 이 스코프 제한을 고정.
- graphqlPerformanceTest 는 성능 태그가 0개면 실패한다. failOnNoDiscoveredTests 는 태그
  필터로 0건이 된 경우를 잡지 못해(Gradle 9.0.0 실측) 결과 검사를 추가했다. 증거 부재를
  통과로 위장하지 않기 위한 fail-closed.

검증: graphqlStableTest 404 / graphqlContractTest 9 / graphqlAdvancedTest 141 tests,
:adapter:inbound:graphql:check, verifyCleanArchitectureDependencies,
CleanArchitectureTest, verifyConfigurationPropertiesProcessor, verifyEnvKeys,
verifyPublicPathSnapshot 전부 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 15:41:38 +09:00
DongHyeonka 71c0d2122f Merge branch 'main' into worktree-messaging-platform
# Conflicts:
#	src/config/spotbugs/exclude.xml
2026-08-14 15:15:37 +09:00
DongHyeonka d646c2f12f feat(messaging): 브로커 중립 메시징 플랫폼 24개 leaf 추가
messaging-superpowers-package 설계서/계획서 기반 구현.
registry를 19 → 43 leaf로 확장하고 src/messaging 아래 24개 leaf를 등록.

- core-api: M1 publish/consume + M2 batch·delayed·pause-resume
- policy/transport-spi: 재시도 결정, DLQ orchestration, admission control, lifecycle
- kafka·rabbit(Stable): contiguous commit, confirm/return 상관, 배치, 보안 설정
- pulsar·nats(Experimental): 기본 비활성, live 인증 없음을 코드로 기록
- outbox/inbox/claim-check: 트랜잭션 결합, lease, 무결성 검증
- admin: plan → approve → execute를 타입으로 강제
- 문서 9종, infra compose 7종, JMH 벤치마크 3종

검증: 아키텍처 게이트 3종 통과, 24개 leaf 전부 check 통과,
messaging 테스트 604개 통과/0 실패.

미완: 계획서가 요구한 실 브로커 IT 40개 중 7개만 작성.
Rabbit 13 / Outbox 6 / Inbox 4 / NATS·Pulsar·Share 5 / testkit 2 /
starter·admin 3, 그리고 TLS·ACL 2개가 남음.
2026-08-14 14:55:38 +09:00
DongHyeonka 539e3eb58b Merge branch 'main' into worktree-jpa-persistence-platform 2026-08-14 14:24:57 +09:00
DongHyeonkaandClaude Opus 5 59a392ee96 build: exclude the deliberate System.gc in the notification gauge test
`spotbugsTest` and `spotbugsSampleOffTest` on :app-bootstrap fail on DM_GC in
NotificationObservationTest, which predates this branch — the file arrives from
701ba67 and this branch never touched it. The merge only surfaces it.

The call is intentional and cannot be removed without removing the assertion:
Micrometer holds gauge referents weakly, so a gauge whose source object is
collected reports NaN from then on, and provoking a collection is the only way
to show the notification metrics do not have that defect.

Scoped to the one method by class and method name, per the filter's own rule
that entries be narrow — a System.gc() anywhere else stays reportable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 14:23:14 +09:00
DongHyeonkaandClaude Opus 5 c1ee1d9dd9 fix(notification): close the static-analysis findings on the merged tree
Checkstyle and SpotBugs run in the module's `check` task, not in `test`, so
these only surfaced once the platform was verified against the merged tree.

- MissingSwitchDefault on the admin runtime-state switch. The enum is
  exhaustive, so the default is unreachable today; it throws rather than
  falling through, so a state added later fails loudly instead of silently
  leaving the runtime in whatever state it already had.
- ConstantName on the two audit loggers: the checkstyle pattern allows
  `log`, `logger` or UPPER_SNAKE, and this class needs two named sinks.
- DMI_RANDOM_USED_ONLY_ONCE in three Web Push fixtures. A fresh SecureRandom
  per call re-seeds from the OS every time, which on a constrained CI runner
  can block on entropy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 14:14:06 +09:00
DongHyeonka ae85f23dd3 Merge branch 'main' into worktree-jpa-persistence-platform 2026-08-14 14:06:21 +09:00
DongHyeonkaandClaude Opus 5 0e61f86eb5 feat(jpa): implement the JPA relational persistence platform
Implements the Stable and Experimental JPA persistence platform designs
against real PostgreSQL, adapted to this repository's fail-closed 19-leaf
registry.

The design models the platform as 25 Gradle projects. `src/settings.gradle`
throws unless the registry holds exactly 19 leaves, so the plan's modules
become packages inside `:adapter:outbound:persistence-jpa` (starter in
`:app-bootstrap`, testkit in its own source set). The full mapping, the
renames this repository's naming gate required, and every deliberate
substitution are recorded in `docs/jpa/repository-adaptation.md`.

Seven Docker-backed lanes replace the plan's seven JVM test suites. Each
fails closed: a lane that discovers nothing, or a container that cannot
start, is an error rather than a skip.

Three defects the contracts found against a real server:

- `CommitFailureClassifier` treated only SQLSTATE 40003, class 08, and
  transport breaks as completion-unknown. A backend terminated mid-commit
  reports 57P01, and the commit record may already be in the WAL — so a
  possibly-committed transaction could be re-run. 57P01/57P02/57P03 now
  classify as completion-unknown.
- `SchemaTenantMigrationOrchestrator` recorded `MigrateResult`'s target
  version, which is empty for a tenant already current, reporting migrated
  tenants as unmigrated during a partial rollout. It now reads the applied
  version back from the tenant's schema history.
- `JpaStreamExecutor` checked only the declared return type for reactive
  publishers, and `RegisteredPostgreSqlCopyLoader` passed the COPY timeout
  to `SET`, which is parsed before parameter binding.

`JpaModuleBoundaryTest` enforces the plan's module map as package rules;
`verifyCleanArchitectureDependencies` governs edges between leaves and
cannot see these. Its first assertion is that the import is non-empty,
because every rule under it is a `noClasses()` rule and would pass
vacuously on an empty import.

Verified: 128 container tests across all seven lanes, 1183 unit tests,
`:adapter:outbound:persistence-jpa:check`, `:app-bootstrap:check`,
`verifyCleanArchitectureDependencies`, `verifyOneTypePerFile`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 14:06:18 +09:00
DongHyeonka 92744c57de merge: integrate the notification delivery platform 2026-08-14 14:00:07 +09:00
DongHyeonkaandClaude Opus 5 701ba67456 feat(notification): implement the notification delivery platform
Maps the 31-module plan onto the registry's 19 leaves as packages; the two
edges the registry forbids (provider->httpclient, inbox->messaging) are
replaced by application-owned ports. See docs/notification/module-mapping.md.

Acceptance is not delivery: ProviderSubmissionResult refuses to carry a
delivery outcome, and AMBIGUOUS is a first-class terminal state that blocks
automatic retry and fallback until reconciliation resolves it.

Providers: SES (SigV4 + SNS callback), Twilio (X-Twilio-Signature +
reconciliation), FCM (FID-primary batch), APNs, Web Push (RFC 8030/8291/8292),
SMTP and webhook. Contact points are AES-256-GCM encrypted with a separate
HMAC lookup fingerprint; nothing raw reaches a log, metric tag or exception.

Dispatch commits the attempt row, calls the provider with no transaction open,
then records the outcome; the durable queue uses FOR UPDATE SKIP LOCKED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:57:27 +09:00
DongHyeonkaandClaude Opus 5 99a51e5a16 merge: integrate the MongoDB document persistence platform
Brings in the mongodb-superpowers-package implementation (Stable Tasks 1-50,
Advanced Tasks 1-15) as packages inside the registered leaf
:adapter:outbound:persistence-mongo, with the design's module dependency table
enforced by ArchUnit.

Shared build files are untouched by this branch: src/build.gradle,
src/settings.gradle, config/architecture/modules.json and
app-bootstrap/build.gradle are all unchanged, so this merge does not move the
19-leaf registry and does not collide with the other platform branches still in
flight.

Verified before merging: scripts/verify-mongodb-platform.sh reports 9 lanes,
0 skipped, 0 failed, every evidence category produced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:46:19 +09:00
DongHyeonkaandClaude Opus 5 d57d2f62a0 feat(mongodb): implement the MongoDB document persistence platform
Implements the mongodb-superpowers-package design: Stable Tasks 1-50 and
Advanced Tasks 1-15.

The design assumes 19 Stable + 12 Advanced Gradle projects under
modules/mongodb*. This repository's fail-closed registry declares exactly 19
leaf identities, so those modules become package boundaries inside the
registered leaf :adapter:outbound:persistence-mongo, with the design's module
dependency table enforced by ten ArchUnit rules. The mapping and every
deviation are recorded in docs/mongodb/repository-adaptation.md.

Contract highlights, all enforced by tests rather than convention:

- Transaction body retry and commit retry are separate loops. A new session per
  body attempt; commit-only retry on an unknown commit. The body is never
  replayed after a commit ambiguity, so a failover cannot become a duplicate.
- MongoExecutionOutcome keeps both ambiguous outcomes distinct from success and
  failure, and MongoFailureContext records only the design-permitted fields.
- Failure classification reads server error labels before numeric codes.
- BSON representations come from a pinned manifest, never a library default,
  and a golden type-signature gate fails on any drift.
- Index and validator changes go through the manifest and the admin plane;
  metadata ownership gates every drop.
- Every Advanced capability refuses construction unless its flag is enabled.

Verified against real servers, not only unit tests. Running the lanes for the
first time exposed four defects that a green `check` had hidden:

- Four release lanes passed while executing zero tests; the gate now counts
  executed tests per lane and fails on zero.
- The "single replica set" fixture was a standalone, because Testcontainers 2.x
  needs withReplicaSet(); its test only asserted a connection string.
- The three-node fixture was three independent clusters, so no election could
  occur, and awaitNewPrimary() compared against the post-stop primary.
- The migration lease checked modifiedCount, so a same-millisecond refresh read
  as a lost lease.

scripts/verify-mongodb-platform.sh now reports:
  9 lanes, 0 skipped, 0 failed, every evidence category produced.

scripts/verify-mongodb-advanced.sh reports NOT PROMOTABLE: actual-topology
evidence (real sharded cluster, real KMS, real target deployment) is
unobtainable here, so it is named rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:41:00 +09:00
DongHyeonka 3b5aee50e3 feat: 설계 문서 추가 2026-08-13 21:09:52 +09:00
2671 changed files with 265846 additions and 754 deletions
+7
View File
@@ -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'
'3be84c9f15fa3b2ac5a085f8d725ec6d05e7007ae0b433da9e79b3bf340d57ea .github/workflows/jpa-next-hibernate8.yml'
'a2b74bfb3af12d6d03cd2ea8a5e48490dd131afb89b79694d498c5798387ac53 .github/workflows/jpa-next-jpa4.yml'
'cd955ef4af895df477896dad9577810f010b2beea8570b09b008f9e94e928bd0 .github/workflows/jpa-next-postgresql19.yml'
'b56b548a867b74eaeccb42e7df4f4e52cf7ce657ab27f91e2c8d7ea9944d64af .github/workflows/jpa-nightly.yml'
'04851f44ba94533bfbc8fabe2b3a2b408726a9996e86ed3864986d1499d16b50 .github/workflows/jpa-pr.yml'
'59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml'
'4748f2ba0a0b77dc1a858ebcfa7db6e41627d97843df5f0aa978bc2facccaad2 .github/workflows/jpa-release.yml'
'5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml'
'4e4ccfa267ecd63b9369803d49f2dbdb2fa899517ad4cf23ab11d29104557a91 .github/workflows/notification-platform.yml'
'64245586cd5936f1a5647b57f2cd9acd316f96fd75f713b1890decb812e7d5fe .github/workflows/object-storage-qualification.yml'
'cbc104ea486c746229895e804e3be7716e056a02cce0588c537bce9f442f8b38 .github/workflows/redis-sdk-topology.yml'
)
+64
View File
@@ -0,0 +1,64 @@
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
- name: Record what this lane did and did not execute
if: always()
run: |
mkdir -p compatibility-evidence
{
echo "target=Hibernate 8"
echo "target-coordinate=org.hibernate.orm:hibernate-core:8.x"
echo "status=NOT_EXECUTABLE"
echo "reason=Hibernate 8 is not resolvable from this build, so nothing has been compiled or run against it"
echo "what-ran=the current runtime's own policy and lane-definition tests"
echo "sha=${{ github.sha }}"
} > compatibility-evidence/status.properties
echo "::notice::Hibernate 8 compatibility is NOT_EXECUTABLE: Hibernate 8 is not resolvable from this build, so nothing has been compiled or run against it"
- name: Upload the compatibility status
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: compatibility-status-hibernate-8
path: compatibility-evidence/status.properties
retention-days: 30
if-no-files-found: error
+64
View File
@@ -0,0 +1,64 @@
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
- name: Record what this lane did and did not execute
if: always()
run: |
mkdir -p compatibility-evidence
{
echo "target=Jakarta Persistence 4"
echo "target-coordinate=jakarta.persistence:jakarta.persistence-api:4.x"
echo "status=NOT_EXECUTABLE"
echo "reason=the JPA 4 API is not on any configuration this build resolves, so nothing has been compiled against it"
echo "what-ran=the current runtime's own policy and lane-definition tests"
echo "sha=${{ github.sha }}"
} > compatibility-evidence/status.properties
echo "::notice::Jakarta Persistence 4 compatibility is NOT_EXECUTABLE: the JPA 4 API is not on any configuration this build resolves, so nothing has been compiled against it"
- name: Upload the compatibility status
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: compatibility-status-jakarta-persistence-4
path: compatibility-evidence/status.properties
retention-days: 30
if-no-files-found: error
@@ -0,0 +1,70 @@
name: jpa-next-postgresql19
# PostgreSQL 19 compatibility lane (experimental plan Task 9).
#
# This lane is NOT_EXECUTABLE against its target.
#
# It runs the current runtime's policy and lane-definition tests; it does not resolve the target
# dependency or start a container of the target version. A green run therefore says "the target is
# absent from this build", which is not the same claim as "we are compatible with the target" — and
# the workflow's name reads as the second one. The status artifact says which it is.
#
# Promotion needs evidence, not availability. Two supported patch runs with no unresolved semantic
# regression, plus a reviewed ADR, before the Stable support matrix changes — which is what
# ExperimentalPromotionGate encodes.
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
- name: Record what this lane did and did not execute
if: always()
run: |
mkdir -p compatibility-evidence
{
echo "target=PostgreSQL 19"
echo "target-coordinate=postgres:19-alpine"
echo "status=NOT_EXECUTABLE"
echo "reason=no PostgreSQL 19 image is published yet, so no container of that major has ever been started by this lane"
echo "what-ran=the current runtime's own policy and lane-definition tests"
echo "sha=${{ github.sha }}"
} > compatibility-evidence/status.properties
echo "::notice::PostgreSQL 19 compatibility is NOT_EXECUTABLE: no PostgreSQL 19 image is published yet, so no container of that major has ever been started by this lane"
- name: Upload the compatibility status
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: compatibility-status-postgresql-19
path: compatibility-evidence/status.properties
retention-days: 30
if-no-files-found: error
+130
View File
@@ -0,0 +1,130 @@
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:jpaPlatformPoolContractTest
--no-daemon
--stacktrace
+114
View File
@@ -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
+145
View File
@@ -0,0 +1,145 @@
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:
# One job per PostgreSQL major, because one job for three majors was one job for one major.
#
# `-Pjpa.matrix.versions=16,17,18` reached JpaPlatformContractSupport.start(), which started
# selectedVersions().get(0) — so twenty-eight integration classes ran against PG16 and nothing
# ran against 17 or 18, while docs/jpa/support-matrix.md recorded all three as "full contract
# suite, release lane". A JSONB mapping, a Hibernate dialect difference or a Flyway upgrade that
# only breaks on 18 shipped with a green release.
#
# start() now fails closed on a multi-version selection, so the fan-out is not optional: the
# matrix is the only way the three majors get covered, and removing a major from it removes the
# evidence rather than quietly reusing another major's.
jpa-release-gate:
runs-on: ubuntu-latest
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
postgresql: ["16", "17", "18"]
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
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 on PostgreSQL ${{ matrix.postgresql }}
working-directory: src
run: >-
./gradlew
jpaReleaseGate
-Pjpa.matrix.versions=${{ matrix.postgresql }}
--no-daemon
--stacktrace
- name: Record which major this evidence covers
if: always()
working-directory: src
run: |
mkdir -p build/jpa-release-evidence
{
echo "sha=${{ github.sha }}"
echo "ref=${{ github.ref }}"
echo "postgresql-major=${{ matrix.postgresql }}"
echo "task=jpaReleaseGate"
} > "build/jpa-release-evidence/manifest-${{ matrix.postgresql }}.properties"
- name: Upload the release evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: jpa-release-evidence-pg${{ matrix.postgresql }}
path: |
src/build/jpa-release-evidence/manifest-${{ matrix.postgresql }}.properties
src/adapter/outbound/persistence-jpa/build/test-results/**/*.xml
retention-days: 30
if-no-files-found: error
# The promotion decision. Three majors' evidence, and all three must come from this SHA — an
# aggregate that accepted a re-run artifact from another commit would promote a release on
# evidence produced by different code.
jpa-release-promotion:
runs-on: ubuntu-latest
timeout-minutes: 15
needs: jpa-release-gate
steps:
- name: Download every major's evidence
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # actions/download-artifact@v4.3.0
with:
pattern: jpa-release-evidence-pg*
path: evidence
- name: Require all three majors, all from this SHA
run: |
set -euo pipefail
missing=0
for major in 16 17 18; do
manifest=$(find evidence -name "manifest-${major}.properties" -print -quit)
if [ -z "${manifest}" ]; then
echo "::error::no release evidence for PostgreSQL ${major}"
missing=1
continue
fi
sha=$(sed -n 's/^sha=//p' "${manifest}")
if [ "${sha}" != "${{ github.sha }}" ]; then
echo "::error::PostgreSQL ${major} evidence is from ${sha}, not ${{ github.sha }}"
missing=1
fi
done
if [ "${missing}" -ne 0 ]; then
echo "::error::the release gate covers three PostgreSQL majors; promotion needs all three"
exit 1
fi
echo "PostgreSQL 16, 17 and 18 evidence all present and all from ${{ github.sha }}."
jpa-architecture-and-docs:
runs-on: ubuntu-latest
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
+202
View File
@@ -0,0 +1,202 @@
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:
# The filter used to stop at the four notification source trees, so a change to the
# composition root, the settings binding, the schema migrations, or the evidence manifest
# ran none of this — and those are exactly the surfaces that decide whether the platform
# assembles, binds and migrates at all.
- 'src/application-core/src/**/notification/**'
- 'src/adapter/outbound/notification/**'
- 'src/adapter/outbound/persistence-jpa/src/**/notification/**'
- 'src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/notification-platform/**'
- 'src/adapter/inbound/web/src/**/notification/**'
- 'src/app-bootstrap/src/**/notification/**'
- 'src/app-bootstrap/src/main/resources/application*.yml'
- 'src/gradle/notification-*.gradle'
- 'src/config/architecture/modules.json'
- 'src/.env'
- 'docs/notification/**'
- 'infra/notification/**'
- '.github/workflows/notification-platform.yml'
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
# The PR tier never touched a database, so every claim about migrations, claim atomicity and
# lease fencing rested on a fake. Docker is available on this runner; the lane fails closed
# when the container cannot start, because a skipped contract reports success for a database
# nobody tested.
- name: Notification schema and claim contracts (real PostgreSQL)
working-directory: src
run: ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformContractTest --console=plain
- name: Notification migration upgrade (real PostgreSQL)
working-directory: src
run: ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest --console=plain
- name: Architecture gates
working-directory: src
run: |
./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
./gradlew verifyNotificationApiSurface verifyNotificationConfiguration --console=plain
# A support grade is a promise about production behaviour. This refuses one the pipeline
# cannot back — the check that would have caught five channels reading "Stable" while no
# request had ever left the process.
- name: Evidence manifest
working-directory: src
run: ./gradlew verifyNotificationEvidence --console=plain
- name: Static analysis
working-directory: src
run: ./gradlew :adapter:outbound:notification:check -x test --console=plain
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
# This job is named for ambiguity, restart recovery and callback burst. It used to run a
# unit-test filter and then `test` — neither of which restarts anything or bursts anything —
# so the job name was the only place those three properties existed.
- name: Ambiguity and fault harness
working-directory: src
run: ./gradlew :adapter:outbound:notification:test --tests '*ChaosSecurity*' --tests '*CrossProviderContractSuite*' --console=plain
- name: Concurrency and rotation races
working-directory: src
run: ./gradlew :adapter:outbound:notification:test --tests '*ConcurrencyTest' --tests '*ProviderRuntimeStateTest' --console=plain
- name: Restart recovery and lease fencing (real PostgreSQL)
working-directory: src
run: ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformContractTest :adapter:outbound:persistence-jpa:jpaPlatformFailureTest --console=plain
- name: Full suite
working-directory: src
run: ./gradlew test --console=plain
# A filter that matches nothing passes. Each --tests filter above names a class that exists
# today; if one is renamed the job must fail rather than quietly stop covering it.
- name: Every named suite actually ran
working-directory: src
run: |
set -euo pipefail
for suite in ChaosSecurity CrossProviderContractSuite ConcurrencyTest ProviderRuntimeStateTest; do
if ! find . -path '*/build/test-results/*' -name "*${suite}*.xml" | grep -q .; then
echo "no test results for ${suite}: the filter matched nothing and the job passed vacuously" >&2
exit 1
fi
done
provider-sandbox:
name: provider sandbox smoke (secret-protected, non-blocking)
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 30
environment: notification-provider-sandbox
# Not a required check: an external outage must not block a merge. But not continue-on-error
# either — a job that cannot fail produces no evidence, and this job's entire previous body was
# two echo statements, which is what let five channels be graded Stable on nothing.
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Refuse to report a pass with no credentials
env:
NOTIFICATION_SANDBOX_CREDENTIALS: ${{ secrets.NOTIFICATION_SANDBOX_CREDENTIALS }}
run: |
set -euo pipefail
if [ -z "${NOTIFICATION_SANDBOX_CREDENTIALS:-}" ]; then
echo "provider sandbox credentials are not configured for this environment." >&2
echo "The job stops here rather than reporting a green run that called nothing." >&2
exit 1
fi
- name: Smoke test against real provider sandboxes
working-directory: src
env:
NOTIFICATION_SANDBOX_ENABLED: 'true'
NOTIFICATION_SANDBOX_CREDENTIALS: ${{ secrets.NOTIFICATION_SANDBOX_CREDENTIALS }}
run: ./gradlew :adapter:outbound:notification:test --tests '*ProviderSandbox*' --console=plain
- name: Upload the wire evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: notification-provider-sandbox-evidence
path: src/adapter/outbound/notification/build/test-results/test/
if-no-files-found: error
retention-days: 90
+1
View File
@@ -1,2 +1,3 @@
.vscode/
src/**/bin/
.claude/
+5 -4
View File
@@ -49,9 +49,10 @@ root `CLAUDE.md`는 이 목록의 동기화된 요약이다. 두 문서가 어
## Gradle 정책 권위
- `src/config/architecture/modules.json`: 정확히 19개 leaf의 ID, repository-relative 소스 경로,
- `src/config/architecture/modules.json`: 등록된 모든 leaf의 ID, repository-relative 소스 경로,
Gradle path, 허용 production project dependency edge, 두 composition root의 실제 runtime
membership
membership. leaf 목록과 그 개수의 SSOT는 registry다. 문서는 개수를 복제하지 않는다 —
산문에 적힌 숫자는 leaf가 추가되는 순간 drift한다. `verifyDocumentedLeafCount`가 이를 강제한다.
- `src/settings.gradle`: registry를 fail-closed로 검증하고 등록된 Gradle project를 include/mapping
- `src/build.gradle`: 같은 registry를 읽는 `verifyCleanArchitectureDependencies`와 그 밖의
architecture-wide verification task
@@ -102,7 +103,7 @@ commit 정책은 모든 플랫폼에서 `human-only`이며 agent는 stage/commit
## 모듈 책임
19개 leaf 모듈의 ID, 실제 소스 경로, Gradle path, 허용 production 의존성, runtime membership은
모든 leaf 모듈의 ID, 실제 소스 경로, Gradle path, 허용 production 의존성, runtime membership은
`src/config/architecture/modules.json`이 SSOT다. focused test는 소유 leaf의 `gradle_path`에서
파생한다. 이 문서는 leaf 목록을 복제하지 않고 family 책임만 정의한다. 작업 파일에서는 가장 가까운
`src/**/CLAUDE.md`를 함께 읽는다.
@@ -182,7 +183,7 @@ cd src
```
소유 leaf의 정확한 Gradle path는 `src/config/architecture/modules.json`에서 읽고 focused test
명령을 파생한다. root 문서에 19개 명령 목록을 복제하지 않는다.
명령을 파생한다. root 문서에 leaf별 명령 목록을 복제하지 않는다.
## 설정과 런타임
+6 -4
View File
@@ -21,9 +21,10 @@ If this summary drifts from `AGENTS.md`, `AGENTS.md` wins and this summary must
## Gradle policy authorities
- `src/config/architecture/modules.json`: exactly 19 leaf identities, repository-relative source
- `src/config/architecture/modules.json`: every registered leaf identity, repository-relative source
paths, Gradle paths, allowed production project dependency edges, and the exact runtime
memberships of both composition roots.
memberships of both composition roots. The registry owns the leaf list and its size; no document
restates the count, because a number written in prose drifts the moment a leaf is added.
- `src/settings.gradle`: fail-closed registry validation, project inclusion, and directory mapping.
- `src/build.gradle`: `verifyCleanArchitectureDependencies` and the other architecture-wide
verification tasks.
@@ -43,8 +44,9 @@ count.
## Module families
`src/config/architecture/modules.json` owns the complete 19-leaf list. Root guidance summarizes
families; the nearest `src/**/CLAUDE.md` owns local rules.
`src/config/architecture/modules.json` owns the complete leaf list. Root guidance summarizes
families; the nearest `src/**/CLAUDE.md` owns local rules. `verifyDocumentedLeafCount` fails the
build when a policy document states a leaf count that the registry does not agree with.
| Family | Responsibility | Stable dependency direction |
| --- | --- | --- |
@@ -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<T, ID>`
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`.
@@ -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`.
@@ -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`.
@@ -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`.
@@ -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`.
@@ -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<T, ID>` 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<T, ID>`.** 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).
@@ -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.
@@ -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.
@@ -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.
@@ -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
```
+399
View File
@@ -0,0 +1,399 @@
# GraphQL leaf public API surface — every public top-level type in src/main/java.
# A public type in a single-jar leaf is reachable from every adopter's code, so
# additions are reviewed rather than discovered. `api` and `spi` are the intended
# external surface; the rest are candidates to become internal when this leaf is
# split into capability artifacts.
# Update only after review with:
# ./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange
# types: 391
dev.caskeleton.adapter.inbound.graphql.HealthGraphqlController
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlAdminPrincipal
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAdminAuthorization
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAdminDeniedException
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAdminPort
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAdminService
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAudit
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationBlockCommand
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationRemovalGate
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationRemovalRejectedException
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationUsage
dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapability
dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapabilityDisabledException
dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapabilityGrade
dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedDependencyRules
dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedFeatureFlags
dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedModuleGuard
dev.caskeleton.adapter.inbound.graphql.advanced.chaining.GraphQlChainedDataLoaderPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.chaining.GraphQlChainedDispatchConfigurer
dev.caskeleton.adapter.inbound.graphql.advanced.chaining.GraphQlChainedLoaderMetrics
dev.caskeleton.adapter.inbound.graphql.advanced.chaining.GraphQlDataLoaderCycleDetector
dev.caskeleton.adapter.inbound.graphql.advanced.chaining.GraphQlDataLoaderDependencyCycleException
dev.caskeleton.adapter.inbound.graphql.advanced.chaining.GraphQlDataLoaderDependencyGraph
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlClientOperationGenerator
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlCodegenBoundaryException
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlCodegenProfile
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlGeneratedCompatibilityGate
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlGeneratedSourceBoundary
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlOperationValidator
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlScalarMapping
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlTransportTypeGenerator
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationCompositionGate
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationCompositionResult
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationDeploymentOrder
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationLatencyBudget
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationReleaseEvidence
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationReleaseRejectedException
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationUsageReport
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlSubgraphContract
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationBatchResolver
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationCapability
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationEntityKey
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationEntityResolver
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationProperties
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationRepresentationException
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationSchemaFactory
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpDraftCompatibilityReport
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpGetCachePolicy
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpGetCsrfPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpGetOperationPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpGetProfile
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpGetRejectedException
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpGetRequestParser
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalCancellation
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalCompatibilityGate
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalDeliveryCapability
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalDeliveryProfile
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalDeliveryRejectedException
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalPatch
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalTransportPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperation
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationConflictException
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationId
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationInterceptor
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationLookup
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationNotFoundException
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationRecordMapping
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationRegistry
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationRejectedException
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationRequest
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationStatus
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationTransition
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedPreparsedBridge
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.OperationalStoreGraphQlPersistedOperationRegistry
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedCompatibilityMatrix
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedPromotionDecision
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedReleaseEvidence
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedReleaseFailure
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedReleaseGate
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedRunbookIndex
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedSoakScenario
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlReplayAuthorization
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlReplayAuthorizationException
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlReplayGapException
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlReplayHistoryLostException
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlReplayPosition
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlReplaySource
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlSnapshotLiveHandoff
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlSubscriptionCursor
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketAdmission
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketAuthentication
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketCapability
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketErrorMapper
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketProperties
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketRoutePolicy
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketRouteRejectedException
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlSubscriptionAuthorizationPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketAuthenticationException
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketAuthenticationInterceptor
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketCloseReason
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketCredentialExpiry
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketPrincipal
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketRevocationSignal
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseAdmission
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseConnectionPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseHeartbeat
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseProperties
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseRejectedException
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseTermination
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSlowConsumerPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionBufferPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionCancellation
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionContext
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionDispatcher
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionDrainCoordinator
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionDrainPhase
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionDrainingException
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionEvent
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionExecutionPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionLease
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionMetrics
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionOrderingProfile
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionSource
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionState
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionTermination
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketAdmission
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketConnectionId
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketLifecycle
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProperties
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProtocol
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProtocolError
dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile
dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfileName
dev.caskeleton.adapter.inbound.graphql.api.GraphQlOperationId
dev.caskeleton.adapter.inbound.graphql.api.GraphQlOperationName
dev.caskeleton.adapter.inbound.graphql.api.GraphQlSchemaCoordinate
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlAsyncReturnShape
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlControllerContractException
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlControllerInspector
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlControllerTransactionRule
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlInputTypePolicy
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlResolverBoundaryRules
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlReturnTypePolicy
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlTransportTypeRules
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlTypeGraph
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformActuatorEndpoint
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformAutoConfiguration
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformConfigurationException
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformConfigurationReport
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformEnvironment
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformProperties
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformRuntime
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformStartupValidator
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlRuntimeTransport
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlChangeKind
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlClientOwnerApproval
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlCompatibilityImpact
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlCompatibilityPolicy
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlCompatibilityReport
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlDeprecationGate
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlRemovalDecision
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlRemovalRequest
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlSchemaChange
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlSchemaComparator
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlSchemaUsage
dev.caskeleton.adapter.inbound.graphql.context.ActorRef
dev.caskeleton.adapter.inbound.graphql.context.GraphQlCommandAttribution
dev.caskeleton.adapter.inbound.graphql.context.GraphQlDeadline
dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext
dev.caskeleton.adapter.inbound.graphql.context.TenantContext
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityCalculator
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityRejectedException
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityResult
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlCostCatalog
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentComplexityScorer
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentShape
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentShapeAnalyzer
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlFieldCostDescriptor
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlParserLimitPolicy
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlParserLimits
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlParserOptionsFactory
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlParserRejectedException
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlResolverWeight
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlResponseByteLimiter
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlResponseNodeCounter
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlRuntimeBudget
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlRuntimeBudgetExceededException
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlRuntimeBudgetTracker
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimitPolicy
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimitViolation
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimits
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchChunker
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchContext
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchErrorPolicy
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchExecutor
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchLoadException
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchObservation
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicy
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicyRegistry
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchResult
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchResultMapper
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchTimeoutException
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchValue
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderName
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderRequestRegistry
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlMissingKeyException
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlMissingKeyPolicy
dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorCategory
dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorCode
dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorContext
dev.caskeleton.adapter.inbound.graphql.error.GraphQlExceptionResolver
dev.caskeleton.adapter.inbound.graphql.error.GraphQlFailureBoundary
dev.caskeleton.adapter.inbound.graphql.error.GraphQlInternalErrorMasker
dev.caskeleton.adapter.inbound.graphql.error.GraphQlNullabilityContract
dev.caskeleton.adapter.inbound.graphql.error.GraphQlRequestErrorMapper
dev.caskeleton.adapter.inbound.graphql.error.GraphQlSubscriptionExceptionResolver
dev.caskeleton.adapter.inbound.graphql.error.GraphQlWireError
dev.caskeleton.adapter.inbound.graphql.execution.BoundedPreparsedDocumentProvider
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlAnonymousOperationException
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlCancellation
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlDeadlinePropagator
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipeline
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipelineException
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipelineValidator
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfile
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfileException
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfileValidator
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionStage
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlOperationNameInterceptor
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlOperationNamePolicy
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlOperationSelection
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlPreparsedCacheKey
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlPreparsedCacheMetrics
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlPreparsedCachePolicy
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlRequestCancelledException
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlResolverBudget
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlResolverCatalog
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlResolverDescriptor
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlTimeoutPolicy
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlFetchProfile
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlFetchProfileClassifier
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlFetchProfileName
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlFetchProfileRegistry
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlFetchProfileRule
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlFetchProfileValidationException
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlSelectionCoordinate
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlSelectionSetView
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlSelectionSignature
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlUnmappedSelectionException
dev.caskeleton.adapter.inbound.graphql.http.GraphQlAcceptHeader
dev.caskeleton.adapter.inbound.graphql.http.GraphQlExecutionOutcome
dev.caskeleton.adapter.inbound.graphql.http.GraphQlExtensionsPolicy
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpContractException
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpExecutor
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpOutcome
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpProfile
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpRequestEnvelope
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponse
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponseFactory
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponsePolicy
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpStatusMapper
dev.caskeleton.adapter.inbound.graphql.http.GraphQlJsonStructurePolicy
dev.caskeleton.adapter.inbound.graphql.http.GraphQlJsonValues
dev.caskeleton.adapter.inbound.graphql.http.GraphQlMediaTypes
dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestEnvelopeValidator
dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestFormatException
dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestSize
dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestTooLargeException
dev.caskeleton.adapter.inbound.graphql.moduleboundary.GraphQlAdvancedModule
dev.caskeleton.adapter.inbound.graphql.moduleboundary.GraphQlModuleBoundary
dev.caskeleton.adapter.inbound.graphql.moduleboundary.GraphQlModulePurity
dev.caskeleton.adapter.inbound.graphql.moduleboundary.GraphQlStableModule
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlBatchMutationItemResult
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlBusinessResult
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlCanonicalInput
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlExpectedVersion
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlIdempotencyConflictException
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlIdempotencyKey
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationContractException
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationContractValidator
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationCoordinate
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationFingerprint
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationIdempotencyContext
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationIdempotencyInterceptor
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationPayload
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationResultMapper
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlDataLoaderObservationConvention
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlMetricCardinalityPolicy
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlObservationContractException
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlObservationNames
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlOperationNameCardinality
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlProfilerAccessPolicy
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlRequestObservationConvention
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlResolverObservationConvention
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlSensitiveAttributeFilter
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlConnection
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlConnectionAssembler
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlConnectionException
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlConnectionPolicy
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlConnectionRequest
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorCodec
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorException
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorFraming
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorKeyRing
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorKeyset
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorPayload
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorScope
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorVersion
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlEdge
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlKeysetWindow
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlPageInfo
dev.caskeleton.adapter.inbound.graphql.pagination.HmacGraphQlCursorCodec
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicyManifest
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlOperationCatalog
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlOperationPolicy
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlOperationType
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlPolicyViolation
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlUnknownClientProfileException
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlUnknownOperationException
dev.caskeleton.adapter.inbound.graphql.policy.ResolverExecutionType
dev.caskeleton.adapter.inbound.graphql.release.GraphQlCompatibilityMatrix
dev.caskeleton.adapter.inbound.graphql.release.GraphQlFaultScenario
dev.caskeleton.adapter.inbound.graphql.release.GraphQlPerformanceScenario
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseEvidence
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseFailure
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseGate
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseOverride
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseReportWriter
dev.caskeleton.adapter.inbound.graphql.release.GraphQlStableCapabilityManifest
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlBatchLoaderRegistrar
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlBlockingBridge
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlBlockingBridgeFullException
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlCostBudgetHandler
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlDataFetcherExceptionResolver
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlDocumentAuthorizationHandler
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlExecutionChain
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlExecutionContext
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlExecutionHandler
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlExecutionRequest
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlOperationSelectionHandler
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformInstrumentation
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformRejectionMapper
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformWebInterceptor
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPreparsedDocumentAdapter
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPrincipalResolver
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlWireErrorMapper
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlWireErrors
dev.caskeleton.adapter.inbound.graphql.runtime.servlet.GraphQlRequestBodyLimitFilter
dev.caskeleton.adapter.inbound.graphql.scalar.BigDecimalScalar
dev.caskeleton.adapter.inbound.graphql.scalar.DateScalar
dev.caskeleton.adapter.inbound.graphql.scalar.GraphQlDecimalBounds
dev.caskeleton.adapter.inbound.graphql.scalar.GraphQlScalarWiringConfigurer
dev.caskeleton.adapter.inbound.graphql.scalar.InstantScalar
dev.caskeleton.adapter.inbound.graphql.scalar.LongScalar
dev.caskeleton.adapter.inbound.graphql.scalar.UuidScalar
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlContractVersion
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlMappingInspectionGate
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlMappingIssue
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlMappingPolicy
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlOneOfInputValidator
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlOneOfPolicy
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlOneOfSchemaGate
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlOneOfViolationException
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlScalarDefinition
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlScalarManifest
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlScalarPolicy
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaAssembler
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaAssemblyException
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaAssemblyResult
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaContract
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaHash
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaMappingException
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaOwnership
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaResource
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticatedPrincipal
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationContextFactory
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationException
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationDecision
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationDeniedException
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationInterceptor
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationPolicy
dev.caskeleton.adapter.inbound.graphql.security.GraphQlBatchContext
dev.caskeleton.adapter.inbound.graphql.security.GraphQlClientProfileResolver
dev.caskeleton.adapter.inbound.graphql.security.GraphQlContextCleanup
dev.caskeleton.adapter.inbound.graphql.security.GraphQlContextPropagator
dev.caskeleton.adapter.inbound.graphql.security.GraphQlObjectAuthorizationPort
dev.caskeleton.adapter.inbound.graphql.security.GraphQlTenantIsolationException
dev.caskeleton.adapter.inbound.graphql.security.GraphQlTenantIsolationPolicy
+349
View File
@@ -0,0 +1,349 @@
# MongoDB leaf public API surface — every public top-level type in src/main/java.
# A public type in a single-jar leaf is reachable from every adopter's code, so
# additions are reviewed rather than discovered. `api` is the intended external
# surface; the rest is implementation that has not been moved under an internal
# root yet.
# Update only after review with:
# ./gradlew :adapter:outbound:persistence-mongo:updateMongoApiSurface -PapproveMongoApiSurfaceChange
# types: 341
dev.caskeleton.adapter.outbound.mongo.MongoOptInAutoConfigurationImportFilter
dev.caskeleton.adapter.outbound.mongo.MongoPersistenceConfig
dev.caskeleton.adapter.outbound.mongo.MongoPersistenceProperties
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedCapabilityFlags
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedCapabilityGuard
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedEntryPoint
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedPolicy
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedPromotionEvidence
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedPromotionGate
dev.caskeleton.adapter.outbound.mongo.advanced.autoconfigure.MongoAdvancedConfiguration
dev.caskeleton.adapter.outbound.mongo.advanced.autoconfigure.MongoAdvancedProperties
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoBridgeCheckpointPolicy
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoBridgeOutboxPolicy
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoChangeMessagingBridge
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoChangeToIntegrationEventMapper
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoIntegrationEventEnvelope
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoIntegrationEventPublisher
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoPublishResult
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle.MongoCsfleClientFactory
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle.MongoCsfleFieldPolicy
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle.MongoCsfleMode
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle.MongoCsfleProfile
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle.MongoDataKeyResolver
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoEncryptedFieldDescriptor
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoEncryptionMetadataOwnership
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoQueryShape
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoQueryShapeSupport
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoQueryableEncryptionCollectionManager
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoQueryableEncryptionProfile
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoQueryableEncryptionQueryType
dev.caskeleton.adapter.outbound.mongo.advanced.gridfs.MongoGridFsCompatibilityReader
dev.caskeleton.adapter.outbound.mongo.advanced.gridfs.MongoGridFsMigrationCheckpoint
dev.caskeleton.adapter.outbound.mongo.advanced.gridfs.MongoGridFsMigrationJob
dev.caskeleton.adapter.outbound.mongo.advanced.gridfs.MongoGridFsObjectReference
dev.caskeleton.adapter.outbound.mongo.advanced.search.MongoSearchIndexDescriptor
dev.caskeleton.adapter.outbound.mongo.advanced.search.MongoSearchIndexState
dev.caskeleton.adapter.outbound.mongo.advanced.search.MongoSearchOperations
dev.caskeleton.adapter.outbound.mongo.advanced.search.MongoSearchQuery
dev.caskeleton.adapter.outbound.mongo.advanced.search.MongoSearchReadinessGate
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.MongoRoutingClassification
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.ShardAwareQueryValidator
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.ShardKeyDescriptor
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.ShardKeyPart
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.ShardStrategy
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.admin.MongoShardingAdminGateway
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.admin.ReshardApproval
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.admin.ShardKeyAnalyzer
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.admin.ShardKeyReadinessReport
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database.MongoTenantClientRegistry
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database.MongoTenantDatabaseResolver
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database.MongoTenantLifecyclePolicy
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database.MongoTenantMigrationCheckpointStore
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database.MongoTenantMigrationCoordinator
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared.MongoTenantContext
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared.MongoTenantManifestValidator
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared.MongoTenantPredicateInjector
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared.TenantScopedMongoOperations
dev.caskeleton.adapter.outbound.mongo.advanced.timeseries.MongoTimeSeriesCapability
dev.caskeleton.adapter.outbound.mongo.advanced.timeseries.MongoTimeSeriesCapabilityValidator
dev.caskeleton.adapter.outbound.mongo.advanced.timeseries.MongoTimeSeriesDescriptor
dev.caskeleton.adapter.outbound.mongo.advanced.timeseries.MongoTimeSeriesGranularity
dev.caskeleton.adapter.outbound.mongo.advanced.timeseries.MongoTimeSeriesOperations
dev.caskeleton.adapter.outbound.mongo.advanced.timeseries.MongoTimeSeriesSupport
dev.caskeleton.adapter.outbound.mongo.advanced.vector.MongoEmbedding
dev.caskeleton.adapter.outbound.mongo.advanced.vector.MongoVectorIndexDescriptor
dev.caskeleton.adapter.outbound.mongo.advanced.vector.MongoVectorQuery
dev.caskeleton.adapter.outbound.mongo.advanced.vector.MongoVectorSearchBenchmarkGate
dev.caskeleton.adapter.outbound.mongo.advanced.vector.MongoVectorSearchOperations
dev.caskeleton.adapter.outbound.mongo.aggregation.MongoAggregationPlan
dev.caskeleton.adapter.outbound.mongo.aggregation.MongoAggregationProfile
dev.caskeleton.adapter.outbound.mongo.aggregation.MongoAggregationRisk
dev.caskeleton.adapter.outbound.mongo.aggregation.MongoAggregationStageDescriptor
dev.caskeleton.adapter.outbound.mongo.aggregation.PolicyAwareMongoAggregationExecutor
dev.caskeleton.adapter.outbound.mongo.api.CollectionProfileName
dev.caskeleton.adapter.outbound.mongo.api.DatabaseProfileName
dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext
dev.caskeleton.adapter.outbound.mongo.api.MongoOperationName
dev.caskeleton.adapter.outbound.mongo.api.MongoOperationScope
dev.caskeleton.adapter.outbound.mongo.api.MongoOperationType
dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapability
dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapabilitySet
dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapabilitySupport
dev.caskeleton.adapter.outbound.mongo.api.capability.MongoServerVersion
dev.caskeleton.adapter.outbound.mongo.api.capability.MongoSupportLevel
dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyDescriptor
dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyGuarantee
dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyProfile
dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyRegistry
dev.caskeleton.adapter.outbound.mongo.api.error.MongoBulkPartialFailureException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoConnectionException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoCursorException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoDataSchemaUnsupportedException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoDocumentTooLargeException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoDuplicateKeyException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoEncryptionException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoExecutionOutcome
dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureCategory
dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureContext
dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoOptimisticConflictException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoPersistenceException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoReadConcernException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoResumeException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoRetryScope
dev.caskeleton.adapter.outbound.mongo.api.error.MongoSchemaValidationException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoServerSelectionException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoShardRoutingException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoTimeoutException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoTransactionCommitUnknownException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoTransactionTransientException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoUnclassifiedFailureException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoWriteConcernException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoWriteConflictException
dev.caskeleton.adapter.outbound.mongo.api.mapping.DomainDocumentId
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoBigIntegerRepresentation
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoDecimalRepresentation
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoEnumRepresentation
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoIdRepresentation
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoTemporalRepresentation
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoTypeMetadataPolicy
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoTypeRepresentationManifest
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoUuidRepresentation
dev.caskeleton.adapter.outbound.mongo.api.observation.MongoOperationObservation
dev.caskeleton.adapter.outbound.mongo.api.observation.MongoOperationObserver
dev.caskeleton.adapter.outbound.mongo.api.profile.MongoClientPlane
dev.caskeleton.adapter.outbound.mongo.api.profile.MongoRuntimeProfile
dev.caskeleton.adapter.outbound.mongo.api.profile.MongoStableApiProfile
dev.caskeleton.adapter.outbound.mongo.api.profile.MongoTopology
dev.caskeleton.adapter.outbound.mongo.api.profile.MongoTopologyRequirement
dev.caskeleton.adapter.outbound.mongo.api.schema.DocumentSchemaVersion
dev.caskeleton.adapter.outbound.mongo.api.schema.MongoSchemaVersionPolicy
dev.caskeleton.adapter.outbound.mongo.api.schema.MongoSchemaVersionRange
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoClientGeneration
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoClientGenerationRegistry
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoDriverObservabilityAutoConfiguration
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformAutoConfiguration
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformHealthIndicator
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformProperties
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoProfileProperties
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoStableReleaseEvidence
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoStableReleaseGate
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoStartupValidator
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoTopologyProbe
dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeEventIdentity
dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamPipeline
dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamState
dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamSubscription
dev.caskeleton.adapter.outbound.mongo.changestream.MongoClusterTime
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpoint
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpointStore
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumePosition
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeClaim
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeDeduplicationStore
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeProjectionResult
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeProjector
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeStreamRunner
dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoChangeHistoryLostException
dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoChangeStreamRecoveryDecision
dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoChangeStreamRecoveryPolicy
dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoInvalidateRecovery
dev.caskeleton.adapter.outbound.mongo.failure.DefaultMongoFailureClassifier
dev.caskeleton.adapter.outbound.mongo.failure.DefaultMongoFailureTranslator
dev.caskeleton.adapter.outbound.mongo.failure.MongoDriverFailureView
dev.caskeleton.adapter.outbound.mongo.failure.MongoFailureClassification
dev.caskeleton.adapter.outbound.mongo.failure.MongoFailureClassifier
dev.caskeleton.adapter.outbound.mongo.failure.MongoFailureExtractor
dev.caskeleton.adapter.outbound.mongo.failure.MongoFailurePhase
dev.caskeleton.adapter.outbound.mongo.failure.MongoFailureTranslator
dev.caskeleton.adapter.outbound.mongo.geo.MongoGeoDistance
dev.caskeleton.adapter.outbound.mongo.geo.MongoGeoPoint
dev.caskeleton.adapter.outbound.mongo.geo.MongoGeoQuery
dev.caskeleton.adapter.outbound.mongo.geo.MongoGeospatialOperations
dev.caskeleton.adapter.outbound.mongo.geo.SpringMongoGeospatialOperations
dev.caskeleton.adapter.outbound.mongo.imperative.DefaultMongoImperativeExecutor
dev.caskeleton.adapter.outbound.mongo.imperative.MongoCollectionAccess
dev.caskeleton.adapter.outbound.mongo.imperative.MongoCollectionProfileRegistry
dev.caskeleton.adapter.outbound.mongo.imperative.MongoCompletion
dev.caskeleton.adapter.outbound.mongo.imperative.MongoConsistencyBinder
dev.caskeleton.adapter.outbound.mongo.imperative.MongoImperativeCallback
dev.caskeleton.adapter.outbound.mongo.imperative.MongoImperativeExecutor
dev.caskeleton.adapter.outbound.mongo.imperative.MongoOperationResult
dev.caskeleton.adapter.outbound.mongo.imperative.MongoPlatformCallback
dev.caskeleton.adapter.outbound.mongo.imperative.MongoPlatformCollectionAccess
dev.caskeleton.adapter.outbound.mongo.imperative.MongoTemplateSupportContract
dev.caskeleton.adapter.outbound.mongo.imperative.ScopedMongoOperations
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicFilter
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicUpdate
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicUpdateResult
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicOperations
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicOperationsTemplate
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicy
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoUpdateOperator
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.ReturnDocumentMode
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkExecutor
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkItemFailure
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkItemOutcome
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkMode
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkResult
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkWritePlan
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.SpringDataBulkFailureExtractor
dev.caskeleton.adapter.outbound.mongo.imperative.revision.MongoDocumentNotFoundException
dev.caskeleton.adapter.outbound.mongo.imperative.revision.MongoOptimisticConflictTranslator
dev.caskeleton.adapter.outbound.mongo.imperative.revision.MongoRevision
dev.caskeleton.adapter.outbound.mongo.imperative.revision.VersionedMongoUpdater
dev.caskeleton.adapter.outbound.mongo.imperative.revision.VersionedUpdateCommand
dev.caskeleton.adapter.outbound.mongo.mapping.BigDecimalToDecimal128Converter
dev.caskeleton.adapter.outbound.mongo.mapping.BigIntegerRepresentationConverters
dev.caskeleton.adapter.outbound.mongo.mapping.Decimal128ToBigDecimalConverter
dev.caskeleton.adapter.outbound.mongo.mapping.DomainIdReadConverter
dev.caskeleton.adapter.outbound.mongo.mapping.DomainIdWriteConverter
dev.caskeleton.adapter.outbound.mongo.mapping.LocalDateTimeMappingGuard
dev.caskeleton.adapter.outbound.mongo.mapping.MongoCustomConversionsFactory
dev.caskeleton.adapter.outbound.mongo.mapping.MongoMappingConfiguration
dev.caskeleton.adapter.outbound.mongo.mapping.MongoTypeMetadataConfigurer
dev.caskeleton.adapter.outbound.mongo.mapping.type.LongLivedMongoDocument
dev.caskeleton.adapter.outbound.mongo.mapping.type.MongoTypeMetadataDescriptor
dev.caskeleton.adapter.outbound.mongo.mapping.type.MongoTypeMetadataRegistry
dev.caskeleton.adapter.outbound.mongo.mapping.type.PolicyAwareMongoTypeMapper
dev.caskeleton.adapter.outbound.mongo.migration.MongoCollectionMigrationLedger
dev.caskeleton.adapter.outbound.mongo.migration.MongoCollectionMigrationLock
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigration
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationCheckpoint
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationChecksum
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationContext
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationHeartbeat
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationId
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationLedger
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationLock
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationPostcondition
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationPrecondition
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationResult
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationRunner
dev.caskeleton.adapter.outbound.mongo.migration.flamingock.FlamingockChangeUnitView
dev.caskeleton.adapter.outbound.mongo.migration.flamingock.FlamingockLedgerAdapter
dev.caskeleton.adapter.outbound.mongo.migration.flamingock.FlamingockLockAdapter
dev.caskeleton.adapter.outbound.mongo.migration.flamingock.FlamingockMigrationConfiguration
dev.caskeleton.adapter.outbound.mongo.migration.flamingock.FlamingockMongoMigrationAdapter
dev.caskeleton.adapter.outbound.mongo.nativecap.ApprovedMongoNativeOperation
dev.caskeleton.adapter.outbound.mongo.nativecap.MongoNativeCapabilityGateway
dev.caskeleton.adapter.outbound.mongo.nativecap.MongoNativeCommandCategory
dev.caskeleton.adapter.outbound.mongo.nativecap.MongoNativeOperationPolicy
dev.caskeleton.adapter.outbound.mongo.nativecap.PolicyAwareMongoNativeGateway
dev.caskeleton.adapter.outbound.mongo.observation.MicrometerMongoOperationObserver
dev.caskeleton.adapter.outbound.mongo.observation.MongoCommandObservationListener
dev.caskeleton.adapter.outbound.mongo.observation.MongoDriverObservabilityConfiguration
dev.caskeleton.adapter.outbound.mongo.observation.MongoObservationConvention
dev.caskeleton.adapter.outbound.mongo.observation.MongoObservationRedactor
dev.caskeleton.adapter.outbound.mongo.observation.MongoPoolObservationListener
dev.caskeleton.adapter.outbound.mongo.observation.MongoSdamObservationListener
dev.caskeleton.adapter.outbound.mongo.query.MongoFieldDescriptor
dev.caskeleton.adapter.outbound.mongo.query.MongoOperator
dev.caskeleton.adapter.outbound.mongo.query.MongoQueryPolicy
dev.caskeleton.adapter.outbound.mongo.query.MongoRegexPolicy
dev.caskeleton.adapter.outbound.mongo.query.MongoSortDescriptor
dev.caskeleton.adapter.outbound.mongo.query.PolicyAwareMongoQueryBuilder
dev.caskeleton.adapter.outbound.mongo.query.budget.MongoBudgetEnforcer
dev.caskeleton.adapter.outbound.mongo.query.budget.MongoBudgetPolicyRegistry
dev.caskeleton.adapter.outbound.mongo.query.budget.MongoOperationBudget
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoKeysetCursor
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoKeysetCursorCodec
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoKeysetPageRequest
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoKeysetQueryBuilder
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoKeysetSlice
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoKeysetSort
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoNullSortOrdering
dev.caskeleton.adapter.outbound.mongo.reactive.DefaultReactiveMongoExecutor
dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveMongoCallback
dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveMongoCollectionAccess
dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveMongoConsistencyBinder
dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveMongoContextKeys
dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveMongoExecutor
dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveScopedMongoOperations
dev.caskeleton.adapter.outbound.mongo.reactive.cursor.MongoCursorGuard
dev.caskeleton.adapter.outbound.mongo.reactive.cursor.MongoCursorLease
dev.caskeleton.adapter.outbound.mongo.reactive.cursor.MongoCursorTermination
dev.caskeleton.adapter.outbound.mongo.reactive.cursor.MongoReactiveCursorPublisher
dev.caskeleton.adapter.outbound.mongo.reactive.cursor.MongoResultBudgetTracker
dev.caskeleton.adapter.outbound.mongo.schema.index.MongoIndexApplyPolicy
dev.caskeleton.adapter.outbound.mongo.schema.index.MongoIndexDescriptorView
dev.caskeleton.adapter.outbound.mongo.schema.index.MongoIndexDiff
dev.caskeleton.adapter.outbound.mongo.schema.index.MongoIndexDiffEngine
dev.caskeleton.adapter.outbound.mongo.schema.index.MongoIndexRetirementPlan
dev.caskeleton.adapter.outbound.mongo.schema.index.MongoIndexRetirementState
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoCollectionManifest
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoIndexDirection
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoIndexKey
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoIndexManifest
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoManifestRegistry
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoMetadataOwnership
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoSchemaManifest
dev.caskeleton.adapter.outbound.mongo.schema.model.EmbeddedCollectionDescriptor
dev.caskeleton.adapter.outbound.mongo.schema.model.MongoBinaryFieldDescriptor
dev.caskeleton.adapter.outbound.mongo.schema.model.MongoDocumentModelManifest
dev.caskeleton.adapter.outbound.mongo.schema.model.MongoDocumentModelValidator
dev.caskeleton.adapter.outbound.mongo.schema.model.MongoDocumentSizeBudget
dev.caskeleton.adapter.outbound.mongo.schema.model.MongoReferenceDescriptor
dev.caskeleton.adapter.outbound.mongo.schema.model.MongoReferenceLifecycle
dev.caskeleton.adapter.outbound.mongo.schema.ttl.MongoExpirationAccessPolicy
dev.caskeleton.adapter.outbound.mongo.schema.ttl.MongoTtlIndexDescriptor
dev.caskeleton.adapter.outbound.mongo.schema.ttl.MongoTtlPolicy
dev.caskeleton.adapter.outbound.mongo.schema.ttl.MongoTtlPolicyValidator
dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidationAction
dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidationLevel
dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidatorApplyPolicy
dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidatorDescriptor
dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidatorDiff
dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidatorDiffEngine
dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialReference
dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialResolver
dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialRotationPolicy
dev.caskeleton.adapter.outbound.mongo.security.MongoPrincipalRole
dev.caskeleton.adapter.outbound.mongo.security.MongoSecurityProfile
dev.caskeleton.adapter.outbound.mongo.security.MongoSecurityProfileValidator
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminApproval
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminAuditPhase
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminAuditRecord
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminAuthorization
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminCommand
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminGateway
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminOperation
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminRuntimeGuard
dev.caskeleton.adapter.outbound.mongo.transaction.MongoTransactionExecutor
dev.caskeleton.adapter.outbound.mongo.transaction.MongoTransactionProfile
dev.caskeleton.adapter.outbound.mongo.transaction.MongoTransactionScope
dev.caskeleton.adapter.outbound.mongo.transaction.MongoTransactionSession
dev.caskeleton.adapter.outbound.mongo.transaction.MongoTransactionSessionFactory
dev.caskeleton.adapter.outbound.mongo.transaction.ReactiveMongoTransactionExecutor
dev.caskeleton.adapter.outbound.mongo.transaction.ReactiveMongoTransactionSession
dev.caskeleton.adapter.outbound.mongo.transaction.ReactiveMongoTransactionSessionFactory
dev.caskeleton.adapter.outbound.mongo.transaction.SpringMongoTransactionExecutor
dev.caskeleton.adapter.outbound.mongo.transaction.SpringMongoTransactionSessionFactory
dev.caskeleton.adapter.outbound.mongo.transaction.SpringReactiveMongoTransactionExecutor
dev.caskeleton.adapter.outbound.mongo.transaction.SpringReactiveMongoTransactionSessionFactory
dev.caskeleton.adapter.outbound.mongo.transaction.retry.MongoCommitReconciler
dev.caskeleton.adapter.outbound.mongo.transaction.retry.MongoRetryBudget
dev.caskeleton.adapter.outbound.mongo.transaction.retry.MongoRetryDecision
dev.caskeleton.adapter.outbound.mongo.transaction.retry.MongoTransactionRetryCoordinator
dev.caskeleton.adapter.outbound.mongo.transaction.session.MongoCausalSessionContext
dev.caskeleton.adapter.outbound.mongo.transaction.session.MongoCausalSessionExecutor
dev.caskeleton.adapter.outbound.mongo.transaction.session.ReactiveMongoCausalSessionExecutor
dev.caskeleton.adapter.outbound.mongo.transaction.session.SpringMongoCausalSessionExecutor
+64
View File
@@ -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<T, ID>` 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.
@@ -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.
+43
View File
@@ -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.
+64
View File
@@ -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.
+61
View File
@@ -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.
+72
View File
@@ -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.
+74
View File
@@ -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.
+167
View File
@@ -0,0 +1,167 @@
# 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`. It holds a closed catalog of the
production root's direct child packages, compares that catalog against the tree for exact equality,
checks every observed top-level edge against the declared ones, and rejects cycles.
This used to be a stronger claim than the test. The catalog listed thirteen packages while the tree
held twenty-two, so nine — `audit`, `config`, `failure`, `fileserver`, `h2`, `idempotency`, `lock`,
`notification`, `outbox` — were governed by nothing, and a `transaction → postgresql` /
`postgresql → transaction` cycle passed. Both are closed now, and the catalog's exact-equality check
is what keeps a new package from being green by omission.
**Known gap.** The catalog governs top-level packages. Sub-package edges inside one top-level
package are not checked, and the target tree in the review's JPA-023 (a `capability/*` layout) is
not implemented — the notification configuration facade is the first step toward it.
## 2. Package mapping
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<T, ID>` 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.
+85
View File
@@ -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.
+66
View File
@@ -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.
+108
View File
@@ -0,0 +1,108 @@
# JPA Persistence Platform — Support Matrix
**This document is a rendering. The machine-readable source is
[`src/config/jpa/release-registry.json`](../../src/config/jpa/release-registry.json).**
`JpaReleaseManifest` used to parse this file with regular expressions: every `PostgreSQL NN` it
mentioned became a supported version, whatever table or sentence produced the match. An Experimental
major joined the Stable list, a version named once in prose counted as supported, and demoting a
major changed nothing so long as the string survived somewhere in the document. Now the registry
declares a support level per major as a field, each gate names the Gradle task that produces its
evidence, and this document describes what the registry says.
## Database
| Database | Support | Evidence |
|---|---|---|
| PostgreSQL 16 | Stable | full contract suite, release lane (own matrix job) |
| PostgreSQL 17 | Stable | full contract suite, release lane (own matrix job) |
| PostgreSQL 18 | Stable | full contract suite, release lane (own matrix job) |
| PostgreSQL 19 | Experimental | compatibility lane only; promotion requires an ADR |
| H2 | Local convenience | **never** evidence of PostgreSQL behaviour |
Each major gets its **own release job**, because for a while it did not. The release lane passed
`-Pjpa.matrix.versions=16,17,18` to a `JpaPlatformContractSupport.start()` that used
`selectedVersions().get(0)`, so the whole integration suite ran against PostgreSQL 16 and this table
recorded 17 and 18 as fully covered on the strength of a three-assertion smoke test. `start()` now
refuses a multi-version selection outright, `jpa-release.yml` fans out to one job per major, and a
promotion job requires all three majors' evidence to carry the same commit SHA — so a removed major
removes the release, not the evidence for it.
**Provider baseline.** The gates run against the Hibernate version the Spring Boot BOM resolves —
**7.1.8.Final** — which the registry records as `stable-tested-baseline`. This document previously
called 7.4 the Stable baseline and the pagination gate was named `hibernate-7.4-fetch-pagination`,
so every run of that gate produced evidence labelled with a provider it had never executed against.
7.4 is recorded as `compatibility-target`; it becomes the baseline when a full lane has actually run
on it.
H2 is not a second production target. It reports different SQLSTATEs for the same violation, no JSONB
operators, no range types, and no concurrent index builds. A green H2 run is evidence that the code
compiles and runs, and nothing more.
`SKIP LOCKED` needs its own sentence, because two documents said different things about it. The
module's `CLAUDE.md` records a measurement: H2 2.4.240 accepts `FOR UPDATE SKIP LOCKED` and does
genuinely skip locked rows, which is why the outbox claim SQL is identical on both vendors. This
document previously said H2 has no such guarantee. Both are right about different questions, and
the distinction is the point: **observed behaviour in the version we measured is not a production
guarantee, and it is never PostgreSQL contract evidence.** The measurement is why the claim SQL
needs no vendor branch; the absence of a guarantee is why every concurrency contract still runs
against a real PostgreSQL.
## Specification and provider
| 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 |
| `collection-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.
+66
View File
@@ -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.
+170
View File
@@ -0,0 +1,170 @@
# 설정 레퍼런스
> **Prefix.** Every property below binds under `app.messaging`, which is the prefix the deployed
> runtime and the `APP_MESSAGING_*` environment variables already use. Earlier revisions of this
> page documented a bare `messaging` prefix and the starter bound `backend.messaging`; neither
> bound what this page describes, so a deployment configured from it changed nothing. A key under
> either of the old prefixes now fails startup with a message naming the key — see
> `MessagingPrefixMigrationValidator`.
## Destination profile
```yaml
app:
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
app:
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
app:
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
app:
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
app:
messaging:
experimental:
kafka-share: false
pulsar: false
nats: false
bridge:
spring-cloud-stream: false
```
## Backpressure
```yaml
app:
messaging:
backpressure:
global-limit: 512
per-destination-limit: 64 # global-limit 이하여야 한다
```
`per-destination-limit > global-limit`이면 global limit이 limit이 아니게 되므로 부팅에 실패한다.
+34
View File
@@ -0,0 +1,34 @@
# 기존 runtime → 신규 messaging platform cutover (MSG-015)
## 왜 기계적 매핑이 안 되는가
두 outbox 모델의 enum 이름이 겹치는데 의미가 반대다.
| 모델 | retryable | terminal |
|---|---|---|
| 기존 `OutboxEventStatus` | `FAILED` (`next_attempt_at` 보유) | `DEAD` |
| 신규 `OutboxStatus` | `AMBIGUOUS` | `FAILED`, `EXHAUSTED` |
이름으로 매핑하면 **확정 거절이 무한 재시도**가 되고 **불확정이 park**된다. 그래서 application은
자기 어휘(`OutboxPublishOutcome`)만 쓰고, 변환은 bridge adapter가 한다.
## 지금 반영된 것
- `OutboxPublishOutcome``CONFIRMED` / `AMBIGUOUS` / `REJECTED_BEFORE_SEND` /
`REJECTED_AFTER_BROKER`. application이 소유하는 canonical 결과 타입이며, "리턴 or throw"만 가능한
기존 어댑터를 위해 `OutboxMessagePublishPort.publishForOutcome`의 default가 `CONFIRMED`를 돌려준다.
- `APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM` ArchUnit 규칙 — application-core가
`dev.caskeleton.messaging..`를 import하면 빌드가 깨진다.
- 반대 방향(신규 `PublishResult` → application outcome) 매핑 규칙을 테스트로 고정.
## 남은 것
- `messaging-platform-bridge` outbound leaf: validated application event → platform envelope,
`PublishResult``OutboxPublishOutcome`. registry에 leaf를 추가하는 변경이라 별도 커밋.
- golden contract 테스트: event/message ID, type, schema revision, partition/order/correlation/
causation/tenant/trace, payload digest, wire version이 bytes 단위로 보존되는지.
- 단일 publication authority: 기존 `OutboxPublicationAuthority` fence를 재사용해 writer/relay가
동시에 ACTIVE가 되지 않도록. **dual write/publish는 금지** — 한 business fact가 두 durable store와
두 relay로 나가는 상태가 cutover에서 가장 위험하다.
- 첫 cutover 범위는 **transport만** 교체(저장소는 기존 유지). storage migration은 shadow read →
authority switch → old backlog drain 순서로 별도 release.
+75
View File
@@ -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`인 조합을 거부한다.
+97
View File
@@ -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. 기본 활성화 여부에 대한 별도 결정
+113
View File
@@ -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<HandleResult> handle(MessageDelivery<OrderCreated> 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` (바뀌지 않는다 — 이것이 의도다)
+113
View File
@@ -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-<requestId>)
기존 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`의 의미가 달라진다.
+112
View File
@@ -0,0 +1,112 @@
# 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','IN_FLIGHT')
AND (lease_expires_at IS NULL OR lease_expires_at <= now)
AND next_attempt_at <= now
AND attempts < maxAttempts
```
`IN_FLIGHT`가 목록에 있는 것이 핵심이다. relay가 publish 도중 죽으면 row는 `IN_FLIGHT`로 남는데,
이를 제외하면 그 메시지는 **영원히** 발행되지 않는다 — outbox가 막으려던 바로 그 실패다. 대신
lease가 만료됐을 때만 회수하므로, 살아 있는 relay가 들고 있는 row는 회수되지 않는다.
회수는 **같은 `message_id`로** 이루어지고 `lease_token`이 1 증가한다. 새 id를 발급하면 "전달됐을
수도 있는 메시지"가 "확실히 두 번째"가 되기 때문이다 (위의 AMBIGUOUS 논의와 같은 이유).
이 문단의 근거는 실제 PostgreSQL 컨테이너 레인이다:
- `OutboxPostgresIT#anExpiredLeaseBecomesClaimableAgain` — 만료된 lease의 재회수
- `OutboxPostgresIT#anExpiryReclaimKeepsTheMessageIdAndAdvancesTheToken` — 같은 id, 증가한 token
- `OutboxPostgresIT#aLeasedRowIsInvisibleToASecondRelayInstance` — 살아 있는 lease는 회수 불가
- `OutboxPostgresIT#aSupersededRelayCannotOverwriteTheOutcomeOfTheOneThatReplacedIt` — fencing
partial index `ix_messaging_outbox_claimable`이 이 쿼리를 backlog 크기에 비례하게 유지한다.
PUBLISHED row는 retention job이 지울 때까지 쌓이기 때문이다.
## 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이기 때문이다.
+103
View File
@@ -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에 남는다.
+92
View File
@@ -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의 두 번째 사본이 되지 않는다.
+133
View File
@@ -0,0 +1,133 @@
# Messaging 지원 매트릭스
플랫폼이 **무엇을 보장하는지**와 **무엇을 보장하지 않는지**를 브로커별로 고정한다.
여기 없는 조합은 지원되지 않는다.
> **인증 근거.** 이 표의 버전은 이 저장소의 컨테이너 레인이 실제로 실행한 이미지다. 이전 판은
> Kafka 4.2/4.3을 선언했지만 fixture는 `apache/kafka:4.1.0`, lockfile client는 4.1.1이었다 — 표와
> 코드 상수가 서로 일치했을 뿐 어느 쪽도 실행된 적이 없었다. 장애 시나리오 커버리지도 마찬가지로
> `BrokerFailureMatrix.shipped()` 하드코딩이 아니라 레인이 낸 증거(`BrokerCertificationEvidence`)에서
> 나온다. 증거가 없는 조합은 `NOT_COVERED`다 (MSG-014).
> **모듈 이름과 런타임 편입.** `messaging-outbox-jdbc-postgresql` / `messaging-inbox-jdbc-postgresql`은
> 이전에 `-jpa`로 불렸다. 구현은 Spring JDBC이고 SQL은 PostgreSQL 전용(`?::jsonb`,
> `FOR UPDATE SKIP LOCKED`, `ON CONFLICT`, `TIMESTAMPTZ`)이므로, 그 이름은 쓰지 않는 기술을
> 광고하고 vendor 중립 port(`messaging-reliability-api`)의 위치를 가렸다 (MSG-023).
>
> 또한 registry의 messaging leaf는 모두 `runtime_memberships`가 비어 있다. 이는 **build-only /
> incubating** — 어느 composition root에도 편입되지 않았다는 뜻이며, 아래의 등급과는 다른 축이다.
> 등급은 "무엇이 증명되었는가", membership은 "무엇이 실행되는가"를 말한다. 애플리케이션에 배선하려면
> registry를 먼저 바꾸고 `verifyRuntimeModuleMembership`을 통과시켜야 한다. 자세한 규칙은
> `src/messaging/CLAUDE.md`가 소유한다.
## 브로커 등급
| 브로커 | 등급 | 인증 기준 | Stable 기능 | 제한 |
|---|---|---|---|---|
| Kafka | Stable | 4.1.x | producer idempotence, consumer group, batch, pause/resume, replay, transaction capability | Share Group은 Experimental |
| RabbitMQ | Stable | 4.3.x | exchange/routing, publisher confirm, mandatory return, manual ACK, quorum queue, retry queue, DLQ | stream 및 특수 plugin 미지원 |
| Pulsar | Experimental | 4.0 LTS + 4.2 | typed publish/consume, Shared, Key_Shared, schema | transaction 미승격, 기본 비활성 |
| NATS JetStream | Experimental | 2.14.x | stream, durable consumer, explicit ACK, dedupe, replay | native DLQ 없음(플랫폼이 대행), 기본 비활성 |
| 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
+91
View File
@@ -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.
+63
View File
@@ -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.
+90
View File
@@ -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<MongoTenantContext>` 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.
+81
View File
@@ -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.
+82
View File
@@ -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.
+14
View File
@@ -0,0 +1,14 @@
# Advanced capability sign-off
`scripts/verify-mongodb-advanced.sh` treats a file in this directory as the evidence that a review
happened:
- `security.md` — per-capability privilege review, naming the roles granted and by whom.
- `migration.md` — per-capability migration path, naming what an existing deployment has to do.
These were previously appended to the gate's missing-evidence list unconditionally, so the gate had
no passing state at all. A gate that can never pass is one nobody can act on, and the thing it was
waiting for — a human review — has an artefact. This is that artefact.
A file here asserts the review was done. Adding one without doing it is the failure mode; that is a
review-process problem, and no script can tell the difference.
+73
View File
@@ -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.
+92
View File
@@ -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.
+105
View File
@@ -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.
@@ -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.
+85
View File
@@ -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<T, ID>` 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.
+140
View File
@@ -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)).
+132
View File
@@ -0,0 +1,132 @@
# 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 enforced by `MongoModuleBoundaryTest` as a **closed edge matrix**:
every top-level package is declared with the packages it may import, the matrix is compared against
the tree for exact equality, and every observed edge must appear in it. A forbidden edge fails the
build the same way a missing Gradle dependency would, and so does a new package nobody registered.
This used to be a stronger claim than the test. The rules forbade a handful of reverse dependencies
and said nothing about the rest, so four edges outside the design's DAG existed and passed:
`reactive → imperative`, `reactive → query`, `transaction → reactive` and `geo → imperative`. They
are declared in the matrix now rather than removed — each is a real coupling the code relies on, and
the point of recording them is that the next one is a decision instead of an accident.
## 2. Package mapping
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`.
+98
View File
@@ -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.
+90
View File
@@ -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.
+87
View File
@@ -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.
@@ -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.
+147
View File
@@ -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 `<redacted>` — 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.
+91
View File
@@ -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<T, ID>`.
- 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.<capability>.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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -0,0 +1,69 @@
# NOTIF-ADR-005 — 어느 notification API가 canonical인가
## 상태
Accepted (2026-08-15). NTF-018 대응.
## 문제
같은 저장소에 notification 모델이 **두 개** 있다.
| 세대 | 위치 | 규모 |
| --- | --- | --- |
| R0 legacy | `adapter:outbound:notification`의 router/provider seam | 삭제 예정 |
| R1 | `dev.caskeleton.application.notification` (직속) | public type 100개 |
| Platform | `dev.caskeleton.application.notification.platform..` | 신규 |
`application-core/CLAUDE.md`는 R1을 "R1 canonical"이라 부르고,
`docs/notification/migration-guide.md`는 R0 → platform 이행만 설명하며 R1의 처분을 전혀 다루지 않는다.
`Channel`, plan, dispatch, receipt/evidence 모델이 두 namespace에 중복 존재하고 둘 사이에 production
bridge도 import도 없다.
**실패 모드는 "무엇이 깨지는가"가 아니라 "무엇을 써야 하는가"다.** 새 consumer가 어느 API를 쓸지 알 수
없고, 두 모델이 각자 진화하며, R0를 지운 뒤에도 R1 graph가 고아로 남거나 platform이 R1 정책을 우회하는
이중 canonical이 된다.
## 결정
**Platform이 canonical이다.** R1은 유지되지만 새 production consumer를 받지 않는다.
이유는 능력이 아니라 증거다. platform은 durable acceptance, fenced claim, event ledger, projection,
reconciliation을 실제 PostgreSQL 레인으로 증명한다(NOTIF-ADR-001~003). R1은 fake로 증명된 R1 계약이며
스스로 그렇게 선언한다 — `application-core/CLAUDE.md`가 "R1 application contract proven with fakes.
It does not claim PostgreSQL schema/locking, provider protocol, cryptographic verifier, or runtime
wiring qualification"이라고 적어 둔 그대로다.
## Disposition
R1 public type 100개의 처분은 네 가지 중 하나다.
| 처분 | 의미 | 대상 |
| --- | --- | --- |
| `replace` | platform에 동등물이 있다. 새 consumer는 platform을 쓴다 | `Channel`, plan/dispatch/receipt/evidence 계열 |
| `bridge` | 변환이 필요하다. 변환은 ACL 한 곳에만 둔다 | writer-cutover / receipt 적용 경로 |
| `retain` | platform이 다루지 않는 관심사다. 그대로 둔다 | consent/quiescence verifier port |
| `delete` | R0와 함께 사라진다 | R0 router가 쓰던 seam |
전수 분류표는 이 ADR이 아니라 `docs/notification/module-mapping.md`가 소유한다. ADR은 규칙을,
mapping 문서는 목록을 소유한다 — 목록을 두 곳에 복제하면 드리프트하는 쪽이 늘어난다.
## 강제
두 namespace 사이의 production dependency는 **0건**이며, 이것은 문서가 아니라 ArchUnit 규칙이 지킨다
(`CleanArchitectureTest``NOTIFICATION_R1_AND_PLATFORM_DO_NOT_DEPEND_ON_EACH_OTHER`).
변환이 필요해지면 `dev.caskeleton.application.notification.compatibility.r1` 한 패키지에만 두고, 그
패키지만 규칙에서 예외로 인정한다. 예외를 한 곳으로 모으는 것이 목적이다 — 두 모델이 서로를 아는
지점이 여러 곳이면 "어느 쪽이 canonical인가"라는 질문에 코드가 답하지 못한다.
## 결과
- 새 production consumer는 `..notification.platform..`만 쓴다.
- R1 type은 남지만, 새 코드가 그것을 import하면 ArchUnit이 막는다.
- R0 삭제는 이 ADR과 무관하게 진행된다. R1은 R0와 함께 사라지지 않는다.
## 하지 않은 것
R1 100개 type에 `@Deprecated(forRemoval = true)`를 붙이지 않았다. 제거 시점이 정해지지 않았고,
`forRemoval`은 "이 릴리스 이후 사라진다"는 약속이라 시점 없이 붙이면 그 자체가 거짓 신호다. 경계는
ArchUnit이 강제하고, deprecation은 제거 계획이 생길 때 붙인다.
+572
View File
@@ -0,0 +1,572 @@
# NTF-022 — public type surface of the notification platform.
# Every top-level public type under the platform packages. Growth is a reviewed
# change: ./gradlew updateNotificationApiSurface -PapproveNotificationApiChange
dev.caskeleton.adapter.outbound.notification.NotificationConfig
dev.caskeleton.adapter.outbound.notification.NotificationRoutesSettings
dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding
dev.caskeleton.adapter.outbound.notification.catalog.NotificationBindingCompiler
dev.caskeleton.adapter.outbound.notification.catalog.NotificationCanonicalRouteCatalog
dev.caskeleton.adapter.outbound.notification.catalog.NotificationCatalogException
dev.caskeleton.adapter.outbound.notification.catalog.NotificationCutoverRouteCatalog
dev.caskeleton.adapter.outbound.notification.catalog.NotificationPlanAdapter
dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderCapabilityCard
dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderCapabilityDescriptorSource
dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderDescriptor
dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderRuntimeProfile
dev.caskeleton.adapter.outbound.notification.catalog.NotificationRouteDescriptor
dev.caskeleton.adapter.outbound.notification.catalog.NotificationTemplateDescriptor
dev.caskeleton.adapter.outbound.notification.core.FailOpenNotificationProvider
dev.caskeleton.adapter.outbound.notification.core.NotificationProvider
dev.caskeleton.adapter.outbound.notification.core.RoutingNotifier
dev.caskeleton.adapter.outbound.notification.email.google.GoogleEmailClient
dev.caskeleton.adapter.outbound.notification.email.google.GoogleEmailNotificationAdapterConfig
dev.caskeleton.adapter.outbound.notification.email.google.GoogleEmailProvider
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.AssembledProvider
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformAutoConfiguration
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformMode
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformSettings
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationProviderAssembly
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.ProviderRuntimeAssembler
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.ProviderType
dev.caskeleton.adapter.outbound.notification.platform.dispatch.AttemptPermit
dev.caskeleton.adapter.outbound.notification.platform.dispatch.CapabilityReconciliationGateway
dev.caskeleton.adapter.outbound.notification.platform.dispatch.ConfiguredProfileCatalog
dev.caskeleton.adapter.outbound.notification.platform.dispatch.CredentialProbe
dev.caskeleton.adapter.outbound.notification.platform.dispatch.CredentialValidationException
dev.caskeleton.adapter.outbound.notification.platform.dispatch.JacksonRoutingPlanCodec
dev.caskeleton.adapter.outbound.notification.platform.dispatch.LeaseRecoveryService
dev.caskeleton.adapter.outbound.notification.platform.dispatch.LoggingInboxSignalPublisher
dev.caskeleton.adapter.outbound.notification.platform.dispatch.MapTemplateRendererRegistry
dev.caskeleton.adapter.outbound.notification.platform.dispatch.NotificationBackgroundWorkers
dev.caskeleton.adapter.outbound.notification.platform.dispatch.NotificationDispatchProperties
dev.caskeleton.adapter.outbound.notification.platform.dispatch.NotificationSchedulerWorker
dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderAttemptLimiter
dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderEventReplayWorker
dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderRuntime
dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderRuntimeRegistry
dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderRuntimeRotator
dev.caskeleton.adapter.outbound.notification.platform.dispatch.ReconciliationJobWorker
dev.caskeleton.adapter.outbound.notification.platform.dispatch.RegistryProviderDispatchGateway
dev.caskeleton.adapter.outbound.notification.platform.dispatch.RegistryProviderRuntimeControl
dev.caskeleton.adapter.outbound.notification.platform.dispatch.RuntimeDrainCoordinator
dev.caskeleton.adapter.outbound.notification.platform.dispatch.SingleTenantContext
dev.caskeleton.adapter.outbound.notification.platform.dispatch.UuidV7Generator
dev.caskeleton.adapter.outbound.notification.platform.observation.LoggingNotificationAudit
dev.caskeleton.adapter.outbound.notification.platform.observation.LoggingNotificationMetrics
dev.caskeleton.adapter.outbound.notification.platform.observation.NotificationHealthReporter
dev.caskeleton.adapter.outbound.notification.platform.observation.NotificationHealthSnapshot
dev.caskeleton.adapter.outbound.notification.platform.observation.NotificationServingThresholds
dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults
dev.caskeleton.adapter.outbound.notification.platform.provider.UnconfiguredAttachmentResolver
dev.caskeleton.adapter.outbound.notification.platform.provider.apns.ApnsFailureClassifier
dev.caskeleton.adapter.outbound.notification.platform.provider.apns.ApnsNotificationProviderAdapter
dev.caskeleton.adapter.outbound.notification.platform.provider.apns.ApnsProviderProperties
dev.caskeleton.adapter.outbound.notification.platform.provider.apns.ApnsRequestMapper
dev.caskeleton.adapter.outbound.notification.platform.provider.fcm.FcmBatchCoordinator
dev.caskeleton.adapter.outbound.notification.platform.provider.fcm.FcmBatchResult
dev.caskeleton.adapter.outbound.notification.platform.provider.fcm.FcmContactPointUpdater
dev.caskeleton.adapter.outbound.notification.platform.provider.fcm.FcmFailureClassifier
dev.caskeleton.adapter.outbound.notification.platform.provider.fcm.FcmGateway
dev.caskeleton.adapter.outbound.notification.platform.provider.fcm.FcmMessageMapper
dev.caskeleton.adapter.outbound.notification.platform.provider.fcm.FcmNotificationProviderAdapter
dev.caskeleton.adapter.outbound.notification.platform.provider.fcm.FcmProviderProperties
dev.caskeleton.adapter.outbound.notification.platform.provider.fcm.FcmTargetMapper
dev.caskeleton.adapter.outbound.notification.platform.provider.fcm.FcmWireTarget
dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway
dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationEndpoints
dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway
dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest
dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse
dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.AwsSignatureV4Signer
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesCallbackAdapter
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesDeliveryProjector
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesEventNormalizer
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesFailureClassifier
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesNotificationProviderAdapter
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesProviderProperties
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesRequestMapper
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesSuppressionUpdater
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SnsCertificateProvider
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SnsSignatureVerifier
dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpDispatch
dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpDispatchException
dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpFailureClassifier
dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpMimeMessageFactory
dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpNotificationProviderAdapter
dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpProviderProperties
dev.caskeleton.adapter.outbound.notification.platform.provider.twilio.TwilioCallbackAdapter
dev.caskeleton.adapter.outbound.notification.platform.provider.twilio.TwilioDeliveryProjector
dev.caskeleton.adapter.outbound.notification.platform.provider.twilio.TwilioFailureClassifier
dev.caskeleton.adapter.outbound.notification.platform.provider.twilio.TwilioProviderProperties
dev.caskeleton.adapter.outbound.notification.platform.provider.twilio.TwilioReconciliationCapability
dev.caskeleton.adapter.outbound.notification.platform.provider.twilio.TwilioRequestMapper
dev.caskeleton.adapter.outbound.notification.platform.provider.twilio.TwilioSignatureValidator
dev.caskeleton.adapter.outbound.notification.platform.provider.twilio.TwilioSmsProviderAdapter
dev.caskeleton.adapter.outbound.notification.platform.provider.twilio.TwilioStatusNormalizer
dev.caskeleton.adapter.outbound.notification.platform.provider.webhook.WebhookNotificationProviderAdapter
dev.caskeleton.adapter.outbound.notification.platform.provider.webhook.WebhookSignatureStrategy
dev.caskeleton.adapter.outbound.notification.platform.provider.webhook.WebhookSubscription
dev.caskeleton.adapter.outbound.notification.platform.provider.webpush.EncryptedWebPushPayload
dev.caskeleton.adapter.outbound.notification.platform.provider.webpush.Rfc8291Aes128GcmEncryptor
dev.caskeleton.adapter.outbound.notification.platform.provider.webpush.VapidAuthorizationProvider
dev.caskeleton.adapter.outbound.notification.platform.provider.webpush.VapidJwtSigner
dev.caskeleton.adapter.outbound.notification.platform.provider.webpush.VapidKeyRegistry
dev.caskeleton.adapter.outbound.notification.platform.provider.webpush.WebPushFailureClassifier
dev.caskeleton.adapter.outbound.notification.platform.provider.webpush.WebPushNotificationProviderAdapter
dev.caskeleton.adapter.outbound.notification.platform.provider.webpush.WebPushProviderProperties
dev.caskeleton.adapter.outbound.notification.platform.provider.webpush.WebPushReceiptCapability
dev.caskeleton.adapter.outbound.notification.platform.provider.webpush.WebPushRequestMapper
dev.caskeleton.adapter.outbound.notification.platform.reactor.ReactiveNotificationOrchestrator
dev.caskeleton.adapter.outbound.notification.platform.reactor.ReactorContextBridge
dev.caskeleton.adapter.outbound.notification.platform.reactor.ReactorNotificationOrchestrator
dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmCallbackPayloadProtection
dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector
dev.caskeleton.adapter.outbound.notification.platform.security.CredentialGeneration
dev.caskeleton.adapter.outbound.notification.platform.security.HmacProviderRequestIdHasher
dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager
dev.caskeleton.adapter.outbound.notification.platform.security.SettingsSecretMaterialProvider
dev.caskeleton.adapter.outbound.notification.platform.template.CanonicalNotificationRenderer
dev.caskeleton.adapter.outbound.notification.platform.template.JacksonInboxContentCodec
dev.caskeleton.adapter.outbound.notification.platform.template.JacksonNotificationVariablesCodec
dev.caskeleton.adapter.outbound.notification.platform.template.JacksonTemplateContentCodec
dev.caskeleton.adapter.outbound.notification.platform.template.JsonSchemaVariableValidator
dev.caskeleton.adapter.outbound.notification.platform.template.NotificationDigest
dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper
dev.caskeleton.adapter.outbound.notification.platform.template.NotificationTemplateEngine
dev.caskeleton.adapter.outbound.notification.platform.template.PlaceholderTemplateEngine
dev.caskeleton.adapter.outbound.notification.platform.template.Sha256MessageDigestAdapter
dev.caskeleton.adapter.outbound.notification.platform.template.TemplateSlotMode
dev.caskeleton.adapter.outbound.notification.platform.template.ThymeleafNotificationRenderer
dev.caskeleton.adapter.outbound.notification.platform.template.ThymeleafStringTemplateEngine
dev.caskeleton.adapter.outbound.notification.provider.AttemptCorrelationId
dev.caskeleton.adapter.outbound.notification.provider.InlineNotificationAttemptAdapter
dev.caskeleton.adapter.outbound.notification.provider.NotificationAdmissionReadinessAdapter
dev.caskeleton.adapter.outbound.notification.provider.NotificationAttemptContext
dev.caskeleton.adapter.outbound.notification.provider.NotificationProviderAttemptAdapter
dev.caskeleton.adapter.outbound.notification.provider.NotificationProviderAttemptClient
dev.caskeleton.adapter.outbound.notification.provider.NotificationProviderRateAdmission
dev.caskeleton.adapter.outbound.notification.provider.NotificationProviderReadinessProbe
dev.caskeleton.adapter.outbound.notification.provider.NotificationProviderReadinessSnapshot
dev.caskeleton.adapter.outbound.notification.provider.NotificationProviderSecretMaterialProvider
dev.caskeleton.adapter.outbound.notification.provider.NotificationReconciliationAdapter
dev.caskeleton.adapter.outbound.notification.provider.NotificationSecretMaterialHandle
dev.caskeleton.adapter.outbound.notification.provider.PreparedNotificationAttempt
dev.caskeleton.adapter.outbound.notification.provider.ProviderMessageReference
dev.caskeleton.adapter.outbound.notification.provider.ReconciliationLookupMode
dev.caskeleton.adapter.outbound.notification.slack.webhook.SlackClient
dev.caskeleton.adapter.outbound.notification.slack.webhook.SlackNotificationAdapterConfig
dev.caskeleton.adapter.outbound.notification.slack.webhook.SlackWebhookProvider
dev.caskeleton.adapter.outbound.notification.template.LocalEmailRenderer
dev.caskeleton.adapter.outbound.notification.template.NotificationTemplateCatalog
dev.caskeleton.adapter.outbound.notification.template.NotificationTemplateManifest
dev.caskeleton.adapter.outbound.notification.template.NotificationTemplateRenderer
dev.caskeleton.adapter.outbound.notification.template.RenderedNotification
dev.caskeleton.adapter.outbound.notification.template.SlackBlockKitRenderer
dev.caskeleton.adapter.outbound.notification.template.TemplateRenderingException
dev.caskeleton.application.notification.ApplyNotificationReceiptCommand
dev.caskeleton.application.notification.ApplyNotificationReceiptResult
dev.caskeleton.application.notification.ApplyNotificationReceiptUseCase
dev.caskeleton.application.notification.Channel
dev.caskeleton.application.notification.ConsentCheckMode
dev.caskeleton.application.notification.EmailRecipientReference
dev.caskeleton.application.notification.InitializeNotificationWriterFencesCommand
dev.caskeleton.application.notification.InitializeNotificationWriterFencesOperation
dev.caskeleton.application.notification.InitializeNotificationWriterFencesResult
dev.caskeleton.application.notification.InitializeNotificationWriterFencesUseCase
dev.caskeleton.application.notification.InlineNotificationAttemptPort
dev.caskeleton.application.notification.NormalizedNotificationReceiptCommand
dev.caskeleton.application.notification.Notification
dev.caskeleton.application.notification.NotificationAdmissionClass
dev.caskeleton.application.notification.NotificationAdmissionGateCommand
dev.caskeleton.application.notification.NotificationAdmissionGateUseCase
dev.caskeleton.application.notification.NotificationAdmissionReadinessPort
dev.caskeleton.application.notification.NotificationAppendResult
dev.caskeleton.application.notification.NotificationApplicationException
dev.caskeleton.application.notification.NotificationAttemptId
dev.caskeleton.application.notification.NotificationCanonicalWriterFenceGuard
dev.caskeleton.application.notification.NotificationCanonicalWriterFencePort
dev.caskeleton.application.notification.NotificationCanonicalWriterRouteSet
dev.caskeleton.application.notification.NotificationCapabilityCompatibilityValidator
dev.caskeleton.application.notification.NotificationChannel
dev.caskeleton.application.notification.NotificationDeliveryId
dev.caskeleton.application.notification.NotificationDeliveryStorePort
dev.caskeleton.application.notification.NotificationDispatchCommand
dev.caskeleton.application.notification.NotificationDispatchResult
dev.caskeleton.application.notification.NotificationDispatchUseCase
dev.caskeleton.application.notification.NotificationEvidenceTrustSnapshot
dev.caskeleton.application.notification.NotificationFaultScope
dev.caskeleton.application.notification.NotificationFrozenPlan
dev.caskeleton.application.notification.NotificationIntentAppendPort
dev.caskeleton.application.notification.NotificationIntentDraft
dev.caskeleton.application.notification.NotificationIntentId
dev.caskeleton.application.notification.NotificationKindId
dev.caskeleton.application.notification.NotificationKindPolicy
dev.caskeleton.application.notification.NotificationLegacyWriterPermitCommand
dev.caskeleton.application.notification.NotificationLegacyWriterPermitResult
dev.caskeleton.application.notification.NotificationLegacyWriterPermitUseCase
dev.caskeleton.application.notification.NotificationMaintenanceCommand
dev.caskeleton.application.notification.NotificationMaintenanceResult
dev.caskeleton.application.notification.NotificationMaintenanceStorePort
dev.caskeleton.application.notification.NotificationMaintenanceUseCase
dev.caskeleton.application.notification.NotificationMode
dev.caskeleton.application.notification.NotificationOperationsSnapshot
dev.caskeleton.application.notification.NotificationOperationsSnapshotPort
dev.caskeleton.application.notification.NotificationOperationsSnapshotQuery
dev.caskeleton.application.notification.NotificationOperationsSnapshotUseCase
dev.caskeleton.application.notification.NotificationPlanPort
dev.caskeleton.application.notification.NotificationPlanningResult
dev.caskeleton.application.notification.NotificationPort
dev.caskeleton.application.notification.NotificationProviderAttemptPort
dev.caskeleton.application.notification.NotificationProviderCapabilityDescriptor
dev.caskeleton.application.notification.NotificationReasonCode
dev.caskeleton.application.notification.NotificationReceiptEventId
dev.caskeleton.application.notification.NotificationReceiptFact
dev.caskeleton.application.notification.NotificationReceiptIngressCapabilityDescriptor
dev.caskeleton.application.notification.NotificationReceiptProjection
dev.caskeleton.application.notification.NotificationReceiptStorePort
dev.caskeleton.application.notification.NotificationRecipientReference
dev.caskeleton.application.notification.NotificationReconciliationPort
dev.caskeleton.application.notification.NotificationRequestResult
dev.caskeleton.application.notification.NotificationRouteId
dev.caskeleton.application.notification.NotificationRouteStrategy
dev.caskeleton.application.notification.NotificationSignedEvidenceHeader
dev.caskeleton.application.notification.NotificationStoreCapabilityDescriptor
dev.caskeleton.application.notification.NotificationTechnicalSuppressionPort
dev.caskeleton.application.notification.NotificationTemplateParameters
dev.caskeleton.application.notification.NotificationTemplateRef
dev.caskeleton.application.notification.NotificationTemplateValue
dev.caskeleton.application.notification.NotificationWriterCutoverPort
dev.caskeleton.application.notification.NotificationWriterInventoryEvidence
dev.caskeleton.application.notification.NotificationWriterInventoryEvidenceVerifierPort
dev.caskeleton.application.notification.NotificationWriterOwnership
dev.caskeleton.application.notification.NotificationWriterQuiescenceAttestationPort
dev.caskeleton.application.notification.NotificationWriterRouteSet
dev.caskeleton.application.notification.ProviderAttemptOutcome
dev.caskeleton.application.notification.ReconcileNotificationDeliveriesCommand
dev.caskeleton.application.notification.ReconcileNotificationDeliveriesResult
dev.caskeleton.application.notification.ReconcileNotificationDeliveriesUseCase
dev.caskeleton.application.notification.RecordNotificationWriterQuiescenceAttestationCommand
dev.caskeleton.application.notification.RecordNotificationWriterQuiescenceAttestationOperation
dev.caskeleton.application.notification.RecordNotificationWriterQuiescenceAttestationResult
dev.caskeleton.application.notification.RecordNotificationWriterQuiescenceAttestationUseCase
dev.caskeleton.application.notification.RetryDisposition
dev.caskeleton.application.notification.SignedNotificationWriterInventoryManifest
dev.caskeleton.application.notification.SignedNotificationWriterQuiescenceManifest
dev.caskeleton.application.notification.SlackAudienceReference
dev.caskeleton.application.notification.SubmissionCertainty
dev.caskeleton.application.notification.SwitchNotificationWriterOwnershipCommand
dev.caskeleton.application.notification.SwitchNotificationWriterOwnershipOperation
dev.caskeleton.application.notification.SwitchNotificationWriterOwnershipResult
dev.caskeleton.application.notification.SwitchNotificationWriterOwnershipUseCase
dev.caskeleton.application.notification.TargetAttemptOutcome
dev.caskeleton.application.notification.TerminalizeExpiredNotificationWriterPermitsCommand
dev.caskeleton.application.notification.TerminalizeExpiredNotificationWriterPermitsOperation
dev.caskeleton.application.notification.TerminalizeExpiredNotificationWriterPermitsResult
dev.caskeleton.application.notification.TerminalizeExpiredNotificationWriterPermitsUseCase
dev.caskeleton.application.notification.platform.admin.AdminAccessDeniedException
dev.caskeleton.application.notification.platform.admin.AdminActor
dev.caskeleton.application.notification.platform.admin.AdminAuthorizationGuard
dev.caskeleton.application.notification.platform.admin.AdminOperationClaim
dev.caskeleton.application.notification.platform.admin.AdminOperationResult
dev.caskeleton.application.notification.platform.admin.AdminOperationStorePort
dev.caskeleton.application.notification.platform.admin.DuplicateRiskApprovalRequiredException
dev.caskeleton.application.notification.platform.admin.DuplicateRiskGuard
dev.caskeleton.application.notification.platform.admin.NotificationAdminApplicationService
dev.caskeleton.application.notification.platform.admin.NotificationAdminAuthority
dev.caskeleton.application.notification.platform.admin.NotificationAdminService
dev.caskeleton.application.notification.platform.admin.ProviderRuntimeControlPort
dev.caskeleton.application.notification.platform.admin.ReconcileCommand
dev.caskeleton.application.notification.platform.admin.RedriveCommand
dev.caskeleton.application.notification.platform.admin.SetProviderStateCommand
dev.caskeleton.application.notification.platform.admin.SuppressCommand
dev.caskeleton.application.notification.platform.api.CallbackIngestionResult
dev.caskeleton.application.notification.platform.api.CallbackRequest
dev.caskeleton.application.notification.platform.api.CancelCommand
dev.caskeleton.application.notification.platform.api.CancelResult
dev.caskeleton.application.notification.platform.api.ChannelPreferenceOverride
dev.caskeleton.application.notification.platform.api.CollapseScope
dev.caskeleton.application.notification.platform.api.CollapseSpec
dev.caskeleton.application.notification.platform.api.ContactPointId
dev.caskeleton.application.notification.platform.api.ContactPointSelector
dev.caskeleton.application.notification.platform.api.CorrelationId
dev.caskeleton.application.notification.platform.api.DeduplicationAction
dev.caskeleton.application.notification.platform.api.DeduplicationSpec
dev.caskeleton.application.notification.platform.api.DeliveryAttemptId
dev.caskeleton.application.notification.platform.api.EncodedNotificationPlan
dev.caskeleton.application.notification.platform.api.IdempotencyKey
dev.caskeleton.application.notification.platform.api.NotificationAcceptance
dev.caskeleton.application.notification.platform.api.NotificationId
dev.caskeleton.application.notification.platform.api.NotificationOrchestrator
dev.caskeleton.application.notification.platform.api.NotificationPlan
dev.caskeleton.application.notification.platform.api.NotificationReceipt
dev.caskeleton.application.notification.platform.api.NotificationSnapshot
dev.caskeleton.application.notification.platform.api.NotificationVariable
dev.caskeleton.application.notification.platform.api.ProviderEventId
dev.caskeleton.application.notification.platform.api.ProviderId
dev.caskeleton.application.notification.platform.api.ProviderProfileId
dev.caskeleton.application.notification.platform.api.RecipientDeliveryId
dev.caskeleton.application.notification.platform.api.RecipientSpec
dev.caskeleton.application.notification.platform.api.RequestStatus
dev.caskeleton.application.notification.platform.api.TemplateSelection
dev.caskeleton.application.notification.platform.api.TenantId
dev.caskeleton.application.notification.platform.api.content.AttachmentDisposition
dev.caskeleton.application.notification.platform.api.content.AttachmentRef
dev.caskeleton.application.notification.platform.api.content.EmailContent
dev.caskeleton.application.notification.platform.api.content.EmailOptions
dev.caskeleton.application.notification.platform.api.content.InAppAction
dev.caskeleton.application.notification.platform.api.content.InAppContent
dev.caskeleton.application.notification.platform.api.content.MobilePushContent
dev.caskeleton.application.notification.platform.api.content.NotificationContent
dev.caskeleton.application.notification.platform.api.content.PushPresentation
dev.caskeleton.application.notification.platform.api.content.SmsContent
dev.caskeleton.application.notification.platform.api.content.SmsOptions
dev.caskeleton.application.notification.platform.api.content.WebPushContent
dev.caskeleton.application.notification.platform.api.content.WebPushOptions
dev.caskeleton.application.notification.platform.api.delivery.AttemptConfirmation
dev.caskeleton.application.notification.platform.api.delivery.DeliveryOutcome
dev.caskeleton.application.notification.platform.api.delivery.EvidenceLevel
dev.caskeleton.application.notification.platform.api.delivery.RecipientDeliveryState
dev.caskeleton.application.notification.platform.api.delivery.SubmissionOutcome
dev.caskeleton.application.notification.platform.api.error.AmbiguousSubmissionException
dev.caskeleton.application.notification.platform.api.error.AttachmentIntegrityException
dev.caskeleton.application.notification.platform.api.error.AttachmentUnavailableException
dev.caskeleton.application.notification.platform.api.error.CallbackProjectionException
dev.caskeleton.application.notification.platform.api.error.CallbackValidationException
dev.caskeleton.application.notification.platform.api.error.FailureCategory
dev.caskeleton.application.notification.platform.api.error.IdempotencyConflictException
dev.caskeleton.application.notification.platform.api.error.InvalidContactPointException
dev.caskeleton.application.notification.platform.api.error.NotificationCapacityException
dev.caskeleton.application.notification.platform.api.error.NotificationException
dev.caskeleton.application.notification.platform.api.error.NotificationExpiredException
dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode
dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor
dev.caskeleton.application.notification.platform.api.error.NotificationSuppressedException
dev.caskeleton.application.notification.platform.api.error.NotificationValidationException
dev.caskeleton.application.notification.platform.api.error.ProviderAuthenticationException
dev.caskeleton.application.notification.platform.api.error.ProviderAuthorizationException
dev.caskeleton.application.notification.platform.api.error.ProviderConfigurationException
dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException
dev.caskeleton.application.notification.platform.api.error.ProviderPermanentException
dev.caskeleton.application.notification.platform.api.error.ProviderRejectedException
dev.caskeleton.application.notification.platform.api.error.ProviderThrottledException
dev.caskeleton.application.notification.platform.api.error.ProviderTransientException
dev.caskeleton.application.notification.platform.api.error.ProviderUnavailableException
dev.caskeleton.application.notification.platform.api.error.ReconciliationException
dev.caskeleton.application.notification.platform.api.error.TemplateNotFoundException
dev.caskeleton.application.notification.platform.api.error.TemplateRenderingException
dev.caskeleton.application.notification.platform.api.error.TemplateVariableValidationException
dev.caskeleton.application.notification.platform.api.routing.Channel
dev.caskeleton.application.notification.platform.api.routing.DeliveryStrategy
dev.caskeleton.application.notification.platform.api.routing.ExplicitChannel
dev.caskeleton.application.notification.platform.api.routing.OrderedFallback
dev.caskeleton.application.notification.platform.callback.AppendEventResult
dev.caskeleton.application.notification.platform.callback.CallbackLimits
dev.caskeleton.application.notification.platform.callback.CallbackPayloadProtectionPort
dev.caskeleton.application.notification.platform.callback.CallbackVerificationResult
dev.caskeleton.application.notification.platform.callback.DeliveryAttemptResolverPort
dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot
dev.caskeleton.application.notification.platform.callback.DeliveryProjection
dev.caskeleton.application.notification.platform.callback.DeliveryProjectionStorePort
dev.caskeleton.application.notification.platform.callback.EngagementFacts
dev.caskeleton.application.notification.platform.callback.IngestProviderCallbackApplicationUseCase
dev.caskeleton.application.notification.platform.callback.NormalizedEventType
dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent
dev.caskeleton.application.notification.platform.callback.NotificationSideEffectPort
dev.caskeleton.application.notification.platform.callback.ProjectionResult
dev.caskeleton.application.notification.platform.callback.ProjectionStatus
dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapter
dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapterRegistry
dev.caskeleton.application.notification.platform.callback.ProviderEventLedger
dev.caskeleton.application.notification.platform.callback.ProviderEventProjectionService
dev.caskeleton.application.notification.platform.callback.ProviderEventProjector
dev.caskeleton.application.notification.platform.callback.ProviderEventProjectorRegistry
dev.caskeleton.application.notification.platform.callback.ProviderEventRecord
dev.caskeleton.application.notification.platform.callback.ProviderEventRecordId
dev.caskeleton.application.notification.platform.callback.ProviderEventSource
dev.caskeleton.application.notification.platform.callback.StandardDeliveryProjector
dev.caskeleton.application.notification.platform.callback.SuppressionFacts
dev.caskeleton.application.notification.platform.callback.VerifiedCallback
dev.caskeleton.application.notification.platform.callback.VerifiedProviderEvent
dev.caskeleton.application.notification.platform.contact.ApnsDeviceToken
dev.caskeleton.application.notification.platform.contact.ApnsEnvironment
dev.caskeleton.application.notification.platform.contact.ContactPointStatus
dev.caskeleton.application.notification.platform.contact.ContactPointType
dev.caskeleton.application.notification.platform.contact.ContactPointValue
dev.caskeleton.application.notification.platform.contact.EmailAddress
dev.caskeleton.application.notification.platform.contact.FcmInstallationId
dev.caskeleton.application.notification.platform.contact.InAppRecipientRef
dev.caskeleton.application.notification.platform.contact.LegacyFcmRegistrationToken
dev.caskeleton.application.notification.platform.contact.MobilePushTarget
dev.caskeleton.application.notification.platform.contact.PhoneNumber
dev.caskeleton.application.notification.platform.contact.WebPushSubscriptionValue
dev.caskeleton.application.notification.platform.dispatch.ApplicationReceiptServiceImpl
dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard
dev.caskeleton.application.notification.platform.dispatch.CancelNotificationApplicationUseCase
dev.caskeleton.application.notification.platform.dispatch.CanonicalNotificationPlanEncoder
dev.caskeleton.application.notification.platform.dispatch.CanonicalNotificationPlanWriter
dev.caskeleton.application.notification.platform.dispatch.ContactPointRecord
dev.caskeleton.application.notification.platform.dispatch.ContactPointStorePort
dev.caskeleton.application.notification.platform.dispatch.DeliveryAttemptFactory
dev.caskeleton.application.notification.platform.dispatch.DeliveryAttemptRecord
dev.caskeleton.application.notification.platform.dispatch.DeliveryAttemptStorePort
dev.caskeleton.application.notification.platform.dispatch.DispatchGuardOutcome
dev.caskeleton.application.notification.platform.dispatch.DispatchOutcomeRecorder
dev.caskeleton.application.notification.platform.dispatch.DispatchPipeline
dev.caskeleton.application.notification.platform.dispatch.DuplicateIdempotencyKeyException
dev.caskeleton.application.notification.platform.dispatch.GetNotificationApplicationUseCase
dev.caskeleton.application.notification.platform.dispatch.MessageDigestPort
dev.caskeleton.application.notification.platform.dispatch.NotificationDispatchService
dev.caskeleton.application.notification.platform.dispatch.NotificationIdGeneratorPort
dev.caskeleton.application.notification.platform.dispatch.NotificationRequestInsertOutcome
dev.caskeleton.application.notification.platform.dispatch.NotificationRequestRecord
dev.caskeleton.application.notification.platform.dispatch.NotificationRequestStatusPolicy
dev.caskeleton.application.notification.platform.dispatch.NotificationRequestStorePort
dev.caskeleton.application.notification.platform.dispatch.NotificationRoutePlannerPort
dev.caskeleton.application.notification.platform.dispatch.NotificationRoutingPlanCodecPort
dev.caskeleton.application.notification.platform.dispatch.NotificationSubmissionService
dev.caskeleton.application.notification.platform.dispatch.NotificationVariablesCodecPort
dev.caskeleton.application.notification.platform.dispatch.PolicyRoutePlanner
dev.caskeleton.application.notification.platform.dispatch.ProviderDispatchGatewayPort
dev.caskeleton.application.notification.platform.dispatch.ProviderProfileCatalogPort
dev.caskeleton.application.notification.platform.dispatch.ProviderRequestIdHasherPort
dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryRecord
dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryStorePort
dev.caskeleton.application.notification.platform.dispatch.RecipientLease
dev.caskeleton.application.notification.platform.dispatch.RecipientLeaseStorePort
dev.caskeleton.application.notification.platform.dispatch.ReconciliationGatewayPort
dev.caskeleton.application.notification.platform.dispatch.ReconciliationJob
dev.caskeleton.application.notification.platform.dispatch.ReconciliationJobStorePort
dev.caskeleton.application.notification.platform.dispatch.ReconciliationService
dev.caskeleton.application.notification.platform.dispatch.RequestFingerprint
dev.caskeleton.application.notification.platform.dispatch.ScheduleNotificationApplicationUseCase
dev.caskeleton.application.notification.platform.dispatch.SubmitNotificationApplicationUseCase
dev.caskeleton.application.notification.platform.dispatch.SyntheticEventFingerprint
dev.caskeleton.application.notification.platform.dispatch.TemplateRendererRegistry
dev.caskeleton.application.notification.platform.dispatch.TenantContextPort
dev.caskeleton.application.notification.platform.email.EmailNotification
dev.caskeleton.application.notification.platform.email.EmailNotifier
dev.caskeleton.application.notification.platform.inbox.CreateInboxItemCommand
dev.caskeleton.application.notification.platform.inbox.InboxContentCodecPort
dev.caskeleton.application.notification.platform.inbox.InboxCursor
dev.caskeleton.application.notification.platform.inbox.InboxItem
dev.caskeleton.application.notification.platform.inbox.InboxItemCreated
dev.caskeleton.application.notification.platform.inbox.InboxItemId
dev.caskeleton.application.notification.platform.inbox.InboxItemState
dev.caskeleton.application.notification.platform.inbox.InboxMutationResult
dev.caskeleton.application.notification.platform.inbox.InboxPage
dev.caskeleton.application.notification.platform.inbox.InboxPrincipal
dev.caskeleton.application.notification.platform.inbox.InboxQuery
dev.caskeleton.application.notification.platform.inbox.MarkAllReadCommand
dev.caskeleton.application.notification.platform.inbox.NotificationInbox
dev.caskeleton.application.notification.platform.inbox.NotificationInboxSignalPort
dev.caskeleton.application.notification.platform.observation.CardinalityGuard
dev.caskeleton.application.notification.platform.observation.IllegalMetricTagException
dev.caskeleton.application.notification.platform.observation.NotificationAuditEvent
dev.caskeleton.application.notification.platform.observation.NotificationAuditPort
dev.caskeleton.application.notification.platform.observation.NotificationMetricName
dev.caskeleton.application.notification.platform.observation.NotificationMetricsPort
dev.caskeleton.application.notification.platform.observation.NotificationSecurityAuditPort
dev.caskeleton.application.notification.platform.observation.NotificationServingState
dev.caskeleton.application.notification.platform.observation.NotificationServingStatePort
dev.caskeleton.application.notification.platform.observation.SensitiveValueDetector
dev.caskeleton.application.notification.platform.policy.CompositeNotificationEligibilityPolicy
dev.caskeleton.application.notification.platform.policy.ConsentRecord
dev.caskeleton.application.notification.platform.policy.ConsentStorePort
dev.caskeleton.application.notification.platform.policy.DeduplicationResult
dev.caskeleton.application.notification.platform.policy.DeduplicationService
dev.caskeleton.application.notification.platform.policy.DeduplicationStorePort
dev.caskeleton.application.notification.platform.policy.DefaultNotificationRetryPolicy
dev.caskeleton.application.notification.platform.policy.EligibilityResult
dev.caskeleton.application.notification.platform.policy.JitterSource
dev.caskeleton.application.notification.platform.policy.NotificationContext
dev.caskeleton.application.notification.platform.policy.NotificationEligibilityPolicy
dev.caskeleton.application.notification.platform.policy.NotificationRetryPolicy
dev.caskeleton.application.notification.platform.policy.PreferenceRecord
dev.caskeleton.application.notification.platform.policy.PreferenceStorePort
dev.caskeleton.application.notification.platform.policy.RecipientIdentity
dev.caskeleton.application.notification.platform.policy.RetryBackoff
dev.caskeleton.application.notification.platform.policy.RetryBudget
dev.caskeleton.application.notification.platform.policy.RetryContext
dev.caskeleton.application.notification.platform.policy.RetryDecision
dev.caskeleton.application.notification.platform.policy.RouteCandidate
dev.caskeleton.application.notification.platform.policy.RoutingContext
dev.caskeleton.application.notification.platform.policy.RoutingDecision
dev.caskeleton.application.notification.platform.policy.RoutingDecisionEngine
dev.caskeleton.application.notification.platform.policy.SuppressionEntry
dev.caskeleton.application.notification.platform.policy.SuppressionId
dev.caskeleton.application.notification.platform.policy.SuppressionReason
dev.caskeleton.application.notification.platform.policy.SuppressionScope
dev.caskeleton.application.notification.platform.policy.SuppressionSource
dev.caskeleton.application.notification.platform.policy.SuppressionStorePort
dev.caskeleton.application.notification.platform.port.in.CancelNotificationCommand
dev.caskeleton.application.notification.platform.port.in.CancelNotificationUseCase
dev.caskeleton.application.notification.platform.port.in.GetNotificationQuery
dev.caskeleton.application.notification.platform.port.in.GetNotificationUseCase
dev.caskeleton.application.notification.platform.port.in.IngestProviderCallbackCommand
dev.caskeleton.application.notification.platform.port.in.IngestProviderCallbackUseCase
dev.caskeleton.application.notification.platform.port.in.ScheduleNotificationCommand
dev.caskeleton.application.notification.platform.port.in.ScheduleNotificationUseCase
dev.caskeleton.application.notification.platform.port.in.SubmitNotificationCommand
dev.caskeleton.application.notification.platform.port.in.SubmitNotificationUseCase
dev.caskeleton.application.notification.platform.provider.AttachmentAccessContext
dev.caskeleton.application.notification.platform.provider.AttachmentResolver
dev.caskeleton.application.notification.platform.provider.BatchNotificationProviderAdapter
dev.caskeleton.application.notification.platform.provider.CollapseCapability
dev.caskeleton.application.notification.platform.provider.EvidenceCertainty
dev.caskeleton.application.notification.platform.provider.EvidenceFact
dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter
dev.caskeleton.application.notification.platform.provider.ProviderCallNotStartedException
dev.caskeleton.application.notification.platform.provider.ProviderCapabilities
dev.caskeleton.application.notification.platform.provider.ProviderCollapseMapping
dev.caskeleton.application.notification.platform.provider.ProviderExecutionEvidence
dev.caskeleton.application.notification.platform.provider.ProviderFailure
dev.caskeleton.application.notification.platform.provider.ProviderProfileSnapshot
dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState
dev.caskeleton.application.notification.platform.provider.ProviderSubmission
dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult
dev.caskeleton.application.notification.platform.provider.ReconciliationCapability
dev.caskeleton.application.notification.platform.provider.ReconciliationResult
dev.caskeleton.application.notification.platform.provider.ResolvedAttachment
dev.caskeleton.application.notification.platform.provider.TraceContext
dev.caskeleton.application.notification.platform.push.ApplicationIdentity
dev.caskeleton.application.notification.platform.push.ApplicationReceipt
dev.caskeleton.application.notification.platform.push.ApplicationReceiptService
dev.caskeleton.application.notification.platform.push.MobilePushNotification
dev.caskeleton.application.notification.platform.push.MobilePushNotifier
dev.caskeleton.application.notification.platform.push.ReceiptAuthorizationException
dev.caskeleton.application.notification.platform.push.ReceiptKind
dev.caskeleton.application.notification.platform.push.ReceiptResult
dev.caskeleton.application.notification.platform.security.AccessContext
dev.caskeleton.application.notification.platform.security.ContactPointProtector
dev.caskeleton.application.notification.platform.security.NotificationRedactor
dev.caskeleton.application.notification.platform.security.ProtectedContactPoint
dev.caskeleton.application.notification.platform.security.SafeDiagnosticContext
dev.caskeleton.application.notification.platform.security.SecretKeyMaterial
dev.caskeleton.application.notification.platform.security.SecretMaterialProvider
dev.caskeleton.application.notification.platform.security.SecretPurpose
dev.caskeleton.application.notification.platform.security.SensitiveValueClassifier
dev.caskeleton.application.notification.platform.security.SensitiveValueKind
dev.caskeleton.application.notification.platform.security.UnsafeDiagnosticFieldException
dev.caskeleton.application.notification.platform.sms.E164PhoneNumberParser
dev.caskeleton.application.notification.platform.sms.GsmAlphabet
dev.caskeleton.application.notification.platform.sms.InvalidPhoneNumberException
dev.caskeleton.application.notification.platform.sms.SmsEncoding
dev.caskeleton.application.notification.platform.sms.SmsEstimate
dev.caskeleton.application.notification.platform.sms.SmsNotification
dev.caskeleton.application.notification.platform.sms.SmsNotifier
dev.caskeleton.application.notification.platform.sms.SmsSegmentEstimator
dev.caskeleton.application.notification.platform.template.NotificationTemplateRenderer
dev.caskeleton.application.notification.platform.template.NotificationTemplateVersion
dev.caskeleton.application.notification.platform.template.RenderCommand
dev.caskeleton.application.notification.platform.template.RenderedNotificationContent
dev.caskeleton.application.notification.platform.template.TemplateContentCodecPort
dev.caskeleton.application.notification.platform.template.TemplateContentDefinition
dev.caskeleton.application.notification.platform.template.TemplateRegistry
dev.caskeleton.application.notification.platform.template.TemplateSlot
dev.caskeleton.application.notification.platform.template.TemplateStatus
dev.caskeleton.application.notification.platform.template.TemplateVariableValidator
dev.caskeleton.application.notification.platform.template.TemplateVersionConflictException
dev.caskeleton.application.notification.platform.template.VariableSchema
dev.caskeleton.application.notification.platform.webpush.WebPushNotification
dev.caskeleton.application.notification.platform.webpush.WebPushNotifier
@@ -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.
@@ -0,0 +1,99 @@
# Configuration reference
The notification delivery platform binds under `ca-skeleton.notification.platform`. The tree lives in
`src/app-bootstrap/src/main/resources/application.yml`, disabled by default, and every value carries
an inline default so a deployment that leaves the platform off supplies nothing.
Until 2026-08-15 this page named properties the binding did not have — `max-retry-concurrency`,
`scheduler-poll-interval`, `callback-worker-concurrency` — and omitted three it did. There was no
tree in `application.yml` at all, so the only way to configure the platform was to guess environment
variable names from Boot's relaxed binding. `./gradlew verifyNotificationConfiguration` now fails
when this page, the YAML tree and `docs/registries/env-keys.yaml` disagree.
## Master switch
| Property | Environment variable | Default | Meaning |
|---|---|---|---|
| `enabled` | `APP_NOTIFICATION_PLATFORM_ENABLED` | `false` | Binds nothing at all while false: no runtime, no schema check, no scheduler thread, no secret required |
| `mode` | `APP_NOTIFICATION_PLATFORM_MODE` | `SERVING` | `SERVING` refuses to start without a working provider; `ACCEPT_ONLY` stores requests and does not dispatch |
## Dispatch
| Property | Environment variable | Default | Bound |
|---|---|---|---|
| `dispatch.claim-batch-size` | `APP_NOTIFICATION_PLATFORM_CLAIM_BATCH_SIZE` | `50` | 1..1000 |
| `dispatch.lease-duration` | `APP_NOTIFICATION_PLATFORM_LEASE_DURATION` | `2m` | positive, finite |
| `dispatch.poll-interval` | `APP_NOTIFICATION_PLATFORM_POLL_INTERVAL` | `1s` | positive, finite |
| `dispatch.max-global-concurrency` | `APP_NOTIFICATION_PLATFORM_MAX_CONCURRENCY` | `64` | positive |
| `dispatch.max-additional-attempts` | `APP_NOTIFICATION_PLATFORM_MAX_ADDITIONAL_ATTEMPTS` | `4` | non-negative |
| `dispatch.max-queue-age` | `APP_NOTIFICATION_PLATFORM_MAX_QUEUE_AGE` | `24h` | positive |
| `dispatch.allow-ambiguous-fallback` | `APP_NOTIFICATION_PLATFORM_ALLOW_AMBIGUOUS_FALLBACK` | `false` | boolean |
Every value is bounded. "Unlimited" is not an accepted configuration.
The lease must outlast a provider call plus its timeout. Below that, a delivery a live worker is
still waiting on gets claimed by a second worker, and the recipient receives the notification twice.
## Callbacks
| Property | Environment variable | Default | Bound |
|---|---|---|---|
| `callbacks.enabled` | `APP_NOTIFICATION_PLATFORM_CALLBACKS_ENABLED` | `false` | boolean |
| `callbacks.max-body-bytes` | `APP_NOTIFICATION_PLATFORM_CALLBACK_MAX_BODY_BYTES` | `65508` | 1..65508 |
| `callbacks.replay-skew` | `APP_NOTIFICATION_PLATFORM_CALLBACK_REPLAY_SKEW` | `5m` | positive |
65508 is not a round number by accident: it is the ciphertext column's 65536 bytes minus the AES-GCM
nonce and tag. A larger configured value would pass every check above the database and fail the
`CHECK` constraint after the callback had already been acknowledged to the provider.
## Provider profiles
Profiles are a map under `providers`, keyed by profile id. There are no environment variables for
them, because the keys are deployment-chosen; supply them as YAML or as
`CA_SKELETON_NOTIFICATION_PLATFORM_PROVIDERS_<ID>_<FIELD>`.
| Field | Meaning |
|---|---|
| `type` | `APNS`, `FCM`, `SES`, `SMTP`, `TWILIO`, `WEB_PUSH`, `WEBHOOK` — a closed enum, so an unknown value fails binding rather than assembling into nothing |
| `enabled` | A disabled profile is bound and validated but contributes no runtime |
| `primary-for-channel` | Exactly one primary per channel |
| `environment` | Required when enabled |
| `credential-profile` | Resolved through `SecretMaterialProvider`; never an inline secret |
| `topic` | APNs bundle id |
| `vapid-public-key` | Web Push application server key |
| `callback-signing-secret-ref` | Reference, not material |
| `timeout` | Positive and finite |
| `max-concurrency` | Positive |
| `rate-per-second` | Positive |
A profile pins provider type, environment, credential profile, timeouts, concurrency and rate limit.
Sender identity and credential profile are separate concerns.
## Startup failures
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
- `mode` is `SERVING` and no provider profile is enabled
- the notification schema stream is not applied and promoted
## Secrets
All key material arrives through `SecretMaterialProvider`. Nothing is read from source, from a
committed file, or from a plaintext log. Contact point encryption and lookup HMAC keys must be
distinct, and the encryption key must be exactly 256 bits.
## Readiness
The platform contributes a `notifications` actuator endpoint and a health indicator. It reports DOWN
when a provider's credentials were rejected, when a configured provider has no channel route, and
when the measured backlog, stuck-lease count, projection lag or reconciliation lag passes the
thresholds in `NotificationServingThresholds`. See [operations.md](operations.md).
+62
View File
@@ -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.
+77
View File
@@ -0,0 +1,77 @@
{
"$comment": [
"NTF-024 — what each support grade requires, as executable artifacts rather than prose.",
"A grade in support-matrix.md is a promise about production behaviour. The workflow that was",
"supposed to back those promises ran a unit subset on PR, a job named 'restart recovery' that",
"ran no restart, and a provider sandbox job whose entire body was two echo statements behind",
"continue-on-error. So the strongest claim in the document rested on the weakest evidence in",
"the pipeline, and nothing connected the two.",
"verifyNotificationEvidence reads this file, checks that every claim marked satisfied names",
"test classes that exist, and refuses a grade whose claims are not all satisfied."
],
"claims": {
"durable": {
"requires": "PostgreSQL migration, CRUD against the real schema, and survival of a restart",
"status": "satisfied",
"lane": ":adapter:outbound:persistence-jpa:jpaPlatformContractTest",
"evidence": [
"adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/RecipientClaimContractTest.java",
"adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/ProjectionFactDurabilityContractTest.java",
"adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/ServingStateContractTest.java"
]
},
"multi-worker-safe": {
"requires": "two workers racing the same claim against a real database, with lease fencing",
"status": "satisfied",
"lane": ":adapter:outbound:persistence-jpa:jpaPlatformContractTest",
"evidence": [
"adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/RecipientClaimContractTest.java",
"adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlRecipientLeaseFencingIntegrationTest.java"
]
},
"callback-supported": {
"requires": "signature verification, duplicate suppression, and an event that arrives before its attempt is stored",
"status": "satisfied",
"lane": ":adapter:outbound:persistence-jpa:jpaPlatformContractTest",
"evidence": [
"adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/LateEventBindingContractTest.java",
"application-core/src/test/java/dev/caskeleton/application/notification/platform/callback/CallbackIngestionAtomicityTest.java"
]
},
"recoverable": {
"requires": "a process-kill matrix covering each dispatch phase, proving no delivery is lost or duplicated",
"status": "satisfied",
"lane": ":adapter:outbound:persistence-jpa:jpaPlatformContractTest",
"evidence": [
"adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/WorkerCrashRecoveryContractTest.java"
]
},
"provider-wire-qualified": {
"requires": "a real provider sandbox call producing an immutable, uploaded evidence artifact with a correlation id",
"status": "unsatisfied",
"lane": null,
"evidence": [],
"gap": "The provider-sandbox job runs two echo statements behind continue-on-error. No request has ever left the process, so no provider protocol is qualified against its real endpoint."
}
},
"grades": {
"Stable": [
"durable",
"multi-worker-safe",
"callback-supported",
"recoverable",
"provider-wire-qualified"
],
"Contract implemented / runtime unqualified": [
"durable",
"multi-worker-safe",
"callback-supported"
],
"Optional stable": [
"durable"
],
"Extension": [],
"Experimental": []
},
"matrixDocument": "docs/notification/support-matrix.md"
}
+44
View File
@@ -0,0 +1,44 @@
# 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.
## R1의 처분 (NOTIF-ADR-005)
이 문서는 R0 router → platform 이행만 설명해 왔고, `dev.caskeleton.application.notification` 직속의
R1 public type 100개를 어떻게 할 것인지 다루지 않았다. 그래서 새 consumer가 어느 API를 써야 하는지
문서 어디에도 답이 없었다.
- **canonical은 `..notification.platform..`이다.** NOTIF-ADR-005가 근거와 함께 정한다.
- **R1은 남지만 새 production consumer를 받지 않는다.** 삭제 계획은 별개이며, R0 삭제와 함께
사라지지 않는다.
- **전수 분류표는 `docs/notification/module-mapping.md`에 있다** (replace / bridge / retain / delete).
- **두 namespace 간 production dependency는 0건이며 ArchUnit이 강제한다.** 변환이 필요하면
`dev.caskeleton.application.notification.compatibility.r1` 한 곳에만 둔다.
+116
View File
@@ -0,0 +1,116 @@
# 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.
## R1 public type disposition (NOTIF-ADR-005)
NOTIF-ADR-005 makes `..notification.platform..` canonical and keeps the R1 namespace in place
without new consumers. The ADR owns the rule; this table owns the list, so the two do not drift
apart by being written twice.
`NOTIFICATION_R1_AND_PLATFORM_DO_NOT_DEPEND_ON_EACH_OTHER` in `CleanArchitectureTest` enforces the
boundary: production dependencies between the two namespaces are zero, and the only permitted
exception is `dev.caskeleton.application.notification.compatibility.r1`.
| Disposition | Meaning | Types |
| --- | --- | --- |
| `replace` (27) | the platform has an equivalent; new consumers use it | `ApplyNotificationReceiptCommand`, `ApplyNotificationReceiptResult`, `ApplyNotificationReceiptUseCase`, `Channel`, `InlineNotificationAttemptPort`, `NormalizedNotificationReceiptCommand`, `NotificationAttemptId`, `NotificationDeliveryId`, `NotificationDeliveryStorePort`, `NotificationDispatchCommand`, `NotificationDispatchResult`, `NotificationDispatchUseCase`, `NotificationEvidenceTrustSnapshot`, `NotificationFrozenPlan`, `NotificationPlanPort`, `NotificationPlanningResult`, `NotificationProviderAttemptPort`, `NotificationReceiptEventId`, `NotificationReceiptFact`, `NotificationReceiptIngressCapabilityDescriptor`, `NotificationReceiptProjection`, `NotificationReceiptStorePort`, `NotificationSignedEvidenceHeader`, `NotificationWriterInventoryEvidence`, `NotificationWriterInventoryEvidenceVerifierPort`, `ProviderAttemptOutcome`, `TargetAttemptOutcome` |
| `bridge` (28) | conversion needed if an R1 caller remains; conversion lives only in the ACL | `InitializeNotificationWriterFencesCommand`, `InitializeNotificationWriterFencesOperation`, `InitializeNotificationWriterFencesResult`, `InitializeNotificationWriterFencesUseCase`, `NotificationAdmissionGateCommand`, `NotificationAdmissionGateUseCase`, `NotificationAppendResult`, `NotificationLegacyWriterPermitCommand`, `NotificationLegacyWriterPermitResult`, `NotificationLegacyWriterPermitUseCase`, `NotificationMaintenanceCommand`, `NotificationMaintenanceResult`, `NotificationMaintenanceUseCase`, `NotificationOperationsSnapshot`, `NotificationOperationsSnapshotQuery`, `NotificationOperationsSnapshotUseCase`, `NotificationRequestResult`, `ReconcileNotificationDeliveriesCommand`, `ReconcileNotificationDeliveriesResult`, `ReconcileNotificationDeliveriesUseCase`, `SwitchNotificationWriterOwnershipCommand`, `SwitchNotificationWriterOwnershipOperation`, `SwitchNotificationWriterOwnershipResult`, `SwitchNotificationWriterOwnershipUseCase`, `TerminalizeExpiredNotificationWriterPermitsCommand`, `TerminalizeExpiredNotificationWriterPermitsOperation`, `TerminalizeExpiredNotificationWriterPermitsResult`, `TerminalizeExpiredNotificationWriterPermitsUseCase` |
| `retain` (45) | a concern the platform does not cover; left as it is | `ConsentCheckMode`, `EmailRecipientReference`, `Notification`, `NotificationAdmissionClass`, `NotificationAdmissionReadinessPort`, `NotificationApplicationException`, `NotificationCanonicalWriterFenceGuard`, `NotificationCanonicalWriterFencePort`, `NotificationCanonicalWriterRouteSet`, `NotificationCapabilityCompatibilityValidator`, `NotificationChannel`, `NotificationFaultScope`, `NotificationIntentAppendPort`, `NotificationIntentDraft`, `NotificationIntentId`, `NotificationKindId`, `NotificationKindPolicy`, `NotificationMaintenanceStorePort`, `NotificationMode`, `NotificationOperationsSnapshotPort`, `NotificationPort`, `NotificationProviderCapabilityDescriptor`, `NotificationReasonCode`, `NotificationRecipientReference`, `NotificationReconciliationPort`, `NotificationRouteId`, `NotificationRouteStrategy`, `NotificationStoreCapabilityDescriptor`, `NotificationTechnicalSuppressionPort`, `NotificationTemplateParameters`, `NotificationTemplateRef`, `NotificationTemplateValue`, `NotificationWriterCutoverPort`, `NotificationWriterOwnership`, `NotificationWriterQuiescenceAttestationPort`, `NotificationWriterRouteSet`, `RecordNotificationWriterQuiescenceAttestationCommand`, `RecordNotificationWriterQuiescenceAttestationOperation`, `RecordNotificationWriterQuiescenceAttestationResult`, `RecordNotificationWriterQuiescenceAttestationUseCase`, `RetryDisposition`, `SignedNotificationWriterInventoryManifest`, `SignedNotificationWriterQuiescenceManifest`, `SlackAudienceReference`, `SubmissionCertainty` |
Total: 100 public types, every one classified.
No type carries `@Deprecated(forRemoval = true)`: no removal release is fixed, and
`forRemoval` without a date is a promise the codebase cannot keep. The boundary is
enforced by the ArchUnit rule instead.
+47
View File
@@ -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.
+65
View File
@@ -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.
+56
View File
@@ -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.
+98
View File
@@ -0,0 +1,98 @@
# Notification support matrix
What each channel can actually prove, and what the platform refuses to claim.
The grade column is not an opinion. `docs/notification/evidence-manifest.json` declares which claims
each grade requires and which executable artifact proves each claim, and
`./gradlew verifyNotificationEvidence` refuses a grade whose claims are not all backed by a file that
exists. Raising a grade means adding the artifact first.
Five channels read `Stable` until 2026-08-15. Nothing in the pipeline had ever sent a request to a
provider — the sandbox job's whole body was two `echo` statements behind `continue-on-error` — and
no test killed a worker mid-dispatch. The protocols are implemented and their contracts are proven
against real PostgreSQL; the wire and the crash are not. That is what the grade now says.
## Channels
| Channel | Reference implementation | Grade | Strongest evidence the platform records by default |
|---|---|---|---|
| Email | SMTP, Amazon SES API | Contract implemented / runtime unqualified | Provider acceptance; recipient mail-server delivery, bounce and complaint when the provider publishes events |
| SMS | Twilio Programmable Messaging | Contract implemented / runtime unqualified | `accepted`/`queued`, `sent`, and carrier-DLR `delivered`/`undelivered` |
| Mobile push (Android and cross-platform) | FCM, FID-first with legacy registration token compatibility | Contract implemented / runtime unqualified | FCM acceptance and explicit failures |
| Mobile push (Apple) | APNs HTTP/2 provider API | Contract implemented / runtime unqualified | APNs acceptance |
| Web Push | RFC 8030, RFC 8291, RFC 8292 | Contract implemented / runtime unqualified | Push-service acceptance; user-agent acknowledgement only where the service offers receipts |
| In-app inbox | Own database | Optional stable | `PERSISTED`, `SEEN`, `READ` |
| Webhook | HTTP client platform | Extension | Whatever the receiving HTTP contract states |
## What each grade requires
| Grade | Requires |
|---|---|
| Stable | durable, multi-worker-safe, callback-supported, recoverable, provider-wire-qualified |
| Contract implemented / runtime unqualified | durable, multi-worker-safe, callback-supported |
| Optional stable | durable |
| Extension | nothing; the receiving contract owns its own guarantees |
| Experimental | nothing; the grade is the warning |
`recoverable` was met on 2026-08-15 by `WorkerCrashRecoveryContractTest`, which walks the four
phases a dispatch passes through — claimed, attempt written, request started, body committed — and
asserts for each that the delivery becomes claimable again or becomes a question for the provider,
never both and never neither.
The remaining unmet claim, and what would meet it:
- **provider-wire-qualified** — a real provider sandbox call producing an immutable, uploaded
artifact with a correlation id. No request has ever left the process in CI.
## Evidence levels
`NONE``PLATFORM_QUEUED``PROVIDER_ACCEPTED``NETWORK_OR_CARRIER_ACCEPTED`
`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.
+161
View File
@@ -4244,3 +4244,164 @@ env_keys:
validation: positive_int_bounded
compatibility_impact: behavior-change
required_test: async-contract:executor-queue-bounded
# === Notification delivery platform (NTF-025 — configuration surface) ===
# The tree exists in application.yml as ca-skeleton.notification.platform, disabled by
# default. Every key carries an inline default so a deployment that leaves the platform off
# supplies nothing. Reference: docs/notification/configuration.md.
- name: APP_NOTIFICATION_PLATFORM_ENABLED
# source: NTF-025 — master switch for the notification delivery platform; false binds nothing at all
type: boolean
default: false
allowed_values: [true, false]
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: boolean
compatibility_impact: behavior-change
required_test: adapter-contract:notification-platform-disabled-safe
- name: APP_NOTIFICATION_PLATFORM_MODE
# source: NTF-025 — SERVING refuses to start without a working provider; ACCEPT_ONLY stores and does not dispatch
type: enum
default: SERVING
allowed_values: [SERVING, ACCEPT_ONLY]
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: enum_of_notification_platform_mode
compatibility_impact: behavior-change
required_test: adapter-contract:notification-platform-mode
- name: APP_NOTIFICATION_PLATFORM_CLAIM_BATCH_SIZE
# source: NTF-025 — how many recipient deliveries one scheduler pass claims; 1..1000, refused outside that at binding
type: integer
default: 50
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: integer_1_to_1000
compatibility_impact: behavior-change
required_test: adapter-contract:notification-dispatch-bounds
- name: APP_NOTIFICATION_PLATFORM_LEASE_DURATION
# source: NTF-025 — must outlast a provider call plus its timeout, or a live worker's delivery is claimed by a second one
type: duration
default: 2m
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: positive_duration
compatibility_impact: behavior-change
required_test: adapter-contract:notification-lease-fencing
- name: APP_NOTIFICATION_PLATFORM_POLL_INTERVAL
# source: NTF-025 — how often the scheduler asks for work when the last pass claimed nothing
type: duration
default: 1s
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: positive_duration
compatibility_impact: behavior-change
required_test: adapter-contract:notification-dispatch-bounds
- name: APP_NOTIFICATION_PLATFORM_MAX_CONCURRENCY
# source: NTF-025 — ceiling on in-flight provider calls across the whole process
type: integer
default: 64
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: positive_integer
compatibility_impact: behavior-change
required_test: adapter-contract:notification-dispatch-bounds
- name: APP_NOTIFICATION_PLATFORM_MAX_ADDITIONAL_ATTEMPTS
# source: NTF-025 — retries after the first attempt; 0 means one attempt and no retry
type: integer
default: 4
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: non_negative_integer
compatibility_impact: behavior-change
required_test: adapter-contract:notification-retry-policy
- name: APP_NOTIFICATION_PLATFORM_MAX_QUEUE_AGE
# source: NTF-025 — after this, a queued delivery expires rather than being sent late
type: duration
default: 24h
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: positive_duration
compatibility_impact: behavior-change
required_test: adapter-contract:notification-expiry
- name: APP_NOTIFICATION_PLATFORM_ALLOW_AMBIGUOUS_FALLBACK
# source: NTF-025 — an ambiguous attempt reached the provider with an unread outcome; falling back risks a duplicate send
type: boolean
default: false
allowed_values: [true, false]
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: boolean
compatibility_impact: behavior-change
required_test: adapter-contract:notification-ambiguity
- name: APP_NOTIFICATION_PLATFORM_CALLBACKS_ENABLED
# source: NTF-025 — whether the platform exposes provider callback endpoints
type: boolean
default: false
allowed_values: [true, false]
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: boolean
compatibility_impact: behavior-change
required_test: adapter-contract:notification-callback-ingestion
- name: APP_NOTIFICATION_PLATFORM_CALLBACK_MAX_BODY_BYTES
# source: NTF-025 — ceiling is 65508 = ciphertext column minus AES-GCM nonce and tag; larger is refused at binding
type: integer
default: 65508
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: integer_1_to_65508
compatibility_impact: behavior-change
required_test: adapter-contract:notification-callback-body-bound
- name: APP_NOTIFICATION_PLATFORM_CALLBACK_REPLAY_SKEW
# source: NTF-025 — how far a callback timestamp may differ from local time before it is treated as a replay
type: duration
default: 5m
allowed_values: null
classification: security-relevant
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: positive_duration
compatibility_impact: behavior-change
required_test: adapter-contract:notification-callback-replay
@@ -0,0 +1,889 @@
# GraphQL 인바운드 모듈 상세 코드·아키텍처 리뷰
- 기준 일자: 2026-08-14
- 기준 Git HEAD: `ac874e49e608b35429f82aa098574b52a68f2069`
- 대상 Gradle leaf: `:adapter:inbound:graphql`
- 주 대상 경로: `src/adapter/inbound/graphql`
- 교차 확인 경로: `src/config/architecture/modules.json`, `src/gradle/graphql-platform-conventions.gradle`, `src/.gitignore`
- 판정: **CHANGES REQUIRED / 현재 컴파일 불가**
- 검토 방식: 전체 파일·import·production reference inventory, 핵심 실행 경로 정독, 세 개의 독립 병렬 리뷰, Gradle focused/architecture 검증
- 변경 범위: 이 리뷰 문서만 추가했다. production/test 코드는 수정하지 않았다.
리뷰 도중 HEAD가 `c3043e530a604315c4df341b87b5470c7617ea03`에서 위 commit으로 이동했지만,
GraphQL tree hash는 두 revision 모두 `bd8307364e2312814995e5f3bb1386cee37b1498`이고
`src/.gitignore`, GraphQL convention, architecture registry에도 delta가 없음을 확인했다.
## 1. 결론
현재 GraphQL leaf는 373개 production Java 파일과 75개 test Java 파일을 가진 큰 실행 플랫폼 후보지만,
두 층의 문제가 겹쳐 있다.
첫 번째는 즉시 고쳐야 하는 **빌드 차단**이다. `src/.gitignore`의 unanchored `build/` 규칙이 Gradle
산출물뿐 아니라 Java source package인 `...graphql.build`까지 무시한다. 문서와 production code는
`GraphQlBuildModel`, `GraphQlStableModule`, `GraphQlAdvancedModule`, `GraphQlModuleBoundaryTest`가 있다고
주장하지만 실제 checkout에는 없다. 그 결과 focused test는 test 실행 전에 `compileJava`에서 7개
오류로 실패하고, 단일 leaf 안의 Stable/Advanced 경계를 지킨다는 핵심 안전망도 함께 사라졌다.
두 번째는 더 근본적인 **런타임 진실성 문제**다. cost, authorization, DataLoader, cursor,
idempotency, observation, persisted operation, subscription 등 많은 정책과 값 객체가 구현되어 있지만,
대부분 Spring GraphQL이 실제 `/graphql` 요청을 처리하는 extension point에 연결되지 않는다. 현재 HTTP
qualification은 Spring Boot 기본 endpoint와 health controller/error resolver를 검증할 뿐, 이 플랫폼의
pipeline을 거치지 않는다. 따라서 unit test가 복구되어 green이 되더라도 “정책 객체가 맞다”는 증거와
“실제 요청에 정책이 강제된다”는 증거를 분리해야 한다.
즉시 적용할 원칙은 다음과 같다.
1. GQL-001을 단독 PR로 먼저 처리해 compile과 내부 경계 검사를 복구한다.
2. 복구 전후 모두 현재 artifact를 `runtime-ready GraphQL execution platform`으로 승격하지 않는다.
3. Spring 기본 `/graphql`을 canonical transport로 정하고, 정책을 공식 extension point에 연결한다.
4. 자체 MVC/WebFlux adapter를 실제 endpoint로 만들 계획이 없다면 제거한다. 평행 실행 경로를 두지 않는다.
5. repository 자동 노출은 Advanced라도 제거한다. application use case를 우회하는 예외를 만들지 않는다.
6. correctness/security red test를 먼저 고정한 뒤 public API와 Gradle leaf를 단계적으로 분리한다.
7. 실제 random-port request가 정책에 의해 거부되고 resolver/use case가 0회 호출됨을 promotion 증거로 삼는다.
## 2. 범위와 증거 경계
### 2.1 현재 규모
| 항목 | 현재 값 |
|---|---:|
| production Java 파일 | 373 |
| production Java LOC | 20,155 |
| test Java 파일 | 75 |
| test Java LOC | 8,424 |
| test annotation (`@Test`, `@ParameterizedTest`) | 524 |
| 최상위 production package | 21 |
| main resource | `graphql/skeleton.graphqls` 1개 |
| test resource | qualification schema 1개 |
| 외부 Spring/GraphQL/Reactor import를 가진 production Java | 19 |
최상위 package는 `advanced`, `api`, `architecture`, `autoconfigure`, `compat`, `context`, `cost`,
`dataloader`, `error`, `execution`, `fetch`, `http`, `mutation`, `observation`, `pagination`, `policy`,
`release`, `scalar`, `schema`, `security`, `testkit`이다.
373개 중 354개가 Spring/GraphQL Java/Reactor type을 직접 import하지 않는다는 점은 framework-free policy
model을 추출할 여지가 크다는 뜻이다. 동시에 거의 모든 최상위 type이 public이어서 현재 한 jar가
사실상 수백 개의 API를 노출한다.
### 2.2 검토 깊이
| Path | Status | Evidence | Extracted facts |
|---|---|---|---|
| `src/adapter/inbound/graphql/build.gradle` | READ_FULL | 1-54 | servlet runtime 의존, WebFlux compile-only, test lane 등록 |
| `src/gradle/graphql-platform-conventions.gradle` | READ_FULL | 1-100 | Stable/contract/Advanced/performance lane과 누락된 boundary model 주장 |
| `src/adapter/inbound/graphql/CLAUDE.md` | READ_FULL | 1-143 | 단일 leaf 내부 28 bounded package, runtime opt-in, 실행 범위·검증 주장 |
| `src/adapter/inbound/graphql/README.md` | READ_FULL | 1-189 | health endpoint, error mapping, 설정·경계·Advanced 설계 근거 |
| `src/config/architecture/modules.json` GraphQL record | READ_FULL | GraphQL leaf record | 허용 project edge와 빈 runtime membership |
| `src/.gitignore` | READ_FULL | 1-16 + `git check-ignore` | `build/`가 Java source package까지 무시하는 직접 원인 |
| root controller/error resolver/schema | READ_FULL | production + 대응 tests | 현재 실제 Spring GraphQL endpoint 표면 |
| `autoconfigure`, `http`, `execution`, `architecture` | READ_FULL | production 핵심 경로 + 대응 tests | auto-config 등록, 실행 연결, transport, 경계 검사 |
| `cost`, `security`, `dataloader`, `pagination`, `mutation` | READ_FULL | 핵심 policy/codec/executor + 대응 tests | 구조 제한, tenant/auth, batch, cursor, idempotency correctness |
| `schema`, `compat`, `scalar`, `error`, `observation` | READ_FULL | production 핵심 경로 + 대응 tests | schema 조립/호환, scalar, wire error, cardinality |
| `advanced/**` | READ_PARTIAL | public entry/state transition/production reference scan + 주요 tests | persisted/admin/codegen/subscription/federation/transport seam |
| `release/**`, `testkit/**` | READ_PARTIAL | public contract/lane/reference scan + suite tests | self-reported evidence와 production artifact 오염 |
| production 373개/test 75개 전체 | READ_PARTIAL | inventory/import/reference/public-surface scan | 파일·package·사용처·실행 연결의 전수 정적 탐색 |
이 문서는 28,579 LOC의 모든 method를 line-by-line 승인한 결과가 아니다. 전체 inventory와 reference scan을
바탕으로 실행 seam과 고위험 policy를 정독한 구조·correctness 리뷰다. `advanced/**`, release/testkit의
세부 알고리즘은 명시한 범위 밖에서 `UNVERIFIED`이며, 실제 adopter/runtime·load·fault evidence도 없다.
## 3. 유지할 설계
리팩터링 과정에서 다음은 보존할 가치가 있다.
- registry상 GraphQL leaf의 production project dependency가 Clean Architecture 방향을 벗어나지 않는다.
- `runtime_memberships`가 비어 있어 현재 app-bootstrap/sample runtime에 조용히 유입되지 않는다.
- 실제 `HealthGraphqlController`는 얇고 feature/domain/repository 지식이 없다.
- 실제 Spring exception resolver는 shared error code만 노출하고 raw exception message를 사용하지 않는다.
- schema compatibility를 SDL 문자열 diff가 아니라 AST로 비교하고 결과를 결정적으로 정렬한다.
- partial data map에 null을 허용하는 defensive copy를 사용한다. 이를 `Map.copyOf`로 바꾸면 안 된다.
- cursor HMAC을 `MessageDigest.isEqual`로 비교하고 query/filter에 bind하려는 방향은 맞다.
- DataLoader 결과에서 `Present`, `Missing`, `Failed`를 구분하려는 결과 algebra는 유용하다.
- document traversal은 fragment cycle과 방문 node budget을 고려한다.
- Advanced capability가 기본 비활성이고 experimental production activation을 명시적으로 거부한다.
- test lane이 빈 performance evidence를 success로 위장하지 않으려는 fail-closed 의도는 좋다.
- broad static import scan에서 Stable package가 `...graphql.advanced`를 직접 import하는 edge와
production repository/JPA/Spring Data 직접 사용은 발견되지 않았다.
## 4. 우선순위 요약
| ID | 우선순위 | 주제 | 완료 조건 |
|---|---|---|---|
| GQL-001 | P0 | ignored `build` source package 때문에 compile 및 경계 모델 소실 | 비-ignore package로 모델 복구, compile/test/boundary negative fixture 통과 |
| GQL-002 | P0 | 플랫폼 정책이 실제 `/graphql` 실행 경로에 미연결 | real interceptor/instrumentation/DataLoader/wiring E2E에서 정책 거부 증명 |
| GQL-003 | P1 | auto-configuration 등록·binding default·실제 bean 검증 불일치 | 무설정 boot, imports metadata, 실제 override bean validation 통과 |
| GQL-004 | P1 | servlet artifact가 reactive profile도 표방 | MVC/WebFlux runtime classpath와 context가 별도 leaf에서 독립 통과 |
| GQL-005 | P1 | request byte 제한 미강제와 valid null variable 거부 | decode 전 body cap, null/omitted/value E2E 통과 |
| GQL-006 | P1 | `Accept` q-value/q=0 무시 | quality/specificity 기반 negotiation contract 통과 |
| GQL-007 | P1 | named fragment introspection 우회와 variable nesting 공백 | reachable fragment/variable JSON budget 거부 E2E 통과 |
| GQL-008 | P1 | resolver 경계 검사가 generic/JAR/subpackage를 놓치고 reactive type을 오판 | actual controller graph와 recursive generic negative fixture 통과 |
| GQL-009 | P1 | Advanced repository 자동 노출이 canonical hard-stop과 충돌 | repository exposure API 제거, application handler만 허용 |
| GQL-010 | P1 | cursor framing·rotation·direction·tenant binding 결함 | versioned codec property tests와 active-key/scope rejection 통과 |
| GQL-011 | P1 | mutation fingerprint collision과 tenant 없는 idempotency scope | typed canonical serialization과 tenant/version scope 테스트 통과 |
| GQL-012 | P1 | error resolver와 category contract가 두 벌 | 하나의 mapper를 모든 Spring/transport path가 사용 |
| GQL-013 | P1 | persisted-operation admin 상태·감사·인가가 durable하지 않음 | authenticated principal, CAS state machine, atomic audit contract 통과 |
| GQL-014 | P1 | codegen이 operation을 검증하지 않고 generator도 code를 생성하지 않음 | executable document validation 또는 정직한 planner 명명 |
| GQL-015 | P1 | schema comparator가 kind/default/extension/applied directive를 놓침 | breaking matrix와 extension ownership tests 통과 |
| GQL-016 | P1 | custom DataLoader와 timeout이 실제 loader 실행을 강제하지 않음 | Spring registry 연결과 real query-count/deadline test 통과 |
| GQL-017 | P1 | MVC concurrency/context와 WebFlux blocking bridge가 안전하지 않음 | bounded admission, context propagation, event-loop nonblocking 증명 |
| GQL-018 | P1 | pipeline stage 순서가 필요한 정보와 모순 | authenticate→parse/select→authorize→cost→execute executable chain |
| GQL-019 | P1 | subscription/replay/drain lifecycle의 race와 scope 공백 | atomic state/lease, actor+tenant+subscription binding 경쟁 test 통과 |
| GQL-020 | P2 | preparsed cache expiry 미사용·global miss serialization | expiry/single-flight/parallel-key test 통과 |
| GQL-021 | P2 | cancellation hook 하나가 나머지 cleanup을 막음 | all-hooks-once + suppressed exception contract 통과 |
| GQL-022 | P2 | scalar input/output bounds와 expansion limit 불일치 | BigDecimal/Long 양방향 boundary test 통과 |
| GQL-023 | P2 | raw operation name metric cardinality와 실제 Observation 미연결 | registered-name/`other` bound와 real MeterRegistry test 통과 |
| GQL-024 | P2 | testkit/fixed secret/in-memory 구현이 main jar에 포함 | test fixtures/optional leaf 분리 및 jar surface gate 통과 |
| GQL-025 | P2 | 373개 type의 과도한 public surface와 한 leaf의 낮은 응집도 | api/spi allowlist와 6~8 capability leaf 독립 compile/test |
| GQL-026 | P2 | GraphQL context/storage SPI ownership이 dependency 방향과 충돌 | inbound-local mapping과 transport-neutral operational port로 분리 |
| GQL-027 | P3 | 문서·설정 namespace·test count·runtime 지원 주장 drift | generated metadata/runtime adoption test 기반 문서 동기화 |
## 5. 상세 발견 사항과 구현 명세
### GQL-001 — `build` Java package가 `.gitignore`에 걸려 compile과 경계 검사가 함께 사라졌다
**근거**
- `src/.gitignore:2`는 root에 고정되지 않은 `build/` 패턴이다.
- `git check-ignore -v --no-index
src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/build/GraphQlBuildModel.java`
는 `src/.gitignore:2:build/`를 반환한다.
- `GraphQlPlatformAutoConfiguration.java:3,107-108`과
`advanced/bootstrap/GraphQlAdvancedDependencyRules.java:3,26-28,43`은 존재하지 않는
`dev...graphql.build.GraphQlBuildModel`을 참조한다.
- `CLAUDE.md:36-40`, `README.md:108-115`, `graphql-platform-conventions.gradle:11-13`은
`GraphQlStableModule`, `GraphQlAdvancedModule`, `GraphQlBuildModel`,
`GraphQlModuleBoundaryTest`가 실제 tree를 검사한다고 기록하지만 네 파일은 main/test tree에 없다.
- focused `:test`는 `compileJava`에서 해당 package/class 관련 7개 오류로 실패했다.
**실패 모드**
로컬 작성자가 ignored package 아래 파일을 생성하면 파일이 보이므로 잠시 compile될 수 있지만 commit에
들어가지 않는다. fresh checkout/CI에서는 소스가 사라져 compile이 깨진다. 더 위험한 변형은 production
참조를 지웠을 때다. build는 green이 될 수 있지만 Stable→Advanced/core purity/등록 package 검사가 없는
false green이 된다.
**구현 결정**
1. source package 이름을 `...graphql.build`가 아니라 `...graphql.moduleboundary`로 바꾼다. `.gitignore`
예외보다 역할이 분명하고 다른 도구의 `build` 디렉터리 규칙과 충돌하지 않는다.
2. 세 production model을 복원한다. 단, source-tree scanner가 runtime에 필요하지 않으면
`GraphQlBuildModel`을 test/build logic으로 이동하고 `GraphQlPlatformAutoConfiguration`의 runtime
source scan을 제거한다.
3. `GraphQlModuleBoundaryTest`는 실제 source/import graph를 검사하며 다음 세 negative fixture를 가진다.
Stable→Advanced import, core package의 Spring/GraphQL/Reactor import, 등록되지 않은 package.
4. `graphqlStableTest`가 해당 FQCN을 명시적으로 포함하고, test가 0개면 실패하게 유지한다.
5. repository-level gate에 `src/**/src/{main,test}/java/**/build/**` 같은 ignored source-package를
탐지하는 검사를 추가한다. Git에 존재하지 않는 파일을 CI가 찾을 수 없으므로, package naming rule과
required boundary-class existence 검사를 함께 둔다.
**필수 테스트/검증**
```bash
cd src
./gradlew :adapter:inbound:graphql:compileJava --console=plain
./gradlew :adapter:inbound:graphql:test --console=plain
./gradlew :adapter:inbound:graphql:graphqlStableTest --console=plain
./gradlew :adapter:inbound:graphql:test \
--tests '*GraphQlModuleBoundaryTest' --rerun-tasks --console=plain
```
### GQL-002 — 정책 카탈로그는 크지만 실제 `/graphql` 요청에는 실행되지 않는다
**근거**
- `GraphQlPlatformAutoConfiguration.java:43-103`은 startup validator, stage 목록, mapping gate,
observation convention POJO를 bean으로 만들지만 실제 request hook을 등록하지 않는다.
- `GraphQlExecutionPipeline`은 실행 가능한 Chain of Responsibility가 아니라 enum stage 순서 record다.
- MVC/WebFlux transport adapter의 `handle()`은 controller/router/filter가 아니며 production 호출처가 없다.
- production에는 `WebGraphQlInterceptor`, GraphQL Java `Instrumentation`, Spring
`BatchLoaderRegistry`, 실제 `PreparsedDocumentProvider` 연결이 없다.
- `GraphQlScalarWiringConfigurer`는 올바른 `RuntimeWiringConfigurer` 구현이지만 production bean이 아니다.
- HTTP qualification은 Spring Boot 기본 `/graphql`, health controller, root exception resolver와
test-only security를 검증한다. platform auto-configuration과 custom adapters를 import하지 않는다.
**실패 모드**
adopter가 최대 depth/complexity, introspection, authorization, timeout, DataLoader policy를 설정하고
안전하다고 판단해도, Spring 기본 endpoint는 이 객체들을 호출하지 않는다. unit test는 각 policy 함수가
정상임만 증명하고 endpoint adoption을 증명하지 못한다.
**구현 결정: Spring-native 단일 실행 경로**
1. Spring 기본 `/graphql`을 canonical HTTP transport로 유지한다.
2. `GraphQlPlatformWebInterceptor implements WebGraphQlInterceptor`에서 인증 principal을 검증된
request context로 매핑하고 GraphQL/Reactor context에 넣는다.
3. `GraphQlPlatformInstrumentation` 또는 `ExecutionGraphQlService` decorator에서 document
parse/selection, introspection, authorization, cost, deadline/cancellation을 실행한다.
4. `RuntimeWiringConfigurer`, `BatchLoaderRegistry` 등록/decorator, actual preparsed document provider,
canonical exception resolver를 auto-configuration이 bean으로 조립한다.
5. 현재 MVC/WebFlux custom adapters는 제거한다. 자체 transport가 반드시 필요하다면 Spring 기본
handler를 끄고 실제 route를 소유하게 하며, 두 경로를 동시에 두지 않는다.
6. 모든 policy stage는 `GraphQlExecutionRequest`와 `GraphQlExecutionContext`를 입력·출력하는 실행 가능한
handler로 바꾼다. 단순 stage catalog는 문서/검증 view로만 파생한다.
**필수 E2E**
- random-port servlet `/graphql`에서 depth/cost/alias/introspection/oversize/authz/timeout 거부.
- 각 거부에서 controller, use case, batch loader 호출 횟수 0.
- actor/tenant/deadline이 controller와 DataLoader에 동일하게 전달됨.
- custom scalar를 포함한 schema boot 및 실제 coercion.
- 같은 document cache hit, request별 DataLoader cache 격리.
- reactive artifact를 유지한다면 동일 contract를 reactive random-port에서도 실행.
Spring GraphQL이 제공하는 공식 연결점은
[`WebGraphQlInterceptor`](https://docs.spring.io/spring-graphql/reference/1.3/request-execution.html),
[`RuntimeWiringConfigurer`](https://docs.spring.io/spring-graphql/docs/current/api/org/springframework/graphql/execution/RuntimeWiringConfigurer.html),
[`BatchLoaderRegistry`](https://docs.spring.io/spring-graphql/docs/current/api/org/springframework/graphql/execution/BatchLoaderRegistry.html)다.
구현 시 repository lock의 Spring GraphQL 2.0.0/Boot 4.0.0 API signature로 다시 확인한다.
### GQL-003 — auto-configuration, binding default, 실제 override 검증이 각각 다른 계약이다
**근거**
- `GraphQlPlatformAutoConfiguration`은 이름과 달리 `@Configuration`이며 auto-configuration imports
metadata가 없다. main resource는 schema 한 개뿐이다.
- `GraphQlPlatformProperties` primitive binding default는 `maximumPageSize=0`,
`maximumComplexity=0`인데 startup validator는 양수만 허용한다.
- `productionDefaults()` factory는 Spring binder default가 아니다.
- custom `backend.graphql.graphiql-enabled/introspection-enabled`와 실제
`spring.graphql.*` framework flags가 분리되어 있다.
- startup check는 주입된 override pipeline이 아니라 `GraphQlExecutionPipeline.stable()` 상수를 검증한다.
**구현 결정**
1. 재사용 starter라면 `@AutoConfiguration(after = GraphQlAutoConfiguration.class)`과
`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`를 추가한다.
내부 composition 전용이면 이름을 `GraphQlPlatformConfiguration`으로 바꾸고 app-bootstrap에서 명시 import한다.
2. properties를 nested record/class로 나누고 binder가 실제로 사용하는 default를 선언한다.
3. framework `GraphQlProperties`를 SSOT로 삼거나 custom flag와의 불일치를 startup failure로 만든다.
4. startup validator는 실제 주입된 pipeline, scalar manifest, client policies, key ring을 검증한다.
5. `ApplicationContextRunner`로 enabled/disabled/servlet/reactive/unsafe override matrix를 고정한다.
**필수 테스트**
- 아무 `backend.graphql.*`도 없는 context가 safe default로 부팅한다.
- custom/framework GraphiQL·introspection 값이 모순되면 부팅 실패한다.
- unsafe custom pipeline override가 startup에서 거부된다.
- auto-configuration imports와 configuration metadata에 모든 property가 존재한다.
### GQL-004 — 하나의 artifact가 servlet runtime을 강제하면서 reactive profile도 표방한다
**근거**
- `build.gradle:19-20`은 `spring-boot-starter-web`을 production implementation으로 둔다.
- WebFlux는 `compileOnly`라 reactive runtime에는 없다.
- `GraphQlWebFluxAutoConfiguration`은 application이 이미 reactive일 때만 활성화된다.
- reactive config는 blocking executor를 `Mono.fromCallable`로 감싸며 scheduler를 바꾸지 않는다.
- request validator 기본 bean은 MVC config 안에 있어 reactive context에서 기본 생성되지 않는다.
**구현 결정**
공통 artifact에 두 runtime을 넣지 말고 다음처럼 나눈다.
- `graphql-spring-execution`: Spring GraphQL execution/interceptor/wiring. servlet/reactive server 없음.
- `graphql-transport-mvc`: 위 leaf + `starter-web`.
- `graphql-transport-webflux`: 위 leaf + `starter-webflux`.
reactive profile은 `GraphQlReactiveExecutor` 전용 interface를 필수로 한다. blocking bridge가 필요하면 명시적
opt-in, bounded scheduler, bulkhead, lifecycle bean과 thread assertion을 함께 둔다.
### GQL-005 — request limit은 역직렬화 전에 강제되지 않고 valid null variable은 NPE가 된다
**근거**
- size policy 메서드는 존재하지만 custom adapters는 `validateEnvelope()`만 호출한다.
- 이미 materialized된 `GraphQlHttpRequestEnvelope`를 받으므로 JSON allocation 전 body cap을 적용할 수 없다.
- `GraphQlHttpRequestEnvelope:23-25`는 variables/extensions에 `Map.copyOf`를 사용해 null value를 거부한다.
- nested map/list는 shallow copy여서 생성 뒤 mutation 가능한 TOCTOU도 남는다.
**구현 결정**
1. servlet filter/reactive web filter 또는 bounded decoder에서 raw HTTP body byte cap을 먼저 적용한다.
2. JSON decode 후 query/variables/extensions를 UTF-8 byte 기준으로 검증한다.
3. variables/extensions는 null-preserving deep immutable JSON value copy를 사용한다.
4. JSON nesting, object key 수, list length에도 별도 bound를 둔다.
**필수 테스트**
- ASCII와 다중바이트 UTF-8의 exact limit/limit+1.
- variables의 omitted/explicit null/non-null 세 의미가 actual coercion까지 보존됨.
- nested original map/list 변경이 envelope에 반영되지 않음.
- oversize body는 decoder/controller/use case 0회와 413.
### GQL-006 — `Accept` 협상에서 client priority와 명시적 거부를 무시한다
**근거**
`GraphQlMediaTypes:45-63`은 parameter를 제거하고 server preference를 먼저 순회한다. 따라서
`application/graphql-response+json;q=0, application/json;q=1`에도 q=0인 첫 media type을 반환한다.
**구현 결정**
Spring `MediaType.parseMediaTypes`로 parse하고 quality/specificity를 정렬한 뒤 q=0을 제외한다. GraphQL
over HTTP profile이 생산 가능한 두 type과 client order의 교집합을 선택하고, malformed/empty/wildcard
정책을 명시한다. GraphQL over HTTP draft도 client가 제시한 우선순위를 존중하도록 요구한다
([GraphQL over HTTP draft](https://graphql.github.io/graphql-over-http/draft/)).
### GQL-007 — named fragment가 custom introspection gate를 우회하고 variable 입력 구조는 측정하지 않는다
**근거**
- `GraphQlDocumentShapeAnalyzer:96-132`의 introspection walk는 Field/InlineFragment만 처리한다.
- 같은 class의 일반 shape walk는 `FragmentSpread`와 cycle path를 처리한다.
- input nesting은 document literal만 보며 variables JSON은 보지 않는다.
- analyzer는 selected operation이 아니라 document의 모든 operation을 합산한다.
**실패 입력**
```graphql
query Q { ...I }
fragment I on Query { __schema { types { name } } }
```
**구현 결정**
operationName으로 선택한 operation과 reachable fragment graph만 하나의 budgeted walker가 순회하도록
합친다. introspection은 custom walker 하나만 신뢰하지 말고 engine validation/field visibility에서도
차단한다. variables는 streaming JSON constraint로 별도 제한한다.
### GQL-008 — resolver 경계 검사는 실제 adopter graph를 보지 못하고 valid reactive query도 거부한다
**근거**
- controller inspector와 boundary rules는 raw `Class<?>`만 검사해 `List<Entity>`, `Mono<Entity>`,
`Optional<Repository>`의 generic 내부 타입을 놓친다.
- package scan은 `file:` protocol, 직접 자식 `.class`만 지원해 JAR/subpackage를 건너뛴다.
- tests는 고정 fixture package만 직접 호출하고 production startup caller가 없다.
- `Publisher`를 subscription 외에서 모두 거부하지만 Spring GraphQL controller는 Query/Mutation에서
`Mono<T>`와 async return을 지원한다
([Spring GraphQL annotated controllers](https://docs.spring.io/spring-graphql/reference/controllers.html)).
**구현 결정**
1. build-time에는 ArchUnit/bytecode scan으로 실제 configured controller packages를 재귀 검사한다.
2. runtime에는 ApplicationContext의 실제 GraphQL controller bean/method를 startup 검사한다.
3. Java `Type`을 재귀 순회해 parameterized/array/wildcard/type-variable bound를 본다.
4. Query/Mutation에는 single-value async(`Mono`, `CompletionStage`)를 허용하고 multi-value publisher만
Subscription에 제한한다.
5. suffix-only `Repository/Dao` 휴리스틱은 보조 신호로 낮추고 package/assignability/annotation 증거를 쓴다.
### GQL-009 — repository 자동 노출은 Advanced여도 이 저장소의 Clean Architecture를 위반한다
**근거**
`advanced/compat/GraphQlRepositoryExposureValidator`와 `GraphQlRepositoryAllowlist`는 allowlisted
repository가 GraphQL field를 직접 back하는 경로를 지원하고 test도 이를 정상으로 고정한다. 현재 실제
controller가 repository를 직접 호출하는 위반은 없지만, 지원 계약 자체가 root HARD-STOP과 충돌한다.
**구현 결정**
- `SPRING_DATA_COMPAT`, repository exposure API와 정상 test를 제거한다.
- 자동 resolver 대상은 application query/use-case handler로 한정한다.
- 생성 resolver의 constructor/field/method generic graph에 repository, Spring Data interface,
persistence entity가 있으면 allowlist와 무관하게 실패한다.
- business transaction과 authorization은 application use case에 남긴다.
### GQL-010 — cursor는 서명되지만 정상 payload가 round-trip되지 않고 rotation/scope 검증도 불완전하다
**근거**
- keyset은 `;`, `=`, `|`, `\`를 escape하지만 decoder는 escape-aware하지 않은 `split`을 먼저 한다.
- queryProfile/filterFingerprint/keyId는 escape조차 하지 않는다.
- `GraphQlCursorKeyRing.activeKeyId()`는 production/test에서 사용처가 없고 기본 factory는 항상
`cursor-key-1`을 payload에 넣는다.
- connection request decode는 payload direction과 request direction을 비교하지 않는다.
- cursor는 tenant/actor scope를 bind하지 않는다.
- `keyIds()`는 mutable backing key set을 반환한다.
**구현 결정: versioned Codec Strategy**
1. v2 payload를 canonical JSON/CBOR 또는 length-prefixed typed framing으로 만든다.
2. codec이 active key id를 선택하고 envelope에 기록한다. caller payload가 signing key를 선택하지 않는다.
3. decode 입력에 expected query/filter/direction/tenant-scope fingerprint를 포함한다.
4. v1 decode를 migration 기간에만 유지하고 v2만 발급한다.
5. key ring map/key set을 완전 불변으로 만들고 secret clone은 유지한다.
6. `forTests()`와 fixed secret은 test fixtures로 이동한다.
**필수 property tests**
- 모든 string field의 delimiter/backslash/unicode round-trip.
- active key2로 신규 발급, key1 과거 cursor 검증, unknown/retired key 거부.
- forward↔backward, tenant A↔B, filter/query 변경 거부.
- tamper, truncation, oversized token, malformed Base64 거부.
### GQL-011 — mutation fingerprint canonical form이 충돌하고 tenant를 scope에 포함하지 않는다
**근거**
`GraphQlMutationFingerprint:29-33`은 top-level key만 정렬해 `key=value;`를 연결한다. 예를 들어
`{a:"b;c=d"}``{a:"b", c:"d"}`가 같은 canonical text가 된다. nested map은 재귀 정렬되지 않는다.
idempotency scope는 actor/coordinate/key만 포함하고 tenant와 contract version은 없다.
**구현 결정**
- recursive key sorting, JSON type, length framing, null/number normalization을 가진 canonical serializer를
하나의 port/service로 둔다.
- tenant fingerprint와 contract version을 scope에 포함한다.
- actor/tenant의 단순 SHA-256 prefix를 비가역이라고 부르지 않는다. 저엔트로피 identifier에는
rotation 가능한 HMAC fingerprint를 사용하고 metric label에는 넣지 않는다.
- `requireSingleUseCase`는 정확히 1을 요구하거나 실제 architecture gate로 교체한다.
### GQL-012 — error contract가 두 resolver와 여러 category vocabulary로 분기한다
**근거**
- root `GraphqlExceptionResolver`만 실제 Spring `DataFetcherExceptionResolverAdapter``@Component`다.
- `error/GraphQlExceptionResolver`는 richer masking/mapping을 제공하지만 Spring path에 연결되지 않는다.
- auth/cursor/idempotency/batch/timeout 예외의 code/category/retryable/executionId 계약이 경로마다 다르다.
- 대소문자만 다른 두 class 이름은 import 실수를 유발한다.
**구현 결정: Mapper + Adapter**
`GraphQlWireErrorMapper`를 canonical pure mapper로 두고 `GraphQlDataFetcherExceptionResolver`가 Spring
`GraphQLError`로 adapt한다. request-level HTTP failure와 field failure는 별도 strategy를 쓰되 code,
category, retryability, masking catalog는 공유한다. unknown failure의 raw message는 어떤 path에서도
노출하지 않는다.
### GQL-013 — persisted operation admin은 존재하지 않는 변경을 성공으로 audit할 수 있다
**근거**
- in-memory registry의 absent `updateStatus`는 no-op인데 admin service는 `ABSENT→BLOCKED/DEPRECATED` audit을 남긴다.
- `remove()`는 실제 삭제가 아니라 BLOCKED 전환이다.
- BLOCKED에서 DEPRECATED로 바꿔 다시 executable하게 만들 수 있는 transition guard가 없다.
- raw operator 문자열 allowlist를 받고 credential kind 거부 메서드는 service가 호출하지 않는다.
- registry 변경과 in-memory `ArrayList` audit은 원자적이지 않고 thread-safe하지 않다.
**구현 결정: State + authenticated command + durable transaction**
1. transport가 만든 `GraphQlAdminPrincipal`만 service에 전달한다.
2. lifecycle transition table을 두고 BLOCKED는 explicit audited unblock 전까지 terminal로 취급한다.
3. registry command는 updated record/version을 반환하거나 not-found/conflict를 던진다.
4. mutation과 audit append를 하나의 durable transactional port로 묶는다.
5. soft delete가 의도면 `remove``retireAndBlock`으로 이름 바꾼다.
### GQL-014 — codegen validator는 operation document를 읽지 않고 generator는 source를 만들지 않는다
**근거**
`GraphQlClientOperationGenerator.validateOperation`은 nonblank만 확인한 뒤 schema를 자기 자신과 비교한다.
`operationDocument`는 검증에 쓰지 않는다. invalid syntax나 unknown field operation이 통과한다. 다른
generator/factory도 실제 handler/source가 아니라 metadata set/report만 반환하는 사례가 많다.
**구현 결정**
- schema를 executable schema로 만들고 GraphQL Java parser/validator로 selected operation을 검증한다.
- 실제 source writer/Gradle task가 없다면 class/package를 `codegen-plan` 또는 `compatibility-policy`
정직하게 이름 바꾼다.
- invalid syntax, unknown field/argument/type, operation name ambiguity, valid fragment operation을 테스트한다.
### GQL-015 — schema compatibility와 ownership이 breaking change를 놓친다
**근거**
- 동일 이름의 `type Foo``input Foo` 같은 kind change를 먼저 비교하지 않는다.
- 기존 argument/input field의 default 추가·제거·변경을 비교하지 않는다.
- `extend type/interface/input/enum/union`의 field/member ownership과 duplicate를 충분히 기록하지 않는다.
- applied directive 변경이 아니라 directive definition만 비교한다.
- scalar SDL print 차이를 coercion change라 부르지만 실제 `Coercing` 구현 교체는 보지 못하고 description
변화는 오탐할 수 있다.
**구현 결정**
1. registry를 extension까지 normalize하거나 executable schema로 compile한 canonical model을 비교한다.
2. `TYPE_KIND_CHANGED`, `INPUT_DEFAULT_REMOVED/CHANGED/ADDED`, applied-directive change를 명시한다.
3. nested list/non-null 변화는 input/output position별 방향성을 재귀 분류한다.
4. scalar coercion compatibility는 SDL이 아니라 scalar manifest codec/version 계약으로 분리한다.
### GQL-016 — custom DataLoader contract는 실제 N+1과 timeout을 보장하지 않는다
**근거**
- `GraphQlDataLoaderRequestRegistry``Object` map이며 Spring/Java DataLoader registry에 연결되지 않는다.
- contract suite는 caller가 전달한 observed query count를 검사하고 test는 임의 숫자 1/2를 넘긴다.
- batch executor는 synchronous chunk 호출 전에만 시간을 보고 long/final chunk를 중단하지 못한다.
- mapped loader의 null은 `Present(null)`, ordered loader의 null은 `Missing`으로 해석되어 의미가 다르다.
- result cardinality가 같아도 requested key 대신 다른 key가 들어간 map을 검출하지 못한다.
**구현 결정: Spring registry adapter + Decorator**
Spring `BatchLoaderRegistry`에 실제 loader를 등록하고 chunk/timeout/auth scope/observation을 loader decorator로
적용한다. loader는 `CompletionStage`/`Mono`로 deadline/cancellation을 전달한다. null 의미는 하나로
정하고 requested key set/cardinality를 검증한다.
**필수 E2E**
- 50개 parent/child query의 fake application port 호출이 1회 또는 bounded chunk 수.
- request 간 cache 비공유, 같은 request duplicate key dedupe, actor/tenant scope 분리.
- never-completing loader timeout/cancel, 첫 chunk budget 소진 뒤 다음 chunk 0회.
- missing/failed/null/wrong-key map 계약.
### GQL-017 — MVC는 bounded라고 설명하지만 concurrency/queue가 unbounded이고 context도 전달하지 않는다
**근거**
- virtual-thread-per-task executor는 task admission을 제한하지 않는다.
- fixed thread pool은 기본 unbounded `LinkedBlockingQueue`를 사용한다.
- MVC adapter가 submit한 task를 `GraphQlContextPropagator.wrap`으로 감싸지 않는다.
- WebFlux blocking fallback은 `subscribeOn`이 없어 subscriber/event-loop thread에서 실행될 수 있다.
**구현 결정**
- executor 앞에 semaphore/bulkhead 또는 bounded `ThreadPoolExecutor` queue/rejection을 둔다.
- Spring GraphQL annotated controller executor를 canonical하게 구성해 double scheduling/wait을 피한다.
- context는 ThreadLocal만 믿지 말고 GraphQLContext/Reactor Context를 SSOT로 삼고 blocking bridge에서만
snapshot/wrap한다.
- timeout은 interrupt가 아니라 downstream deadline propagation과 함께 검증한다.
### GQL-018 — pipeline stage 순서는 authorization에 필요한 정보를 만들기 전에 authorize한다
**근거**
pipeline은 `AUTHORIZATION``PARSE_VALIDATE`보다 앞에 두지만 field authorization은 schema coordinate와
selected operation을 필요로 한다. 현재 pipeline이 실행되지 않아 장애는 잠복해 있지만 그대로 wiring할
수 없는 순서다.
**구현 결정: 실제 Chain of Responsibility**
```text
authenticate transport principal
→ create request context
→ persisted lookup / raw document admission
→ parse + validate + select operation
→ document/coordinate authorization
→ structural + complexity budget
→ execute + field/object authorization + DataLoader
→ map errors + observe + cleanup
```
각 handler는 입력 상태와 산출 상태를 typed record로 표현하고, 필요한 이전 stage가 없으면 compile-time 또는
startup validation에서 실패하게 한다.
### GQL-019 — subscription/replay/drain policy는 concurrent runtime state machine이 아니다
**근거**
- replay cursor는 expected subscription과 tenant를 검증하지 않는다.
- subscription event byte estimate는 실제 serialized bytes가 아니라 `payload.toString()`을 사용한다.
- drain coordinator는 draining check와 registration increment 사이 race가 있고, state publication 순서에
따라 startedAt을 null로 볼 수 있다.
- cancellation/listener collections와 protocol lifecycle의 thread-safety/ownership이 명시되지 않았다.
- WebSocket/SSE/RSocket “handler factory”는 실제 Spring transport handler가 아니라 policy 객체를 반환한다.
**구현 결정**
atomic immutable state 또는 lock-protected State pattern으로 `ACCEPTING→DRAINING→CLOSED`를 모델링한다.
registration은 lease를 받아 close 시 release한다. replay cursor는 actor+tenant+subscription profile에
bind한다. queue byte bound는 실제 serializer 결과로 계산한다. 실제 handler가 없으면 factory 명명과
지원 등급을 policy/catalog로 낮춘다.
### GQL-020 — preparsed cache의 expiry policy가 사용되지 않고 unrelated miss가 직렬화된다
`GraphQlPreparsedCachePolicy`의 expire-after-access 값은 provider에서 사용되지 않는다. cache miss parse가
synchronized block 안에서 실행되어 서로 다른 document도 직렬화된다. injected Clock/Ticker를 쓰는 bounded
cache와 per-key single-flight를 적용하고 expiry/access-refresh/same-key-once/different-key-parallel을 테스트한다.
### GQL-021 — cancellation hook 하나의 실패가 나머지 cleanup을 막는다
request/subscription cancellation listener loop가 exception을 aggregate하지 않는다. 세 hook 중 두 번째가
throw해도 세 개 모두 정확히 한 번 실행하고 첫 실패에 나머지를 suppressed로 붙이는 공통 cancellation
primitive로 합친다. 이미 `GraphQlContextCleanup`이 가진 all-cleanups 실행 의미를 재사용한다.
### GQL-022 — scalar input/output limit이 대칭이 아니고 작은 입력이 큰 출력을 만들 수 있다
- BigDecimal은 precision/scale/exponent/serialized length 제한 없이 parse 후 `toPlainString()`을 사용한다.
작은 `1E+1000000`이 매우 큰 output allocation을 만들 수 있다.
- custom Long scalar는 parse에 configured min/max를 적용하지만 serialize/valueToLiteral은 그 범위를 무시한다.
lexical length, precision, absolute scale, output length를 먼저 제한하고 Long의 input/output에 같은 range를
적용한다. coercion error에는 raw input을 포함하지 않는 기존 원칙을 유지한다.
### GQL-023 — operation name을 low-cardinality tag라고 가정할 수 없다
operation name은 길이/문법만 제한되어 client가 매번 임의 이름을 만들 수 있고 observation convention은 raw
name을 tag로 사용한다. 실제 Micrometer/Spring Observation interface 연결도 없다. persisted/registered
operation만 이름 tag로 사용하고 나머지는 `other`로 collapse하거나 production에서 anonymous/unregistered
operation을 거부한다. 10,000개 임의 name을 actual MeterRegistry에 넣어 series bound를 검증한다.
### GQL-024 — production jar가 testkit, fixed secret, in-memory development 구현을 함께 배포한다
main source에는 `testkit` 12개 class, `GraphQlConnectionAssembler.forTests()`의 fixed signing secret,
`GraphQlAuthenticationContextFactory.testContext`, test error context, in-memory persisted registry가 있다.
`java-test-fixtures` 또는 별도 `graphql-testkit` leaf로 옮기고 production jar에 `.testkit.`, `forTests`,
`testContext`, fixed secret이 없는 jar content gate를 둔다.
### GQL-025 — 373개 public 중심 type과 Stable/Advanced/testkit/release의 한 jar 결합은 변경 비용이 크다
package import graph에 명백한 cycle이 없는 방향성은 좋지만 package만으로 외부 API와 classpath isolation을
보장하지 못한다. 이번 ignored boundary package가 그 취약성을 실제로 보여 줬다. package-private를 default로
하고 explicit `api`/`spi`만 public으로 허용하는 API surface snapshot을 둔다. Gradle 분리는 28개를 한 번에
늘리지 않고 §7의 6~8개 capability 단위로 진행한다.
### GQL-026 — GraphQL request context와 storage SPI가 inbound에 있어 downstream 구현 방향과 충돌한다
문서는 `GraphQlRequestContext`/deadline을 application/JPA/Mongo/HTTP client까지 전달하고 persisted registry를
외부 durable store가 구현한다고 설명한다. application/outbound가 inbound leaf type을 구현하면 의존 방향이
뒤집힌다.
- GraphQL context는 inbound-local로 유지하고 application command의 actor/tenant/deadline 값으로 명시 매핑한다.
- object authorization은 application-core의 transport-neutral use case로 두고 GraphQL bridge가 호출한다.
- persisted operation 저장은 generic operational store/cache port를 neutral contract owner에 두고 GraphQL
adapter가 key/value mapping만 소유한다. inbound→outbound 직접 edge는 만들지 않는다.
- composition root는 연결만 하고 business/storage policy를 소유하지 않는다.
### GQL-027 — 문서와 실제 설정·테스트·지원 등급이 drift했다
- README는 custom `@ConfigurationProperties`가 없다고 하지만 `backend.graphql` properties가 있다.
- build comment는 `spring.graphql.platform.*`를 언급하지만 실제 prefix는 `backend.graphql`이다.
- CLAUDE/README는 누락된 boundary classes/tests가 있다고 기록한다.
- “더 이상 미구현이 아니다”라는 표현은 policy object 존재와 runtime integration을 구분하지 않는다.
- test count는 compile이 깨진 현재 실행 증거가 아니라 과거/문서 count다.
generated configuration metadata, actual bean inventory, random-port adoption test, task JUnit XML에서 문서를
생성/검증한다. capability마다 `modelled`, `wired`, `integration-verified`, `production-verified`를 분리하고
현재 수준 이상으로 표현하지 않는다.
## 6. 디자인 패턴 적용 제안
### 6.1 적용할 패턴
| 위치 | 패턴 | 적용 형태 | 해결하는 문제 |
|---|---|---|---|
| 실행 pipeline | Chain of Responsibility | typed stage handler + actual Spring execution decorator | stage 목록만 있고 실행되지 않는 문제 |
| Spring integration | Adapter | pure policy를 interceptor/instrumentation/wiring으로 변환 | framework-free core와 runtime 연결 분리 |
| transport | Strategy | MVC/WebFlux leaf별 transport strategy | 두 runtime classpath와 blocking policy 혼합 제거 |
| DataLoader | Decorator | loader에 chunk/deadline/auth/observation을 조합 | 병렬 custom framework와 정책 중복 제거 |
| error | Mapper + Adapter | pure wire-error mapper + Spring resolver | 두 resolver/category drift 제거 |
| cursor | Versioned Codec Strategy | v1 read/v2 write codec과 key-ring signer | framing migration과 rotation 분리 |
| persisted/admin/subscription | State | 허용 transition과 CAS version 명시 | blocked 재활성, drain race, 허위 audit 제거 |
| application 경계 | Anti-Corruption Mapper | GraphQL context/input → command/context | transport DTO/application leakage 방지 |
| configuration | Validated Plan/Builder | bind → aggregate validate → immutable runtime plan | resource 생성 뒤 validation과 inert setting 제거 |
### 6.2 피할 패턴
- `Factory`, `Generator`, `Interceptor`라는 이름만 붙이고 metadata/policy 객체만 반환하지 않는다.
- 28개 설계상 “모듈”을 근거 없이 28개 Gradle leaf로 기계 분해하지 않는다.
- controller/router와 Spring 기본 endpoint를 병렬로 유지하지 않는다.
- custom DataLoader, custom preparsed cache, custom transport를 framework가 제공하는 extension point와 경쟁시키지 않는다.
- architecture rule을 runtime reflection suffix 검사 하나로만 강제하지 않는다.
- Advanced라는 이유로 repository/use-case 경계를 완화하지 않는다.
## 7. 권장 Gradle·폴더 구조
### 7.1 대안 비교
| 대안 | 장점 | 단점 | 판정 |
|---|---|---|---|
| A. 현재 단일 leaf 유지 + 경계 test 복구 | 가장 빠름, registry 변경 최소 | public/classpath/runtime 결합 유지 | GQL-001 응급 복구용 |
| B. 6~8 capability leaf로 단계 분리 | 실제 runtime 책임과 dependency를 격리 | registry/settings/lock/CI 갱신 필요 | **권장** |
| C. 설계의 28 package를 28 leaf로 분리 | 가장 강한 compile boundary | Gradle/lock/CI 비용과 빈 facade 증가 | 현재 과도함 |
### 7.2 권장 target
```text
graphql-platform-core
src/main/java/.../graphql/core/api
src/main/java/.../graphql/core/policy
# pure Java, framework/transport/application type 없음
graphql-schema
src/main/java/.../graphql/schema
src/main/java/.../graphql/scalar
src/main/java/.../graphql/compat
# GraphQL Java AST/wiring, no web server
graphql-spring-execution
src/main/java/.../graphql/execution
src/main/java/.../graphql/security
src/main/java/.../graphql/error
src/main/java/.../graphql/dataloader
src/main/java/.../graphql/autoconfigure
# application-core bridge + Spring GraphQL extension points
graphql-transport-mvc
src/main/java/.../graphql/http/mvc
# starter-web only
graphql-transport-webflux
src/main/java/.../graphql/http/webflux
# starter-webflux only
graphql-advanced
src/main/java/.../graphql/advanced/{persisted,subscription,federation,...}
# 실제 wired capability만 opt-in; feature가 커지면 사용 단위별 추가 분리
graphql-testkit
src/testFixtures/java 또는 전용 leaf
graphql-release-verification
# Gradle/build logic와 evidence manifest, production runtime에 포함하지 않음
```
허용 방향의 기본안은 다음과 같다.
```text
transport-mvc/webflux → spring-execution → schema → platform-core
spring-execution → application-core → domain-core
advanced → spring-execution/schema/platform-core
testkit → 공개 api/spi만
release-verification → 각 leaf의 test/evidence artifact만
```
실제 edge와 runtime membership은 반드시 `src/config/architecture/modules.json`에 먼저 등록하고 같은 SSOT의
Gradle gate로 검증한다. `graphql-persisted-<database>`가 inbound contract에 역의존하는 구조는 만들지 않는다.
### 7.3 package visibility
- public 허용: 외부 resolver/adopter가 구현·호출해야 하는 `api`, `spi`, configuration properties.
- package-private/internal: calculator, parser walker, state transition, mapper implementation, factory implementation.
- test-only: fixture, fake/in-memory, fixed key/principal/context, contract assertion helper.
- public API snapshot에는 FQCN, constructor/method signature, stability level을 기록한다.
## 8. 구현 순서 — 그대로 issue/PR로 분리 가능한 단위
### Wave 0 — build와 증거 복구
1. **PR GQL-001A**: ignored package red test와 `moduleboundary` package 복구.
2. **PR GQL-001B**: module boundary negative fixtures와 required FQCN/lane 연결.
3. focused test, Stable/contract/Advanced lane을 실행한다. 여기서 발견되는 test failure는 다음 wave의
characterization backlog로 분리한다.
### Wave 1 — 실제 endpoint baseline
1. 현재 기본 `/graphql`에 health query를 보내는 full configuration test를 만든다.
2. cost/auth/DataLoader/custom adapter bean이 존재하지만 호출되지 않는 현재 상태를 failing test로 증명한다.
3. Spring-native endpoint를 canonical로 확정하고 custom transport dead path를 제거한다.
4. auto-configuration imports, binder defaults, framework property cross-check를 추가한다.
### Wave 2 — executable pipeline
1. request context interceptor.
2. parse/select/introspection/cost/auth instrumentation/decorator.
3. scalar/preparsed/DataLoader/error wiring.
4. actual observation과 cleanup/cancellation.
5. servlet random-port qualification을 platform adoption test로 교체한다.
### Wave 3 — correctness/security
서로 독립인 작은 PR로 다음을 처리한다.
- null-preserving request JSON + byte/nesting limit.
- Accept negotiation.
- fragment introspection/selected-operation analyzer.
- cursor v2/rotation/direction/tenant.
- mutation canonical fingerprint/tenant.
- schema kind/default/extensions/directives.
- scalar bounds.
- persisted admin/subscription state machines.
각 PR은 먼저 failing unit/property/integration test를 추가한다.
### Wave 4 — architecture와 모듈 분리
1. actual controller generic/bytecode gate와 application import gate.
2. repository exposure capability 제거.
3. testkit/fixed/in-memory API를 test fixtures로 이동.
4. `platform-core`, `schema`, `spring-execution` 추출.
5. MVC/WebFlux leaf 분리와 runtime classpath tests.
6. Advanced/release verification을 runtime jar에서 분리.
7. public API snapshot과 package-private 축소.
### Wave 5 — Advanced promotion
각 capability는 다음 네 증거가 모두 있을 때만 `wired` 이상으로 승격한다.
1. 실제 Spring handler/extension point가 존재한다.
2. real request 또는 protocol-level integration test가 해당 path를 호출한다.
3. disabled 상태에서 bean/resource/route가 0개다.
4. restart/concurrency/fault가 필요한 stateful capability는 durable evidence가 있다.
codegen/federation/subscription/persisted operation이 이 기준을 못 채우면 policy/catalog로 이름과 문서를
낮추고 production support claim을 하지 않는다. Spring GraphQL은 federation에 `@EntityMapping`을 포함한
공식 통합을 제공하므로 별도 facade보다 이를 우선 검토한다
([Spring GraphQL federation](https://docs.spring.io/spring-graphql/reference/federation.html)).
## 9. 테스트 전략과 Definition of Done
### 9.1 최소 테스트 피라미드
| 계층 | 테스트 | 핵심 assertion |
|---|---|---|
| pure policy | unit + property | canonicalization, bounds, transition, deterministic output |
| Spring composition | `ApplicationContextRunner` | enabled/disabled, bean exact set, unsafe config failure |
| schema/execution | `ExecutionGraphQlServiceTester` | scalar, parse, validation, error path, DataLoader |
| transport | random-port MVC/WebFlux | media type, auth, body cap, actual policy rejection |
| architecture | ArchUnit/bytecode + Gradle edge | generic DTO/entity/repository, package/leaf edge, public API |
| stateful Advanced | concurrency/restart/store integration | CAS/fencing/audit/replay/durable transition |
| release | same-SHA evidence manifest | 실행한 lane/version/scenario와 지원 문서 일치 |
### 9.2 전체 완료 조건
- `:adapter:inbound:graphql:compileJava`, focused `test`, Stable/contract/Advanced lane이 실행되고 green이다.
- performance lane이 필요한 support claim은 실제 tagged scenario/evidence 없이는 승격되지 않는다.
- real `/graphql` E2E에서 모든 mandatory policy가 최소 한 번 차단/허용 경로를 가진다.
- GraphQL DTO/context/framework type이 application/domain에 유출되지 않는다.
- controller/resolver가 repository, persistence entity, transaction을 직접 소유하지 않는다.
- MVC/WebFlux runtime dependency가 서로의 server stack을 끌어오지 않는다.
- production jar에 testkit/fixed secret/in-memory development facade가 없다.
- public API와 capability support status가 snapshot/manifest로 검증된다.
- docs의 property name/test count/support status는 generated metadata와 JUnit evidence에서 파생된다.
## 10. 이번 리뷰에서 실행한 검증
### 성공
```bash
cd src
./gradlew verifyCleanArchitectureDependencies --console=plain
```
- current HEAD fresh run은 `BUILD SUCCESSFUL in 769ms`, 1 actionable task executed였다.
- 이 결과는 registry에 선언된 project dependency edge가 맞다는 증거다.
- 누락된 내부 package boundary, runtime wiring, correctness를 승인하는 증거는 아니다.
### 실패
```bash
cd src
./gradlew :adapter:inbound:graphql:compileJava --console=plain
./gradlew :adapter:inbound:graphql:test --console=plain
```
- direct `compileJava`의 current HEAD fresh 재현은 `BUILD FAILED in 1s`, 7 errors였다
(직전 첫 재현도 같은 7 errors, 7s).
- focused `test`도 같은 `compileJava` 단계에서 실패했다.
- 누락 package: `dev.caskeleton.adapter.inbound.graphql.build`.
- 참조 파일: `GraphQlPlatformAutoConfiguration`, `GraphQlAdvancedDependencyRules`.
- test 75개는 실행 단계에 진입하지 못했다.
### 정적 재현
```bash
git check-ignore -v --no-index \
src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/build/GraphQlBuildModel.java
```
- `src/.gitignore:2:build/`이 반환되어 누락 source package와 ignore rule의 충돌을 확인했다.
### 미실행
- `graphqlStableTest`, `graphqlContractTest`, `graphqlAdvancedTest`: 동일 compile blocker 때문에 실행 불가.
- `graphqlPerformanceTest`: compile blocker에 더해 실제 tagged load/fault scenario가 없는 상태.
- repository 전체 `test`/`check`: review-only 범위이며 focused compile blocker가 먼저 존재한다.
- production adopter, actual feature schema, JPA/Mongo query-count, real WebSocket/SSE/RSocket, load/soak/fault.
## 11. 남은 위험과 판정 범위
- GraphQL leaf는 현재 두 composition root runtime에 포함되지 않으므로 발견 사항을 현 서비스의 즉시 runtime
장애로 확대하지 않는다.
- 반대로 빈 runtime membership은 adopter 안전성의 증거도 아니다. opt-in 직후 compile/auto-config/runtime
wiring 문제가 드러난다.
- build blocker가 해결되면 지금까지 실행되지 못한 524 test annotation에서 추가 failure가 나올 수 있다.
- Advanced 130개 production class의 모든 concurrent/protocol path를 실환경에서 검증하지 않았다.
- 공식 Spring GraphQL extension point 선택은 타당하지만 정확한 Boot 4.0.0/Spring GraphQL 2.0.0 API
signature와 auto-configuration ordering은 구현 시 lock 기준으로 확인해야 한다.
- 이 리뷰의 `FACT`는 명시한 source/command에 한정되고, target module split은 그 사실에서 도출한
`INFERENCE/권고`다. registry 변경 전에 별도 설계 문서와 실행 계획을 남겨야 한다.
최종 판정은 **CHANGES REQUIRED**다. 구현 순서는 `GQL-001 → GQL-002/003 → GQL-005~018 →
GQL-024~026 → Advanced promotion`을 권장한다.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -25,8 +25,12 @@ status: stub
### Step 1 — 확인
1. ERROR log에서 `OUTBOX_PUBLISH_FAILED` 라인 확인: `event_type`, `event_id`, `correlation_id`, `attempt_count` 추출
2. broker(기본 Kafka adapter) 상태 확인: `APP_MESSAGING_KAFKA_ENABLED`과 broker endpoint 가용성
- Kafka disabled(default) 상태에서 outbox 이벤트가 append 되고 있으면 publish 경로가 `AdapterDisabledException`으로 전부 실패하는 구성 오류 — 이 경우 producer use case 쪽 활성화/구성을 먼저 의심
2. broker 상태 확인: `APP_MESSAGING_BROKER` 값(공백이면 messaging 비활성)과 broker endpoint 가용성
- `APP_MESSAGING_BROKER`가 공백인 채로 relay가 켜져 있으면 **애플리케이션이 기동하지 않는다**
(`OutboxRelayBrokerRequirementValidator`, MSG-024). 이 조합에서는 publish가 전부
`AdapterDisabledException`으로 실패하며 PENDING row가 DEAD까지 소진되기 때문이다.
기동 실패를 보고 있다면 broker를 설정하거나 `ca-skeleton.outbox.relay-enabled=false`로 둔다.
- 기동은 했는데 실패가 쌓인다면 broker는 설정돼 있고 도달이 안 되는 것이다 — endpoint부터 본다.
3. `outbox.pending.size` status 분포 확인 (FAILED 누적 vs PENDING 누적)
### Step 2 — 임시 격리
@@ -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<String, Boolean> 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<TenantId> 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<JvmTestSuite>("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<JvmTestSuite>("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 증거를 요구한다.
```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,6 @@
44ba9931722364a53fcb3b5f31a1d539eabcaf42db775f5a33fb558f558c7504 README.md
d064f0ac6c3be0e5c76ef22454db2a97e1d78ed287bd22f4c125f19aba3ad8e3 VALIDATION.md
1ef15812f33dc998a6332b87523ed5942ba46d79d984a0ca776b05bb9247a06a docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md
5ae70b53e22cdb852b2bb0df171dec868bfe99b15bb8e71fb2b0b3431cd7e2cd docs/superpowers/plans/2026-08-12-graphql-api-execution-platform-implementation-plan.md
8d0203203f6bfe4b2e18625eff23bb308ba6454703a4ca4cd3236dab31ecafc3 docs/superpowers/specs/2026-08-12-graphql-api-execution-platform-design.md
8048fe6a536de67d2cf5b0df05d35128f2c68ba8f0dd615831b40430fc76277b validate_graphql_docs.py
+43
View File
@@ -0,0 +1,43 @@
# GraphQL Superpowers 설계 패키지
이 패키지는 `GraphQL API 실행 플랫폼 심층 리서치`를 구현 기준선으로 변환한 설계서와 실행 계획서다.
## 문서
- `docs/superpowers/specs/2026-08-12-graphql-api-execution-platform-design.md`
- Stable·Advanced 전체 아키텍처, 공개 계약, 경계, 실패 의미론, 테스트와 지원 등급
- 입력 심층 리서치 원문을 추적 부록으로 포함
- `docs/superpowers/plans/2026-08-12-graphql-api-execution-platform-implementation-plan.md`
- Stable 구현 Task 148
- `docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md`
- Stable Release Gate 이후 실행하는 Advanced·Experimental Task 119
- `VALIDATION.md`
- 정적 검증 결과와 검증 범위
- `validate_graphql_docs.py`
- 패키지 내부 문서 재검증 스크립트
- `MANIFEST.sha256`
- 패키지 파일 무결성 목록
## 구현 순서
```text
Stable Task 148
→ Stable Release Gate
→ Advanced Task 119
→ Capability별 Promotion Gate
```
## 명시적 전제
```text
Java 21
Gradle Kotlin DSL
Spring Boot 4.1 BOM
Spring for GraphQL 2.0
Boot-managed GraphQL Java v25 계열
Stable module root: modules/graphql
Advanced module root: modules/graphql-advanced
Root package: io.backend.skeleton.graphql
```
실제 저장소에 적용할 때 기존 package·version catalog·module naming에 맞춰 경로만 조정하고, 문서의 공개 계약·불변 조건·테스트 의미는 유지한다.
+98
View File
@@ -0,0 +1,98 @@
# GraphQL Superpowers 문서 정적 검증 결과
- **검증 시각 기준:** 2026-08-12
- **검증 대상:** 설계서 1개, Stable 구현 계획서 1개, Advanced·Experimental 확장 계획서 1개
- **검증 명령:** `python3 validate_graphql_docs.py`
- **결과:** **PASS**
- **실행 검사:** 1,475
- **통과:** 1,475
- **실패:** 0
## 문서 규모
| 문서 | 행 수 | 크기 |
|---|---:|---:|
| GraphQL API 실행 플랫폼 설계서 | 2,553 | 93,359 bytes |
| Stable 구현 계획서 | 4,560 | 209,041 bytes |
| Advanced 확장 계획서 | 1,976 | 105,717 bytes |
## 계획 구조
| 항목 | Stable | Advanced |
|---|---:|---:|
| Task 수 | 48 | 19 |
| Create 경로 수 | 227 | 113 |
| Task 번호 연속성 | PASS | PASS |
| 모든 Task의 `Files`·`Interfaces` | PASS | PASS |
| 모든 Task의 Implementation Requirements | PASS | PASS |
| 모든 Task의 Step 15 | PASS | PASS |
| 실패·통과 예상 결과 | PASS | PASS |
| Task별 Git commit 명령 | PASS | PASS |
| Create 경로 중복 | 없음 | 없음 |
| Stable·Advanced 경로 충돌 | 없음 | 없음 |
## 핵심 계약 검증
```text
SDL-first external contract
Single Executable Schema Stable default
HTTP POST Stable profile
application/graphql-response+json preferred
Validation 이후 Field Error·Partial Data는 HTTP 200
Draft 294는 Stable에서 제외
JPA Entity·MongoDB Document 직접 노출 금지
GraphQL Multipart Upload 미지원·Fileserver 사용
request-wide database transaction 금지
DataLoader request scope
Finite Fetch Profile
HMAC-signed cursor
Mutation idempotency·expected version 분리
Parser·shape·complexity·runtime response budget
Actor·Field·Object·Tenant authorization
Low-cardinality observability
Stable/Advanced dependency isolation
Persisted Operation·WebSocket·SSE·Federation 분리
RSocket·HTTP GET·Incremental Delivery Experimental
```
위 계약은 설계서와 계획서의 필수 문자열·모듈 경로·Task별 파일·테스트를 대조해 검증했습니다.
## 입력 리서치 추적성
- 첨부된 `GraphQL API 실행 플랫폼 심층 리서치` 원문 전체가 설계서의 `부록 B`에 포함되어 있습니다.
- 설계 본문은 원문의 용어와 결론을 유지하면서 구현 판단을 Stable·Advanced·Experimental로 고정합니다.
- 설계서와 입력 원문의 exact text 포함 검사를 별도로 통과했습니다.
## 패키지 검증 항목
```text
문서 파일 존재
Markdown code fence 균형
Task 148 / 119 연속성
Task별 테스트·명령·commit
정확한 Create 경로
Placeholder 금지
Stable module에 WebSocket·Federation·Persisted Operation 경로 부재
Advanced module에 feature flag와 capability 경로 존재
금지 API pattern 부재
문서 SHA-256 계산
```
## 검증 범위의 한계
현재 PASS는 **문서의 정적 구조, 요구사항 추적성, 내부 계약과 실행 계획의 완결성**을 의미합니다. 실제 Backend Skeleton 저장소가 입력으로 제공되지 않았으므로 다음은 실행하지 않았습니다.
```text
Gradle configuration·compile
Spring Boot ApplicationContext 기동
SchemaMappingInspector 실제 결과
GraphQlTester HTTP·WebFlux contract
JPA·MongoDB statement/query-count integration
query bomb·complexity load test
Virtual Thread·event-loop blocking test
WebSocket·SSE soak test
Federation composition·router integration
actual Git commit
```
실제 구현에서는 Stable Task 148을 먼저 수행해 Stable Release Gate를 통과한 뒤 Advanced Task 119를 시작해야 합니다.
+249
View File
@@ -0,0 +1,249 @@
#!/usr/bin/env python3
from __future__ import annotations
from pathlib import Path
import re
import sys
import hashlib
ROOT = Path(__file__).resolve().parent
DESIGN = ROOT / "docs/superpowers/specs/2026-08-12-graphql-api-execution-platform-design.md"
STABLE = ROOT / "docs/superpowers/plans/2026-08-12-graphql-api-execution-platform-implementation-plan.md"
ADVANCED = ROOT / "docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md"
checks: list[tuple[str, bool, str]] = []
def check(name: str, condition: bool, detail: str = "") -> None:
checks.append((name, bool(condition), detail))
def read(path: Path) -> str:
check(f"file exists: {path.name}", path.exists(), str(path))
return path.read_text(encoding="utf-8") if path.exists() else ""
design = read(DESIGN)
stable = read(STABLE)
advanced = read(ADVANCED)
# Basic document integrity
check("design line floor", len(design.splitlines()) >= 2000, str(len(design.splitlines())))
check("stable plan line floor", len(stable.splitlines()) >= 4000, str(len(stable.splitlines())))
check("advanced plan line floor", len(advanced.splitlines()) >= 1500, str(len(advanced.splitlines())))
for label, text in [("design", design), ("stable", stable), ("advanced", advanced)]:
check(f"{label} code fences balanced", text.count("```") % 2 == 0, str(text.count("```")))
for marker in ["TODO", "TBD", "FIXME", "implement later", "fill in details"]:
check(f"{label} no placeholder {marker}", marker.lower() not in text.lower())
# Design required sections and source traceability
required_design_terms = [
"# GraphQL API 실행 플랫폼 설계서",
"GraphQL Platform owns",
"Domain/Application owns",
"G1 Standard GraphQL API",
"G2 Advanced Execution",
"G3 GraphQL Extension",
"G4 Admin Plane",
"SDL",
"September 2025",
"application/graphql-response+json",
"HTTP `200`",
"GraphQlRequestContext",
"DataLoader",
"GraphQlFetchProfile",
"HMAC",
"Idempotency",
"Partial Data",
"Persisted Operation",
"Subscription",
"Federation",
"GraphQL Multipart Upload",
"Fileserver",
"부록 B. 입력 심층 리서치 원문",
"# GraphQL API 실행 플랫폼 심층 리서치",
]
for term in required_design_terms:
check(f"design contains {term}", term in design)
# Critical design invariants
critical_pairs = [
("field error uses HTTP 200", "field error" in design.lower() and "HTTP `200`" in design),
("no draft 294 stable", "294" in design and "Stable" in design),
("dataloader request scope", "request" in design.lower() and "DataLoader" in design),
("cursor HMAC", "Cursor" in design and "HMAC" in design),
("no multipart upload", "Multipart Upload" in design and "Fileserver" in design),
("single schema default", "Single Executable Schema" in design),
("request-wide transaction prohibited", "request-wide" in design.lower() and "transaction" in design.lower()),
("entity/document boundary", "JPA Entity" in design and "MongoDB Document" in design),
]
for name, condition in critical_pairs:
check(name, condition)
# Plan headers and global constraints
stable_header_terms = [
"# GraphQL API 실행 플랫폼 Implementation Plan",
"REQUIRED SUB-SKILL",
"**Goal:**",
"**Architecture:**",
"**Tech Stack:**",
"## Global Constraints",
"Stable Task",
]
advanced_header_terms = [
"# GraphQL Advanced Capability Expansion Implementation Plan",
"REQUIRED SUB-SKILL",
"backend.graphql.advanced.*",
"Stable 구현 계획 Task `148`",
]
for term in stable_header_terms:
check(f"stable header contains {term}", term in stable)
for term in advanced_header_terms:
check(f"advanced header contains {term}", term in advanced)
# Task sequence and per-task structure
def task_sections(text: str) -> list[tuple[int, str]]:
matches = list(re.finditer(r"^### Task (\d+): .+$", text, re.MULTILINE))
result = []
for i, match in enumerate(matches):
start = match.start()
end = matches[i+1].start() if i+1 < len(matches) else len(text)
result.append((int(match.group(1)), text[start:end]))
return result
stable_tasks = task_sections(stable)
advanced_tasks = task_sections(advanced)
check("stable task count", len(stable_tasks) == 48, str(len(stable_tasks)))
check("advanced task count", len(advanced_tasks) == 19, str(len(advanced_tasks)))
check("stable task sequence", [n for n, _ in stable_tasks] == list(range(1, 49)))
check("advanced task sequence", [n for n, _ in advanced_tasks] == list(range(1, 20)))
def validate_tasks(label: str, tasks: list[tuple[int, str]]) -> None:
required = [
"**Files:**",
"**Interfaces:**",
"**Implementation requirements:**",
"**Step 1: Write the failing test**",
"**Step 2: Run the focused test and verify the failure**",
"**Step 3: Implement the smallest complete production contract**",
"**Step 4: Run the focused test and the owning suite**",
"**Step 5: Commit the independently reviewable change**",
"Expected: FAIL",
"Expected: PASS",
"git commit -m",
]
for number, section in tasks:
for token in required:
check(f"{label} task {number} contains {token}", token in section)
check(f"{label} task {number} has test path", "- Test: `" in section)
check(f"{label} task {number} has production file", "- Create: `" in section)
check(f"{label} task {number} fences balanced", section.count("```") % 2 == 0)
check(f"{label} task {number} has gradle test", "./gradlew" in section and ":test" in section)
validate_tasks("stable", stable_tasks)
validate_tasks("advanced", advanced_tasks)
# Create paths
def create_paths(text: str) -> list[str]:
return re.findall(r"^- Create: `([^`]+)`$", text, re.MULTILINE)
stable_paths = create_paths(stable)
advanced_paths = create_paths(advanced)
check("stable create paths exist", len(stable_paths) >= 150, str(len(stable_paths)))
check("advanced create paths exist", len(advanced_paths) >= 80, str(len(advanced_paths)))
check("stable create paths unique", len(stable_paths) == len(set(stable_paths)))
check("advanced create paths unique", len(advanced_paths) == len(set(advanced_paths)))
check("stable and advanced paths disjoint", set(stable_paths).isdisjoint(advanced_paths))
for index, path in enumerate(stable_paths, 1):
check(f"stable create path {index} exact", "*" not in path and "..." not in path and (path.startswith("modules/graphql/") or path.startswith("build-logic/")))
for index, path in enumerate(advanced_paths, 1):
check(f"advanced create path {index} exact", "*" not in path and "..." not in path and path.startswith("modules/graphql-advanced/"))
# Stable/Advanced separation
for forbidden in [
"modules/graphql/graphql-websocket/",
"modules/graphql/graphql-federation/",
"modules/graphql/graphql-persisted-operation/",
"modules/graphql/graphql-rsocket/",
]:
check(f"stable excludes {forbidden}", forbidden not in stable)
for required in [
"modules/graphql-advanced/graphql-persisted-operation/",
"modules/graphql-advanced/graphql-websocket/",
"modules/graphql-advanced/graphql-subscription/",
"modules/graphql-advanced/graphql-federation/",
"modules/graphql-advanced/graphql-rsocket/",
]:
check(f"advanced includes {required}", required in advanced)
# Stable coverage
stable_required_terms = [
"GraphQlRequestContext",
"GraphQlClientPolicy",
"GraphQlSchemaContract",
"SchemaMappingInspector",
"@oneOf",
"GraphQlHttpProfile",
"application/graphql-response+json",
"GraphQlExecutionProfile",
"GraphQlWireError",
"GraphQlTenantIsolationPolicy",
"GraphQlParserLimits",
"GraphQlComplexityCalculator",
"GraphQlRuntimeBudget",
"GraphQlPreparsedCacheKey",
"GraphQlBatchPolicy",
"GraphQlFetchProfile",
"HmacGraphQlCursorCodec",
"GraphQlConnection",
"GraphQlMutationIdempotencyContext",
"GraphQlMetricCardinalityPolicy",
"GraphQlPlatformStartupValidator",
"GraphQlReleaseGate",
]
for term in stable_required_terms:
check(f"stable coverage {term}", term in stable)
advanced_required_terms = [
"GraphQlPersistedOperation",
"GraphQlWebSocketProtocol",
"GraphQlSubscriptionBufferPolicy",
"GraphQlSubscriptionOrderingProfile",
"GraphQlSseConnectionPolicy",
"GraphQlReplayPosition",
"GraphQlDataLoaderDependencyGraph",
"GraphQlFederationEntityKey",
"GraphQlFederationCompositionGate",
"GraphQlGeneratedSourceBoundary",
"GraphQlRepositoryAllowlist",
"GraphQlRSocketRoutePolicy",
"GraphQlHttpGetOperationPolicy",
"GraphQlIncrementalCompatibilityGate",
"GraphQlAdvancedReleaseGate",
]
for term in advanced_required_terms:
check(f"advanced coverage {term}", term in advanced)
# Prohibited API patterns
prohibited_patterns = [
(r"interface\s+GenericGraphQlRepository", "no generic graphql repository"),
(r"public\s+.*\bEntityManager\b", "no public entity manager"),
(r"public\s+.*\bMongoTemplate\b", "no public mongo template"),
(r"scalar\s+Upload\b", "no upload scalar declaration"),
(r"@Transactional\s+.*GraphQL request", "no request-wide transaction implementation"),
]
for pattern, name in prohibited_patterns:
check(name, re.search(pattern, stable, re.IGNORECASE | re.MULTILINE) is None)
# File hashes can be printed for package evidence
for path in [DESIGN, STABLE, ADVANCED]:
if path.exists():
digest = hashlib.sha256(path.read_bytes()).hexdigest()
check(f"sha256 computed: {path.name}", len(digest) == 64, digest)
failed = [(n, d) for n, ok, d in checks if not ok]
print(f"CHECKS={len(checks)}")
print(f"PASSED={len(checks)-len(failed)}")
print(f"FAILED={len(failed)}")
for name, detail in failed:
print(f"FAIL: {name}" + (f" :: {detail}" if detail else ""))
sys.exit(1 if failed else 0)
+39
View File
@@ -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.
+54
View File
@@ -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;
+43
View File
@@ -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"]

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