From 3b5aee50e33c44c02d08c94bb39ad34814482010 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 13 Aug 2026 21:09:52 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=84=A4=EA=B3=84=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- graphql-superpowers-package/MANIFEST.sha256 | 6 + graphql-superpowers-package/README.md | 43 + graphql-superpowers-package/VALIDATION.md | 98 + ...ql-advanced-capabilities-expansion-plan.md | 1976 ++++++ ...-execution-platform-implementation-plan.md | 4560 ++++++++++++++ ...2-graphql-api-execution-platform-design.md | 2553 ++++++++ .../validate_graphql_docs.py | 249 + jpa-superpowers-package/MANIFEST.sha256 | 6 + jpa-superpowers-package/README.md | 68 + jpa-superpowers-package/VALIDATION.md | 829 +++ ...persistence-experimental-expansion-plan.md | 771 +++ ...ersistence-platform-implementation-plan.md | 4716 +++++++++++++++ ...6-08-11-jpa-persistence-platform-design.md | 3276 ++++++++++ jpa-superpowers-package/validate_jpa_docs.py | 210 + messaging-superpowers-package/MANIFEST.sha256 | 5 + messaging-superpowers-package/README.md | 55 + messaging-superpowers-package/VALIDATION.md | 432 ++ ...-messaging-platform-implementation-plan.md | 5056 ++++++++++++++++ .../2026-08-10-messaging-platform-design.md | 2608 ++++++++ .../validate_messaging_docs.py | 164 + mongodb-superpowers-package/MANIFEST.sha256 | 6 + mongodb-superpowers-package/README.md | 42 + mongodb-superpowers-package/VALIDATION.md | 737 +++ ...db-advanced-capabilities-expansion-plan.md | 1139 ++++ ...ersistence-platform-implementation-plan.md | 3943 ++++++++++++ ...db-document-persistence-platform-design.md | 1303 ++++ .../validate_mongodb_docs.py | 73 + .../MANIFEST.sha256 | 5 + notification-superpowers-package/README.md | 60 + .../VALIDATION.md | 590 ++ ...tification-platform-implementation-plan.md | 5324 +++++++++++++++++ ...2026-08-10-notification-platform-design.md | 4770 +++++++++++++++ .../validate_notification_docs.py | 188 + 33 files changed, 45861 insertions(+) create mode 100644 graphql-superpowers-package/MANIFEST.sha256 create mode 100644 graphql-superpowers-package/README.md create mode 100644 graphql-superpowers-package/VALIDATION.md create mode 100644 graphql-superpowers-package/docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md create mode 100644 graphql-superpowers-package/docs/superpowers/plans/2026-08-12-graphql-api-execution-platform-implementation-plan.md create mode 100644 graphql-superpowers-package/docs/superpowers/specs/2026-08-12-graphql-api-execution-platform-design.md create mode 100755 graphql-superpowers-package/validate_graphql_docs.py create mode 100644 jpa-superpowers-package/MANIFEST.sha256 create mode 100644 jpa-superpowers-package/README.md create mode 100644 jpa-superpowers-package/VALIDATION.md create mode 100644 jpa-superpowers-package/docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md create mode 100644 jpa-superpowers-package/docs/superpowers/plans/2026-08-11-jpa-persistence-platform-implementation-plan.md create mode 100644 jpa-superpowers-package/docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md create mode 100644 jpa-superpowers-package/validate_jpa_docs.py create mode 100644 messaging-superpowers-package/MANIFEST.sha256 create mode 100644 messaging-superpowers-package/README.md create mode 100644 messaging-superpowers-package/VALIDATION.md create mode 100644 messaging-superpowers-package/docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md create mode 100644 messaging-superpowers-package/docs/superpowers/specs/2026-08-10-messaging-platform-design.md create mode 100755 messaging-superpowers-package/validate_messaging_docs.py create mode 100644 mongodb-superpowers-package/MANIFEST.sha256 create mode 100644 mongodb-superpowers-package/README.md create mode 100644 mongodb-superpowers-package/VALIDATION.md create mode 100644 mongodb-superpowers-package/docs/superpowers/plans/2026-08-11-mongodb-advanced-capabilities-expansion-plan.md create mode 100644 mongodb-superpowers-package/docs/superpowers/plans/2026-08-11-mongodb-document-persistence-platform-implementation-plan.md create mode 100644 mongodb-superpowers-package/docs/superpowers/specs/2026-08-11-mongodb-document-persistence-platform-design.md create mode 100644 mongodb-superpowers-package/validate_mongodb_docs.py create mode 100644 notification-superpowers-package/MANIFEST.sha256 create mode 100644 notification-superpowers-package/README.md create mode 100644 notification-superpowers-package/VALIDATION.md create mode 100644 notification-superpowers-package/docs/superpowers/plans/2026-08-10-notification-platform-implementation-plan.md create mode 100644 notification-superpowers-package/docs/superpowers/specs/2026-08-10-notification-platform-design.md create mode 100755 notification-superpowers-package/validate_notification_docs.py diff --git a/graphql-superpowers-package/MANIFEST.sha256 b/graphql-superpowers-package/MANIFEST.sha256 new file mode 100644 index 00000000..f6000c29 --- /dev/null +++ b/graphql-superpowers-package/MANIFEST.sha256 @@ -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 diff --git a/graphql-superpowers-package/README.md b/graphql-superpowers-package/README.md new file mode 100644 index 00000000..4eff19b3 --- /dev/null +++ b/graphql-superpowers-package/README.md @@ -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 1–48 +- `docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md` + - Stable Release Gate 이후 실행하는 Advanced·Experimental Task 1–19 +- `VALIDATION.md` + - 정적 검증 결과와 검증 범위 +- `validate_graphql_docs.py` + - 패키지 내부 문서 재검증 스크립트 +- `MANIFEST.sha256` + - 패키지 파일 무결성 목록 + +## 구현 순서 + +```text +Stable Task 1–48 +→ Stable Release Gate +→ Advanced Task 1–19 +→ 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에 맞춰 경로만 조정하고, 문서의 공개 계약·불변 조건·테스트 의미는 유지한다. diff --git a/graphql-superpowers-package/VALIDATION.md b/graphql-superpowers-package/VALIDATION.md new file mode 100644 index 00000000..725a4b2a --- /dev/null +++ b/graphql-superpowers-package/VALIDATION.md @@ -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 1–5 | 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 1–48 / 1–19 연속성 +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 1–48을 먼저 수행해 Stable Release Gate를 통과한 뒤 Advanced Task 1–19를 시작해야 합니다. diff --git a/graphql-superpowers-package/docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md b/graphql-superpowers-package/docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md new file mode 100644 index 00000000..904319ae --- /dev/null +++ b/graphql-superpowers-package/docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md @@ -0,0 +1,1976 @@ +# GraphQL Advanced Capability 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 GraphQL API 실행 플랫폼 위에 Persisted Operation, WebSocket·SSE Subscription, optional replay, Federation Subgraph, Code Generation과 제한된 Spring Data 호환 기능을 추가하고 RSocket·HTTP GET·Incremental Delivery를 격리된 Experimental capability로 검증한다. + +**Architecture:** Advanced capability는 `modules/graphql-advanced`에만 존재하며 Stable module의 public contract를 소비하되 Stable starter의 transitive dependency가 되지 않는다. 모든 capability는 `backend.graphql.advanced.*` feature flag와 capability별 release evidence를 요구하며, 장기 연결·schema composition·provider-specific behavior가 Stable query/mutation path를 오염시키지 않도록 분리한다. + +**Tech Stack:** Stable GraphQL platform, Java 21, Spring Boot 4.1 BOM, Spring for GraphQL 2.0, Boot-managed GraphQL Java v25, GraphQL Java DataLoader, Spring WebFlux WebSocket, Spring GraphQL SSE/RSocket, federation-jvm, Micrometer, JUnit 5, Reactor Test. + +## Global Constraints + +- 이 계획은 Stable 구현 계획 Task `1–48`과 Stable Release Gate가 통과한 후 시작한다. +- Advanced module root는 `modules/graphql-advanced`이다. +- Root package는 `io.backend.skeleton.graphql.advanced`이다. +- 모든 capability는 `backend.graphql.advanced.*` 아래의 명시적 feature flag를 요구한다. +- Stable starter는 Advanced module에 compile·runtime dependency를 갖지 않는다. +- Persisted Operation은 Parse cache 및 Response cache와 다른 기능이다. +- WebSocket protocol은 `graphql-transport-ws`만 지원하고 `subscriptions-transport-ws`를 신규 지원하지 않는다. +- GraphQL Subscription은 Messaging의 ACK·offset·replay·DLQ를 대체하지 않는다. +- Slow consumer 기본 정책은 무음 event drop이 아니라 종료다. +- SSE는 Distinct Connection subscription transport이며 Query·Mutation response mode가 아니다. +- Replay extension의 durability·offset guarantee는 Messaging platform이 소유한다. +- Single Executable Schema는 계속 Stable 기본값이다. +- Federation은 Subgraph capability만 이 저장소에 구현하고 Router/Supergraph 운영은 별도 프로젝트가 소유한다. +- Client/transport DTO code generation은 허용하지만 Domain Entity·Use Case·Repository generation은 금지한다. +- Spring Data automatic GraphQL repository exposure는 allowlist compatibility module로만 제공한다. +- RSocket, HTTP GET, Incremental Delivery는 승격 ADR 전까지 Experimental이다. +- Advanced capability가 Stable HTTP POST, error, security, cost, DataLoader와 observability guardrail을 우회해서는 안 된다. +- 모든 task는 red-green TDD와 독립 commit으로 끝난다. + +--- + +## Advanced Module Map + +```text +modules/graphql-advanced/ +├── graphql-advanced-bootstrap +├── graphql-persisted-operation +├── graphql-websocket +├── graphql-subscription +├── graphql-sse +├── graphql-dataloader-chaining +├── graphql-federation +├── graphql-codegen +├── graphql-spring-data-compat +├── graphql-rsocket +├── graphql-http-draft +└── graphql-incremental-delivery +``` + +## Capability Classification + +| Capability | Initial grade | Promotion evidence | +|---|---|---| +| Persisted Operation | Advanced Stable | durable registry, block propagation, schema/usage gate | +| WebSocket Subscription | Advanced Stable | auth, backpressure, soak, cancellation, drain | +| SSE Subscription | Advanced | HTTP/2 connection scale, proxy behavior, auth | +| Messaging-backed Replay | Advanced Extension | snapshot/live gap, history loss, authorization | +| Chained DataLoader | Advanced | dispatch/query-count regression | +| Federation Subgraph | Advanced | composition, router integration, failure/latency | +| Client Codegen | Optional Stable Tooling | generated-source compatibility | +| Spring Data Compat | Restricted Advanced | allowlist, projection, pagination/query guard | +| RSocket | Experimental | explicit consumers and transport evidence | +| HTTP GET | Experimental | draft, cache, CSRF and URL disclosure evidence | +| Incremental Delivery | Experimental | engine/transport interoperability and client negotiation | + +## Delivery Phases + +| Phase | Tasks | Result | +|---|---:|---| +| Boundary | 1 | Stable/Advanced dependency and feature flag isolation | +| Persisted Operations | 2–4 | Registry, execution lookup and G4 admin | +| Live Transport | 5–10 | WebSocket, auth, backpressure, ordering, SSE, replay | +| Data·Schema Extensions | 11–15 | Chained loader, Federation, Codegen, Spring Data compat | +| Experimental Transport | 16–18 | RSocket, HTTP GET, Incremental Delivery | +| Promotion | 19 | Capability-specific release and promotion gate | + +--- + +### Task 1: Advanced Module Boundary와 Feature Flag + +**Files:** +- Create: `modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/bootstrap/GraphQlAdvancedCapability.java` +- Create: `modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/bootstrap/GraphQlAdvancedFeatureFlags.java` +- Create: `modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/bootstrap/GraphQlAdvancedModuleGuard.java` +- Create: `modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/bootstrap/GraphQlAdvancedDependencyRules.java` +- Test: `modules/graphql-advanced/graphql-advanced-bootstrap/src/test/java/io/backend/skeleton/graphql/advanced/bootstrap/GraphQlAdvancedModuleGuardTest.java` + +**Interfaces:** +- Consumes: Stable GraphQL API 실행 플랫폼의 public contract와 Spring Boot environment. +- Produces: Advanced·Experimental 모듈이 Stable starter에 자동 유입되지 않도록 하는 dependency·feature flag 경계. + +**Implementation requirements:** +- 모든 Advanced capability는 `backend.graphql.advanced.*` 아래의 명시적 flag를 요구한다. +- Stable starter는 advanced module에 compile/runtime dependency를 갖지 않는다. +- Advanced module은 Stable public types를 소비할 수 있지만 Stable module을 수정하지 않는다. +- Experimental capability는 production에서 별도 승인 profile 없이는 시작되지 않는다. +- Capability 상태는 `ADVANCED_STABLE`, `EXPERIMENTAL`, `DISABLED`로 구분한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlAdvancedModuleGuardTest { + @org.junit.jupiter.api.Test + void disabledCapabilityCannotStart() { + var flags = GraphQlAdvancedFeatureFlags.disabled(); + var guard = new GraphQlAdvancedModuleGuard(flags); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> guard.requireEnabled( + GraphQlAdvancedCapability.PERSISTED_OPERATION)) + .isInstanceOf( + GraphQlAdvancedCapabilityDisabledException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-advanced-bootstrap:test --tests 'io.backend.skeleton.graphql.advanced.bootstrap.GraphQlAdvancedModuleGuardTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GraphQlAdvancedCapability { + PERSISTED_OPERATION, + WEBSOCKET_SUBSCRIPTION, + SSE_SUBSCRIPTION, + FEDERATION_SUBGRAPH, + CODE_GENERATION, + SPRING_DATA_COMPAT, + RSOCKET, + HTTP_GET, + INCREMENTAL_DELIVERY +} + +public record GraphQlAdvancedFeatureFlags( + java.util.Set enabled) { + + public static GraphQlAdvancedFeatureFlags disabled() { + return new GraphQlAdvancedFeatureFlags( + java.util.Set.of()); + } + + public boolean isEnabled( + GraphQlAdvancedCapability capability) { + return enabled.contains(capability); + } +} + +public final class GraphQlAdvancedModuleGuard { + private final GraphQlAdvancedFeatureFlags flags; + + public GraphQlAdvancedModuleGuard( + GraphQlAdvancedFeatureFlags flags) { + this.flags = flags; + } + + public void requireEnabled( + GraphQlAdvancedCapability capability) { + if (!flags.isEnabled(capability)) { + throw new GraphQlAdvancedCapabilityDisabledException( + capability.name()); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-advanced-bootstrap:test --tests 'io.backend.skeleton.graphql.advanced.bootstrap.GraphQlAdvancedModuleGuardTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/bootstrap/GraphQlAdvancedCapability.java' 'modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/bootstrap/GraphQlAdvancedFeatureFlags.java' 'modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/bootstrap/GraphQlAdvancedModuleGuard.java' 'modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/bootstrap/GraphQlAdvancedDependencyRules.java' 'modules/graphql-advanced/graphql-advanced-bootstrap/src/test/java/io/backend/skeleton/graphql/advanced/bootstrap/GraphQlAdvancedModuleGuardTest.java' +git commit -m "build: isolate graphql advanced modules" +``` + +### Task 2: Persisted Operation Model과 Registry + +**Files:** +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationId.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperation.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationStatus.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationRegistry.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/InMemoryGraphQlPersistedOperationRegistry.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationConflictException.java` +- Test: `modules/graphql-advanced/graphql-persisted-operation/src/test/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationRegistryTest.java` + +**Interfaces:** +- Consumes: Stable schema contract hash, operation name, canonical document hash와 client profile. +- Produces: 승인된 operation document의 versioned registry와 ACTIVE·DEPRECATED·BLOCKED lifecycle. + +**Implementation requirements:** +- Registry record에 operation ID, operation name, SHA-256 document hash, canonical document, schema hash, allowed client profiles, maximum complexity와 variable bytes를 보존한다. +- 같은 operation ID로 다른 document를 등록하면 conflict다. +- BLOCKED operation은 cache에 남아 있어도 실행되지 않는다. +- Raw variables와 credential을 registry에 저장하지 않는다. +- Persistent implementation SPI를 제공하되 Stable DB 선택을 강제하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlPersistedOperationRegistryTest { + @org.junit.jupiter.api.Test + void sameIdWithDifferentDocumentIsConflict() { + var registry = + new InMemoryGraphQlPersistedOperationRegistry(); + registry.register(GraphQlPersistedOperation.active( + "get-order-v1", "GetOrder", "sha256:a", + "query GetOrder { order { id } }", "schema-a")); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> registry.register( + GraphQlPersistedOperation.active( + "get-order-v1", "GetOrder", "sha256:b", + "query GetOrder { order { status } }", + "schema-a"))) + .isInstanceOf( + GraphQlPersistedOperationConflictException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-persisted-operation:test --tests 'io.backend.skeleton.graphql.advanced.persisted.GraphQlPersistedOperationRegistryTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GraphQlPersistedOperationStatus { + ACTIVE, + DEPRECATED, + BLOCKED +} + +public record GraphQlPersistedOperation( + GraphQlPersistedOperationId id, + String operationName, + String documentHash, + String canonicalDocument, + String schemaContractHash, + java.util.Set allowedClientProfiles, + long maximumComplexity, + int maximumVariablesBytes, + GraphQlPersistedOperationStatus status) { + + public static GraphQlPersistedOperation active( + String id, + String operationName, + String documentHash, + String canonicalDocument, + String schemaHash) { + return new GraphQlPersistedOperation( + new GraphQlPersistedOperationId(id), + operationName, documentHash, canonicalDocument, + schemaHash, java.util.Set.of("FIRST_PARTY"), + 10_000, 65_536, + GraphQlPersistedOperationStatus.ACTIVE); + } +} + +public interface GraphQlPersistedOperationRegistry { + void register(GraphQlPersistedOperation operation); + java.util.Optional find( + GraphQlPersistedOperationId id); +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-persisted-operation:test --tests 'io.backend.skeleton.graphql.advanced.persisted.GraphQlPersistedOperationRegistryTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationId.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperation.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationStatus.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationRegistry.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/InMemoryGraphQlPersistedOperationRegistry.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationConflictException.java' 'modules/graphql-advanced/graphql-persisted-operation/src/test/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationRegistryTest.java' +git commit -m "feat: add graphql persisted operation registry" +``` + +### Task 3: Persisted Operation Lookup과 Execution Interceptor + +**Files:** +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationRequest.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationLookup.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationInterceptor.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationPolicy.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationRejectedException.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedPreparsedBridge.java` +- Test: `modules/graphql-advanced/graphql-persisted-operation/src/test/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationInterceptorTest.java` + +**Interfaces:** +- Consumes: Persisted registry, Stable request context, client policy, schema hash와 preparsed cache. +- Produces: Operation ID로 canonical document를 복원하고 client·schema·variables·complexity를 재검증하는 interceptor. + +**Implementation requirements:** +- Client가 operation ID와 임의 query를 동시에 보내면 hash 일치 여부를 검증하거나 profile에 따라 거부한다. +- Registry의 allowed client profile과 현재 client가 일치해야 한다. +- Schema contract hash가 현재 schema와 맞지 않으면 실행하지 않는다. +- Persisted record의 maximum complexity·variables limit와 현재 client policy 중 더 엄격한 값을 사용한다. +- Lookup 성공이 authorization 성공을 의미하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlPersistedOperationInterceptorTest { + @org.junit.jupiter.api.Test + void blockedOperationIsRejectedBeforeExecution() { + var operation = GraphQlPersistedOperation.active( + "dangerous-v1", "Dangerous", "sha256:a", + "query Dangerous { expensive }", "schema-a"); + operation = new GraphQlPersistedOperation( + operation.id(), operation.operationName(), + operation.documentHash(), operation.canonicalDocument(), + operation.schemaContractHash(), + operation.allowedClientProfiles(), + operation.maximumComplexity(), + operation.maximumVariablesBytes(), + GraphQlPersistedOperationStatus.BLOCKED); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> GraphQlPersistedOperationPolicy.requireActive( + operation)) + .isInstanceOf( + GraphQlPersistedOperationRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-persisted-operation:test --tests 'io.backend.skeleton.graphql.advanced.persisted.GraphQlPersistedOperationInterceptorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlPersistedOperationPolicy { + public static GraphQlPersistedOperation requireActive( + GraphQlPersistedOperation operation) { + if (operation.status() + != GraphQlPersistedOperationStatus.ACTIVE) { + throw new GraphQlPersistedOperationRejectedException( + "persisted operation is not active"); + } + return operation; + } + + private GraphQlPersistedOperationPolicy() {} +} + +public record GraphQlPersistedOperationRequest( + GraphQlPersistedOperationId operationId, + String suppliedDocumentHash, + int variablesBytes) { +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-persisted-operation:test --tests 'io.backend.skeleton.graphql.advanced.persisted.GraphQlPersistedOperationInterceptorTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationRequest.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationLookup.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationInterceptor.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationPolicy.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationRejectedException.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedPreparsedBridge.java' 'modules/graphql-advanced/graphql-persisted-operation/src/test/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationInterceptorTest.java' +git commit -m "feat: execute registered graphql operations" +``` + +### Task 4: G4 Persisted Operation Admin·Usage·Block Plane + +**Files:** +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationAdminService.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationUsage.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationAudit.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationBlockCommand.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationRemovalGate.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationAdminAuthorization.java` +- Test: `modules/graphql-advanced/graphql-persisted-operation/src/test/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationRemovalGateTest.java` + +**Interfaces:** +- Consumes: Persisted registry, schema usage observation, admin actor·audit port. +- Produces: Operation 등록·deprecate·incident block·remove를 감사 가능한 G4 관리 작업으로 제공. + +**Implementation requirements:** +- 일반 GraphQL resolver나 application credential은 admin service에 접근하지 않는다. +- Block는 즉시 실행 경로에 반영되며 cache를 우회하지 않는다. +- Removal은 최근 usage, schema compatibility와 owner 승인 evidence를 요구한다. +- 모든 변경에 operator, reason, before/after, timestamp와 trace ID를 감사한다. +- Raw variables와 credential은 audit에 포함하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlPersistedOperationRemovalGateTest { + @org.junit.jupiter.api.Test + void recentUsageBlocksRemoval() { + var gate = new GraphQlPersistedOperationRemovalGate( + java.time.Duration.ofDays(30)); + var usage = new GraphQlPersistedOperationUsage( + java.time.Instant.now().minus( + java.time.Duration.ofDays(1)), 12); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> gate.verify(usage, java.time.Instant.now())) + .isInstanceOf( + GraphQlPersistedOperationRemovalRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-persisted-operation:test --tests 'io.backend.skeleton.graphql.advanced.admin.GraphQlPersistedOperationRemovalGateTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlPersistedOperationUsage( + java.time.Instant lastUsedAt, + long executions) { +} + +public final class GraphQlPersistedOperationRemovalGate { + private final java.time.Duration quietPeriod; + + public GraphQlPersistedOperationRemovalGate( + java.time.Duration quietPeriod) { + this.quietPeriod = quietPeriod; + } + + public void verify( + GraphQlPersistedOperationUsage usage, + java.time.Instant now) { + if (usage.executions() > 0 + && usage.lastUsedAt().plus(quietPeriod) + .isAfter(now)) { + throw new + GraphQlPersistedOperationRemovalRejectedException( + "persisted operation used within quiet period"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-persisted-operation:test --tests 'io.backend.skeleton.graphql.advanced.admin.GraphQlPersistedOperationRemovalGateTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationAdminService.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationUsage.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationAudit.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationBlockCommand.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationRemovalGate.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationAdminAuthorization.java' 'modules/graphql-advanced/graphql-persisted-operation/src/test/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationRemovalGateTest.java' +git commit -m "feat: add graphql operation admin plane" +``` + +### Task 5: `graphql-transport-ws` WebSocket Protocol Adapter + +**Files:** +- Create: `modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketProtocol.java` +- Create: `modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketProperties.java` +- Create: `modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketHandlerFactory.java` +- Create: `modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketLifecycle.java` +- Create: `modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketConnectionId.java` +- Create: `modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketProtocolError.java` +- Test: `modules/graphql-advanced/graphql-websocket/src/test/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketProtocolTest.java` + +**Interfaces:** +- Consumes: Spring GraphQL WebSocket handler, Stable execution service와 advanced feature guard. +- Produces: `graphql-transport-ws` connection_init·subscribe·complete lifecycle와 bounded connection policy. + +**Implementation requirements:** +- 과거 `subscriptions-transport-ws`를 지원 protocol로 광고하지 않는다. +- connection init timeout, idle timeout, maximum connection age와 maximum subscriptions per connection을 설정한다. +- Protocol error는 해당 operation 또는 connection scope에 맞게 종료한다. +- 서버 shutdown에서 신규 subscribe를 거부하고 기존 stream을 drain한다. +- Connection ID와 operation ID를 metric label로 사용하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlWebSocketProtocolTest { + @org.junit.jupiter.api.Test + void connectionInitAfterDeadlineIsRejected() { + var lifecycle = new GraphQlWebSocketLifecycle( + java.time.Duration.ofSeconds(5), + java.time.Instant.parse("2026-08-12T00:00:00Z")); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> lifecycle.onConnectionInit( + java.time.Instant.parse( + "2026-08-12T00:00:06Z"))) + .isInstanceOf(GraphQlWebSocketProtocolError.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-websocket:test --tests 'io.backend.skeleton.graphql.advanced.websocket.GraphQlWebSocketProtocolTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GraphQlWebSocketProtocol { + GRAPHQL_TRANSPORT_WS("graphql-transport-ws"); + + private final String subProtocol; + + GraphQlWebSocketProtocol(String subProtocol) { + this.subProtocol = subProtocol; + } + + public String subProtocol() { + return subProtocol; + } +} + +public final class GraphQlWebSocketLifecycle { + private final java.time.Duration initTimeout; + private final java.time.Instant connectedAt; + + public GraphQlWebSocketLifecycle( + java.time.Duration initTimeout, + java.time.Instant connectedAt) { + this.initTimeout = initTimeout; + this.connectedAt = connectedAt; + } + + public void onConnectionInit(java.time.Instant now) { + if (now.isAfter(connectedAt.plus(initTimeout))) { + throw new GraphQlWebSocketProtocolError( + "connection_init timeout"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-websocket:test --tests 'io.backend.skeleton.graphql.advanced.websocket.GraphQlWebSocketProtocolTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketProtocol.java' 'modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketProperties.java' 'modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketHandlerFactory.java' 'modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketLifecycle.java' 'modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketConnectionId.java' 'modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketProtocolError.java' 'modules/graphql-advanced/graphql-websocket/src/test/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketProtocolTest.java' +git commit -m "feat: add graphql websocket protocol" +``` + +### Task 6: WebSocket Authentication Expiry·Revocation Lifecycle + +**Files:** +- Create: `modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/security/GraphQlWebSocketAuthenticationInterceptor.java` +- Create: `modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/security/GraphQlWebSocketPrincipal.java` +- Create: `modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/security/GraphQlWebSocketCredentialExpiry.java` +- Create: `modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/security/GraphQlWebSocketRevocationSignal.java` +- Create: `modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/security/GraphQlSubscriptionAuthorizationPolicy.java` +- Create: `modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/security/GraphQlWebSocketCloseReason.java` +- Test: `modules/graphql-advanced/graphql-websocket/src/test/java/io/backend/skeleton/graphql/advanced/security/GraphQlWebSocketAuthenticationInterceptorTest.java` + +**Interfaces:** +- Consumes: connection_init payload, security adapter, actor·tenant context, credential expiry와 revocation signal. +- Produces: Connection과 subscription에 actor context를 고정하고 expiry·revocation 시 fail-closed 종료하는 lifecycle. + +**Implementation requirements:** +- connection_init payload의 credential을 metric·log에 남기지 않는다. +- 인증된 actor·tenant를 이후 operation context에 전달한다. +- Credential expiry가 오면 connection을 종료하고 silent refresh를 임의 구현하지 않는다. +- Resource 권한이 변할 수 있는 sensitive subscription은 event delivery 전 재검증 profile을 지원한다. +- 한 connection에서 tenant context를 operation마다 바꿀 수 없다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlWebSocketAuthenticationInterceptorTest { + @org.junit.jupiter.api.Test + void expiredCredentialClosesConnection() { + var expiry = new GraphQlWebSocketCredentialExpiry( + java.time.Instant.parse("2026-08-12T00:00:00Z")); + + org.assertj.core.api.Assertions.assertThat( + expiry.isExpired( + java.time.Instant.parse( + "2026-08-12T00:00:01Z"))) + .isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-websocket:test --tests 'io.backend.skeleton.graphql.advanced.security.GraphQlWebSocketAuthenticationInterceptorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlWebSocketCredentialExpiry( + java.time.Instant expiresAt) { + + public boolean isExpired(java.time.Instant now) { + return !now.isBefore(expiresAt); + } +} + +public record GraphQlWebSocketPrincipal( + String actorFingerprint, + String tenantFingerprint, + java.time.Instant expiresAt) { +} + +public enum GraphQlWebSocketCloseReason { + AUTHENTICATION_FAILED, + CREDENTIAL_EXPIRED, + AUTHORIZATION_REVOKED, + SERVER_DRAINING +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-websocket:test --tests 'io.backend.skeleton.graphql.advanced.security.GraphQlWebSocketAuthenticationInterceptorTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/security/GraphQlWebSocketAuthenticationInterceptor.java' 'modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/security/GraphQlWebSocketPrincipal.java' 'modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/security/GraphQlWebSocketCredentialExpiry.java' 'modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/security/GraphQlWebSocketRevocationSignal.java' 'modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/security/GraphQlSubscriptionAuthorizationPolicy.java' 'modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/security/GraphQlWebSocketCloseReason.java' 'modules/graphql-advanced/graphql-websocket/src/test/java/io/backend/skeleton/graphql/advanced/security/GraphQlWebSocketAuthenticationInterceptorTest.java' +git commit -m "feat: secure graphql websocket lifecycle" +``` + +### Task 7: Subscription Source SPI와 Bounded Backpressure + +**Files:** +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionSource.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionEvent.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionContext.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionBufferPolicy.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSlowConsumerPolicy.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionDispatcher.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionTermination.java` +- Test: `modules/graphql-advanced/graphql-subscription/src/test/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionBufferPolicyTest.java` + +**Interfaces:** +- Consumes: Messaging/Application Publisher source, actor context, Reactor Publisher와 WebSocket/SSE transport. +- Produces: 내구성 보장을 과장하지 않는 bounded live stream과 slow-consumer termination. + +**Implementation requirements:** +- Messaging ACK·offset·DLQ·replay를 GraphQL subscription이 재구현하지 않는다. +- Default slow-consumer 정책은 silent drop이 아니라 connection/operation 종료다. +- Maximum buffered events와 maximum event bytes를 설정한다. +- Subscriber cancel이 upstream source에 전파된다. +- Integration Event를 GraphQL DTO로 변환하고 원본 broker message를 그대로 노출하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlSubscriptionBufferPolicyTest { + @org.junit.jupiter.api.Test + void defaultPolicyTerminatesSlowConsumer() { + var policy = GraphQlSubscriptionBufferPolicy.defaultPolicy(); + + org.assertj.core.api.Assertions.assertThat( + policy.slowConsumerPolicy()) + .isEqualTo( + GraphQlSlowConsumerPolicy.TERMINATE); + org.assertj.core.api.Assertions.assertThat( + policy.maximumBufferedEvents()).isGreaterThan(0); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-subscription:test --tests 'io.backend.skeleton.graphql.advanced.subscription.GraphQlSubscriptionBufferPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GraphQlSlowConsumerPolicy { + TERMINATE, + DROP_ALLOWED_FOR_TELEMETRY +} + +public record GraphQlSubscriptionBufferPolicy( + int maximumBufferedEvents, + long maximumBufferedBytes, + GraphQlSlowConsumerPolicy slowConsumerPolicy) { + + public static GraphQlSubscriptionBufferPolicy defaultPolicy() { + return new GraphQlSubscriptionBufferPolicy( + 128, 1_048_576, + GraphQlSlowConsumerPolicy.TERMINATE); + } +} + +public interface GraphQlSubscriptionSource { + org.reactivestreams.Publisher open( + GraphQlSubscriptionContext context); +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-subscription:test --tests 'io.backend.skeleton.graphql.advanced.subscription.GraphQlSubscriptionBufferPolicyTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionSource.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionEvent.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionContext.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionBufferPolicy.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSlowConsumerPolicy.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionDispatcher.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionTermination.java' 'modules/graphql-advanced/graphql-subscription/src/test/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionBufferPolicyTest.java' +git commit -m "feat: add bounded graphql subscriptions" +``` + +### Task 8: Subscription Ordering·Cancellation·Shutdown Profiles + +**Files:** +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionOrderingProfile.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionExecutionPolicy.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionCancellation.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionDrainCoordinator.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionState.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionMetrics.java` +- Test: `modules/graphql-advanced/graphql-subscription/src/test/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionExecutionPolicyTest.java` + +**Interfaces:** +- Consumes: Subscription source, GraphQL Java subscription execution strategy와 transport lifecycle. +- Produces: `LOW_LATENCY`·`ORDERED` profile, cancellation과 graceful shutdown drain. + +**Implementation requirements:** +- `ORDERED`는 source order를 보존하지만 head-of-line blocking과 buffer 비용을 명시한다. +- `LOW_LATENCY`는 completion 순서가 source 순서와 다를 수 있음을 계약한다. +- Ordering profile은 operation catalog에 등록한다. +- Shutdown에서 신규 subscription을 거부하고 bounded time 동안 기존 subscription을 drain한다. +- Cancellation은 resolver nested publisher와 upstream source 모두에 전파한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlSubscriptionExecutionPolicyTest { + @org.junit.jupiter.api.Test + void orderedProfileRequestsGraphQlJavaOrderingFlag() { + var policy = GraphQlSubscriptionExecutionPolicy.ordered(); + + org.assertj.core.api.Assertions.assertThat( + policy.profile()) + .isEqualTo( + GraphQlSubscriptionOrderingProfile.ORDERED); + org.assertj.core.api.Assertions.assertThat( + policy.keepSourceOrder()).isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-subscription:test --tests 'io.backend.skeleton.graphql.advanced.subscription.GraphQlSubscriptionExecutionPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GraphQlSubscriptionOrderingProfile { + LOW_LATENCY, + ORDERED +} + +public record GraphQlSubscriptionExecutionPolicy( + GraphQlSubscriptionOrderingProfile profile, + boolean keepSourceOrder) { + + public static GraphQlSubscriptionExecutionPolicy ordered() { + return new GraphQlSubscriptionExecutionPolicy( + GraphQlSubscriptionOrderingProfile.ORDERED, true); + } + + public static GraphQlSubscriptionExecutionPolicy lowLatency() { + return new GraphQlSubscriptionExecutionPolicy( + GraphQlSubscriptionOrderingProfile.LOW_LATENCY, + false); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-subscription:test --tests 'io.backend.skeleton.graphql.advanced.subscription.GraphQlSubscriptionExecutionPolicyTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionOrderingProfile.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionExecutionPolicy.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionCancellation.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionDrainCoordinator.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionState.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionMetrics.java' 'modules/graphql-advanced/graphql-subscription/src/test/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionExecutionPolicyTest.java' +git commit -m "feat: add graphql subscription ordering profiles" +``` + +### Task 9: SSE Distinct Connection Subscription Adapter + +**Files:** +- Create: `modules/graphql-advanced/graphql-sse/src/main/java/io/backend/skeleton/graphql/advanced/sse/GraphQlSseProperties.java` +- Create: `modules/graphql-advanced/graphql-sse/src/main/java/io/backend/skeleton/graphql/advanced/sse/GraphQlSseHandlerFactory.java` +- Create: `modules/graphql-advanced/graphql-sse/src/main/java/io/backend/skeleton/graphql/advanced/sse/GraphQlSseConnectionPolicy.java` +- Create: `modules/graphql-advanced/graphql-sse/src/main/java/io/backend/skeleton/graphql/advanced/sse/GraphQlSseHeartbeat.java` +- Create: `modules/graphql-advanced/graphql-sse/src/main/java/io/backend/skeleton/graphql/advanced/sse/GraphQlSseTermination.java` +- Test: `modules/graphql-advanced/graphql-sse/src/test/java/io/backend/skeleton/graphql/advanced/sse/GraphQlSseConnectionPolicyTest.java` + +**Interfaces:** +- Consumes: Spring `GraphQlSseHandler`, Stable execution service, subscription buffer and auth policy. +- Produces: POST JSON + `Accept: text/event-stream` 기반 Distinct Connection subscription transport. + +**Implementation requirements:** +- SSE를 Query·Mutation의 일반 response mode로 사용하지 않고 subscription-only로 제한한다. +- Connection당 하나의 subscription이라는 Distinct Connection 특성을 문서·metric에 반영한다. +- Heartbeat, idle timeout, maximum duration과 proxy buffering 요구를 설정한다. +- WebSocket과 동일한 actor·tenant·authorization·cost policy를 사용한다. +- HTTP/2 사용 여부와 연결 규모를 load test로 검증한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlSseConnectionPolicyTest { + @org.junit.jupiter.api.Test + void acceptsOnlySubscriptionOperation() { + var policy = GraphQlSseConnectionPolicy.standard(); + + org.assertj.core.api.Assertions.assertThat( + policy.supports("subscription")).isTrue(); + org.assertj.core.api.Assertions.assertThat( + policy.supports("query")).isFalse(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-sse:test --tests 'io.backend.skeleton.graphql.advanced.sse.GraphQlSseConnectionPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlSseConnectionPolicy { + public static GraphQlSseConnectionPolicy standard() { + return new GraphQlSseConnectionPolicy(); + } + + public boolean supports(String operationType) { + return "subscription".equals(operationType); + } +} + +public record GraphQlSseProperties( + java.time.Duration heartbeatInterval, + java.time.Duration idleTimeout, + java.time.Duration maximumDuration) { +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-sse:test --tests 'io.backend.skeleton.graphql.advanced.sse.GraphQlSseConnectionPolicyTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-sse/src/main/java/io/backend/skeleton/graphql/advanced/sse/GraphQlSseProperties.java' 'modules/graphql-advanced/graphql-sse/src/main/java/io/backend/skeleton/graphql/advanced/sse/GraphQlSseHandlerFactory.java' 'modules/graphql-advanced/graphql-sse/src/main/java/io/backend/skeleton/graphql/advanced/sse/GraphQlSseConnectionPolicy.java' 'modules/graphql-advanced/graphql-sse/src/main/java/io/backend/skeleton/graphql/advanced/sse/GraphQlSseHeartbeat.java' 'modules/graphql-advanced/graphql-sse/src/main/java/io/backend/skeleton/graphql/advanced/sse/GraphQlSseTermination.java' 'modules/graphql-advanced/graphql-sse/src/test/java/io/backend/skeleton/graphql/advanced/sse/GraphQlSseConnectionPolicyTest.java' +git commit -m "feat: add graphql sse subscription transport" +``` + +### Task 10: Messaging-backed Subscription Replay Extension + +**Files:** +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/replay/GraphQlSubscriptionCursor.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/replay/GraphQlReplayPosition.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/replay/GraphQlReplaySource.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/replay/GraphQlSnapshotLiveHandoff.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/replay/GraphQlReplayAuthorization.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/replay/GraphQlReplayGapException.java` +- Test: `modules/graphql-advanced/graphql-subscription/src/test/java/io/backend/skeleton/graphql/advanced/replay/GraphQlSnapshotLiveHandoffTest.java` + +**Interfaces:** +- Consumes: Messaging replay/offset capability, snapshot query, actor authorization와 signed cursor. +- Produces: GraphQL 표준 보장과 분리된 snapshot + live handoff 및 explicit replay cursor extension. + +**Implementation requirements:** +- GraphQL Core에 resume 표준이 있다고 광고하지 않는다. +- Replay durability와 offset 보장은 Messaging platform이 소유한다. +- Snapshot sequence와 live source 시작 position 사이에 gap·duplicate가 없도록 handoff를 검증한다. +- Cursor는 actor/client·subscription profile에 bind하고 서명한다. +- Replay window 밖의 cursor는 history-lost error로 반환한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlSnapshotLiveHandoffTest { + @org.junit.jupiter.api.Test + void gapBetweenSnapshotAndLivePositionIsRejected() { + var handoff = new GraphQlSnapshotLiveHandoff( + new GraphQlReplayPosition(10), + new GraphQlReplayPosition(12)); + + org.assertj.core.api.Assertions.assertThatThrownBy( + handoff::verifyContiguous) + .isInstanceOf(GraphQlReplayGapException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-subscription:test --tests 'io.backend.skeleton.graphql.advanced.replay.GraphQlSnapshotLiveHandoffTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlReplayPosition(long sequence) { + public GraphQlReplayPosition { + if (sequence < 0) { + throw new IllegalArgumentException( + "sequence cannot be negative"); + } + } +} + +public record GraphQlSnapshotLiveHandoff( + GraphQlReplayPosition snapshotPosition, + GraphQlReplayPosition liveStartPosition) { + + public void verifyContiguous() { + if (liveStartPosition.sequence() + > snapshotPosition.sequence() + 1) { + throw new GraphQlReplayGapException( + "snapshot and live stream contain a gap"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-subscription:test --tests 'io.backend.skeleton.graphql.advanced.replay.GraphQlSnapshotLiveHandoffTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/replay/GraphQlSubscriptionCursor.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/replay/GraphQlReplayPosition.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/replay/GraphQlReplaySource.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/replay/GraphQlSnapshotLiveHandoff.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/replay/GraphQlReplayAuthorization.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/replay/GraphQlReplayGapException.java' 'modules/graphql-advanced/graphql-subscription/src/test/java/io/backend/skeleton/graphql/advanced/replay/GraphQlSnapshotLiveHandoffTest.java' +git commit -m "feat: add graphql subscription replay extension" +``` + +### Task 11: GraphQL Java 25 Chained DataLoader Opt-in + +**Files:** +- Create: `modules/graphql-advanced/graphql-dataloader-chaining/src/main/java/io/backend/skeleton/graphql/advanced/chaining/GraphQlChainedDataLoaderPolicy.java` +- Create: `modules/graphql-advanced/graphql-dataloader-chaining/src/main/java/io/backend/skeleton/graphql/advanced/chaining/GraphQlDataLoaderDependencyGraph.java` +- Create: `modules/graphql-advanced/graphql-dataloader-chaining/src/main/java/io/backend/skeleton/graphql/advanced/chaining/GraphQlDataLoaderCycleDetector.java` +- Create: `modules/graphql-advanced/graphql-dataloader-chaining/src/main/java/io/backend/skeleton/graphql/advanced/chaining/GraphQlChainedDispatchConfigurer.java` +- Create: `modules/graphql-advanced/graphql-dataloader-chaining/src/main/java/io/backend/skeleton/graphql/advanced/chaining/GraphQlChainedLoaderMetrics.java` +- Test: `modules/graphql-advanced/graphql-dataloader-chaining/src/test/java/io/backend/skeleton/graphql/advanced/chaining/GraphQlDataLoaderCycleDetectorTest.java` + +**Interfaces:** +- Consumes: Stable request-scoped DataLoader registry와 GraphQL Java 25 chained dispatch capability. +- Produces: 명시적 dependency graph를 가진 opt-in chained loader execution과 cycle·dispatch regression gate. + +**Implementation requirements:** +- Stable default에서는 chained dispatch를 활성화하지 않는다. +- 각 loader dependency를 manifest에 선언한다. +- Dependency cycle이 있으면 startup 실패다. +- Dispatch 순서 변경이 query count·batch size·result ordering에 미치는 영향을 contract test로 검증한다. +- Request scope, actor·tenant isolation과 maximum batch size 규칙을 그대로 유지한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlDataLoaderCycleDetectorTest { + @org.junit.jupiter.api.Test + void detectsLoaderDependencyCycle() { + var graph = new GraphQlDataLoaderDependencyGraph() + .dependsOn("orders", "customers") + .dependsOn("customers", "orders"); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> new GraphQlDataLoaderCycleDetector().verify(graph)) + .isInstanceOf( + GraphQlDataLoaderDependencyCycleException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-dataloader-chaining:test --tests 'io.backend.skeleton.graphql.advanced.chaining.GraphQlDataLoaderCycleDetectorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlDataLoaderDependencyGraph { + private final java.util.Map> edges = + new java.util.LinkedHashMap<>(); + + public GraphQlDataLoaderDependencyGraph dependsOn( + String loader, String dependency) { + edges.computeIfAbsent(loader, + ignored -> new java.util.LinkedHashSet<>()) + .add(dependency); + return this; + } + + java.util.Map> edges() { + return java.util.Collections.unmodifiableMap(edges); + } +} + +public record GraphQlChainedDataLoaderPolicy( + boolean enabled, + int maximumDepth) { +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-dataloader-chaining:test --tests 'io.backend.skeleton.graphql.advanced.chaining.GraphQlDataLoaderCycleDetectorTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-dataloader-chaining/src/main/java/io/backend/skeleton/graphql/advanced/chaining/GraphQlChainedDataLoaderPolicy.java' 'modules/graphql-advanced/graphql-dataloader-chaining/src/main/java/io/backend/skeleton/graphql/advanced/chaining/GraphQlDataLoaderDependencyGraph.java' 'modules/graphql-advanced/graphql-dataloader-chaining/src/main/java/io/backend/skeleton/graphql/advanced/chaining/GraphQlDataLoaderCycleDetector.java' 'modules/graphql-advanced/graphql-dataloader-chaining/src/main/java/io/backend/skeleton/graphql/advanced/chaining/GraphQlChainedDispatchConfigurer.java' 'modules/graphql-advanced/graphql-dataloader-chaining/src/main/java/io/backend/skeleton/graphql/advanced/chaining/GraphQlChainedLoaderMetrics.java' 'modules/graphql-advanced/graphql-dataloader-chaining/src/test/java/io/backend/skeleton/graphql/advanced/chaining/GraphQlDataLoaderCycleDetectorTest.java' +git commit -m "feat: add opt in chained graphql dataloaders" +``` + +### Task 12: Federation Subgraph Schema와 Entity Resolver + +**Files:** +- Create: `modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationProperties.java` +- Create: `modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationSchemaFactory.java` +- Create: `modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationEntityKey.java` +- Create: `modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationEntityResolver.java` +- Create: `modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationBatchResolver.java` +- Create: `modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationCapability.java` +- Test: `modules/graphql-advanced/graphql-federation/src/test/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationEntityResolverTest.java` + +**Interfaces:** +- Consumes: Stable SDL assembly, federation-jvm integration, Application query services와 DataLoader. +- Produces: 별도 opt-in Subgraph schema, `@key` entity mapping과 batch reference resolution. + +**Implementation requirements:** +- Single executable schema가 기본이며 federation flag가 없으면 federation wiring을 등록하지 않는다. +- Entity reference resolver가 Repository를 직접 호출하지 않고 Application query service를 사용한다. +- Entity key는 versioned contract이며 변경 시 breaking review를 요구한다. +- Batch entity resolution은 요청 단위 DataLoader와 tenant context를 사용한다. +- Federation router나 supergraph 운영은 이 모듈이 소유하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlFederationEntityResolverTest { + @org.junit.jupiter.api.Test + void rejectsRepresentationMissingDeclaredKey() { + var key = new GraphQlFederationEntityKey( + "Order", java.util.List.of("id")); + var resolver = new GraphQlFederationEntityResolver(key); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> resolver.validateRepresentation( + java.util.Map.of("__typename", "Order"))) + .isInstanceOf( + GraphQlFederationRepresentationException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-federation:test --tests 'io.backend.skeleton.graphql.advanced.federation.GraphQlFederationEntityResolverTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlFederationEntityKey( + String typeName, + java.util.List fields) { + public GraphQlFederationEntityKey { + fields = java.util.List.copyOf(fields); + if (fields.isEmpty()) { + throw new IllegalArgumentException( + "federation entity key cannot be empty"); + } + } +} + +public final class GraphQlFederationEntityResolver { + private final GraphQlFederationEntityKey key; + + public GraphQlFederationEntityResolver( + GraphQlFederationEntityKey key) { + this.key = key; + } + + public void validateRepresentation( + java.util.Map representation) { + for (String field : key.fields()) { + if (!representation.containsKey(field)) { + throw new + GraphQlFederationRepresentationException( + "missing federation entity key field"); + } + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-federation:test --tests 'io.backend.skeleton.graphql.advanced.federation.GraphQlFederationEntityResolverTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationProperties.java' 'modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationSchemaFactory.java' 'modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationEntityKey.java' 'modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationEntityResolver.java' 'modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationBatchResolver.java' 'modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationCapability.java' 'modules/graphql-advanced/graphql-federation/src/test/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationEntityResolverTest.java' +git commit -m "feat: add graphql federation subgraph" +``` + +### Task 13: Federation Composition·Deployment·Failure Gate + +**Files:** +- Create: `modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlSubgraphContract.java` +- Create: `modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationCompositionResult.java` +- Create: `modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationCompositionGate.java` +- Create: `modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationUsageReport.java` +- Create: `modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationLatencyBudget.java` +- Create: `modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationDeploymentOrder.java` +- Create: `modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationReleaseEvidence.java` +- Test: `modules/graphql-advanced/graphql-federation/src/test/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationCompositionGateTest.java` + +**Interfaces:** +- Consumes: Subgraph SDL, entity keys, router composition result, usage·latency·failure evidence. +- Produces: Composition CI, cross-subgraph dependency·latency·partial failure 검증과 배포 순서 gate. + +**Implementation requirements:** +- Composition 성공만으로 release를 승인하지 않고 entity key, owner, downstream dependency, latency budget을 검증한다. +- Cross-subgraph N+1과 per-entity downstream call을 performance gate에서 탐지한다. +- Subgraph schema가 router보다 먼저 또는 호환되지 않는 순서로 배포되지 않도록 deployment order를 검증한다. +- 부분 장애에서 nullable boundary와 error ownership을 contract test로 검증한다. +- Router 설정과 운영은 별도 프로젝트의 evidence로 입력받는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlFederationCompositionGateTest { + @org.junit.jupiter.api.Test + void compositionWithoutLatencyEvidenceIsRejected() { + var evidence = new GraphQlFederationReleaseEvidence( + true, true, false, true); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> new GraphQlFederationCompositionGate() + .verify(evidence)) + .isInstanceOf( + GraphQlFederationReleaseRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-federation:test --tests 'io.backend.skeleton.graphql.advanced.composition.GraphQlFederationCompositionGateTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlFederationReleaseEvidence( + boolean compositionPassed, + boolean entityContractsPassed, + boolean latencyPassed, + boolean failureContractsPassed) { +} + +public final class GraphQlFederationCompositionGate { + public void verify( + GraphQlFederationReleaseEvidence evidence) { + if (!evidence.compositionPassed() + || !evidence.entityContractsPassed() + || !evidence.latencyPassed() + || !evidence.failureContractsPassed()) { + throw new + GraphQlFederationReleaseRejectedException( + "federation composition evidence incomplete"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-federation:test --tests 'io.backend.skeleton.graphql.advanced.composition.GraphQlFederationCompositionGateTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlSubgraphContract.java' 'modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationCompositionResult.java' 'modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationCompositionGate.java' 'modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationUsageReport.java' 'modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationLatencyBudget.java' 'modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationDeploymentOrder.java' 'modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationReleaseEvidence.java' 'modules/graphql-advanced/graphql-federation/src/test/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationCompositionGateTest.java' +git commit -m "chore: add graphql federation composition gate" +``` + +### Task 14: Client·Transport DTO Code Generation + +**Files:** +- Create: `modules/graphql-advanced/graphql-codegen/src/main/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlCodegenProfile.java` +- Create: `modules/graphql-advanced/graphql-codegen/src/main/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlScalarMapping.java` +- Create: `modules/graphql-advanced/graphql-codegen/src/main/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlGeneratedSourceBoundary.java` +- Create: `modules/graphql-advanced/graphql-codegen/src/main/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlClientOperationGenerator.java` +- Create: `modules/graphql-advanced/graphql-codegen/src/main/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlTransportTypeGenerator.java` +- Create: `modules/graphql-advanced/graphql-codegen/src/main/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlGeneratedCompatibilityGate.java` +- Test: `modules/graphql-advanced/graphql-codegen/src/test/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlGeneratedSourceBoundaryTest.java` + +**Interfaces:** +- Consumes: SDL, operation documents, scalar manifest와 selected codegen engine. +- Produces: Client request/response model과 transport-only type을 생성하되 domain·repository를 생성하지 않는 tooling. + +**Implementation requirements:** +- Domain Entity, Application Use Case interface와 Repository를 생성하지 않는다. +- Generated source는 별도 directory·package에 두고 사람이 작성한 domain code를 덮어쓰지 않는다. +- Custom scalar, nullability, union, interface와 oneOf mapping을 명시한다. +- Operation document를 schema와 함께 compile-time 검증한다. +- Schema additive change가 generated client의 exhaustive enum/union source compatibility를 깨는지 별도 보고한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlGeneratedSourceBoundaryTest { + @org.junit.jupiter.api.Test + void domainAndRepositoryGenerationAreForbidden() { + var boundary = GraphQlGeneratedSourceBoundary.standard(); + + org.assertj.core.api.Assertions.assertThat( + boundary.isAllowed("CLIENT_RESPONSE")).isTrue(); + org.assertj.core.api.Assertions.assertThat( + boundary.isAllowed("DOMAIN_ENTITY")).isFalse(); + org.assertj.core.api.Assertions.assertThat( + boundary.isAllowed("REPOSITORY")).isFalse(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-codegen:test --tests 'io.backend.skeleton.graphql.advanced.codegen.GraphQlGeneratedSourceBoundaryTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlGeneratedSourceBoundary { + private static final java.util.Set ALLOWED = + java.util.Set.of( + "CLIENT_REQUEST", + "CLIENT_RESPONSE", + "TRANSPORT_INPUT", + "TRANSPORT_OUTPUT"); + + public static GraphQlGeneratedSourceBoundary standard() { + return new GraphQlGeneratedSourceBoundary(); + } + + public boolean isAllowed(String generatedType) { + return ALLOWED.contains(generatedType); + } +} + +public record GraphQlScalarMapping( + String scalarName, + String javaType, + String codecId) { +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-codegen:test --tests 'io.backend.skeleton.graphql.advanced.codegen.GraphQlGeneratedSourceBoundaryTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-codegen/src/main/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlCodegenProfile.java' 'modules/graphql-advanced/graphql-codegen/src/main/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlScalarMapping.java' 'modules/graphql-advanced/graphql-codegen/src/main/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlGeneratedSourceBoundary.java' 'modules/graphql-advanced/graphql-codegen/src/main/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlClientOperationGenerator.java' 'modules/graphql-advanced/graphql-codegen/src/main/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlTransportTypeGenerator.java' 'modules/graphql-advanced/graphql-codegen/src/main/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlGeneratedCompatibilityGate.java' 'modules/graphql-advanced/graphql-codegen/src/test/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlGeneratedSourceBoundaryTest.java' +git commit -m "build: add graphql client code generation" +``` + +### Task 15: Allowlisted Spring Data GraphQL Compatibility + +**Files:** +- Create: `modules/graphql-advanced/graphql-spring-data-compat/src/main/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryExposure.java` +- Create: `modules/graphql-advanced/graphql-spring-data-compat/src/main/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryAllowlist.java` +- Create: `modules/graphql-advanced/graphql-spring-data-compat/src/main/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryArgumentPolicy.java` +- Create: `modules/graphql-advanced/graphql-spring-data-compat/src/main/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryPaginationPolicy.java` +- Create: `modules/graphql-advanced/graphql-spring-data-compat/src/main/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryProjectionPolicy.java` +- Create: `modules/graphql-advanced/graphql-spring-data-compat/src/main/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryExposureValidator.java` +- Test: `modules/graphql-advanced/graphql-spring-data-compat/src/test/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryExposureValidatorTest.java` + +**Interfaces:** +- Consumes: Spring `@GraphQlRepository`, Querydsl/QBE repository, schema coordinate와 explicit allowlist. +- Produces: 자동 DataFetcher 기능을 제한된 compatibility path로 제공하고 persistence model 자동 노출을 차단. + +**Implementation requirements:** +- 등록되지 않은 repository는 GraphQL DataFetcher로 자동 노출하지 않는다. +- 허용 filter, sort, projection과 maximum page size를 coordinate별로 정의한다. +- 기본 offset pagination 20개 동작을 암묵적으로 사용하지 않고 explicit policy를 요구한다. +- Entity/Document를 output으로 직접 반환하지 않고 승인된 projection만 허용한다. +- Stable resolver·Application Service 경계를 대체하는 주류 API로 문서화하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlRepositoryExposureValidatorTest { + @org.junit.jupiter.api.Test + void unregisteredRepositoryIsRejected() { + var validator = + new GraphQlRepositoryExposureValidator( + GraphQlRepositoryAllowlist.empty()); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> validator.verify( + new GraphQlRepositoryExposure( + "OrderRepository", "Query.orders"))) + .isInstanceOf( + GraphQlRepositoryExposureRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-spring-data-compat:test --tests 'io.backend.skeleton.graphql.advanced.compat.GraphQlRepositoryExposureValidatorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlRepositoryExposure( + String repositoryName, + String schemaCoordinate) { +} + +public final class GraphQlRepositoryAllowlist { + private final java.util.Set repositoryNames; + + private GraphQlRepositoryAllowlist( + java.util.Set repositoryNames) { + this.repositoryNames = + java.util.Set.copyOf(repositoryNames); + } + + public static GraphQlRepositoryAllowlist empty() { + return new GraphQlRepositoryAllowlist( + java.util.Set.of()); + } + + public boolean contains(String repositoryName) { + return repositoryNames.contains(repositoryName); + } +} + +public final class GraphQlRepositoryExposureValidator { + private final GraphQlRepositoryAllowlist allowlist; + + public GraphQlRepositoryExposureValidator( + GraphQlRepositoryAllowlist allowlist) { + this.allowlist = allowlist; + } + + public void verify(GraphQlRepositoryExposure exposure) { + if (!allowlist.contains(exposure.repositoryName())) { + throw new + GraphQlRepositoryExposureRejectedException( + exposure.repositoryName()); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-spring-data-compat:test --tests 'io.backend.skeleton.graphql.advanced.compat.GraphQlRepositoryExposureValidatorTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-spring-data-compat/src/main/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryExposure.java' 'modules/graphql-advanced/graphql-spring-data-compat/src/main/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryAllowlist.java' 'modules/graphql-advanced/graphql-spring-data-compat/src/main/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryArgumentPolicy.java' 'modules/graphql-advanced/graphql-spring-data-compat/src/main/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryPaginationPolicy.java' 'modules/graphql-advanced/graphql-spring-data-compat/src/main/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryProjectionPolicy.java' 'modules/graphql-advanced/graphql-spring-data-compat/src/main/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryExposureValidator.java' 'modules/graphql-advanced/graphql-spring-data-compat/src/test/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryExposureValidatorTest.java' +git commit -m "feat: add graphql spring data compat guard" +``` + +### Task 16: RSocket GraphQL Experimental Transport + +**Files:** +- Create: `modules/graphql-advanced/graphql-rsocket/src/main/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketProperties.java` +- Create: `modules/graphql-advanced/graphql-rsocket/src/main/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketRoutePolicy.java` +- Create: `modules/graphql-advanced/graphql-rsocket/src/main/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketHandlerFactory.java` +- Create: `modules/graphql-advanced/graphql-rsocket/src/main/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketAuthentication.java` +- Create: `modules/graphql-advanced/graphql-rsocket/src/main/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketErrorMapper.java` +- Create: `modules/graphql-advanced/graphql-rsocket/src/main/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketCapability.java` +- Test: `modules/graphql-advanced/graphql-rsocket/src/test/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketRoutePolicyTest.java` + +**Interfaces:** +- Consumes: Spring GraphQL RSocket handler, Stable execution service와 experimental feature approval. +- Produces: 내부 시스템용 request-response Query/Mutation과 request-stream Subscription experimental transport. + +**Implementation requirements:** +- RSocket는 public default transport가 아니다. +- Query·Mutation은 request-response, Subscription은 request-stream으로만 매핑한다. +- Stable HTTP/WebSocket과 동일한 actor·tenant·cost·error policy를 적용한다. +- Route·metadata MIME type·authentication을 allowlist한다. +- Production 활성화에는 explicit consumer, load·failure evidence와 owner가 필요하다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlRSocketRoutePolicyTest { + @org.junit.jupiter.api.Test + void rejectsUnknownRoute() { + var policy = new GraphQlRSocketRoutePolicy( + java.util.Set.of("graphql")); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> policy.requireAllowed("admin.raw")) + .isInstanceOf( + GraphQlRSocketRouteRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-rsocket:test --tests 'io.backend.skeleton.graphql.advanced.rsocket.GraphQlRSocketRoutePolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlRSocketRoutePolicy { + private final java.util.Set allowedRoutes; + + public GraphQlRSocketRoutePolicy( + java.util.Set allowedRoutes) { + this.allowedRoutes = + java.util.Set.copyOf(allowedRoutes); + } + + public String requireAllowed(String route) { + if (!allowedRoutes.contains(route)) { + throw new GraphQlRSocketRouteRejectedException(route); + } + return route; + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-rsocket:test --tests 'io.backend.skeleton.graphql.advanced.rsocket.GraphQlRSocketRoutePolicyTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-rsocket/src/main/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketProperties.java' 'modules/graphql-advanced/graphql-rsocket/src/main/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketRoutePolicy.java' 'modules/graphql-advanced/graphql-rsocket/src/main/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketHandlerFactory.java' 'modules/graphql-advanced/graphql-rsocket/src/main/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketAuthentication.java' 'modules/graphql-advanced/graphql-rsocket/src/main/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketErrorMapper.java' 'modules/graphql-advanced/graphql-rsocket/src/main/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketCapability.java' 'modules/graphql-advanced/graphql-rsocket/src/test/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketRoutePolicyTest.java' +git commit -m "feat: add experimental graphql rsocket" +``` + +### Task 17: GraphQL over HTTP GET Draft Profile + +**Files:** +- Create: `modules/graphql-advanced/graphql-http-draft/src/main/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpGetProfile.java` +- Create: `modules/graphql-advanced/graphql-http-draft/src/main/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpGetRequestParser.java` +- Create: `modules/graphql-advanced/graphql-http-draft/src/main/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpGetCachePolicy.java` +- Create: `modules/graphql-advanced/graphql-http-draft/src/main/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpGetCsrfPolicy.java` +- Create: `modules/graphql-advanced/graphql-http-draft/src/main/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpGetOperationPolicy.java` +- Create: `modules/graphql-advanced/graphql-http-draft/src/main/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpDraftCompatibilityReport.java` +- Test: `modules/graphql-advanced/graphql-http-draft/src/test/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpGetOperationPolicyTest.java` + +**Interfaces:** +- Consumes: GraphQL over HTTP Stage 2 Draft, Stable HTTP V1 policy와 experimental flag. +- Produces: Query-only GET parsing, cache·CSRF 정책과 draft compatibility report. + +**Implementation requirements:** +- Mutation과 Subscription을 GET으로 허용하지 않는다. +- Query, operationName, variables, extensions의 URI 크기와 encoding을 제한한다. +- Cookie credential 사용 시 CSRF와 cache behavior를 명시적으로 검증한다. +- Raw query와 variables가 access log, referer 또는 metric에 노출될 위험을 문서·테스트한다. +- Draft 변화가 Stable POST contract를 변경하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlHttpGetOperationPolicyTest { + @org.junit.jupiter.api.Test + void mutationOverGetIsRejected() { + var policy = GraphQlHttpGetOperationPolicy.queryOnly(); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> policy.verify("mutation")) + .isInstanceOf( + GraphQlHttpGetRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-http-draft:test --tests 'io.backend.skeleton.graphql.advanced.get.GraphQlHttpGetOperationPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlHttpGetOperationPolicy { + public static GraphQlHttpGetOperationPolicy queryOnly() { + return new GraphQlHttpGetOperationPolicy(); + } + + public void verify(String operationType) { + if (!"query".equals(operationType)) { + throw new GraphQlHttpGetRejectedException( + "GET supports query operations only"); + } + } +} + +public record GraphQlHttpGetProfile( + int maximumUriBytes, + boolean sharedCacheAllowed, + boolean csrfRequired) { +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-http-draft:test --tests 'io.backend.skeleton.graphql.advanced.get.GraphQlHttpGetOperationPolicyTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-http-draft/src/main/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpGetProfile.java' 'modules/graphql-advanced/graphql-http-draft/src/main/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpGetRequestParser.java' 'modules/graphql-advanced/graphql-http-draft/src/main/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpGetCachePolicy.java' 'modules/graphql-advanced/graphql-http-draft/src/main/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpGetCsrfPolicy.java' 'modules/graphql-advanced/graphql-http-draft/src/main/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpGetOperationPolicy.java' 'modules/graphql-advanced/graphql-http-draft/src/main/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpDraftCompatibilityReport.java' 'modules/graphql-advanced/graphql-http-draft/src/test/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpGetOperationPolicyTest.java' +git commit -m "feat: add experimental graphql http get" +``` + +### Task 18: Incremental Delivery Experimental Profile + +**Files:** +- Create: `modules/graphql-advanced/graphql-incremental-delivery/src/main/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalDeliveryCapability.java` +- Create: `modules/graphql-advanced/graphql-incremental-delivery/src/main/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalDeliveryProfile.java` +- Create: `modules/graphql-advanced/graphql-incremental-delivery/src/main/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalPatch.java` +- Create: `modules/graphql-advanced/graphql-incremental-delivery/src/main/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalTransportPolicy.java` +- Create: `modules/graphql-advanced/graphql-incremental-delivery/src/main/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalCancellation.java` +- Create: `modules/graphql-advanced/graphql-incremental-delivery/src/main/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalCompatibilityGate.java` +- Test: `modules/graphql-advanced/graphql-incremental-delivery/src/test/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalCompatibilityGateTest.java` + +**Interfaces:** +- Consumes: 실험 기능을 지원하는 GraphQL Java/Spring 조합, multipart/mixed 또는 streaming transport capability. +- Produces: `@defer`·`@stream` 계열 기능을 Stable contract와 분리한 version-gated experimental profile. + +**Implementation requirements:** +- September 2025 Stable schema contract에 존재하지 않거나 구현 조합이 안정화되지 않은 기능을 자동 활성화하지 않는다. +- 초기 result와 후속 patch의 error·path·ordering·cancellation 계약을 별도로 검증한다. +- Stable HTTP response byte budget과 timeout을 우회하지 않는다. +- Client capability negotiation 없이 incremental response를 반환하지 않는다. +- Compatibility gate가 실패하면 일반 non-incremental execution으로 silent fallback하지 않고 설정 오류로 차단한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlIncrementalCompatibilityGateTest { + @org.junit.jupiter.api.Test + void unsupportedRuntimeCannotEnableIncrementalDelivery() { + var gate = new GraphQlIncrementalCompatibilityGate(); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> gate.verify( + new GraphQlIncrementalDeliveryCapability( + false, false))) + .isInstanceOf( + GraphQlIncrementalDeliveryRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-incremental-delivery:test --tests 'io.backend.skeleton.graphql.advanced.incremental.GraphQlIncrementalCompatibilityGateTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlIncrementalDeliveryCapability( + boolean engineSupported, + boolean transportSupported) { +} + +public final class GraphQlIncrementalCompatibilityGate { + public void verify( + GraphQlIncrementalDeliveryCapability capability) { + if (!capability.engineSupported() + || !capability.transportSupported()) { + throw new + GraphQlIncrementalDeliveryRejectedException( + "incremental delivery runtime unsupported"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-incremental-delivery:test --tests 'io.backend.skeleton.graphql.advanced.incremental.GraphQlIncrementalCompatibilityGateTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-incremental-delivery/src/main/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalDeliveryCapability.java' 'modules/graphql-advanced/graphql-incremental-delivery/src/main/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalDeliveryProfile.java' 'modules/graphql-advanced/graphql-incremental-delivery/src/main/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalPatch.java' 'modules/graphql-advanced/graphql-incremental-delivery/src/main/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalTransportPolicy.java' 'modules/graphql-advanced/graphql-incremental-delivery/src/main/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalCancellation.java' 'modules/graphql-advanced/graphql-incremental-delivery/src/main/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalCompatibilityGate.java' 'modules/graphql-advanced/graphql-incremental-delivery/src/test/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalCompatibilityGateTest.java' +git commit -m "feat: add experimental graphql incremental delivery" +``` + +### Task 19: Advanced Capability Promotion·Soak·Release Gate + +**Files:** +- Create: `modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedReleaseEvidence.java` +- Create: `modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedReleaseGate.java` +- Create: `modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedPromotionDecision.java` +- Create: `modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedSoakScenario.java` +- Create: `modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedCompatibilityMatrix.java` +- Create: `modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedRunbookIndex.java` +- Create: `modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedReleaseFailure.java` +- Test: `modules/graphql-advanced/graphql-advanced-bootstrap/src/test/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedReleaseGateTest.java` + +**Interfaces:** +- Consumes: Stable Task 1–48 release evidence와 Advanced capability별 composition·soak·security·compatibility evidence. +- Produces: Advanced Stable 승격과 Experimental 유지·철회를 결정하는 capability별 release gate. + +**Implementation requirements:** +- Stable Task 1–48의 release gate가 통과하지 않으면 Advanced 계획을 시작하지 않는다. +- WebSocket·SSE는 장기 connection soak, slow consumer, auth expiry, cancellation, shutdown drain evidence를 요구한다. +- Federation은 composition, router integration, cross-subgraph latency와 partial failure evidence를 요구한다. +- Persisted Operation은 registry durability, block propagation, usage와 schema compatibility evidence를 요구한다. +- RSocket·HTTP GET·Incremental Delivery는 명시적 승격 ADR 전까지 Experimental로 남는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlAdvancedReleaseGateTest { + @org.junit.jupiter.api.Test + void advancedCannotReleaseBeforeStableBaseline() { + var evidence = new GraphQlAdvancedReleaseEvidence( + false, true, true, true, true); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> new GraphQlAdvancedReleaseGate().verify(evidence)) + .isInstanceOf(GraphQlAdvancedReleaseFailure.class) + .hasMessageContaining("stable"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-advanced-bootstrap:test --tests 'io.backend.skeleton.graphql.advanced.release.GraphQlAdvancedReleaseGateTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlAdvancedReleaseEvidence( + boolean stableBaselinePassed, + boolean capabilityContractsPassed, + boolean securityPassed, + boolean soakPassed, + boolean compatibilityPassed) { +} + +public final class GraphQlAdvancedReleaseGate { + public void verify(GraphQlAdvancedReleaseEvidence evidence) { + if (!evidence.stableBaselinePassed()) { + throw new GraphQlAdvancedReleaseFailure( + "stable graphql baseline must pass first"); + } + if (!evidence.capabilityContractsPassed() + || !evidence.securityPassed() + || !evidence.soakPassed() + || !evidence.compatibilityPassed()) { + throw new GraphQlAdvancedReleaseFailure( + "advanced graphql evidence incomplete"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-advanced-bootstrap:test --tests 'io.backend.skeleton.graphql.advanced.release.GraphQlAdvancedReleaseGateTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedReleaseEvidence.java' 'modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedReleaseGate.java' 'modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedPromotionDecision.java' 'modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedSoakScenario.java' 'modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedCompatibilityMatrix.java' 'modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedRunbookIndex.java' 'modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedReleaseFailure.java' 'modules/graphql-advanced/graphql-advanced-bootstrap/src/test/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedReleaseGateTest.java' +git commit -m "chore: add graphql advanced release gate" +``` diff --git a/graphql-superpowers-package/docs/superpowers/plans/2026-08-12-graphql-api-execution-platform-implementation-plan.md b/graphql-superpowers-package/docs/superpowers/plans/2026-08-12-graphql-api-execution-platform-implementation-plan.md new file mode 100644 index 00000000..2f5204e1 --- /dev/null +++ b/graphql-superpowers-package/docs/superpowers/plans/2026-08-12-graphql-api-execution-platform-implementation-plan.md @@ -0,0 +1,4560 @@ +# GraphQL API 실행 플랫폼 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:** SDL 기반 GraphQL 외부 계약을 Spring MVC·WebFlux transport, 정책 기반 실행, 요청 단위 DataLoader, signed cursor, 일관된 error·security·observability 계약으로 Application Use Case에 연결하는 Stable GraphQL API 실행 플랫폼을 구축한다. + +**Architecture:** 도메인 모듈이 SDL fragment, resolver, transport DTO와 Application Use Case 연결을 소유하고 GraphQL 플랫폼은 schema assembly, execution policy, transport, security, cost, DataLoader, pagination, error, observability와 release gate를 소유한다. JPA Entity·Mongo Document·Messaging event·Fileserver binary를 직접 노출하지 않으며, Single Executable Schema와 HTTP POST를 Stable 기본값으로 구현한다. + +**Tech Stack:** Java 21, Gradle Kotlin DSL, Spring Boot 4.1 BOM, Spring for GraphQL 2.0 계열, Boot-managed GraphQL Java v25 계열, Spring MVC, Spring WebFlux, Project Reactor, Micrometer Observation, JUnit 5, AssertJ, ArchUnit, Testcontainers PostgreSQL·MongoDB. + +## Global Constraints + +- Java runtime은 `21`이다. +- Dependency version의 Source of Truth는 Spring Boot `4.1` BOM이다. +- Spring for GraphQL은 `2.0` 계열을 사용하며 GraphQL Java를 독립적으로 임의 override하지 않는다. +- GraphQL language·execution contract는 September 2025 Edition을 기준으로 한다. +- GraphQL over HTTP는 Stage 2 Draft이므로 플랫폼 `GraphQlHttpProfile.V1`으로 동작을 고정한다. +- Stable HTTP transport는 JSON body를 받는 `POST`만 지원한다. +- `application/graphql-response+json`을 preferred response media type으로 사용하고 `application/json`은 compatibility로 유지한다. +- Validation을 통과해 execution이 시작된 field error와 partial data는 HTTP `200`을 사용한다. +- Draft의 이동 중인 `294` status 제안은 Stable contract에 포함하지 않는다. +- SDL이 외부 API 계약의 Source of Truth이다. +- JPA Entity, MongoDB Document, provider SDK model과 자유형 `Map`를 GraphQL output으로 반환하지 않는다. +- GraphQL resolver는 Repository, `EntityManager`, `MongoTemplate`, broker ACK, HTTP retry와 binary streaming을 직접 소유하지 않는다. +- Mutation root field 하나는 Application Use Case 하나를 호출한다. +- 여러 mutation root field를 하나의 request-wide DB transaction으로 묶지 않는다. +- DataLoader instance와 cache는 GraphQL execution 단위이다. +- Cursor는 version, query profile, sort keyset, filter fingerprint와 HMAC을 포함한다. +- Binary upload는 GraphQL multipart가 아니라 Fileserver upload reservation을 사용한다. +- Query cost는 depth뿐 아니라 field·alias·fragment·cardinality·resolver weight를 함께 계산한다. +- Metric label에 raw query, variables, cursor, object ID, raw tenant/user ID와 token을 넣지 않는다. +- Stable 기본은 Single Executable Schema이다. +- WebSocket·SSE·Persisted Operation·Federation·RSocket·HTTP GET은 이 계획이 아니라 Advanced 계획에서 구현한다. +- Stable module root는 `modules/graphql`이다. +- Root package는 `io.backend.skeleton.graphql`이다. +- 모든 task는 red-green TDD와 독립 commit으로 끝난다. +- 실제 저장소 구조가 이 문서의 예상 경로와 다르면 경로만 매핑하고 공개 계약·불변 조건·테스트 의미는 변경하지 않는다. + +--- + +## Execution Baseline + +```text +Stable Task 1–48 +→ Stable Release Gate +→ Advanced Task 1–19 +``` + +## Stable Module Map + +```text +modules/graphql/ +├── graphql-core-api +├── graphql-schema +├── graphql-execution +├── graphql-controller +├── graphql-http +├── graphql-dataloader +├── graphql-pagination +├── graphql-security +├── graphql-cost-control +├── graphql-error +├── graphql-observability +├── graphql-spring-boot-starter +├── graphql-testkit-core +├── graphql-testkit-schema +├── graphql-testkit-http +└── graphql-testkit-security +``` + +## File Ownership Rules + +```text +graphql-core-api +→ bounded identifiers, request context, client/operation policy contracts + +graphql-schema +→ SDL discovery, assembly, scalar, oneOf, mapping inspection, + compatibility and usage gates + +graphql-http +→ HTTP V1 transport envelope, media type and status behavior + +graphql-execution +→ interceptor order, operation policy, timeout, fetch profile, + mutation execution and preparsed cache + +graphql-controller +→ annotated resolver conventions, DTO and mutation result mapping + +graphql-dataloader +→ request-scoped loader, batch policy, chunking and key-level result + +graphql-pagination +→ signed cursor, connection, edge and page info + +graphql-security +→ authentication, actor/tenant context and authorization boundary + +graphql-cost-control +→ parser, structural, complexity and runtime response budgets + +graphql-error +→ stable wire error and exception resolution + +graphql-observability +→ Spring GraphQL/Micrometer convention and cardinality controls + +graphql-spring-boot-starter +→ auto-configuration, startup validation and actuator report + +graphql-testkit-* +→ schema, transport, security, persistence, fault and release evidence +``` + +## Delivery Phases + +| Phase | Tasks | Independently testable result | +|---|---:|---| +| Foundation | 1–7 | 모듈·identifier·context·policy·schema/scalar core | +| Schema Contract | 8–14 | deterministic SDL, mapping, evolution, scalar, oneOf | +| HTTP·Execution | 15–23 | Stable HTTP V1, MVC/WebFlux, timeout, resolver boundary | +| Error·Security | 24–29 | partial data/error, auth, field/object/tenant isolation | +| Cost·Cache | 30–35 | parser/shape/complexity/runtime budgets, operation naming, cache | +| Data Access Planning | 36–40 | request-scoped DataLoader와 finite Fetch Profile | +| Pagination·Mutation | 41–44 | signed connection cursor와 mutation contracts | +| Operations·Release | 45–48 | observability, starter, cross-module contracts, release gate | + +--- + +### Task 1: Gradle 멀티모듈과 GraphQL 품질 Test Suite 구성 + +**Files:** +- Create: `build-logic/src/main/kotlin/graphql-library-conventions.gradle.kts` +- Create: `modules/graphql/graphql-core-api/build.gradle.kts` +- Create: `modules/graphql/graphql-schema/build.gradle.kts` +- Create: `modules/graphql/graphql-execution/build.gradle.kts` +- Create: `modules/graphql/graphql-controller/build.gradle.kts` +- Create: `modules/graphql/graphql-http/build.gradle.kts` +- Create: `modules/graphql/graphql-dataloader/build.gradle.kts` +- Create: `modules/graphql/graphql-pagination/build.gradle.kts` +- Create: `modules/graphql/graphql-security/build.gradle.kts` +- Create: `modules/graphql/graphql-cost-control/build.gradle.kts` +- Create: `modules/graphql/graphql-error/build.gradle.kts` +- Create: `modules/graphql/graphql-observability/build.gradle.kts` +- Create: `modules/graphql/graphql-spring-boot-starter/build.gradle.kts` +- Create: `modules/graphql/graphql-testkit-core/build.gradle.kts` +- Create: `modules/graphql/graphql-testkit-schema/build.gradle.kts` +- Create: `modules/graphql/graphql-testkit-http/build.gradle.kts` +- Create: `modules/graphql/graphql-testkit-integration/build.gradle.kts` +- Test: `build-logic/src/test/java/GraphQlModuleBoundaryTest.java` + +**Interfaces:** +- Consumes: Host repository version catalog and Spring Boot 4.1 dependency management. +- Produces: 16 isolated Stable modules and `graphqlStableTest`, `graphqlContractTest`, `graphqlPerformanceTest` aggregate tasks. + +**Implementation requirements:** +- Apply Java 21 toolchains and use the Spring Boot BOM for Spring for GraphQL and GraphQL Java. +- Keep `graphql-core-api` free of Spring, GraphQL Java, Reactor and persistence dependencies. +- Do not include any Advanced module in the Stable dependency graph. +- Keep external load and soak tests outside the default unit test task. +- Fail the build when a Stable module depends on `modules/graphql-advanced`. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlModuleBoundaryTest { + @org.junit.jupiter.api.Test + void stableGraphDoesNotContainAdvancedModules() { + org.assertj.core.api.Assertions.assertThat(GraphQlBuildModel.stableModules()) + .contains("graphql-core-api", "graphql-http") + .doesNotContain("graphql-websocket", "graphql-federation"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew build-logic:test --tests 'GraphQlModuleBoundaryTest' +``` + +Expected: FAIL because the GraphQL module graph and build model do not exist. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlBuildModel { + private static final java.util.Set STABLE = java.util.Set.of( + "graphql-core-api", "graphql-schema", "graphql-execution", + "graphql-controller", "graphql-http", "graphql-dataloader", + "graphql-pagination", "graphql-security", "graphql-cost-control", + "graphql-error", "graphql-observability", + "graphql-spring-boot-starter", "graphql-testkit-core", + "graphql-testkit-schema", "graphql-testkit-http", + "graphql-testkit-integration"); + + public static java.util.Set stableModules() { + return STABLE; + } + + private GraphQlBuildModel() {} +} +``` + +Create all module build files, aggregate suites and boundary checks listed above. Do not add Advanced dependencies to the Stable starter. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew build-logic:test --tests 'GraphQlModuleBoundaryTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the boundary test and the Stable aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'build-logic/src/main/kotlin/graphql-library-conventions.gradle.kts' 'modules/graphql/graphql-core-api/build.gradle.kts' 'modules/graphql/graphql-schema/build.gradle.kts' 'modules/graphql/graphql-execution/build.gradle.kts' 'modules/graphql/graphql-controller/build.gradle.kts' 'modules/graphql/graphql-http/build.gradle.kts' 'modules/graphql/graphql-dataloader/build.gradle.kts' 'modules/graphql/graphql-pagination/build.gradle.kts' 'modules/graphql/graphql-security/build.gradle.kts' 'modules/graphql/graphql-cost-control/build.gradle.kts' 'modules/graphql/graphql-error/build.gradle.kts' 'modules/graphql/graphql-observability/build.gradle.kts' 'modules/graphql/graphql-spring-boot-starter/build.gradle.kts' 'modules/graphql/graphql-testkit-core/build.gradle.kts' 'modules/graphql/graphql-testkit-schema/build.gradle.kts' 'modules/graphql/graphql-testkit-http/build.gradle.kts' 'modules/graphql/graphql-testkit-integration/build.gradle.kts' 'build-logic/src/test/java/GraphQlModuleBoundaryTest.java' +git commit -m "build: add graphql stable modules and test suites" +``` + +### Task 2: Core Operation·Client Profile 식별자 + +**Files:** +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/api/GraphQlOperationName.java` +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/api/GraphQlOperationId.java` +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/api/GraphQlClientProfile.java` +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/api/GraphQlSchemaCoordinate.java` +- Test: `modules/graphql/graphql-core-api/src/test/java/io/backend/skeleton/graphql/api/GraphQlIdentifiersTest.java` + +**Interfaces:** +- Consumes: Java 21 standard library only. +- Produces: Bounded low-cardinality identifiers shared by every platform module. + +**Implementation requirements:** +- Operation names match `[A-Za-z][_0-9A-Za-z]{2,127}`; anonymous operations use an explicit type rather than an empty string. +- Client profiles and schema coordinates reject path separators, whitespace and UUID-like dynamic values. +- Identifiers never contain actor, tenant, object or provider request IDs. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlIdentifiersTest { + @org.junit.jupiter.api.Test + void rejectsDynamicClientProfile() { + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> new GraphQlClientProfile("tenant/" + java.util.UUID.randomUUID())) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-core-api:test --tests 'io.backend.skeleton.graphql.api.GraphQlIdentifiersTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlClientProfile(String value) { + public GraphQlClientProfile { + if (value == null || !value.matches("[a-z][a-z0-9.-]{2,63}")) { + throw new IllegalArgumentException("invalid GraphQL client profile"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-core-api:test --tests 'io.backend.skeleton.graphql.api.GraphQlIdentifiersTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/api/GraphQlOperationName.java' 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/api/GraphQlOperationId.java' 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/api/GraphQlClientProfile.java' 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/api/GraphQlSchemaCoordinate.java' 'modules/graphql/graphql-core-api/src/test/java/io/backend/skeleton/graphql/api/GraphQlIdentifiersTest.java' +git commit -m "feat: add graphql bounded identifiers" +``` + +### Task 3: Immutable GraphQlRequestContext와 Deadline + +**Files:** +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/context/GraphQlRequestContext.java` +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/context/GraphQlDeadline.java` +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/context/ActorRef.java` +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/context/TenantContext.java` +- Test: `modules/graphql/graphql-core-api/src/test/java/io/backend/skeleton/graphql/context/GraphQlRequestContextTest.java` + +**Interfaces:** +- Consumes: Core identifiers from Task 2. +- Produces: Immutable actor, tenant, client, locale, operation and deadline context. + +**Implementation requirements:** +- Tenant context is created from trusted authentication data, never a GraphQL argument. +- Deadline exposes remaining time from an injected Clock and has no mutable state. +- Context contains no access token, cookie or raw provider claim. +- The same semantic context can be bridged to executor and Reactor Context. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlRequestContextTest { + @org.junit.jupiter.api.Test + void rejectsExpiredDeadlineAgainstClock() { + java.time.Clock clock = java.time.Clock.fixed( + java.time.Instant.parse("2026-08-12T00:00:00Z"), + java.time.ZoneOffset.UTC); + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> GraphQlDeadline.of(java.time.Instant.parse("2026-08-11T23:59:59Z"), clock)) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-core-api:test --tests 'io.backend.skeleton.graphql.context.GraphQlRequestContextTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlDeadline(java.time.Instant value) { + public static GraphQlDeadline of(java.time.Instant value, java.time.Clock clock) { + if (value == null || !value.isAfter(clock.instant())) { + throw new IllegalArgumentException("deadline must be in the future"); + } + return new GraphQlDeadline(value); + } + + public java.time.Duration remaining(java.time.Clock clock) { + return java.time.Duration.between(clock.instant(), value); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-core-api:test --tests 'io.backend.skeleton.graphql.context.GraphQlRequestContextTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/context/GraphQlRequestContext.java' 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/context/GraphQlDeadline.java' 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/context/ActorRef.java' 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/context/TenantContext.java' 'modules/graphql/graphql-core-api/src/test/java/io/backend/skeleton/graphql/context/GraphQlRequestContextTest.java' +git commit -m "feat: add graphql request context and deadline" +``` + +### Task 4: Client Policy와 환경별 Manifest + +**Files:** +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/GraphQlClientPolicy.java` +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/GraphQlClientPolicyManifest.java` +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/GraphQlPolicyViolation.java` +- Test: `modules/graphql/graphql-core-api/src/test/java/io/backend/skeleton/graphql/policy/GraphQlClientPolicyTest.java` + +**Interfaces:** +- Consumes: Client profile identifiers and Java time types. +- Produces: Validated request, page, cost, response and introspection limits per client profile. + +**Implementation requirements:** +- Every numeric limit is positive. +- Maximum page size is not below default page size. +- Production profiles can require named operations and persisted-only mode. +- Policy objects contain no raw query, actor, tenant or credential value. +- Manifests reject duplicate client profiles. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlClientPolicyTest { + @org.junit.jupiter.api.Test + void rejectsDefaultPageAboveMaximum() { + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + new GraphQlClientPolicy( + 65536, 65536, 12, 500, 50, 50, 1000, + 100, 20, 10000, 10000, 5_242_880, + java.time.Duration.ofSeconds(5), false, false, true)) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-core-api:test --tests 'io.backend.skeleton.graphql.policy.GraphQlClientPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlClientPolicy( + int maxDocumentBytes, + int maxVariablesBytes, + int maxDepth, + int maxFields, + int maxAliases, + int maxFragments, + int maxInputListElements, + int defaultPageSize, + int maxPageSize, + long maxComplexity, + long maxResponseNodes, + long maxResponseBytes, + java.time.Duration maxExecutionTime, + boolean introspectionAllowed, + boolean persistedOperationOnly, + boolean namedOperationRequired) { + + public GraphQlClientPolicy { + if (defaultPageSize < 1 || maxPageSize < defaultPageSize) { + throw new IllegalArgumentException("invalid page policy"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-core-api:test --tests 'io.backend.skeleton.graphql.policy.GraphQlClientPolicyTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/GraphQlClientPolicy.java' 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/GraphQlClientPolicyManifest.java' 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/GraphQlPolicyViolation.java' 'modules/graphql/graphql-core-api/src/test/java/io/backend/skeleton/graphql/policy/GraphQlClientPolicyTest.java' +git commit -m "feat: add graphql client policy manifest" +``` + +### Task 5: Operation Policy와 실행 유형 Catalog + +**Files:** +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/GraphQlOperationPolicy.java` +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/GraphQlOperationType.java` +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/ResolverExecutionType.java` +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/GraphQlOperationCatalog.java` +- Test: `modules/graphql/graphql-core-api/src/test/java/io/backend/skeleton/graphql/policy/GraphQlOperationPolicyTest.java` + +**Interfaces:** +- Consumes: Core identifiers and client policy. +- Produces: Registered operation metadata for query, mutation and subscription execution. + +**Implementation requirements:** +- Every production operation has a registered name and schema coordinate. +- Mutation policy may require idempotency and expected version. +- `STREAM` execution type is valid only for subscription operations. +- Dynamic resolver names are rejected. +- Catalog registration fails on duplicate operation names. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlOperationPolicyTest { + @org.junit.jupiter.api.Test + void rejectsStreamQuery() { + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + new GraphQlOperationPolicy( + new GraphQlOperationName("GetOrder"), + GraphQlOperationType.QUERY, + ResolverExecutionType.STREAM, + false)) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-core-api:test --tests 'io.backend.skeleton.graphql.policy.GraphQlOperationPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlOperationPolicy( + GraphQlOperationName name, + GraphQlOperationType operationType, + ResolverExecutionType executionType, + boolean idempotencyRequired) { + + public GraphQlOperationPolicy { + if (executionType == ResolverExecutionType.STREAM + && operationType != GraphQlOperationType.SUBSCRIPTION) { + throw new IllegalArgumentException("stream resolver requires subscription"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-core-api:test --tests 'io.backend.skeleton.graphql.policy.GraphQlOperationPolicyTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/GraphQlOperationPolicy.java' 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/GraphQlOperationType.java' 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/ResolverExecutionType.java' 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/GraphQlOperationCatalog.java' 'modules/graphql/graphql-core-api/src/test/java/io/backend/skeleton/graphql/policy/GraphQlOperationPolicyTest.java' +git commit -m "feat: add graphql operation policy catalog" +``` + +### Task 6: Schema Contract와 Fingerprint + +**Files:** +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/schema/GraphQlSchemaContract.java` +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/schema/GraphQlSchemaHash.java` +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/schema/GraphQlContractVersion.java` +- Test: `modules/graphql/graphql-core-api/src/test/java/io/backend/skeleton/graphql/schema/GraphQlSchemaContractTest.java` + +**Interfaces:** +- Consumes: Core identifiers and standard cryptography. +- Produces: Schema hash plus breaking, scalar and directive policy versions. + +**Implementation requirements:** +- Canonical SDL bytes are hashed with SHA-256. +- Schema hash is never the only compatibility decision. +- Breaking, scalar and directive policy versions are mandatory. +- Hash formatting is lowercase hexadecimal. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlSchemaContractTest { + @org.junit.jupiter.api.Test + void sameCanonicalSdlProducesSameHash() { + org.assertj.core.api.Assertions.assertThat( + GraphQlSchemaHash.sha256("type Query { ping: String! }").value()) + .isEqualTo(GraphQlSchemaHash.sha256("type Query { ping: String! }").value()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-core-api:test --tests 'io.backend.skeleton.graphql.schema.GraphQlSchemaContractTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlSchemaContract( + GraphQlSchemaHash schemaHash, + GraphQlContractVersion breakingPolicyVersion, + GraphQlContractVersion scalarManifestVersion, + GraphQlContractVersion directiveManifestVersion) { +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-core-api:test --tests 'io.backend.skeleton.graphql.schema.GraphQlSchemaContractTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/schema/GraphQlSchemaContract.java' 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/schema/GraphQlSchemaHash.java' 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/schema/GraphQlContractVersion.java' 'modules/graphql/graphql-core-api/src/test/java/io/backend/skeleton/graphql/schema/GraphQlSchemaContractTest.java' +git commit -m "feat: add graphql schema contract fingerprint" +``` + +### Task 7: Scalar Manifest와 Coercion 계약 + +**Files:** +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlScalarManifest.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlScalarDefinition.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlScalarPolicy.java` +- Test: `modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/schema/GraphQlScalarManifestTest.java` + +**Interfaces:** +- Consumes: Schema contract and GraphQL Java scalar SPI. +- Produces: Approved ID, UUID, Instant, Date, BigDecimal and Long scalar definitions. + +**Implementation requirements:** +- `Upload` scalar is always rejected. +- `JSON` scalar requires a coordinate allowlist and cannot be a global default input. +- BigDecimal and Long coercion reject silent precision loss. +- Every custom scalar has a stable name and optional specified-by URI. +- Duplicate scalar names fail manifest construction. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlScalarManifestTest { + @org.junit.jupiter.api.Test + void uploadScalarIsForbidden() { + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> GraphQlScalarManifest.of(GraphQlScalarDefinition.named("Upload"))) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.schema.GraphQlScalarManifestTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlScalarDefinition(String name, java.net.URI specifiedBy) { + public GraphQlScalarDefinition { + if ("Upload".equals(name)) { + throw new IllegalArgumentException("Upload scalar is unsupported"); + } + } + + public static GraphQlScalarDefinition named(String name) { + return new GraphQlScalarDefinition(name, null); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.schema.GraphQlScalarManifestTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlScalarManifest.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlScalarDefinition.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlScalarPolicy.java' 'modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/schema/GraphQlScalarManifestTest.java' +git commit -m "feat: add graphql scalar manifest" +``` + +### Task 8: SDL Resource Discovery와 Deterministic Assembly + +**Files:** +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlSchemaResource.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlSchemaAssembler.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlSchemaAssemblyResult.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlSchemaOwnership.java` +- Test: `modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/schema/GraphQlSchemaAssemblerTest.java` + +**Interfaces:** +- Consumes: Scalar manifest, GraphQL Java SDL parser and Spring resource abstraction. +- Produces: Deterministic classpath fragment assembly with ownership and duplicate detection. + +**Implementation requirements:** +- Load only `.graphqls` and `.gqls` under approved roots. +- Sort resources by logical module and path before assembly. +- Reject duplicate type, field, directive and scalar declarations. +- Preserve a resource-to-coordinate ownership map for diagnostics. +- Do not rely on filesystem enumeration order. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlSchemaAssemblerTest { + @org.junit.jupiter.api.Test + void duplicateRootTypeFailsAssembly() { + var resources = java.util.List.of( + GraphQlSchemaResource.memory("a", "type Query { a: String }"), + GraphQlSchemaResource.memory("b", "type Query { b: String }")); + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> GraphQlSchemaAssembler.defaults().assemble(resources)) + .isInstanceOf(GraphQlSchemaAssemblyException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.schema.GraphQlSchemaAssemblerTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlSchemaAssembler { + public GraphQlSchemaAssemblyResult assemble( + java.util.List resources) { + java.util.List ordered = resources.stream() + .sorted(java.util.Comparator.comparing(GraphQlSchemaResource::logicalPath)) + .toList(); + return GraphQlSchemaAssemblyResult.parseAndValidate(ordered); + } + + public static GraphQlSchemaAssembler defaults() { + return new GraphQlSchemaAssembler(); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.schema.GraphQlSchemaAssemblerTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlSchemaResource.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlSchemaAssembler.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlSchemaAssemblyResult.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlSchemaOwnership.java' 'modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/schema/GraphQlSchemaAssemblerTest.java' +git commit -m "feat: add deterministic graphql schema assembly" +``` + +### Task 9: SchemaMappingInspector Fail-fast Gate + +**Files:** +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlMappingInspectionGate.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlMappingIssue.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlMappingPolicy.java` +- Test: `modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/schema/GraphQlMappingInspectionGateTest.java` + +**Interfaces:** +- Consumes: Assembled schema and Spring SchemaMappingInspector output. +- Produces: Startup gate for unmapped fields, unknown resolvers, argument and nullability mismatches. + +**Implementation requirements:** +- Stable profile fails on blocking mapping issues. +- Local profile may report warnings but cannot ignore forbidden scalar or unknown resolver. +- Issue output identifies schema coordinate and owning resource without PII. +- The gate runs after all controller, scalar and type-resolver wiring is registered. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlMappingInspectionGateTest { + @org.junit.jupiter.api.Test + void stableProfileRejectsUnmappedField() { + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + GraphQlMappingInspectionGate.stable().verify( + java.util.List.of(GraphQlMappingIssue.unmapped("Order.total")))) + .isInstanceOf(GraphQlSchemaMappingException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.schema.GraphQlMappingInspectionGateTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlMappingInspectionGate { + public void verify(java.util.List issues) { + if (issues.stream().anyMatch(GraphQlMappingIssue::blocking)) { + throw new GraphQlSchemaMappingException(issues); + } + } + + public static GraphQlMappingInspectionGate stable() { + return new GraphQlMappingInspectionGate(); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.schema.GraphQlMappingInspectionGateTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlMappingInspectionGate.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlMappingIssue.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlMappingPolicy.java' 'modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/schema/GraphQlMappingInspectionGateTest.java' +git commit -m "feat: add graphql mapping inspection gate" +``` + +### Task 10: Schema Compatibility Diff와 Breaking Policy + +**Files:** +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlSchemaChange.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlCompatibilityPolicy.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlCompatibilityReport.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlSchemaComparator.java` +- Test: `modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/compat/GraphQlSchemaComparatorTest.java` + +**Interfaces:** +- Consumes: Previous and candidate schema contracts. +- Produces: Wire and generated-client impact classification for schema changes. + +**Implementation requirements:** +- Field removal, required argument addition, input strengthening and output nullable transition are breaking. +- Enum and union additions are additive with generated-client review. +- Scalar coercion change requires a new scalar or version. +- Every change reports coordinate, wire impact, client impact and reason. +- Comparison order is deterministic. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlSchemaComparatorTest { + @org.junit.jupiter.api.Test + void requiredArgumentAdditionIsBreaking() { + GraphQlCompatibilityReport report = GraphQlSchemaComparator.compare( + "type Query { order: String }", + "type Query { order(id: ID!): String }"); + org.assertj.core.api.Assertions.assertThat(report.breaking()).isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.compat.GraphQlSchemaComparatorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlSchemaChange( + String coordinate, + GraphQlChangeKind kind, + GraphQlCompatibilityImpact wireImpact, + GraphQlCompatibilityImpact generatedClientImpact, + String reason) { +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.compat.GraphQlSchemaComparatorTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlSchemaChange.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlCompatibilityPolicy.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlCompatibilityReport.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlSchemaComparator.java' 'modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/compat/GraphQlSchemaComparatorTest.java' +git commit -m "feat: add graphql schema compatibility policy" +``` + +### Task 11: Schema Usage와 Deprecation Removal Gate + +**Files:** +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlSchemaUsage.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlDeprecationGate.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlRemovalDecision.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlClientOwnerApproval.java` +- Test: `modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/compat/GraphQlDeprecationGateTest.java` + +**Interfaces:** +- Consumes: Compatibility report and bounded operation usage catalog. +- Produces: Removal gate requiring deprecation, zero usage, persisted-reference scan and owner approval. + +**Implementation requirements:** +- Unknown usage is not treated as zero usage. +- Support window must have elapsed. +- Persisted operation references must be absent. +- Required input elements cannot be removed through a deprecation shortcut. +- Approval records contain owner references and reason, not secrets. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlDeprecationGateTest { + @org.junit.jupiter.api.Test + void unknownUsageBlocksRemoval() { + org.assertj.core.api.Assertions.assertThat( + GraphQlDeprecationGate.evaluate(GraphQlSchemaUsage.unknown()).allowed()) + .isFalse(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.compat.GraphQlDeprecationGateTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlRemovalDecision( + boolean allowed, + java.util.List reasons) { + + public GraphQlRemovalDecision { + reasons = java.util.List.copyOf(reasons); + } + + public static GraphQlRemovalDecision blocked(String reason) { + return new GraphQlRemovalDecision(false, java.util.List.of(reason)); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.compat.GraphQlDeprecationGateTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlSchemaUsage.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlDeprecationGate.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlRemovalDecision.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlClientOwnerApproval.java' 'modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/compat/GraphQlDeprecationGateTest.java' +git commit -m "feat: add graphql deprecation removal gate" +``` + +### Task 12: Resolver·DTO·Repository Boundary Architecture Rules + +**Files:** +- Create: `modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/architecture/GraphQlResolverBoundaryRules.java` +- Create: `modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/architecture/GraphQlTransportTypeRules.java` +- Create: `modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/architecture/GraphQlControllerTransactionRule.java` +- Test: `modules/graphql/graphql-controller/src/test/java/io/backend/skeleton/graphql/architecture/GraphQlResolverBoundaryRulesTest.java` + +**Interfaces:** +- Consumes: Annotated controller package conventions and ArchUnit. +- Produces: Architecture rules that block persistence and provider exposure. + +**Implementation requirements:** +- Resolvers may depend on Application Use Case interfaces and DTO mappers. +- Resolvers cannot return JPA entities, Mongo documents, provider SDK types or unrestricted maps. +- GraphQL controller classes cannot carry transaction annotations. +- Raw DataFetcher implementation is restricted to infrastructure packages. +- Resolvers cannot depend directly on EntityManager, MongoTemplate or repository implementations. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlResolverBoundaryRulesTest { + @org.junit.jupiter.api.Test + void controllerMustNotDependOnEntityManager() { + GraphQlResolverBoundaryRules.assertNoPersistenceAccess( + "io.backend.skeleton.example.graphql"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-controller:test --tests 'io.backend.skeleton.graphql.architecture.GraphQlResolverBoundaryRulesTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlResolverBoundaryRules { + public static void assertNoPersistenceAccess(String packageName) { + // Build the ArchUnit rule against EntityManager, MongoTemplate, + // repository implementations and provider SDK packages. + } + + private GraphQlResolverBoundaryRules() {} +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-controller:test --tests 'io.backend.skeleton.graphql.architecture.GraphQlResolverBoundaryRulesTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/architecture/GraphQlResolverBoundaryRules.java' 'modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/architecture/GraphQlTransportTypeRules.java' 'modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/architecture/GraphQlControllerTransactionRule.java' 'modules/graphql/graphql-controller/src/test/java/io/backend/skeleton/graphql/architecture/GraphQlResolverBoundaryRulesTest.java' +git commit -m "test: enforce graphql resolver architecture boundaries" +``` + +### Task 13: Standard Custom Scalar Wiring + +**Files:** +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/scalar/UuidScalar.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/scalar/InstantScalar.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/scalar/DateScalar.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/scalar/BigDecimalScalar.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/scalar/LongScalar.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/scalar/GraphQlScalarWiringConfigurer.java` +- Test: `modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/scalar/GraphQlScalarContractTest.java` + +**Interfaces:** +- Consumes: Scalar manifest and GraphQL Java Coercing API. +- Produces: Strict serialization, parsing and variable coercion for Stable custom scalars. + +**Implementation requirements:** +- UUID accepts canonical string only. +- Instant emits UTC ISO-8601. +- BigDecimal rejects NaN, infinity and precision-loss conversion. +- Long follows the configured client numeric range policy. +- Coercion errors do not echo sensitive input values. +- `@oneOf` coercion is covered by a separate schema contract test. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlScalarContractTest { + @org.junit.jupiter.api.Test + void uuidRejectsInvalidValue() { + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> UuidScalar.parse("not-a-uuid")) + .isInstanceOf(graphql.schema.CoercingParseValueException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.scalar.GraphQlScalarContractTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class UuidScalar { + public static java.util.UUID parse(String value) { + try { + return java.util.UUID.fromString(value); + } + catch (IllegalArgumentException ex) { + throw new graphql.schema.CoercingParseValueException("invalid UUID"); + } + } + + private UuidScalar() {} +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.scalar.GraphQlScalarContractTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/scalar/UuidScalar.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/scalar/InstantScalar.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/scalar/DateScalar.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/scalar/BigDecimalScalar.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/scalar/LongScalar.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/scalar/GraphQlScalarWiringConfigurer.java' 'modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/scalar/GraphQlScalarContractTest.java' +git commit -m "feat: add graphql stable scalar wiring" +``` + +### Task 14: September 2025 `@oneOf` Input Contract + +**Files:** +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlOneOfPolicy.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlOneOfInputValidator.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlOneOfSchemaGate.java` +- Test: `modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/schema/GraphQlOneOfInputValidatorTest.java` + +**Interfaces:** +- Consumes: September 2025 schema contract and GraphQL Java input coercion. +- Produces: Stable one-of input validation and schema restrictions. + +**Implementation requirements:** +- Exactly one member field must be present with a non-null value. +- Member fields remain nullable in SDL. +- Member fields cannot declare default values. +- Zero or multiple supplied fields fail before resolver execution. +- Validation errors do not echo sensitive input values. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlOneOfInputValidatorTest { + @org.junit.jupiter.api.Test + void rejectsTwoValues() { + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + GraphQlOneOfInputValidator.validate( + java.util.Map.of("id", "o-1", "orderNumber", "N-1"))) + .isInstanceOf(GraphQlOneOfViolationException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.schema.GraphQlOneOfInputValidatorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlOneOfInputValidator { + public static void validate(java.util.Map values) { + long present = values.values().stream().filter(java.util.Objects::nonNull).count(); + if (present != 1L) { + throw new GraphQlOneOfViolationException("exactly one value required"); + } + } + + private GraphQlOneOfInputValidator() {} +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.schema.GraphQlOneOfInputValidatorTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlOneOfPolicy.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlOneOfInputValidator.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlOneOfSchemaGate.java' 'modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/schema/GraphQlOneOfInputValidatorTest.java' +git commit -m "feat: add graphql one-of input contract" +``` + +### Task 15: HTTP V1 Profile과 Media Contract + +**Files:** +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlHttpProfile.java` +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlMediaTypes.java` +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlHttpRequestEnvelope.java` +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlHttpResponsePolicy.java` +- Test: `modules/graphql/graphql-http/src/test/java/io/backend/skeleton/graphql/http/GraphQlHttpProfileTest.java` + +**Interfaces:** +- Consumes: Core policy and Spring HTTP media types. +- Produces: POST-only V1 request and preferred `application/graphql-response+json` response contract. + +**Implementation requirements:** +- Stable profile rejects GET, multipart, array batch and unapproved extensions. +- Prefer `application/graphql-response+json` while supporting legacy `application/json` responses. +- Model request errors separately from execution and field errors. +- Do not introduce draft HTTP 294 in Stable. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlHttpProfileTest { + @org.junit.jupiter.api.Test + void stableProfileRejectsGet() { + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> GraphQlHttpProfile.V1.validateMethod("GET")) + .isInstanceOf(GraphQlHttpContractException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-http:test --tests 'io.backend.skeleton.graphql.http.GraphQlHttpProfileTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GraphQlHttpProfile { + V1; + + public void validateMethod(String method) { + if (!"POST".equals(method)) { + throw new GraphQlHttpContractException("POST required"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-http:test --tests 'io.backend.skeleton.graphql.http.GraphQlHttpProfileTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlHttpProfile.java' 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlMediaTypes.java' 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlHttpRequestEnvelope.java' 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlHttpResponsePolicy.java' 'modules/graphql/graphql-http/src/test/java/io/backend/skeleton/graphql/http/GraphQlHttpProfileTest.java' +git commit -m "feat: add graphql HTTP V1 profile" +``` + +### Task 16: Request Envelope·Variables·Extensions 제한 + +**Files:** +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlRequestEnvelopeValidator.java` +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlRequestSize.java` +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlExtensionsPolicy.java` +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlRequestFormatException.java` +- Test: `modules/graphql/graphql-http/src/test/java/io/backend/skeleton/graphql/http/GraphQlRequestEnvelopeValidatorTest.java` + +**Interfaces:** +- Consumes: HTTP profile and client policy. +- Produces: Pre-parse limits for body, variables, operation name and extensions. + +**Implementation requirements:** +- Reject oversized JSON before GraphQL parsing. +- `variables` and `extensions` must be JSON objects when present. +- Only registered extension keys are accepted. +- Production client policy enforces a named operation. +- Diagnostics report byte counts, never query or variable content. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlRequestEnvelopeValidatorTest { + @org.junit.jupiter.api.Test + void rejectsOversizedVariables() { + var validator = GraphQlRequestEnvelopeValidator.maxVariablesBytes(16); + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> validator.validateVariables( + "{\"value\":\"01234567890123456789\"}".getBytes( + java.nio.charset.StandardCharsets.UTF_8))) + .isInstanceOf(GraphQlRequestTooLargeException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-http:test --tests 'io.backend.skeleton.graphql.http.GraphQlRequestEnvelopeValidatorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlRequestEnvelopeValidator { + private final int maxVariablesBytes; + + private GraphQlRequestEnvelopeValidator(int maxVariablesBytes) { + this.maxVariablesBytes = maxVariablesBytes; + } + + public static GraphQlRequestEnvelopeValidator maxVariablesBytes(int value) { + return new GraphQlRequestEnvelopeValidator(value); + } + + public void validateVariables(byte[] bytes) { + if (bytes.length > maxVariablesBytes) { + throw new GraphQlRequestTooLargeException("variables too large"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-http:test --tests 'io.backend.skeleton.graphql.http.GraphQlRequestEnvelopeValidatorTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlRequestEnvelopeValidator.java' 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlRequestSize.java' 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlExtensionsPolicy.java' 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlRequestFormatException.java' 'modules/graphql/graphql-http/src/test/java/io/backend/skeleton/graphql/http/GraphQlRequestEnvelopeValidatorTest.java' +git commit -m "feat: add graphql request envelope limits" +``` + +### Task 17: HTTP Request·Execution Error Status Mapper + +**Files:** +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlHttpStatusMapper.java` +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlHttpOutcome.java` +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlHttpResponseFactory.java` +- Test: `modules/graphql/graphql-http/src/test/java/io/backend/skeleton/graphql/http/GraphQlHttpStatusMapperTest.java` + +**Interfaces:** +- Consumes: HTTP V1 profile and GraphQL response classification. +- Produces: Stable 4xx request error and HTTP 200 execution error mapping. + +**Implementation requirements:** +- Malformed JSON, parse, validation and coercion failures map to bounded 4xx statuses. +- Execution begun with field errors maps to HTTP 200 and preserves partial data. +- Legacy JSON response mode remains compatible. +- Status mapping is versioned by HTTP profile. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlHttpStatusMapperTest { + @org.junit.jupiter.api.Test + void fieldErrorUsesHttp200() { + org.assertj.core.api.Assertions.assertThat( + GraphQlHttpStatusMapper.V1.status(GraphQlHttpOutcome.FIELD_ERROR)) + .isEqualTo(200); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-http:test --tests 'io.backend.skeleton.graphql.http.GraphQlHttpStatusMapperTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GraphQlHttpStatusMapper { + V1; + + public int status(GraphQlHttpOutcome outcome) { + return switch (outcome) { + case MALFORMED_REQUEST, PARSE_ERROR, VALIDATION_ERROR -> 400; + case FIELD_ERROR, SUCCESS -> 200; + }; + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-http:test --tests 'io.backend.skeleton.graphql.http.GraphQlHttpStatusMapperTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlHttpStatusMapper.java' 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlHttpOutcome.java' 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlHttpResponseFactory.java' 'modules/graphql/graphql-http/src/test/java/io/backend/skeleton/graphql/http/GraphQlHttpStatusMapperTest.java' +git commit -m "feat: add graphql HTTP status mapping" +``` + +### Task 18: MVC Transport Adapter와 Virtual Thread 경로 + +**Files:** +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/mvc/GraphQlMvcTransportAdapter.java` +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/mvc/GraphQlMvcExecutorPolicy.java` +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/mvc/GraphQlMvcAutoConfiguration.java` +- Test: `modules/graphql/graphql-http/src/test/java/io/backend/skeleton/graphql/http/mvc/GraphQlMvcTransportAdapterTest.java` + +**Interfaces:** +- Consumes: Request envelope validation, execution service and HTTP status mapper. +- Produces: Blocking MVC transport with virtual-thread or bounded executor policy. + +**Implementation requirements:** +- Use Java 21 virtual thread or a bounded platform-thread executor. +- Propagate request context and deadline. +- Cancel or close execution on client disconnect and timeout. +- Expose no WebFlux or Reactor type in the MVC public contract. +- Do not place transaction boundaries in transport code. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlMvcTransportAdapterTest { + @org.junit.jupiter.api.Test + void virtualThreadPolicyAllowsBlockingResolvers() { + org.assertj.core.api.Assertions.assertThat( + GraphQlMvcExecutorPolicy.VIRTUAL_THREAD.blockingAllowed()) + .isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-http:test --tests 'io.backend.skeleton.graphql.http.mvc.GraphQlMvcTransportAdapterTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GraphQlMvcExecutorPolicy { + VIRTUAL_THREAD(true), + BOUNDED_PLATFORM_THREAD(true); + + private final boolean blockingAllowed; + + GraphQlMvcExecutorPolicy(boolean blockingAllowed) { + this.blockingAllowed = blockingAllowed; + } + + public boolean blockingAllowed() { + return blockingAllowed; + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-http:test --tests 'io.backend.skeleton.graphql.http.mvc.GraphQlMvcTransportAdapterTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/mvc/GraphQlMvcTransportAdapter.java' 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/mvc/GraphQlMvcExecutorPolicy.java' 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/mvc/GraphQlMvcAutoConfiguration.java' 'modules/graphql/graphql-http/src/test/java/io/backend/skeleton/graphql/http/mvc/GraphQlMvcTransportAdapterTest.java' +git commit -m "feat: add graphql mvc transport adapter" +``` + +### Task 19: WebFlux Transport Adapter와 Event-loop Guard + +**Files:** +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/webflux/GraphQlWebFluxTransportAdapter.java` +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/webflux/GraphQlEventLoopGuard.java` +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/webflux/GraphQlWebFluxAutoConfiguration.java` +- Test: `modules/graphql/graphql-http/src/test/java/io/backend/skeleton/graphql/http/webflux/GraphQlEventLoopGuardTest.java` + +**Interfaces:** +- Consumes: Request envelope validation, execution service and Reactor. +- Produces: Reactive transport with cancellation and explicit blocking detection. + +**Implementation requirements:** +- Reject blocking resolver execution on an event-loop thread unless an approved scheduler bridge is registered. +- Propagate Reactor Context to `GraphQlRequestContext`. +- Release response buffers on cancellation. +- Cancellation reaches reactive DataFetchers and downstream publishers. +- Do not call `.block()` in WebFlux infrastructure. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlEventLoopGuardTest { + @org.junit.jupiter.api.Test + void blockingResolverIsRejectedOnEventLoop() { + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> GraphQlEventLoopGuard.verify( + ResolverExecutionType.BLOCKING, true, false)) + .isInstanceOf(GraphQlExecutionProfileException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-http:test --tests 'io.backend.skeleton.graphql.http.webflux.GraphQlEventLoopGuardTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlEventLoopGuard { + public static void verify( + ResolverExecutionType type, + boolean eventLoopThread, + boolean approvedBridge) { + + if (eventLoopThread + && type == ResolverExecutionType.BLOCKING + && !approvedBridge) { + throw new GraphQlExecutionProfileException( + "blocking resolver on event loop"); + } + } + + private GraphQlEventLoopGuard() {} +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-http:test --tests 'io.backend.skeleton.graphql.http.webflux.GraphQlEventLoopGuardTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/webflux/GraphQlWebFluxTransportAdapter.java' 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/webflux/GraphQlEventLoopGuard.java' 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/webflux/GraphQlWebFluxAutoConfiguration.java' 'modules/graphql/graphql-http/src/test/java/io/backend/skeleton/graphql/http/webflux/GraphQlEventLoopGuardTest.java' +git commit -m "feat: add graphql webflux event-loop guard" +``` + +### Task 20: Execution Interceptor 순서와 Policy Pipeline + +**Files:** +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlExecutionPipeline.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlExecutionStage.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlExecutionPipelineValidator.java` +- Test: `modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/execution/GraphQlExecutionPipelineTest.java` + +**Interfaces:** +- Consumes: Request context, HTTP request envelope and Spring WebGraphQlInterceptor. +- Produces: Immutable ordered pipeline from context through policy, cost and execution. + +**Implementation requirements:** +- Context is established before authorization. +- Persisted lookup precedes parse when an operation ID is supplied. +- Cost and authorization run before resolver execution. +- Custom interceptors cannot bypass required stages. +- Pipeline diagnostics expose stage names only. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlExecutionPipelineTest { + @org.junit.jupiter.api.Test + void costRunsBeforeExecution() { + GraphQlExecutionPipeline pipeline = GraphQlExecutionPipeline.stable(); + org.assertj.core.api.Assertions.assertThat( + pipeline.indexOf(GraphQlExecutionStage.COST)) + .isLessThan(pipeline.indexOf(GraphQlExecutionStage.EXECUTE)); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.execution.GraphQlExecutionPipelineTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlExecutionPipeline( + java.util.List stages) { + + public GraphQlExecutionPipeline { + stages = java.util.List.copyOf(stages); + } + + public int indexOf(GraphQlExecutionStage stage) { + return stages.indexOf(stage); + } + + public static GraphQlExecutionPipeline stable() { + return new GraphQlExecutionPipeline(java.util.List.of( + GraphQlExecutionStage.CONTEXT, + GraphQlExecutionStage.AUTHORIZATION, + GraphQlExecutionStage.PARSE_VALIDATE, + GraphQlExecutionStage.COST, + GraphQlExecutionStage.EXECUTE)); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.execution.GraphQlExecutionPipelineTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlExecutionPipeline.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlExecutionStage.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlExecutionPipelineValidator.java' 'modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/execution/GraphQlExecutionPipelineTest.java' +git commit -m "feat: add graphql execution policy pipeline" +``` + +### Task 21: Execution Profile과 Resolver Catalog 검증 + +**Files:** +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlExecutionProfile.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlResolverCatalog.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlResolverDescriptor.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlExecutionProfileValidator.java` +- Test: `modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/execution/GraphQlExecutionProfileValidatorTest.java` + +**Interfaces:** +- Consumes: Operation policy and runtime transport profile. +- Produces: Blocking, reactive and controlled mixed profile compatibility checks. + +**Implementation requirements:** +- `REACTIVE_WEBFLUX` rejects unbridged blocking resolvers. +- `BLOCKING_MVC` accepts reactive return only through an explicit adapter. +- `STREAM` requires a subscription and a Publisher return type. +- Unknown resolver descriptors fail startup. +- Resolver catalog entries use bounded schema coordinates. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlExecutionProfileValidatorTest { + @org.junit.jupiter.api.Test + void reactiveProfileRejectsBlockingDescriptor() { + var descriptor = new GraphQlResolverDescriptor( + "Order.total", ResolverExecutionType.BLOCKING, false); + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> GraphQlExecutionProfileValidator.validate( + GraphQlExecutionProfile.REACTIVE_WEBFLUX, descriptor)) + .isInstanceOf(GraphQlExecutionProfileException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.execution.GraphQlExecutionProfileValidatorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GraphQlExecutionProfile { + BLOCKING_MVC, + REACTIVE_WEBFLUX, + MIXED_CONTROLLED +} + +public final class GraphQlExecutionProfileValidator { + public static void validate( + GraphQlExecutionProfile profile, + GraphQlResolverDescriptor descriptor) { + + if (profile == GraphQlExecutionProfile.REACTIVE_WEBFLUX + && descriptor.executionType() == ResolverExecutionType.BLOCKING + && !descriptor.approvedBridge()) { + throw new GraphQlExecutionProfileException( + "blocking resolver requires bridge"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.execution.GraphQlExecutionProfileValidatorTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlExecutionProfile.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlResolverCatalog.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlResolverDescriptor.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlExecutionProfileValidator.java' 'modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/execution/GraphQlExecutionProfileValidatorTest.java' +git commit -m "feat: add graphql execution profile validation" +``` + +### Task 22: Request Timeout·Resolver Budget·Cancellation + +**Files:** +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlTimeoutPolicy.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlCancellation.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlResolverBudget.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlDeadlinePropagator.java` +- Test: `modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/execution/GraphQlTimeoutPolicyTest.java` + +**Interfaces:** +- Consumes: Request deadline, execution pipeline and Spring timeout interceptor. +- Produces: Layered request, resolver, DataLoader and shutdown deadlines with cancellation. + +**Implementation requirements:** +- No child deadline exceeds the parent remaining time. +- Execution timeout maps to a stable error code. +- Reactive timeout cancels downstream publishers. +- Blocking work uses actual transport/database timeouts and does not assume interrupt alone is sufficient. +- Normal request timeout does not govern subscription lifetime. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlTimeoutPolicyTest { + @org.junit.jupiter.api.Test + void childBudgetCannotExceedParent() { + GraphQlTimeoutPolicy policy = + new GraphQlTimeoutPolicy(java.time.Duration.ofSeconds(2)); + org.assertj.core.api.Assertions.assertThat( + policy.child(java.time.Duration.ofSeconds(5))) + .isEqualTo(java.time.Duration.ofSeconds(2)); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.execution.GraphQlTimeoutPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlTimeoutPolicy(java.time.Duration remaining) { + public GraphQlTimeoutPolicy { + if (remaining.isZero() || remaining.isNegative()) { + throw new IllegalArgumentException("remaining time must be positive"); + } + } + + public java.time.Duration child(java.time.Duration requested) { + return requested.compareTo(remaining) < 0 ? requested : remaining; + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.execution.GraphQlTimeoutPolicyTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlTimeoutPolicy.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlCancellation.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlResolverBudget.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlDeadlinePropagator.java' 'modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/execution/GraphQlTimeoutPolicyTest.java' +git commit -m "feat: add graphql timeout and cancellation policy" +``` + +### Task 23: Resolver Return Type와 Transport DTO Guard + +**Files:** +- Create: `modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/architecture/GraphQlReturnTypePolicy.java` +- Create: `modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/architecture/GraphQlInputTypePolicy.java` +- Create: `modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/architecture/GraphQlControllerInspector.java` +- Test: `modules/graphql/graphql-controller/src/test/java/io/backend/skeleton/graphql/architecture/GraphQlControllerInspectorTest.java` + +**Interfaces:** +- Consumes: Resolver catalog and reflection/ArchUnit. +- Produces: Startup inspection for DTO, read model, connection, mutation payload and publisher types. + +**Implementation requirements:** +- Block JPA entity, Mongo document, provider SDK and unrestricted map return types. +- Block direct binding of GraphQL input to persistence types. +- Report schema coordinate and Java method. +- Publisher return type is allowed only for subscriptions. +- Generated transport DTOs remain separate from domain types. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlControllerInspectorTest { + @org.junit.jupiter.api.Test + void mapReturnTypeIsRejected() throws Exception { + java.lang.reflect.Method method = + BadController.class.getDeclaredMethod("query"); + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> GraphQlControllerInspector.inspect(method)) + .isInstanceOf(GraphQlControllerContractException.class); + } + + static class BadController { + java.util.Map query() { + return java.util.Map.of(); + } + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-controller:test --tests 'io.backend.skeleton.graphql.architecture.GraphQlControllerInspectorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlReturnTypePolicy { + public static boolean allowed(Class type) { + return !java.util.Map.class.isAssignableFrom(type) + && !type.isAnnotationPresent(jakarta.persistence.Entity.class); + } + + private GraphQlReturnTypePolicy() {} +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-controller:test --tests 'io.backend.skeleton.graphql.architecture.GraphQlControllerInspectorTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/architecture/GraphQlReturnTypePolicy.java' 'modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/architecture/GraphQlInputTypePolicy.java' 'modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/architecture/GraphQlControllerInspector.java' 'modules/graphql/graphql-controller/src/test/java/io/backend/skeleton/graphql/architecture/GraphQlControllerInspectorTest.java' +git commit -m "test: enforce graphql transport DTO contract" +``` + +### Task 24: GraphQL Error Wire Model + +**Files:** +- Create: `modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlErrorCategory.java` +- Create: `modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlWireError.java` +- Create: `modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlErrorContext.java` +- Create: `modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlErrorCode.java` +- Test: `modules/graphql/graphql-error/src/test/java/io/backend/skeleton/graphql/error/GraphQlWireErrorTest.java` + +**Interfaces:** +- Consumes: Core identifiers and GraphQL error model. +- Produces: Stable allowlisted error extensions without internal diagnostics. + +**Implementation requirements:** +- Expose only code, category, retryable, executionId, safe constraint and logical field. +- Client message is independent of exception text. +- Path and location remain GraphQL top-level error fields. +- Error codes use a bounded catalog. +- Extension maps are immutable. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlWireErrorTest { + @org.junit.jupiter.api.Test + void internalErrorHasOnlyAllowedExtensions() { + GraphQlWireError error = GraphQlWireError.internal("exec-1"); + org.assertj.core.api.Assertions.assertThat(error.extensions()) + .containsOnlyKeys( + "code", "category", "retryable", "executionId"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-error:test --tests 'io.backend.skeleton.graphql.error.GraphQlWireErrorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlWireError( + String message, + java.util.Map extensions) { + + public GraphQlWireError { + extensions = java.util.Map.copyOf(extensions); + } + + public static GraphQlWireError internal(String executionId) { + return new GraphQlWireError( + "요청을 처리할 수 없습니다.", + java.util.Map.of( + "code", "INTERNAL_ERROR", + "category", "INTERNAL", + "retryable", false, + "executionId", executionId)); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-error:test --tests 'io.backend.skeleton.graphql.error.GraphQlWireErrorTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlErrorCategory.java' 'modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlWireError.java' 'modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlErrorContext.java' 'modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlErrorCode.java' 'modules/graphql/graphql-error/src/test/java/io/backend/skeleton/graphql/error/GraphQlWireErrorTest.java' +git commit -m "feat: add graphql error wire contract" +``` + +### Task 25: Exception Resolver와 Partial Data Contract + +**Files:** +- Create: `modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlExceptionResolver.java` +- Create: `modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlRequestErrorMapper.java` +- Create: `modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlSubscriptionExceptionResolver.java` +- Create: `modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlInternalErrorMasker.java` +- Test: `modules/graphql/graphql-error/src/test/java/io/backend/skeleton/graphql/error/GraphQlExceptionResolverTest.java` + +**Interfaces:** +- Consumes: Wire error model and Spring exception resolution APIs. +- Produces: Request, field, business and internal failure mapping with partial data preservation. + +**Implementation requirements:** +- Expected business outcomes remain typed data when configured. +- Unresolved execution failures become opaque `INTERNAL_ERROR`. +- Parse and validation errors use a request mapper rather than a DataFetcher resolver. +- Subscription post-start failures use a dedicated resolver. +- SQL, queries, URLs, provider body and stack trace never reach the client. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlExceptionResolverTest { + @org.junit.jupiter.api.Test + void internalExceptionIsMasked() { + GraphQlWireError error = GraphQlExceptionResolver.defaults() + .resolve( + new RuntimeException("select secret from users"), + GraphQlErrorContext.test()); + org.assertj.core.api.Assertions.assertThat(error.message()) + .doesNotContain("select", "users"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-error:test --tests 'io.backend.skeleton.graphql.error.GraphQlExceptionResolverTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlExceptionResolver { + public GraphQlWireError resolve( + Throwable failure, + GraphQlErrorContext context) { + return GraphQlWireError.internal(context.executionId()); + } + + public static GraphQlExceptionResolver defaults() { + return new GraphQlExceptionResolver(); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-error:test --tests 'io.backend.skeleton.graphql.error.GraphQlExceptionResolverTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlExceptionResolver.java' 'modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlRequestErrorMapper.java' 'modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlSubscriptionExceptionResolver.java' 'modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlInternalErrorMasker.java' 'modules/graphql/graphql-error/src/test/java/io/backend/skeleton/graphql/error/GraphQlExceptionResolverTest.java' +git commit -m "feat: add graphql exception resolvers" +``` + +### Task 26: Null Propagation Golden Contract + +**Files:** +- Create: `modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlNullabilityContract.java` +- Create: `modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlPartialResponseFixture.java` +- Create: `modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlFailureBoundary.java` +- Test: `modules/graphql/graphql-error/src/test/java/io/backend/skeleton/graphql/error/GraphQlNullPropagationContractTest.java` + +**Interfaces:** +- Consumes: Schema contract and execution testkit. +- Produces: Golden contracts for nullable and non-null failure propagation. + +**Implementation requirements:** +- Snapshot expected partial data and error paths. +- Adding non-null requires an explicit contract fixture. +- Authorization redaction cannot silently violate a non-null field. +- External enrichment fields default to nullable. +- List nullability and element nullability are tested independently. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlNullPropagationContractTest { + @org.junit.jupiter.api.Test + void nullableChildPreservesParent() { + GraphQlPartialResponseFixture response = + GraphQlPartialResponseFixture.nullableChildFailure(); + org.assertj.core.api.Assertions.assertThat( + response.dataPath("order.id")).isEqualTo("o-1"); + org.assertj.core.api.Assertions.assertThat( + response.dataPath("order.payment")).isNull(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-error:test --tests 'io.backend.skeleton.graphql.error.GraphQlNullPropagationContractTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlNullabilityContract( + String coordinate, + boolean nonNull, + GraphQlFailureBoundary boundary) { +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-error:test --tests 'io.backend.skeleton.graphql.error.GraphQlNullPropagationContractTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlNullabilityContract.java' 'modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlPartialResponseFixture.java' 'modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlFailureBoundary.java' 'modules/graphql/graphql-error/src/test/java/io/backend/skeleton/graphql/error/GraphQlNullPropagationContractTest.java' +git commit -m "test: add graphql null propagation contract" +``` + +### Task 27: Authentication Context Factory + +**Files:** +- Create: `modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlAuthenticationContextFactory.java` +- Create: `modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlAuthenticatedPrincipal.java` +- Create: `modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlClientProfileResolver.java` +- Test: `modules/graphql/graphql-security/src/test/java/io/backend/skeleton/graphql/security/GraphQlAuthenticationContextFactoryTest.java` + +**Interfaces:** +- Consumes: Spring Security authentication and core request context. +- Produces: Trusted conversion from HTTP or session principal to immutable request context. + +**Implementation requirements:** +- Reject unauthenticated requests for protected profiles. +- Resolve client profile from trusted credential metadata, not variables. +- Do not copy access tokens, cookies or raw claims into context. +- Locale and tenant resolution are explicit policies. +- Authentication failures happen before GraphQL resolver execution. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlAuthenticationContextFactoryTest { + @org.junit.jupiter.api.Test + void principalTenantIsAuthoritative() { + GraphQlRequestContext context = + GraphQlAuthenticationContextFactory.testContext("tenant-a"); + org.assertj.core.api.Assertions.assertThat( + context.tenant().value()).isEqualTo("tenant-a"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-security:test --tests 'io.backend.skeleton.graphql.security.GraphQlAuthenticationContextFactoryTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlAuthenticationContextFactory { + public GraphQlRequestContext create( + GraphQlAuthenticatedPrincipal principal, + GraphQlDeadline deadline) { + + return new GraphQlRequestContext( + principal.actor(), + principal.tenant(), + principal.clientProfile(), + java.util.Locale.ROOT, + new GraphQlOperationId("pending"), + principal.traceId(), + deadline); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-security:test --tests 'io.backend.skeleton.graphql.security.GraphQlAuthenticationContextFactoryTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlAuthenticationContextFactory.java' 'modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlAuthenticatedPrincipal.java' 'modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlClientProfileResolver.java' 'modules/graphql/graphql-security/src/test/java/io/backend/skeleton/graphql/security/GraphQlAuthenticationContextFactoryTest.java' +git commit -m "feat: add graphql authentication context" +``` + +### Task 28: Operation·Field·Object Authorization Boundary + +**Files:** +- Create: `modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlAuthorizationPolicy.java` +- Create: `modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlAuthorizationDecision.java` +- Create: `modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlAuthorizationInterceptor.java` +- Create: `modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlObjectAuthorizationPort.java` +- Test: `modules/graphql/graphql-security/src/test/java/io/backend/skeleton/graphql/security/GraphQlAuthorizationPolicyTest.java` + +**Interfaces:** +- Consumes: Request context, schema coordinate and Application authorization ports. +- Produces: Layered operation, field/use-case and object authorization decisions. + +**Implementation requirements:** +- Field visibility never counts as authorization. +- Object authorization uses an Application port, not a repository from platform code. +- Denied decisions use stable error codes. +- Existence-hiding policy is configurable per coordinate. +- Batch loader authorization is defined for every key. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlAuthorizationPolicyTest { + @org.junit.jupiter.api.Test + void hiddenFieldStillRequiresAuthorization() { + GraphQlAuthorizationDecision decision = + GraphQlAuthorizationPolicy.deny("ORDER_READ_DENIED"); + org.assertj.core.api.Assertions.assertThat( + decision.allowed()).isFalse(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-security:test --tests 'io.backend.skeleton.graphql.security.GraphQlAuthorizationPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlAuthorizationDecision( + boolean allowed, + String code) { + + public static GraphQlAuthorizationDecision deny(String code) { + return new GraphQlAuthorizationDecision(false, code); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-security:test --tests 'io.backend.skeleton.graphql.security.GraphQlAuthorizationPolicyTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlAuthorizationPolicy.java' 'modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlAuthorizationDecision.java' 'modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlAuthorizationInterceptor.java' 'modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlObjectAuthorizationPort.java' 'modules/graphql/graphql-security/src/test/java/io/backend/skeleton/graphql/security/GraphQlAuthorizationPolicyTest.java' +git commit -m "feat: add graphql authorization policy" +``` + +### Task 29: Tenant Isolation과 Context Propagation + +**Files:** +- Create: `modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlTenantIsolationPolicy.java` +- Create: `modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlContextPropagator.java` +- Create: `modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlBatchContext.java` +- Create: `modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlContextCleanup.java` +- Test: `modules/graphql/graphql-security/src/test/java/io/backend/skeleton/graphql/security/GraphQlTenantIsolationPolicyTest.java` + +**Interfaces:** +- Consumes: Trusted request context, executor bridge and Reactor Context. +- Produces: Fail-closed tenant propagation across resolver, DataLoader, async and reactive work. + +**Implementation requirements:** +- Missing tenant context fails protected operations. +- Tenant cannot be sourced from a GraphQL argument. +- DataLoader keys are not cached across tenant boundaries. +- Thread and Reactor context are cleared after execution. +- Context diagnostics never contain raw tenant identifiers in metrics. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlTenantIsolationPolicyTest { + @org.junit.jupiter.api.Test + void missingTenantFailsClosed() { + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> GraphQlTenantIsolationPolicy.require(null)) + .isInstanceOf(GraphQlTenantIsolationException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-security:test --tests 'io.backend.skeleton.graphql.security.GraphQlTenantIsolationPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlTenantIsolationPolicy { + public static TenantContext require(TenantContext tenant) { + if (tenant == null) { + throw new GraphQlTenantIsolationException( + "tenant context required"); + } + return tenant; + } + + private GraphQlTenantIsolationPolicy() {} +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-security:test --tests 'io.backend.skeleton.graphql.security.GraphQlTenantIsolationPolicyTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlTenantIsolationPolicy.java' 'modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlContextPropagator.java' 'modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlBatchContext.java' 'modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlContextCleanup.java' 'modules/graphql/graphql-security/src/test/java/io/backend/skeleton/graphql/security/GraphQlTenantIsolationPolicyTest.java' +git commit -m "feat: add graphql tenant isolation" +``` + +### Task 30: Parser Character·Token·Grammar 제한 + +**Files:** +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlParserLimits.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlParserLimitPolicy.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlParserRejectedException.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlParserOptionsFactory.java` +- Test: `modules/graphql/graphql-cost-control/src/test/java/io/backend/skeleton/graphql/cost/GraphQlParserLimitPolicyTest.java` + +**Interfaces:** +- Consumes: `GraphQlClientPolicy`에서 선택된 문서 크기·token·grammar 제한과 GraphQL Java parser options. +- Produces: 실행 전에 문서 문자 수, token 수, whitespace token 수, grammar depth를 거부하는 parser gate. + +**Implementation requirements:** +- Library ceiling을 public API의 business limit로 그대로 사용하지 않고 client profile 값으로 제한한다. +- 문서가 parser에 전달되기 전에 byte·character 제한을 검사한다. +- Token·whitespace·grammar depth 제한은 GraphQL Java parser options에 정확히 매핑한다. +- 거부 결과는 resolver를 실행하지 않고 안정적인 `GRAPHQL_DOCUMENT_LIMIT_EXCEEDED` request error로 변환한다. +- Raw document를 로그와 metric에 기록하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlParserLimitPolicyTest { + @org.junit.jupiter.api.Test + void rejectsDocumentBeforeParserWhenCharacterBudgetIsExceeded() { + var limits = new GraphQlParserLimits(32, 20, 40, 8); + var policy = new GraphQlParserLimitPolicy(limits); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> policy.verifyDocument("query TooLong { " + "x".repeat(64) + " }")) + .isInstanceOf(GraphQlParserRejectedException.class) + .hasMessageContaining("CHARACTERS"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-cost-control:test --tests 'io.backend.skeleton.graphql.cost.GraphQlParserLimitPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlParserLimits( + int maxCharacters, + int maxTokens, + int maxWhitespaceTokens, + int maxGrammarRuleDepth) { + + public GraphQlParserLimits { + if (maxCharacters < 1 || maxTokens < 1 + || maxWhitespaceTokens < 1 || maxGrammarRuleDepth < 1) { + throw new IllegalArgumentException( + "all parser limits must be positive"); + } + } +} + +public final class GraphQlParserLimitPolicy { + private final GraphQlParserLimits limits; + + public GraphQlParserLimitPolicy(GraphQlParserLimits limits) { + this.limits = java.util.Objects.requireNonNull(limits); + } + + public void verifyDocument(String document) { + if (document == null || document.length() > limits.maxCharacters()) { + throw GraphQlParserRejectedException.characters( + document == null ? 0 : document.length(), + limits.maxCharacters()); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-cost-control:test --tests 'io.backend.skeleton.graphql.cost.GraphQlParserLimitPolicyTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlParserLimits.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlParserLimitPolicy.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlParserRejectedException.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlParserOptionsFactory.java' 'modules/graphql/graphql-cost-control/src/test/java/io/backend/skeleton/graphql/cost/GraphQlParserLimitPolicyTest.java' +git commit -m "feat: add graphql parser limit policy" +``` + +### Task 31: Selection Depth·Field·Alias·Fragment 구조 제한 + +**Files:** +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlStructuralLimits.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlDocumentShape.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlDocumentShapeAnalyzer.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlStructuralLimitPolicy.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlStructuralLimitViolation.java` +- Test: `modules/graphql/graphql-cost-control/src/test/java/io/backend/skeleton/graphql/cost/GraphQlStructuralLimitPolicyTest.java` + +**Interfaces:** +- Consumes: Parser를 통과한 GraphQL `Document`, 선택된 `GraphQlClientPolicy`와 fragment graph. +- Produces: Depth, field, alias, fragment, spread, operation count 및 input nesting의 bounded 구조 검사. + +**Implementation requirements:** +- Fragment cycle은 GraphQL validation과 별도로 analyzer recursion을 무한 반복시키지 않는다. +- Alias 수와 field 수를 별도로 계산해 alias bomb를 탐지한다. +- Introspection field는 client profile 허용 여부에 따라 구조 검사 단계에서 거부한다. +- 한 문서에 여러 operation이 있으면 `operationName` 선택 전 전체 문서 비용을 우회하지 못하도록 operation count를 검증한다. +- 구조 계산은 document 크기에 대해 선형 또는 bounded하게 동작한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlStructuralLimitPolicyTest { + @org.junit.jupiter.api.Test + void rejectsAliasBombEvenWhenDepthIsSmall() { + var shape = new GraphQlDocumentShape( + 2, 40, 35, 0, 0, 1, 1); + var limits = new GraphQlStructuralLimits( + 8, 100, 10, 20, 40, 2, 8); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> new GraphQlStructuralLimitPolicy(limits).verify(shape)) + .isInstanceOf(GraphQlStructuralLimitViolation.class) + .hasMessageContaining("ALIASES"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-cost-control:test --tests 'io.backend.skeleton.graphql.cost.GraphQlStructuralLimitPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlDocumentShape( + int depth, + int fieldCount, + int aliasCount, + int fragmentCount, + int fragmentSpreadCount, + int operationCount, + int inputNestingDepth) { +} + +public record GraphQlStructuralLimits( + int maxDepth, + int maxFields, + int maxAliases, + int maxFragments, + int maxFragmentSpreads, + int maxOperations, + int maxInputNestingDepth) { +} + +public final class GraphQlStructuralLimitPolicy { + private final GraphQlStructuralLimits limits; + + public GraphQlStructuralLimitPolicy(GraphQlStructuralLimits limits) { + this.limits = java.util.Objects.requireNonNull(limits); + } + + public void verify(GraphQlDocumentShape shape) { + if (shape.aliasCount() > limits.maxAliases()) { + throw GraphQlStructuralLimitViolation.of( + "ALIASES", shape.aliasCount(), limits.maxAliases()); + } + if (shape.depth() > limits.maxDepth() + || shape.fieldCount() > limits.maxFields() + || shape.operationCount() > limits.maxOperations()) { + throw GraphQlStructuralLimitViolation.of( + "DOCUMENT_SHAPE", shape.fieldCount(), limits.maxFields()); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-cost-control:test --tests 'io.backend.skeleton.graphql.cost.GraphQlStructuralLimitPolicyTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlStructuralLimits.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlDocumentShape.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlDocumentShapeAnalyzer.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlStructuralLimitPolicy.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlStructuralLimitViolation.java' 'modules/graphql/graphql-cost-control/src/test/java/io/backend/skeleton/graphql/cost/GraphQlStructuralLimitPolicyTest.java' +git commit -m "feat: add graphql structural limits" +``` + +### Task 32: Cardinality-aware Query Complexity 정책 + +**Files:** +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlResolverWeight.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlFieldCostDescriptor.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlCostCatalog.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlComplexityCalculator.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlComplexityResult.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlComplexityRejectedException.java` +- Test: `modules/graphql/graphql-cost-control/src/test/java/io/backend/skeleton/graphql/cost/GraphQlComplexityCalculatorTest.java` + +**Interfaces:** +- Consumes: 검증된 operation, resolver catalog, connection argument와 client profile의 default·maximum page size. +- Produces: List cardinality와 resolver 유형을 반영한 deterministic complexity score 및 거부 결과. + +**Implementation requirements:** +- Connection에서 `first`·`last`가 없으면 1이 아니라 profile의 default page size를 비용에 사용한다. +- 요청 page size가 maximum을 넘으면 complexity 계산 전에 거부한다. +- JPA indexed lookup, Mongo aggregation, downstream HTTP 등 bounded catalog 기반 resolver weight를 사용한다. +- 알 수 없는 schema coordinate는 비용 0이 아니라 보수적인 default weight를 사용한다. +- 동일 operation과 variables에 대해 계산 결과가 항상 동일해야 한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlComplexityCalculatorTest { + @org.junit.jupiter.api.Test + void multipliesConnectionChildrenByEffectivePageSize() { + var catalog = GraphQlCostCatalog.of( + new GraphQlFieldCostDescriptor( + "Query.orders", 2, + GraphQlResolverWeight.BATCHED_RELATION, + true)); + var calculator = new GraphQlComplexityCalculator( + catalog, 20, 100); + + var result = calculator.connectionCost( + "Query.orders", null, null, 5); + + org.assertj.core.api.Assertions.assertThat(result.total()) + .isEqualTo(2L + (20L * 5L)); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-cost-control:test --tests 'io.backend.skeleton.graphql.cost.GraphQlComplexityCalculatorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GraphQlResolverWeight { + PROPERTY(1), + INDEXED_LOOKUP(2), + BATCHED_RELATION(3), + BOUNDED_AGGREGATION(8), + EXTERNAL_BATCH(10), + EXTERNAL_PER_OBJECT(20); + + private final int weight; + + GraphQlResolverWeight(int weight) { + this.weight = weight; + } + + public int value() { + return weight; + } +} + +public record GraphQlComplexityResult(long total) {} + +public final class GraphQlComplexityCalculator { + private final GraphQlCostCatalog catalog; + private final int defaultPageSize; + private final int maximumPageSize; + + public GraphQlComplexityCalculator( + GraphQlCostCatalog catalog, + int defaultPageSize, + int maximumPageSize) { + this.catalog = catalog; + this.defaultPageSize = defaultPageSize; + this.maximumPageSize = maximumPageSize; + } + + public GraphQlComplexityResult connectionCost( + String coordinate, + Integer first, + Integer last, + long childCost) { + int requested = first != null ? first + : last != null ? last : defaultPageSize; + if (requested > maximumPageSize) { + throw new GraphQlComplexityRejectedException( + "page size exceeds maximum"); + } + long root = catalog.require(coordinate).baseCost(); + return new GraphQlComplexityResult( + Math.addExact(root, + Math.multiplyExact((long) requested, childCost))); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-cost-control:test --tests 'io.backend.skeleton.graphql.cost.GraphQlComplexityCalculatorTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlResolverWeight.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlFieldCostDescriptor.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlCostCatalog.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlComplexityCalculator.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlComplexityResult.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlComplexityRejectedException.java' 'modules/graphql/graphql-cost-control/src/test/java/io/backend/skeleton/graphql/cost/GraphQlComplexityCalculatorTest.java' +git commit -m "feat: add graphql complexity calculator" +``` + +### Task 33: Runtime Response Node·Byte Budget + +**Files:** +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlRuntimeBudget.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlRuntimeBudgetTracker.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlResponseNodeCounter.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlResponseByteLimiter.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlRuntimeBudgetExceededException.java` +- Test: `modules/graphql/graphql-cost-control/src/test/java/io/backend/skeleton/graphql/cost/GraphQlRuntimeBudgetTrackerTest.java` + +**Interfaces:** +- Consumes: 실행 전 complexity 결과, execution context, response serialization pipeline. +- Produces: 실행 중 response node 수와 직렬화 byte 수를 제한하고 cancellation을 전파하는 runtime budget. + +**Implementation requirements:** +- 예상 비용을 통과했어도 실제 result cardinality가 커지면 runtime budget이 실행을 중단한다. +- Node 수와 wire byte 수를 각각 제한한다. +- 제한 초과 후 추가 resolver·publisher 작업에 cancellation을 전달한다. +- 이미 HTTP body가 commit된 뒤의 초과는 connection 종료와 관측 가능한 `PARTIAL_RESPONSE`로 분류한다. +- Error response에 실제 data나 변수 값을 포함하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlRuntimeBudgetTrackerTest { + @org.junit.jupiter.api.Test + void rejectsTheNodeThatCrossesTheBudget() { + var tracker = new GraphQlRuntimeBudgetTracker( + new GraphQlRuntimeBudget(2, 1024)); + tracker.recordNode(); + tracker.recordNode(); + + org.assertj.core.api.Assertions.assertThatThrownBy( + tracker::recordNode) + .isInstanceOf(GraphQlRuntimeBudgetExceededException.class) + .hasMessageContaining("nodes"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-cost-control:test --tests 'io.backend.skeleton.graphql.cost.GraphQlRuntimeBudgetTrackerTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlRuntimeBudget( + long maxResponseNodes, + long maxResponseBytes) { + public GraphQlRuntimeBudget { + if (maxResponseNodes < 1 || maxResponseBytes < 1) { + throw new IllegalArgumentException( + "runtime budgets must be positive"); + } + } +} + +public final class GraphQlRuntimeBudgetTracker { + private final GraphQlRuntimeBudget budget; + private final java.util.concurrent.atomic.AtomicLong nodes = + new java.util.concurrent.atomic.AtomicLong(); + + public GraphQlRuntimeBudgetTracker(GraphQlRuntimeBudget budget) { + this.budget = java.util.Objects.requireNonNull(budget); + } + + public void recordNode() { + long current = nodes.incrementAndGet(); + if (current > budget.maxResponseNodes()) { + throw new GraphQlRuntimeBudgetExceededException( + "response nodes exceeded"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-cost-control:test --tests 'io.backend.skeleton.graphql.cost.GraphQlRuntimeBudgetTrackerTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlRuntimeBudget.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlRuntimeBudgetTracker.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlResponseNodeCounter.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlResponseByteLimiter.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlRuntimeBudgetExceededException.java' 'modules/graphql/graphql-cost-control/src/test/java/io/backend/skeleton/graphql/cost/GraphQlRuntimeBudgetTrackerTest.java' +git commit -m "feat: add graphql runtime response budget" +``` + +### Task 34: Production Operation Name 정책 + +**Files:** +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlOperationNamePolicy.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlOperationSelection.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlAnonymousOperationException.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlOperationNameInterceptor.java` +- Test: `modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/execution/GraphQlOperationNamePolicyTest.java` + +**Interfaces:** +- Consumes: 선택된 operation definition, `GraphQlClientPolicy`, environment profile. +- Produces: Production에서 client profile별 named operation 요구와 low-cardinality operation identity. + +**Implementation requirements:** +- Local에서는 단일 anonymous operation을 허용할 수 있지만 Production FIRST_PARTY·PARTNER에는 이름을 요구한다. +- 여러 operation이 있는 document에서 `operationName`이 없으면 항상 request error다. +- Operation name은 bounded catalog와 naming pattern을 검증한다. +- Metric에는 raw query 대신 검증된 operation name만 사용한다. +- Persisted operation은 registry의 operation name과 요청의 name이 일치해야 한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlOperationNamePolicyTest { + @org.junit.jupiter.api.Test + void productionFirstPartyRejectsAnonymousOperation() { + var policy = GraphQlOperationNamePolicy.production(); + var client = GraphQlClientProfileName.of("FIRST_PARTY"); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> policy.verify( + client, new GraphQlOperationSelection(null, 1, false))) + .isInstanceOf(GraphQlAnonymousOperationException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.execution.GraphQlOperationNamePolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlOperationSelection( + String operationName, + int operationsInDocument, + boolean persisted) { +} + +public final class GraphQlOperationNamePolicy { + private final boolean production; + + private GraphQlOperationNamePolicy(boolean production) { + this.production = production; + } + + public static GraphQlOperationNamePolicy production() { + return new GraphQlOperationNamePolicy(true); + } + + public void verify( + GraphQlClientProfileName client, + GraphQlOperationSelection selection) { + boolean namedRequired = production + && !"ADMIN".equals(client.value()); + if (selection.operationsInDocument() > 1 + && selection.operationName() == null) { + throw new GraphQlAnonymousOperationException( + "operationName required for multi-operation document"); + } + if (namedRequired && selection.operationName() == null) { + throw new GraphQlAnonymousOperationException( + "named operation required"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.execution.GraphQlOperationNamePolicyTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlOperationNamePolicy.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlOperationSelection.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlAnonymousOperationException.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlOperationNameInterceptor.java' 'modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/execution/GraphQlOperationNamePolicyTest.java' +git commit -m "feat: enforce graphql operation names" +``` + +### Task 35: Bounded Preparsed Document Cache + +**Files:** +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlPreparsedCacheKey.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlPreparsedCachePolicy.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/BoundedPreparsedDocumentProvider.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlPreparsedCacheMetrics.java` +- Test: `modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/execution/BoundedPreparsedDocumentProviderTest.java` + +**Interfaces:** +- Consumes: Document hash, schema contract hash, validation policy version, client schema profile. +- Produces: Parse·validation 결과만 재사용하는 bounded `PreparsedDocumentProvider`. + +**Implementation requirements:** +- 실행 결과를 cache하지 않는다. +- Cache key에 document hash, schema hash, validation policy version, client schema profile을 모두 포함한다. +- Raw query text는 metric label에 사용하지 않는다. +- Maximum entries, maximum weight와 expiry를 설정하며 unbounded map을 사용하지 않는다. +- Schema 또는 validation policy가 바뀌면 이전 entry가 재사용되지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class BoundedPreparsedDocumentProviderTest { + @org.junit.jupiter.api.Test + void schemaHashSeparatesOtherwiseIdenticalDocuments() { + var a = new GraphQlPreparsedCacheKey( + "doc", "schema-a", "policy-1", "FIRST_PARTY"); + var b = new GraphQlPreparsedCacheKey( + "doc", "schema-b", "policy-1", "FIRST_PARTY"); + + org.assertj.core.api.Assertions.assertThat(a) + .isNotEqualTo(b); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.execution.BoundedPreparsedDocumentProviderTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlPreparsedCacheKey( + String documentHash, + String schemaContractHash, + String validationPolicyVersion, + String clientSchemaProfile) { + + public GraphQlPreparsedCacheKey { + java.util.Objects.requireNonNull(documentHash); + java.util.Objects.requireNonNull(schemaContractHash); + java.util.Objects.requireNonNull(validationPolicyVersion); + java.util.Objects.requireNonNull(clientSchemaProfile); + } +} + +public record GraphQlPreparsedCachePolicy( + long maximumEntries, + long maximumWeight, + java.time.Duration expireAfterAccess) { +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.execution.BoundedPreparsedDocumentProviderTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlPreparsedCacheKey.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlPreparsedCachePolicy.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/BoundedPreparsedDocumentProvider.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlPreparsedCacheMetrics.java' 'modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/execution/BoundedPreparsedDocumentProviderTest.java' +git commit -m "feat: add bounded graphql preparsed cache" +``` + +### Task 36: Request-scoped DataLoader Policy와 Registry + +**Files:** +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchPolicy.java` +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchPolicyRegistry.java` +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlDataLoaderFactory.java` +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlDataLoaderRequestRegistry.java` +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlDataLoaderName.java` +- Test: `modules/graphql/graphql-dataloader/src/test/java/io/backend/skeleton/graphql/dataloader/GraphQlDataLoaderRequestRegistryTest.java` + +**Interfaces:** +- Consumes: `GraphQlRequestContext`, Spring `BatchLoaderRegistry`, bounded loader catalog. +- Produces: Execution마다 새 DataLoader를 생성하고 loader별 batch size·timeout·cache 정책을 적용하는 registry. + +**Implementation requirements:** +- DataLoader instance와 cache는 GraphQL execution 범위를 넘지 않는다. +- Loader 이름은 bounded catalog에 등록돼야 한다. +- Actor·tenant가 다른 execution 사이에 key나 value가 공유되지 않는다. +- Loader별 maximum batch size와 timeout을 startup에서 검증한다. +- Cross-request cache는 이 모듈이 제공하지 않고 Redis/Application Cache에 위임한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlDataLoaderRequestRegistryTest { + @org.junit.jupiter.api.Test + void createsDifferentRegistryForEachExecution() { + var factory = GraphQlDataLoaderRequestRegistry::new; + + org.assertj.core.api.Assertions.assertThat(factory.get()) + .isNotSameAs(factory.get()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-dataloader:test --tests 'io.backend.skeleton.graphql.dataloader.GraphQlDataLoaderRequestRegistryTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlBatchPolicy( + GraphQlDataLoaderName loaderName, + int maxBatchSize, + java.time.Duration timeout, + boolean requestCacheEnabled) { + + public GraphQlBatchPolicy { + if (maxBatchSize < 1 || timeout.isZero() + || timeout.isNegative()) { + throw new IllegalArgumentException( + "invalid data loader policy"); + } + } +} + +public record GraphQlDataLoaderName(String value) { + public GraphQlDataLoaderName { + if (value == null || !value.matches("[a-z][a-z0-9.-]{2,63}")) { + throw new IllegalArgumentException("invalid loader name"); + } + } +} + +public final class GraphQlDataLoaderRequestRegistry { + private final java.util.Map loaders = + new java.util.HashMap<>(); + + public boolean isEmpty() { + return loaders.isEmpty(); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-dataloader:test --tests 'io.backend.skeleton.graphql.dataloader.GraphQlDataLoaderRequestRegistryTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchPolicy.java' 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchPolicyRegistry.java' 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlDataLoaderFactory.java' 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlDataLoaderRequestRegistry.java' 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlDataLoaderName.java' 'modules/graphql/graphql-dataloader/src/test/java/io/backend/skeleton/graphql/dataloader/GraphQlDataLoaderRequestRegistryTest.java' +git commit -m "feat: add request scoped graphql dataloaders" +``` + +### Task 37: Batch Chunking·Context·Deadline 전파 + +**Files:** +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchContext.java` +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchChunker.java` +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchExecutor.java` +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchTimeoutException.java` +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchObservation.java` +- Test: `modules/graphql/graphql-dataloader/src/test/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchChunkerTest.java` + +**Interfaces:** +- Consumes: Loader policy, actor·tenant·deadline request context, 저장소 또는 downstream batch function. +- Produces: JPA `IN`, Mongo `$in`, HTTP batch 상한에 맞춘 deterministic chunking과 context-safe batch execution. + +**Implementation requirements:** +- 입력 key 순서를 보존한다. +- Chunk 크기는 loader policy와 downstream hard limit 중 작은 값이다. +- Actor·tenant·deadline을 모든 chunk에 동일하게 전달한다. +- 하나의 chunk timeout이 전체 execution deadline을 초과하지 않는다. +- Batch key 원문을 metric label에 기록하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlBatchChunkerTest { + @org.junit.jupiter.api.Test + void preservesOrderAcrossChunks() { + var chunks = new GraphQlBatchChunker(2) + .chunk(java.util.List.of("a", "b", "c", "d", "e")); + + org.assertj.core.api.Assertions.assertThat(chunks) + .containsExactly( + java.util.List.of("a", "b"), + java.util.List.of("c", "d"), + java.util.List.of("e")); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-dataloader:test --tests 'io.backend.skeleton.graphql.dataloader.GraphQlBatchChunkerTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlBatchChunker { + private final int maximumChunkSize; + + public GraphQlBatchChunker(int maximumChunkSize) { + if (maximumChunkSize < 1) { + throw new IllegalArgumentException( + "maximumChunkSize must be positive"); + } + this.maximumChunkSize = maximumChunkSize; + } + + public java.util.List> chunk( + java.util.List keys) { + var result = new java.util.ArrayList>(); + for (int start = 0; start < keys.size(); + start += maximumChunkSize) { + int end = Math.min(keys.size(), + start + maximumChunkSize); + result.add(java.util.List.copyOf( + keys.subList(start, end))); + } + return java.util.List.copyOf(result); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-dataloader:test --tests 'io.backend.skeleton.graphql.dataloader.GraphQlBatchChunkerTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchContext.java' 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchChunker.java' 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchExecutor.java' 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchTimeoutException.java' 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchObservation.java' 'modules/graphql/graphql-dataloader/src/test/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchChunkerTest.java' +git commit -m "feat: add graphql batch chunk execution" +``` + +### Task 38: Missing Key·Per-key Error Batch Result + +**Files:** +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchResult.java` +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchValue.java` +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlMissingKeyPolicy.java` +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchErrorPolicy.java` +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchResultMapper.java` +- Test: `modules/graphql/graphql-dataloader/src/test/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchResultMapperTest.java` + +**Interfaces:** +- Consumes: Ordered or mapped batch loader output, requested key order and stable GraphQL error mapper. +- Produces: Value, missing value와 key별 실패를 구분하면서 요청 순서를 유지하는 batch result. + +**Implementation requirements:** +- 없는 key와 loader 장애를 모두 null로 평탄화하지 않는다. +- Mapped loader는 요청 key마다 결과를 하나 생성한다. +- Ordered loader의 결과 개수가 key 수와 다르면 contract violation이다. +- Key별 오류는 다른 key의 성공 결과를 제거하지 않는다. +- Error message에는 실제 key 원문을 포함하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlBatchResultMapperTest { + @org.junit.jupiter.api.Test + void distinguishesMissingFromFailure() { + var mapper = new GraphQlBatchResultMapper(); + var result = mapper.map( + java.util.List.of("a", "b"), + java.util.Map.of("a", "value")); + + org.assertj.core.api.Assertions.assertThat(result.values()) + .containsEntry("a", GraphQlBatchValue.present("value")) + .containsEntry("b", GraphQlBatchValue.missing()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-dataloader:test --tests 'io.backend.skeleton.graphql.dataloader.GraphQlBatchResultMapperTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public sealed interface GraphQlBatchValue + permits GraphQlBatchValue.Present, + GraphQlBatchValue.Missing, + GraphQlBatchValue.Failed { + + record Present(V value) implements GraphQlBatchValue {} + record Missing() implements GraphQlBatchValue {} + record Failed(String errorCode) implements GraphQlBatchValue {} + + static GraphQlBatchValue present(V value) { + return new Present<>(value); + } + + static GraphQlBatchValue missing() { + return new Missing<>(); + } +} + +public record GraphQlBatchResult( + java.util.Map> values) { +} + +public final class GraphQlBatchResultMapper { + public GraphQlBatchResult map( + java.util.List keys, + java.util.Map loaded) { + var result = + new java.util.LinkedHashMap>(); + for (K key : keys) { + result.put(key, loaded.containsKey(key) + ? GraphQlBatchValue.present(loaded.get(key)) + : GraphQlBatchValue.missing()); + } + return new GraphQlBatchResult<>( + java.util.Collections.unmodifiableMap(result)); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-dataloader:test --tests 'io.backend.skeleton.graphql.dataloader.GraphQlBatchResultMapperTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchResult.java' 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchValue.java' 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlMissingKeyPolicy.java' 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchErrorPolicy.java' 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchResultMapper.java' 'modules/graphql/graphql-dataloader/src/test/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchResultMapperTest.java' +git commit -m "feat: add graphql per key batch results" +``` + +### Task 39: Registered Fetch Profile Catalog + +**Files:** +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileName.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfile.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileRegistry.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlSelectionCoordinate.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileValidationException.java` +- Test: `modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileRegistryTest.java` + +**Interfaces:** +- Consumes: Schema coordinates, 도메인 모듈이 등록한 bounded read-model profile와 repository query name. +- Produces: GraphQL selection을 JPA·Mongo 구현 세부와 분리하는 유한 Fetch Profile catalog. + +**Implementation requirements:** +- Profile은 schema type과 bounded field set을 명시한다. +- JPA EntityGraph, JPQL, Mongo projection 같은 저장소 구현 타입을 public API에 노출하지 않는다. +- 동일 coordinate·profile 이름의 중복 등록은 startup 실패다. +- Default profile을 type마다 하나만 허용한다. +- Profile에 필드 권한 우회 또는 비공개 schema coordinate가 포함되면 등록을 거부한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlFetchProfileRegistryTest { + @org.junit.jupiter.api.Test + void duplicateProfileNameFailsAtRegistration() { + var registry = new GraphQlFetchProfileRegistry(); + var profile = new GraphQlFetchProfile( + new GraphQlFetchProfileName("Order.BASIC"), + "Order", + java.util.Set.of("id", "status"), + "order-basic", + true); + registry.register(profile); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> registry.register(profile)) + .isInstanceOf( + GraphQlFetchProfileValidationException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.fetch.GraphQlFetchProfileRegistryTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlFetchProfileName(String value) { + public GraphQlFetchProfileName { + if (value == null + || !value.matches("[A-Z][A-Za-z0-9]+\\.[A-Z_]+")) { + throw new IllegalArgumentException( + "invalid fetch profile name"); + } + } +} + +public record GraphQlFetchProfile( + GraphQlFetchProfileName name, + String schemaType, + java.util.Set fields, + String applicationQueryProfile, + boolean defaultProfile) { + public GraphQlFetchProfile { + fields = java.util.Set.copyOf(fields); + } +} + +public final class GraphQlFetchProfileRegistry { + private final java.util.Map profiles = new java.util.LinkedHashMap<>(); + + public void register(GraphQlFetchProfile profile) { + if (profiles.putIfAbsent(profile.name(), profile) != null) { + throw new GraphQlFetchProfileValidationException( + "duplicate fetch profile " + profile.name().value()); + } + } + + public GraphQlFetchProfile require( + GraphQlFetchProfileName name) { + var value = profiles.get(name); + if (value == null) { + throw new GraphQlFetchProfileValidationException( + "unknown fetch profile " + name.value()); + } + return value; + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.fetch.GraphQlFetchProfileRegistryTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileName.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfile.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileRegistry.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlSelectionCoordinate.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileValidationException.java' 'modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileRegistryTest.java' +git commit -m "feat: add graphql fetch profile catalog" +``` + +### Task 40: Selection Set → Fetch Profile Classifier + +**Files:** +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlSelectionSetView.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlSelectionSignature.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileRule.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileClassifier.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlUnmappedSelectionException.java` +- Test: `modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileClassifierTest.java` + +**Interfaces:** +- Consumes: Validated `DataFetchingFieldSelectionSet`, Fetch Profile catalog and coordinate-specific mapping rules. +- Produces: 자유형 SQL·Mongo projection 생성 없이 하나의 등록 profile을 선택하는 deterministic classifier. + +**Implementation requirements:** +- Selection의 field path는 정규화된 schema coordinate로만 비교한다. +- Alias는 실제 field coordinate로 환원한다. +- Fragment·inline fragment를 펼친 뒤 동일 의미 selection은 같은 signature를 생성한다. +- 어떤 profile에도 안전하게 매핑되지 않는 selection은 full entity 자동 조회가 아니라 명시적 오류 또는 승인된 fallback profile을 사용한다. +- 권한상 보이지 않는 field는 profile 선택 전에 제거하는 것이 아니라 authorization에서 거부한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlFetchProfileClassifierTest { + @org.junit.jupiter.api.Test + void choosesSmallestProfileCoveringTheSelection() { + var basic = new GraphQlFetchProfile( + new GraphQlFetchProfileName("Order.BASIC"), "Order", + java.util.Set.of("id", "status"), "order-basic", true); + var full = new GraphQlFetchProfile( + new GraphQlFetchProfileName("Order.FULL_DETAIL"), "Order", + java.util.Set.of("id", "status", "items", "customer"), + "order-full", false); + var classifier = + new GraphQlFetchProfileClassifier(java.util.List.of(full, basic)); + + org.assertj.core.api.Assertions.assertThat( + classifier.classify("Order", + java.util.Set.of("id", "status")).name()) + .isEqualTo(new GraphQlFetchProfileName("Order.BASIC")); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.fetch.GraphQlFetchProfileClassifierTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlFetchProfileClassifier { + private final java.util.List profiles; + + public GraphQlFetchProfileClassifier( + java.util.List profiles) { + this.profiles = profiles.stream() + .sorted(java.util.Comparator.comparingInt( + profile -> profile.fields().size())) + .toList(); + } + + public GraphQlFetchProfile classify( + String schemaType, + java.util.Set selectedFields) { + return profiles.stream() + .filter(profile -> profile.schemaType().equals(schemaType)) + .filter(profile -> + profile.fields().containsAll(selectedFields)) + .findFirst() + .orElseThrow(() -> + new GraphQlUnmappedSelectionException( + schemaType, selectedFields.size())); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.fetch.GraphQlFetchProfileClassifierTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlSelectionSetView.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlSelectionSignature.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileRule.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileClassifier.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlUnmappedSelectionException.java' 'modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileClassifierTest.java' +git commit -m "feat: classify graphql selections into fetch profiles" +``` + +### Task 41: Versioned HMAC Cursor Codec + +**Files:** +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlCursorVersion.java` +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlCursorPayload.java` +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlCursorKeyset.java` +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlCursorCodec.java` +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/HmacGraphQlCursorCodec.java` +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlCursorKeyRing.java` +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlCursorException.java` +- Test: `modules/graphql/graphql-pagination/src/test/java/io/backend/skeleton/graphql/pagination/HmacGraphQlCursorCodecTest.java` + +**Interfaces:** +- Consumes: Query profile, direction, keyset, filter fingerprint와 rotation 가능한 HMAC key ring. +- Produces: Client에게 opaque하고 변조·profile 재사용을 탐지하는 signed versioned cursor. + +**Implementation requirements:** +- Base64 encoding만으로 무결성을 주장하지 않는다. +- Cursor에 version, query profile, direction, complete sort keyset, filter fingerprint와 key ID를 포함한다. +- Unknown version, unknown key ID, invalid MAC, 다른 filter/profile 재사용을 거부한다. +- HMAC 비교는 constant-time API를 사용한다. +- Cursor payload에 credential, raw tenant ID 또는 불필요한 PII를 넣지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class HmacGraphQlCursorCodecTest { + @org.junit.jupiter.api.Test + void rejectsCursorWhenFilterFingerprintChanges() { + var codec = HmacGraphQlCursorCodec.testCodec( + "cursor-key-1", "secret-secret-secret".getBytes()); + var payload = GraphQlCursorPayload.of( + "orders-by-created", "FORWARD", + java.util.Map.of("createdAt", "2026-08-12T00:00:00Z", + "id", "01J0"), + "filter-a"); + var encoded = codec.encode(payload); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> codec.decode( + encoded, "orders-by-created", "filter-b")) + .isInstanceOf(GraphQlCursorException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-pagination:test --tests 'io.backend.skeleton.graphql.pagination.HmacGraphQlCursorCodecTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlCursorPayload( + int version, + String queryProfile, + String direction, + java.util.Map keyset, + String filterFingerprint, + String keyId) { + + public static GraphQlCursorPayload of( + String queryProfile, + String direction, + java.util.Map keyset, + String filterFingerprint) { + return new GraphQlCursorPayload( + 1, queryProfile, direction, + java.util.Map.copyOf(keyset), + filterFingerprint, "cursor-key-1"); + } +} + +public interface GraphQlCursorCodec { + String encode(GraphQlCursorPayload payload); + + GraphQlCursorPayload decode( + String cursor, + String expectedQueryProfile, + String expectedFilterFingerprint); +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-pagination:test --tests 'io.backend.skeleton.graphql.pagination.HmacGraphQlCursorCodecTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlCursorVersion.java' 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlCursorPayload.java' 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlCursorKeyset.java' 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlCursorCodec.java' 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/HmacGraphQlCursorCodec.java' 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlCursorKeyRing.java' 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlCursorException.java' 'modules/graphql/graphql-pagination/src/test/java/io/backend/skeleton/graphql/pagination/HmacGraphQlCursorCodecTest.java' +git commit -m "feat: add signed graphql cursor codec" +``` + +### Task 42: Connection·Edge·PageInfo와 Storage Keyset Adapter + +**Files:** +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlConnection.java` +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlEdge.java` +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlPageInfo.java` +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlConnectionRequest.java` +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlConnectionPolicy.java` +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlConnectionAssembler.java` +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlKeysetWindow.java` +- Test: `modules/graphql/graphql-pagination/src/test/java/io/backend/skeleton/graphql/pagination/GraphQlConnectionAssemblerTest.java` + +**Interfaces:** +- Consumes: 검증된 `first/after/last/before`, signed cursor codec와 JPA·Mongo·upstream이 반환한 keyset window. +- Produces: 저장소에 독립적인 Connection·Edge·PageInfo wire model과 cursor assembly. + +**Implementation requirements:** +- Forward와 backward 요청에서 한 방향의 argument 조합만 허용한다. +- Default page size와 maximum page size를 client profile에서 적용한다. +- Storage query는 요청 크기보다 한 건 더 읽어 `hasNextPage` 또는 `hasPreviousPage`를 계산한다. +- `totalCount`를 모든 connection에 강제하지 않는다. +- Tie-breaker 없는 keyset profile은 startup 또는 request 전에 거부한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlConnectionAssemblerTest { + @org.junit.jupiter.api.Test + void extraRowBecomesHasNextPageAndIsNotReturned() { + var window = new GraphQlKeysetWindow<>( + java.util.List.of("a", "b", "c"), 2, false); + var assembler = GraphQlConnectionAssembler.forTests(); + + var connection = assembler.forward( + window, value -> java.util.Map.of("id", value)); + + org.assertj.core.api.Assertions.assertThat(connection.edges()) + .extracting(GraphQlEdge::node) + .containsExactly("a", "b"); + org.assertj.core.api.Assertions.assertThat( + connection.pageInfo().hasNextPage()).isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-pagination:test --tests 'io.backend.skeleton.graphql.pagination.GraphQlConnectionAssemblerTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlEdge(T node, String cursor) {} + +public record GraphQlPageInfo( + boolean hasNextPage, + boolean hasPreviousPage, + String startCursor, + String endCursor) { +} + +public record GraphQlConnection( + java.util.List> edges, + GraphQlPageInfo pageInfo) { + public GraphQlConnection { + edges = java.util.List.copyOf(edges); + } +} + +public record GraphQlKeysetWindow( + java.util.List values, + int requestedSize, + boolean hasPreviousPage) { +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-pagination:test --tests 'io.backend.skeleton.graphql.pagination.GraphQlConnectionAssemblerTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlConnection.java' 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlEdge.java' 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlPageInfo.java' 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlConnectionRequest.java' 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlConnectionPolicy.java' 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlConnectionAssembler.java' 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlKeysetWindow.java' 'modules/graphql/graphql-pagination/src/test/java/io/backend/skeleton/graphql/pagination/GraphQlConnectionAssemblerTest.java' +git commit -m "feat: add graphql connection pagination" +``` + +### Task 43: Mutation Idempotency Context와 Fingerprint + +**Files:** +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationCoordinate.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlIdempotencyKey.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationFingerprint.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationIdempotencyContext.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationIdempotencyInterceptor.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlIdempotencyConflictException.java` +- Test: `modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/mutation/GraphQlMutationIdempotencyContextTest.java` + +**Interfaces:** +- Consumes: HTTP `Idempotency-Key` 또는 typed mutation input key, actor/client identity, mutation coordinate, normalized input. +- Produces: Application Use Case의 idempotency capability에 전달할 bounded mutation identity와 conflict 판정. + +**Implementation requirements:** +- Idempotency 범위는 GraphQL transport 전체가 아니라 side-effecting mutation coordinate와 actor/client identity다. +- 같은 key와 같은 fingerprint는 기존 결과를 조회할 수 있게 한다. +- 같은 key와 다른 normalized input fingerprint는 conflict다. +- Platform은 DB replay를 직접 구현하지 않고 Application Idempotency Port로 context를 전달한다. +- Raw variables와 idempotency key를 metric·일반 로그에 기록하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlMutationIdempotencyContextTest { + @org.junit.jupiter.api.Test + void sameKeyWithDifferentFingerprintIsConflict() { + var key = new GraphQlIdempotencyKey("request-1"); + var first = GraphQlMutationIdempotencyContext.of( + "actor-fingerprint", + new GraphQlMutationCoordinate("Mutation.createOrder"), + key, + new GraphQlMutationFingerprint("sha256:a")); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> first.assertCompatible( + new GraphQlMutationFingerprint("sha256:b"))) + .isInstanceOf(GraphQlIdempotencyConflictException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.mutation.GraphQlMutationIdempotencyContextTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlMutationCoordinate(String value) {} + +public record GraphQlIdempotencyKey(String value) { + public GraphQlIdempotencyKey { + if (value == null || value.length() < 8 + || value.length() > 128) { + throw new IllegalArgumentException( + "invalid idempotency key"); + } + } +} + +public record GraphQlMutationFingerprint(String value) {} + +public record GraphQlMutationIdempotencyContext( + String actorFingerprint, + GraphQlMutationCoordinate coordinate, + GraphQlIdempotencyKey key, + GraphQlMutationFingerprint fingerprint) { + + public static GraphQlMutationIdempotencyContext of( + String actorFingerprint, + GraphQlMutationCoordinate coordinate, + GraphQlIdempotencyKey key, + GraphQlMutationFingerprint fingerprint) { + return new GraphQlMutationIdempotencyContext( + actorFingerprint, coordinate, key, fingerprint); + } + + public void assertCompatible( + GraphQlMutationFingerprint candidate) { + if (!fingerprint.equals(candidate)) { + throw new GraphQlIdempotencyConflictException( + "idempotency fingerprint conflict"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.mutation.GraphQlMutationIdempotencyContextTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationCoordinate.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlIdempotencyKey.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationFingerprint.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationIdempotencyContext.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationIdempotencyInterceptor.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlIdempotencyConflictException.java' 'modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/mutation/GraphQlMutationIdempotencyContextTest.java' +git commit -m "feat: add graphql mutation idempotency context" +``` + +### Task 44: Optimistic Version과 Typed Business Result 계약 + +**Files:** +- Create: `modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlExpectedVersion.java` +- Create: `modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationPayload.java` +- Create: `modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlBusinessResult.java` +- Create: `modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationResultMapper.java` +- Create: `modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlBatchMutationItemResult.java` +- Create: `modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationContractValidator.java` +- Test: `modules/graphql/graphql-controller/src/test/java/io/backend/skeleton/graphql/mutation/GraphQlMutationResultMapperTest.java` + +**Interfaces:** +- Consumes: Application Use Case의 success·conflict·validation result와 persistence 모듈의 optimistic conflict. +- Produces: 예상 가능한 업무 결과는 typed payload/union으로, 예상 밖 장애는 GraphQL error로 분리하는 mutation contract. + +**Implementation requirements:** +- Mutation root field 하나가 Application Use Case 하나를 호출한다. +- 여러 root mutation field를 하나의 DB transaction으로 묶지 않는다. +- Atomic해야 하는 복합 업무는 하나의 mutation/use case로 모델링한다. +- Batch mutation은 item별 success·failure를 보존하고 top-level error 하나로 결과를 잃지 않는다. +- Expected version은 Application command로 전달하며 GraphQL 계층이 persistence retry를 수행하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlMutationResultMapperTest { + @org.junit.jupiter.api.Test + void businessConflictBecomesTypedResultNotInternalError() { + var mapper = new GraphQlMutationResultMapper(); + + var result = mapper.map( + GraphQlBusinessResult.conflict("ORDER_VERSION_CONFLICT")); + + org.assertj.core.api.Assertions.assertThat(result.status()) + .isEqualTo("CONFLICT"); + org.assertj.core.api.Assertions.assertThat(result.code()) + .isEqualTo("ORDER_VERSION_CONFLICT"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-controller:test --tests 'io.backend.skeleton.graphql.mutation.GraphQlMutationResultMapperTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlExpectedVersion(long value) { + public GraphQlExpectedVersion { + if (value < 0) { + throw new IllegalArgumentException( + "expected version cannot be negative"); + } + } +} + +public record GraphQlMutationPayload( + String status, + String code, + T value) { +} + +public sealed interface GraphQlBusinessResult + permits GraphQlBusinessResult.Success, + GraphQlBusinessResult.Conflict, + GraphQlBusinessResult.Invalid { + + record Success(T value) implements GraphQlBusinessResult {} + record Conflict(String code) + implements GraphQlBusinessResult {} + record Invalid(String code) + implements GraphQlBusinessResult {} + + static GraphQlBusinessResult conflict(String code) { + return new Conflict<>(code); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-controller:test --tests 'io.backend.skeleton.graphql.mutation.GraphQlMutationResultMapperTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlExpectedVersion.java' 'modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationPayload.java' 'modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlBusinessResult.java' 'modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationResultMapper.java' 'modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlBatchMutationItemResult.java' 'modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationContractValidator.java' 'modules/graphql/graphql-controller/src/test/java/io/backend/skeleton/graphql/mutation/GraphQlMutationResultMapperTest.java' +git commit -m "feat: add graphql typed mutation results" +``` + +### Task 45: Request·Resolver·DataLoader Observability 계약 + +**Files:** +- Create: `modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlObservationNames.java` +- Create: `modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlRequestObservationConvention.java` +- Create: `modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlResolverObservationConvention.java` +- Create: `modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlDataLoaderObservationConvention.java` +- Create: `modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlMetricCardinalityPolicy.java` +- Create: `modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlSensitiveAttributeFilter.java` +- Create: `modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlProfilerAccessPolicy.java` +- Test: `modules/graphql/graphql-observability/src/test/java/io/backend/skeleton/graphql/observation/GraphQlMetricCardinalityPolicyTest.java` + +**Interfaces:** +- Consumes: Spring for GraphQL Micrometer observations, operation/resolver/loader bounded catalogs와 execution outcome. +- Produces: 논리 request, resolver와 DataLoader의 low-cardinality metric·trace naming 및 민감 attribute 필터. + +**Implementation requirements:** +- `graphql.request`, `graphql.datafetcher`, `graphql.dataloader` 기본 observation을 재사용한다. +- 허용 tag는 등록된 operationName, operationType, clientProfile, schemaCoordinate, loaderName, outcome, error category다. +- Raw query, variables, cursor, object ID, user/tenant raw ID, token을 tag와 일반 trace attribute에 넣지 않는다. +- Anonymous operation은 Production 정책에서 이미 차단되며 fallback tag는 bounded `anonymous`만 사용한다. +- GraphQL Java Profiler는 Local/Dev 또는 G4 diagnostic에서만 활성화한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlMetricCardinalityPolicyTest { + @org.junit.jupiter.api.Test + void rejectsVariablesAndRawQueryAsMetricTags() { + var policy = GraphQlMetricCardinalityPolicy.standard(); + + org.assertj.core.api.Assertions.assertThat( + policy.isAllowed("graphql.document")).isFalse(); + org.assertj.core.api.Assertions.assertThat( + policy.isAllowed("graphql.variables")).isFalse(); + org.assertj.core.api.Assertions.assertThat( + policy.isAllowed("graphql.operation.name")).isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-observability:test --tests 'io.backend.skeleton.graphql.observation.GraphQlMetricCardinalityPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlMetricCardinalityPolicy { + private static final java.util.Set ALLOWED = + java.util.Set.of( + "graphql.operation.name", + "graphql.operation.type", + "graphql.client.profile", + "graphql.schema.coordinate", + "graphql.dataloader.name", + "graphql.outcome", + "error.type"); + + public static GraphQlMetricCardinalityPolicy standard() { + return new GraphQlMetricCardinalityPolicy(); + } + + public boolean isAllowed(String attribute) { + return ALLOWED.contains(attribute); + } +} + +public final class GraphQlObservationNames { + public static final String REQUEST = "graphql.request"; + public static final String RESOLVER = "graphql.datafetcher"; + public static final String DATA_LOADER = "graphql.dataloader"; + + private GraphQlObservationNames() {} +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-observability:test --tests 'io.backend.skeleton.graphql.observation.GraphQlMetricCardinalityPolicyTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlObservationNames.java' 'modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlRequestObservationConvention.java' 'modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlResolverObservationConvention.java' 'modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlDataLoaderObservationConvention.java' 'modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlMetricCardinalityPolicy.java' 'modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlSensitiveAttributeFilter.java' 'modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlProfilerAccessPolicy.java' 'modules/graphql/graphql-observability/src/test/java/io/backend/skeleton/graphql/observation/GraphQlMetricCardinalityPolicyTest.java' +git commit -m "feat: add graphql observability policy" +``` + +### Task 46: Spring Boot Starter와 Startup Validation + +**Files:** +- Create: `modules/graphql/graphql-spring-boot-starter/src/main/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformProperties.java` +- Create: `modules/graphql/graphql-spring-boot-starter/src/main/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformAutoConfiguration.java` +- Create: `modules/graphql/graphql-spring-boot-starter/src/main/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformStartupValidator.java` +- Create: `modules/graphql/graphql-spring-boot-starter/src/main/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformActuatorEndpoint.java` +- Create: `modules/graphql/graphql-spring-boot-starter/src/main/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformConfigurationReport.java` +- Create: `modules/graphql/graphql-spring-boot-starter/src/main/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformEnvironment.java` +- Test: `modules/graphql/graphql-spring-boot-starter/src/test/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformStartupValidatorTest.java` + +**Interfaces:** +- Consumes: Schema, policy, resolver, DataLoader, fetch profile, cursor key, transport와 security manifest. +- Produces: Stable 모듈만 조립하고 위험하거나 모순된 설정을 시작 단계에서 차단하는 Boot starter. + +**Implementation requirements:** +- Production에서 GraphiQL 활성, 무제한 request/complexity/page size, cursor HMAC key 누락을 거부한다. +- GraphQL multipart upload, HTTP batch, request-wide DB transaction과 raw repository auto-exposure 설정이 있으면 거부한다. +- WebFlux profile에서 BLOCKING resolver가 executor bridge 없이 등록되면 거부한다. +- Schema mapping, scalar, DataLoader, Fetch Profile, cost catalog와 operation catalog drift를 startup에서 검증한다. +- Actuator endpoint는 hash·지원 capability·bounded 상태만 노출하고 SDL, persisted document, secret을 반환하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlPlatformStartupValidatorTest { + @org.junit.jupiter.api.Test + void productionRejectsGraphiqlAndMissingCursorKey() { + var properties = GraphQlPlatformProperties.productionDefaults() + .withGraphiqlEnabled(true) + .withCursorKeyIds(java.util.Set.of()); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> new GraphQlPlatformStartupValidator() + .validate(properties)) + .isInstanceOf( + GraphQlPlatformConfigurationException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-spring-boot-starter:test --tests 'io.backend.skeleton.graphql.autoconfigure.GraphQlPlatformStartupValidatorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +@org.springframework.boot.context.properties.ConfigurationProperties( + "backend.graphql") +public record GraphQlPlatformProperties( + boolean production, + boolean graphiqlEnabled, + int maximumPageSize, + long maximumComplexity, + java.util.Set cursorKeyIds) { + + public static GraphQlPlatformProperties productionDefaults() { + return new GraphQlPlatformProperties( + true, false, 100, 10_000, + java.util.Set.of("cursor-key-1")); + } + + public GraphQlPlatformProperties withGraphiqlEnabled( + boolean enabled) { + return new GraphQlPlatformProperties( + production, enabled, maximumPageSize, + maximumComplexity, cursorKeyIds); + } + + public GraphQlPlatformProperties withCursorKeyIds( + java.util.Set keyIds) { + return new GraphQlPlatformProperties( + production, graphiqlEnabled, maximumPageSize, + maximumComplexity, java.util.Set.copyOf(keyIds)); + } +} + +public final class GraphQlPlatformStartupValidator { + public void validate(GraphQlPlatformProperties properties) { + if (properties.production() + && (properties.graphiqlEnabled() + || properties.cursorKeyIds().isEmpty())) { + throw new GraphQlPlatformConfigurationException( + "unsafe graphql production configuration"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-spring-boot-starter:test --tests 'io.backend.skeleton.graphql.autoconfigure.GraphQlPlatformStartupValidatorTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-spring-boot-starter/src/main/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformProperties.java' 'modules/graphql/graphql-spring-boot-starter/src/main/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformAutoConfiguration.java' 'modules/graphql/graphql-spring-boot-starter/src/main/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformStartupValidator.java' 'modules/graphql/graphql-spring-boot-starter/src/main/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformActuatorEndpoint.java' 'modules/graphql/graphql-spring-boot-starter/src/main/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformConfigurationReport.java' 'modules/graphql/graphql-spring-boot-starter/src/main/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformEnvironment.java' 'modules/graphql/graphql-spring-boot-starter/src/test/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformStartupValidatorTest.java' +git commit -m "feat: add graphql boot starter validation" +``` + +### Task 47: Cross-module Contract Testkit와 실제 Transport·Storage 검증 + +**Files:** +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlContractFixture.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlSchemaContractSuite.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlHttpContractSuite.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlSecurityContractSuite.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlDataLoaderContractSuite.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlPaginationContractSuite.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlJpaIntegrationFixture.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlMongoIntegrationFixture.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlDownstreamFailureFixture.java` +- Test: `modules/graphql/graphql-testkit-core/src/test/java/io/backend/skeleton/graphql/testkit/GraphQlCrossModuleContractSuiteTest.java` + +**Interfaces:** +- Consumes: `ExecutionGraphQlServiceTester`, `WebGraphQlTester`, `HttpGraphQlTester`, PostgreSQL·MongoDB testkit과 HTTP fault fixture. +- Produces: 같은 operation document를 execution, actual HTTP, security, JPA, Mongo, downstream failure 경로에서 반복 검증하는 reusable suite. + +**Implementation requirements:** +- Schema test는 parse, mapping, scalar, compatibility와 null propagation을 검증한다. +- HTTP test는 preferred/legacy media type, 4xx request error와 HTTP 200 partial field error를 검증한다. +- JPA/Mongo test는 operation별 statement/query count와 DataLoader N+1 방지를 검증한다. +- Security test는 actor·tenant·field·object authorization 우회를 검증한다. +- Downstream failure test는 partial data, error masking, timeout와 cancellation을 검증한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlCrossModuleContractSuiteTest { + @org.junit.jupiter.api.Test + void fieldFailureKeepsSiblingDataAndHttp200() { + var fixture = GraphQlContractFixture.standard(); + var response = fixture.executeHttp( + "query Contract { stableField failingField }"); + + org.assertj.core.api.Assertions.assertThat(response.status()) + .isEqualTo(200); + org.assertj.core.api.Assertions.assertThat(response.data()) + .containsKey("stableField"); + org.assertj.core.api.Assertions.assertThat(response.errors()) + .isNotEmpty(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-testkit-core:test --tests 'io.backend.skeleton.graphql.testkit.GraphQlCrossModuleContractSuiteTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlContractResponse( + int status, + java.util.Map data, + java.util.List> errors) { +} + +public final class GraphQlContractFixture { + public static GraphQlContractFixture standard() { + return new GraphQlContractFixture(); + } + + public GraphQlContractResponse executeHttp(String document) { + // The concrete fixture boots the owning test application, + // executes the document through HttpGraphQlTester, and maps + // the actual exchange into this stable assertion model. + throw new UnsupportedOperationException( + "implemented by graphql-testkit-http fixture"); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-testkit-core:test --tests 'io.backend.skeleton.graphql.testkit.GraphQlCrossModuleContractSuiteTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlContractFixture.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlSchemaContractSuite.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlHttpContractSuite.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlSecurityContractSuite.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlDataLoaderContractSuite.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlPaginationContractSuite.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlJpaIntegrationFixture.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlMongoIntegrationFixture.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlDownstreamFailureFixture.java' 'modules/graphql/graphql-testkit-core/src/test/java/io/backend/skeleton/graphql/testkit/GraphQlCrossModuleContractSuiteTest.java' +git commit -m "test: add graphql cross module contract suites" +``` + +### Task 48: Performance·Fault·Compatibility·Release Gate와 Runbook + +**Files:** +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlReleaseGate.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlReleaseEvidence.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlPerformanceScenario.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlFaultScenario.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlCompatibilityMatrix.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlReleaseReportWriter.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlStableCapabilityManifest.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlReleaseFailure.java` +- Test: `modules/graphql/graphql-testkit-core/src/test/java/io/backend/skeleton/graphql/release/GraphQlReleaseGateTest.java` + +**Interfaces:** +- Consumes: 모든 Stable contract suite, schema diff, load·fault evidence와 Spring Boot BOM compatibility matrix. +- Produces: Stable 배포를 차단하거나 승인하는 기계 판독 가능한 release evidence와 운영 Runbook 입력. + +**Implementation requirements:** +- PR lane은 schema, unit, architecture, HTTP contract, PostgreSQL·Mongo integration을 실행한다. +- Nightly lane은 query bomb, pool saturation, downstream timeout, cancellation, memory와 event-loop blocking을 실행한다. +- Release lane은 Boot 4.1 BOM, Spring GraphQL 2.0 계열, GraphQL Java Boot-managed v25 조합을 실제 transport로 검증한다. +- Stable gate는 query p95/p99, DB statement count, DataLoader batch ratio, response bytes, allocation, active resolver와 timeout/cancel evidence를 요구한다. +- 검증 실패를 경고로 낮추는 override는 G4 감사와 만료 시각이 있는 승인 레코드 없이는 허용하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlReleaseGateTest { + @org.junit.jupiter.api.Test + void missingFaultEvidenceBlocksStableRelease() { + var evidence = GraphQlReleaseEvidence.builder() + .schemaPassed(true) + .contractsPassed(true) + .performancePassed(true) + .faultPassed(false) + .compatibilityPassed(true) + .build(); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> new GraphQlReleaseGate().verify(evidence)) + .isInstanceOf(GraphQlReleaseFailure.class) + .hasMessageContaining("fault"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-testkit-core:test --tests 'io.backend.skeleton.graphql.release.GraphQlReleaseGateTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlReleaseEvidence( + boolean schemaPassed, + boolean contractsPassed, + boolean performancePassed, + boolean faultPassed, + boolean compatibilityPassed) { + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private boolean schema; + private boolean contracts; + private boolean performance; + private boolean fault; + private boolean compatibility; + + public Builder schemaPassed(boolean value) { + schema = value; return this; + } + public Builder contractsPassed(boolean value) { + contracts = value; return this; + } + public Builder performancePassed(boolean value) { + performance = value; return this; + } + public Builder faultPassed(boolean value) { + fault = value; return this; + } + public Builder compatibilityPassed(boolean value) { + compatibility = value; return this; + } + public GraphQlReleaseEvidence build() { + return new GraphQlReleaseEvidence( + schema, contracts, performance, fault, compatibility); + } + } +} + +public final class GraphQlReleaseGate { + public void verify(GraphQlReleaseEvidence evidence) { + if (!evidence.schemaPassed() + || !evidence.contractsPassed() + || !evidence.performancePassed() + || !evidence.faultPassed() + || !evidence.compatibilityPassed()) { + throw new GraphQlReleaseFailure( + "schema, contract, performance, fault and " + + "compatibility evidence are all required"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-testkit-core:test --tests 'io.backend.skeleton.graphql.release.GraphQlReleaseGateTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlReleaseGate.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlReleaseEvidence.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlPerformanceScenario.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlFaultScenario.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlCompatibilityMatrix.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlReleaseReportWriter.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlStableCapabilityManifest.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlReleaseFailure.java' 'modules/graphql/graphql-testkit-core/src/test/java/io/backend/skeleton/graphql/release/GraphQlReleaseGateTest.java' +git commit -m "chore: add graphql stable release gate" +``` diff --git a/graphql-superpowers-package/docs/superpowers/specs/2026-08-12-graphql-api-execution-platform-design.md b/graphql-superpowers-package/docs/superpowers/specs/2026-08-12-graphql-api-execution-platform-design.md new file mode 100644 index 00000000..836c9d33 --- /dev/null +++ b/graphql-superpowers-package/docs/superpowers/specs/2026-08-12-graphql-api-execution-platform-design.md @@ -0,0 +1,2553 @@ +# GraphQL API 실행 플랫폼 설계서 + +- **문서 상태:** 구현 기준선 확정 +- **기준일:** 2026-08-12 +- **대상 저장소:** Java/Spring Backend Skeleton +- **Root package:** `io.backend.skeleton.graphql` +- **Stable module root:** `modules/graphql` +- **Advanced module root:** `modules/graphql-advanced` +- **요구사항 원본:** `GraphQL API 실행 플랫폼 심층 리서치` + +## 0. 확정 경계 요약 + +- Stable 기본 토폴로지는 **Single Executable Schema**다. +- **JPA Entity와 MongoDB Document**는 GraphQL Input·Output 계약으로 직접 노출하지 않는다. +- **GraphQL Multipart Upload**는 지원하지 않으며 binary lifecycle은 Fileserver가 소유한다. +- Validation 이후 execution 중 Field Error가 발생해도 가능한 Partial Data는 HTTP `200`으로 반환한다. +- 여러 Mutation Root Field를 하나의 request-wide database transaction으로 묶지 않는다. + +## 1. 문서 목적 + +이 문서는 GraphQL Java와 Spring for GraphQL의 편의 Wrapper를 만드는 문서가 아니다. **SDL로 정의된 외부 API 계약이 인증, 요청 제한, parse·validation, operation 정책, 비용 판정, resolver, DataLoader, Application Use Case, partial data와 error, 실시간 stream으로 실행되는 전 과정**을 통제하는 플랫폼의 설계 기준을 확정한다. + +구현자가 다시 결정하지 않도록 다음 항목을 명시적으로 고정한다. + +```text +Schema 소유권과 조립 방식 +Transport 지원 범위 +Request Context +Resolver와 Application Service 경계 +Blocking·Reactive 실행 Profile +DataLoader 요청 범위와 Batch 계약 +Selection Set과 Fetch Profile +Cursor 형식과 서명 +Mutation·Transaction·Idempotency 의미 +Error wire contract +Field·Object·Tenant 권한 +Query cost와 abuse control +Preparsed cache와 Persisted Operation 경계 +Subscription의 delivery 한계 +Schema compatibility와 Release Gate +``` + +## 2. 핵심 결론 + +```text +GraphQL Platform += Schema Contract ++ Transport Profile ++ Execution Policy ++ Governance ++ Security ++ Verification + +GraphQL Platform +≠ Database Gateway +≠ JPA/Mongo Repository 자동 노출기 +≠ Durable Messaging Broker +≠ Binary Upload Server +≠ Request-wide Database Transaction Manager +``` + +플랫폼은 GraphQL operation을 Application Use Case에 연결한다. 저장소와 외부 시스템의 고유 의미론은 기존 JPA, MongoDB, HTTP Client, Messaging, Fileserver, Object Storage 모듈이 계속 소유한다. + +### 2.1 GraphQL 플랫폼이 소유한다 + +- SDL resource discovery, assembly, validation, fingerprint +- Schema 변경 호환성 정책과 deprecation removal gate +- HTTP, WebSocket, SSE transport profile +- immutable `GraphQlRequestContext` +- client·operation policy manifest +- parser, depth, field, alias, fragment, complexity, response budget +- resolver return/input type와 application boundary +- DataLoader request scope, batch, timeout, missing key, key error +- Selection Set을 유한한 Fetch Profile로 분류하는 규칙 +- signed cursor envelope +- GraphQL error wire contract와 내부 오류 마스킹 +- persisted operation registry와 operation block +- subscription connection, auth, buffer, ordering 정책 +- metric·trace cardinality policy +- schema·transport·security·performance release gate + +### 2.2 도메인·Application 모듈이 소유한다 + +- Query, Mutation, Subscription의 업무 의미 +- GraphQL Input·Output DTO와 Read Model +- Application Use Case +- 도메인 상태가 필요한 authorization decision +- transaction 요구 +- 저장소 query, fetch, pagination 의미 +- integration event의 업무 의미 +- 예상 가능한 typed business result + +### 2.3 기존 기술 모듈이 소유한다 + +| 영역 | 기존 모듈 책임 | GraphQL 책임 | +|---|---|---| +| JPA | Entity, Repository, Transaction, Fetch Plan, Lock | Fetch Profile을 선택해 Application Query 호출 | +| MongoDB | Document, Query, Aggregation, Consistency | Fetch Profile을 선택해 Application Query 호출 | +| HTTP Client | Timeout, Retry, TLS, SSRF, Bulkhead | Application 결과를 GraphQL DTO로 조립 | +| Messaging | ACK, Replay, DLQ, durable event | Subscription source에 event 제공 | +| Fileserver | Binary lifecycle, 검사, Range, upload | Upload reservation과 file metadata 반환 | +| Object Storage | bytes, checksum, delegated access | 직접 호출하지 않음 | +| WebSocket | 범용 연결 인프라 | GraphQL subscription protocol과 execution | + +## 3. 지원 기준 + +| 구성 | 기준 | 등급 | +|---|---|---| +| Java | 21 | Stable | +| Spring Boot | 프로젝트 4.1 BOM | Source of Truth | +| Spring for GraphQL | 2.0 계열 | Stable | +| GraphQL Java | Boot-managed v25 계열 | Stable | +| GraphQL Specification | September 2025 | Contract | +| GraphQL over HTTP | Stage 2 Draft | Versioned compatibility profile | +| HTTP POST | MVC·WebFlux | Stable | +| WebSocket | `graphql-transport-ws` | Advanced Stable | +| SSE | Distinct Connection | Advanced | +| RSocket | Spring Extension | Experimental | +| Federation Subgraph | federation-jvm | Advanced | +| HTTP GET | Draft compatibility | Experimental | +| Multipart Upload | Fileserver 사용 | Unsupported | +| HTTP array batch | Core 밖 | Unsupported | +| Incremental Delivery | 별도 실험 | Experimental | + +Spring Boot BOM이 Spring for GraphQL과 GraphQL Java 조합의 기준이다. GraphQL Java 직접 override는 보안 대응이나 신규 기능 검증을 위한 별도 compatibility lane에서만 허용한다. + +### 3.1 HTTP Draft 상태 코드 결정 + +Stable HTTP profile은 Spring for GraphQL 2.0 계열의 실제 동작을 계약으로 삼는다. + +```text +malformed JSON / parse / validation / coercion +→ application/graphql-response+json에서 4xx + +validation을 통과해 execution 시작 후 field error +→ HTTP 200 + data/errors + +partial data + errors +→ HTTP 200 +``` + +이동 중인 GraphQL over HTTP Draft의 294 제안은 Stable에 선제 도입하지 않는다. Draft 변화는 별도 compatibility job에서 추적한다. + +## 4. 공개 기능 계층 + +```text +G1 Standard GraphQL API +- SDL +- Query / Mutation +- Annotated Controller +- HTTP POST +- Error Contract +- Request Context +- DataLoader +- Cursor Connection +- Cost Control + +G2 Advanced Execution +- Persisted Operation +- Registered Fetch Profile +- WebSocket / SSE Subscription +- DataLoader Chaining +- Advanced Directive / Scalar + +G3 Extension +- Federation Subgraph +- RSocket +- Code Generation +- Spring Data Compatibility +- HTTP Draft Compatibility +- Incremental Delivery + +G4 Admin Plane +- Schema Diff +- Persisted Operation 등록·차단 +- Schema Usage +- Cost Profile +- Subscription Runtime 진단 +- Federation Composition +``` + +일반 애플리케이션에는 `graphql.GraphQL`, 자유형 `GraphQLCodeRegistry`, raw `DataFetcher` registry를 공개하지 않는다. Infrastructure SPI 또는 명시적 G3 모듈에서만 사용한다. + +## 5. 확정 모듈 구조 + +```text +modules/graphql/ +├── graphql-core-api +├── graphql-schema +├── graphql-execution +├── graphql-controller +├── graphql-http +├── graphql-dataloader +├── graphql-pagination +├── graphql-security +├── graphql-cost-control +├── graphql-error +├── graphql-observability +├── graphql-spring-boot-starter +├── graphql-testkit-core +├── graphql-testkit-schema +├── graphql-testkit-http +└── graphql-testkit-integration + +modules/graphql-advanced/ +├── graphql-persisted-operation +├── graphql-admin +├── graphql-subscription +├── graphql-websocket +├── graphql-sse +├── graphql-federation +├── graphql-codegen +├── graphql-spring-data-compat +├── graphql-rsocket +├── graphql-http-draft-compat +├── graphql-incremental-experimental +├── graphql-testkit-realtime +└── graphql-testkit-federation +``` + +### 5.1 Stable dependency map + +```text +graphql-core-api + → Java standard library only + +graphql-schema + → core-api + → GraphQL Java schema API + +graphql-execution + → core-api + → schema + → Spring GraphQL execution API + +graphql-controller + → core-api + → execution + → Spring annotated controller + +graphql-http + → core-api + → execution + → Spring MVC / WebFlux + +graphql-dataloader / pagination / security / cost-control / error + → core-api + → execution + +graphql-observability + → core-api + → execution + +graphql-spring-boot-starter + → every Stable runtime module + → no Advanced module +``` + +## 6. 중심 공개 계약 + +```java +public record GraphQlSchemaContract( + String schemaHash, + String breakingPolicyVersion, + String scalarManifestVersion, + String directiveManifestVersion) {} + +public record GraphQlRequestContext( + ActorRef actor, + TenantContext tenant, + GraphQlClientProfile clientProfile, + Locale locale, + GraphQlOperationId operationId, + String traceId, + GraphQlDeadline deadline) {} + +public record GraphQlClientPolicy( + int maxDocumentBytes, + int maxVariablesBytes, + int maxDepth, + int maxFields, + int maxAliases, + int maxFragments, + int maxInputListElements, + int defaultPageSize, + int maxPageSize, + long maxComplexity, + long maxResponseNodes, + long maxResponseBytes, + Duration maxExecutionTime, + boolean introspectionAllowed, + boolean persistedOperationOnly, + boolean namedOperationRequired) {} + +public record GraphQlBatchPolicy( + String loaderName, + int maxBatchSize, + Duration timeout, + MissingKeyPolicy missingKeyPolicy, + BatchErrorPolicy errorPolicy) {} +``` + +```java +public interface GraphQlFetchProfileRegistry { + GraphQlFetchProfile select( + GraphQlSchemaCoordinate coordinate, + Set selectedFields, + GraphQlClientProfile clientProfile); +} + +public interface GraphQlCursorCodec { + String encode(GraphQlCursorEnvelope cursor); + GraphQlCursorEnvelope decode(String encoded); +} + +public interface GraphQlErrorContract { + GraphQlWireError map(Throwable failure, GraphQlErrorContext context); +} +``` + +## 7. Schema 계약 + +### 7.1 SDL First + +SDL이 외부 API 계약의 Source of Truth다. 도메인 모듈이 자기 schema fragment를 소유하고 플랫폼이 조립·검증한다. + +```text +modules/order/src/main/resources/graphql/order/ +├── order-type.graphqls +├── order-query.graphqls +└── order-mutation.graphqls + +modules/graphql/graphql-schema/src/main/resources/graphql/common/ +├── scalar.graphqls +├── directive.graphqls +├── connection.graphqls +└── error.graphqls +``` + +### 7.2 Input·Output·Persistence 분리 + +- JPA Entity와 Mongo Document를 GraphQL output으로 반환하지 않는다. +- GraphQL input을 Entity·Document에 직접 bind하지 않는다. +- Provider SDK 객체와 자유형 `Map`를 wire contract로 쓰지 않는다. +- generated type은 client 또는 transport DTO까지만 허용한다. + +### 7.3 Nullability + +Non-null은 DB column의 `NOT NULL`이 아니다. **Resolver, authorization, dependency failure까지 포함해 항상 값을 제공한다는 API 보장**이다. + +- identity는 `ID!` 후보 +- 외부 enrichment와 부분 실패 가능한 child는 nullable 우선 +- 신규 field는 nullable로 도입하고 보장을 검증한 뒤 강화 +- Non-null 변경은 schema diff와 data/resolver contract test 요구 +- null propagation 경계를 golden response로 고정 + +### 7.4 Scalar + +| Scalar | 정책 | +|---|---| +| ID | opaque string | +| UUID | canonical string | +| Instant | UTC ISO-8601 | +| Date | ISO local date | +| BigDecimal | precision-loss 없는 coercion | +| Long | client numeric range 정책 | +| URL | Advanced parser·normalization | +| Email | format 검증만, ownership은 업무 | +| JSON | allowlist된 coordinate만 | +| Upload | 금지 | + +### 7.5 `@oneOf` + +September 2025 규격의 `@oneOf`를 Stable 기능으로 지원한다. 정확히 하나의 nullable field가 non-null 값으로 제공되어야 하며 default value를 허용하지 않는다. Schema build와 coercion contract test를 Release Gate에 포함한다. + +## 8. Schema assembly·검증·진화 + +Startup과 CI에서 다음 순서를 실행한다. + +```text +SDL resource discovery +→ parse +→ schema validation +→ duplicate type / field / directive +→ scalar / directive wiring +→ interface / union TypeResolver +→ SchemaMappingInspector +→ argument / nullability mapping +→ forbidden feature scan +→ compatibility diff +→ schema contract fingerprint +``` + +Stable profile에서는 unmapped field, unknown resolver, argument mismatch, nullability mismatch가 startup 실패다. + +### 8.1 Compatibility 정책 + +- field 삭제·rename, required argument 추가, input 강화, output nullable 전환은 Breaking +- output enum/union possible type 추가는 wire additive이나 generated-client review 요구 +- scalar coercion 변경은 새 scalar/version +- directive 의미 변경은 behavioral compatibility review +- deprecated element 삭제 전 usage, persisted operation reference, support window, client owner 승인 확인 + +```text +@deprecated +→ Schema Usage Observation +→ Persisted Operation Reference Scan +→ Support Window +→ Client Owner Approval +→ Removal +``` + +## 9. HTTP Transport Profile + +### 9.1 Stable profile + +```text +Method: POST only +Content-Type: application/json +Accept: application/graphql-response+json preferred +Legacy response: application/json compatible +Request fields: query, operationName, variables, extensions +``` + +### 9.2 요청 제한 + +- request bytes와 variable bytes를 GraphQL parse 전에 제한 +- `variables`와 `extensions`는 object만 허용 +- `extensions` key allowlist +- Production named operation 필수 +- Cookie 인증 profile은 CSRF 필수 +- HTTP GET, array batch, multipart upload는 Stable 비지원 + +### 9.3 파일 Upload + +```text +GraphQL Mutation +→ Fileserver upload reservation / ticket 생성 + +Client +→ Fileserver 또는 Object Storage로 binary 전송 + +GraphQL Query +→ file metadata와 상태 조회 +``` + +GraphQL `Upload` scalar, multipart parser, checksum, quarantine, Range download를 구현하지 않는다. + +## 10. 실행 Profile + +```text +BLOCKING_MVC +- JPA / blocking Mongo / blocking SDK +- Java 21 virtual thread 또는 bounded executor + +REACTIVE_WEBFLUX +- Reactive Mongo / WebClient / subscription +- Reactor Context +- event-loop blocking 금지 + +MIXED_CONTROLLED +- 명시적 bridge와 executor/scheduler 전환 +- 아무 반환형이나 자동 허용하지 않음 +``` + +Resolver catalog에는 `BLOCKING`, `ASYNC`, `REACTIVE`, `STREAM`을 등록한다. Runtime profile과 실행 유형이 충돌하면 startup 또는 architecture test에서 실패한다. + +Timeout은 분리한다. + +```text +transportHandshakeTimeout +requestExecutionTimeout +resolverBudget +dataLoaderBatchTimeout +subscriptionIdleTimeout +subscriptionMaxAge +shutdownDrainTimeout +``` + +GraphQL deadline은 JPA, MongoDB, HTTP Client의 하위 deadline에 전파한다. Timeout 이후 reactive publisher와 실제 resource가 취소되는지 검증한다. + +## 11. Resolver와 Application Service 경계 + +일반 진입점은 `@QueryMapping`, `@MutationMapping`, `@SubscriptionMapping`, `@SchemaMapping`, `@BatchMapping`이다. + +Resolver가 수행한다. + +- GraphQL input을 application command/query로 변환 +- Bean Validation +- Actor·Tenant·Locale·Deadline 전달 +- Application Use Case 호출 +- GraphQL DTO·Payload·Connection 변환 + +Resolver가 수행하지 않는다. + +- `EntityManager`, `MongoTemplate` 직접 Query +- HTTP retry·circuit breaker +- 메시지 ACK·DLQ +- Object Storage binary I/O +- multi-step domain transition +- provider exception 공개 + +Transaction annotation은 Resolver가 아니라 Application Service에 둔다. + +## 12. Selection Set과 Fetch Profile + +Selection Set을 자유형 SQL/Mongo projection으로 변환하지 않는다. + +```text +Selection Set +→ SelectionClassifier +→ registered finite FetchProfile +→ Application Query +→ JPA/Mongo implementation +``` + +예: + +```text +Order.BASIC +Order.WITH_ITEMS +Order.WITH_CUSTOMER +Order.FULL_DETAIL +``` + +실제 EntityGraph, DTO projection, JPQL, Native SQL, Mongo aggregation은 저장소 모듈이 소유한다. 등록되지 않은 조합은 명시적 fallback 또는 오류로 처리한다. + +## 13. DataLoader 계약 + +- DataLoader instance와 cache는 GraphQL execution 요청 범위 +- cross-request cache는 Redis/Application Cache로 분리 +- Actor·Tenant·Locale·Deadline을 loader context에 전달 +- batch size는 JPA IN, Mongo `$in`, downstream batch API 상한으로 제한 +- ordered loader는 입력 key 순서를 유지 +- mapped loader는 missing key 정책 명시 +- key별 failure와 batch 전체 failure 분리 +- timeout, cancel, metric을 loader 단위로 제공 +- chained dispatch는 Advanced opt-in + +DataLoader는 root query의 과도한 Entity graph, Cartesian product, 잘못된 index, unbounded child collection을 해결하지 않는다. + +## 14. Cursor Connection + +Cursor는 Base64만 적용한 JSON이 아니라 versioned authenticated envelope다. + +```json +{ + "v": 1, + "profile": "orders-by-created-at", + "direction": "FORWARD", + "keyset": { + "createdAt": "2026-08-12T01:00:00Z", + "id": "..." + }, + "filter": "sha256:...", + "kid": "cursor-key-2026-01", + "mac": "..." +} +``` + +- query profile, filter fingerprint, sort tie-breaker 고정 +- HMAC key rotation +- unknown version, signature mismatch, key mismatch, profile mismatch 거부 +- page size를 decode 후 서버 정책으로 재검증 +- `totalCount`는 opt-in resolver +- JPA는 keyset/Scroll, MongoDB는 range+`_id`, 외부 API는 upstream cursor를 signed envelope 안에 보관 + +## 15. Mutation·Transaction·Idempotency + +```text +Mutation root field serial execution +≠ request-wide DB transaction +``` + +하나의 Mutation Resolver는 하나의 Application Use Case를 호출한다. 여러 변경이 원자적이어야 하면 하나의 명시적 Use Case Mutation을 제공한다. + +Optimistic version과 idempotency key는 Application command로 전달한다. + +```text +idempotency scope += actor/client identity ++ mutation coordinate ++ idempotency key ++ normalized input fingerprint +``` + +동일 key와 다른 fingerprint는 conflict다. GraphQL transport는 DB retry, idempotency record, lock을 직접 구현하지 않는다. + +예상 가능한 업무 분기는 typed payload/union으로 표현할 수 있다. 예기치 않은 dependency/internal failure는 GraphQL error로 남긴다. + +## 16. Error wire contract + +```text +REQUEST_ERROR +- malformed JSON +- parse +- validation +- variable coercion + +FIELD_ERROR +- resolver execution +- partial data 가능 + +BUSINESS_RESULT +- 예상 가능한 업무 결과 +- typed payload/union 우선 + +INTERNAL_ERROR +- 예상 밖 장애 +- opaque message + executionId +``` + +공개 `extensions` allowlist: + +```text +code +category +retryable +executionId +constraint +safe logical field +``` + +공개 금지: + +```text +Java exception class +stack trace +SQL / JPQL / Mongo query +downstream URL과 provider body +credential / token +internal host +raw tenant/user/object ID +``` + +Non-null propagation은 오류 계약과 함께 golden test로 고정한다. + +## 17. Security + +```text +Transport Authentication +→ Client Profile Authorization +→ Operation Authorization +→ Field / Use Case Authorization +→ Object Authorization +→ Tenant Isolation +``` + +Schema visibility는 authorization을 대체하지 않는다. Tenant는 GraphQL argument가 아니라 인증 Context에서 결정한다. BatchLoader도 동일한 Actor·Tenant Context를 사용한다. + +환경별 정책: + +| 환경 | Introspection | GraphiQL | +|---|---|---| +| Local | 허용 | 허용 | +| Test | 허용 | 선택 | +| Dev | 인증된 사용자 | 인증된 사용자 | +| Staging | Admin/CI | 비활성 | +| Prod internal | Client Profile 기준 | 비활성 | +| Prod public | 제한 또는 비활성 | 비활성 | + +Introspection 차단만으로 보안을 완성하지 않는다. Field/Object authorization, cost limit, persisted operation, request size limit을 함께 적용한다. + +## 18. Cost·DoS control + +방어 순서: + +```text +HTTP body bytes +→ variables bytes +→ request envelope +→ parser character/token/grammar limits +→ parse +→ validation +→ operation count/name/type +→ introspection policy +→ depth/fields/aliases/fragments +→ input list/string +→ complexity +→ estimated response nodes +→ execution timeout +→ actual response bytes +``` + +Complexity는 cardinality와 resolver class를 포함한다. + +```text +field cost += base field cost ++ resolver weight ++ child cost × effective cardinality +``` + +Resolver catalog 예: + +| 유형 | 상대 비용 | +|---|---:| +| in-memory scalar/property | 1 | +| indexed DB lookup | 2 | +| batched relation | 3 | +| bounded aggregation | 8 | +| external batch API | 10 | +| external per-object call | 20 | +| search/heavy aggregation | 별도 승인 | + +숫자는 표준값이 아니라 calibration 시작점이며 실제 latency, DB query, examined rows/documents, downstream call과 비교해 profile manifest에서 조정한다. + +## 19. Cache와 Persisted Operation 경계 + +```text +DataLoader Cache +→ request internal data loading + +Preparsed Document Cache +→ parse/validation result + +Persisted Operation Registry +→ approved operation document + +Response Cache +→ Stable 초기 비지원 +``` + +Preparsed cache key: + +```text +documentHash +schemaContractHash +validationPolicyVersion +clientSchemaProfile +``` + +Persisted Operation은 Advanced 모듈에서 다음을 소유한다. + +```text +operationId +operationName +sha256Document +canonicalDocument +schemaContractHash +allowedClientProfiles +maximumComplexity +maximumVariablesBytes +status: ACTIVE | DEPRECATED | BLOCKED +``` + +Incident 시 특정 operation을 application redeploy 없이 차단할 수 있어야 한다. + +## 20. Subscription + +Subscription은 live response stream이며 durable messaging이 아니다. + +```text +Messaging +→ persistence / ACK / replay / retry / DLQ + +GraphQL Subscription +→ client selection / connected actor / authorization / live delivery / cancellation +``` + +상태: + +```text +CONNECTING → AUTHENTICATING → READY → SUBSCRIBED → STREAMING +→ CANCELLING → COMPLETED + +AUTH_EXPIRED / SLOW_CONSUMER / SOURCE_FAILED / SERVER_DRAINING / PROTOCOL_ERROR +``` + +정책: + +- `graphql-transport-ws` 사용 +- connection-init timeout, max subscription, max connection age +- credential expiry 시 connection 종료 +- 민감한 profile은 event 전달 시 재인가 +- bounded buffer +- 기본 slow-consumer 정책은 연결 종료 +- `LOW_LATENCY`와 `ORDERED` 분리 +- replay는 표준 보장이 아니며 Messaging 기반 Advanced extension +- SSE는 subscription-only Distinct Connection + +## 21. Federation·Codegen·Spring Data Compatibility + +### 21.1 Federation + +- 단일 executable schema가 Stable 기본 +- Federation Subgraph는 독립 배포, 실제 schema ownership, composition CI, router owner, distributed trace, latency budget이 있을 때만 opt-in +- Federation Router는 이 저장소 밖 +- entity key는 owner, stability, deprecation policy 필요 +- entity resolution은 request-scoped batch와 authorization 사용 + +### 21.2 Code Generation + +허용: + +```text +client request/response +operation validation +transport-only DTO +``` + +금지: + +```text +Domain Entity +Application Use Case +Repository +Persistence model +``` + +### 21.3 Spring Data Compatibility + +`@GraphQlRepository` 자동 노출은 별도 compatibility 모듈에서 allowlist한다. Filter, sort, projection, pagination 정책을 명시하고 offset pagination 기본값을 조용히 채택하지 않는다. + +## 22. Observability + +Spring의 `graphql.request`, `graphql.datafetcher`, `graphql.dataloader` observation을 재사용하고 플랫폼은 naming과 cardinality를 통제한다. + +허용 tag: + +```text +operationName +operationType +clientProfile +persisted +outcome +errorCategory +complexityBucket +depthBucket +bounded schemaCoordinate +bounded loaderName +``` + +금지: + +```text +raw query +variables +userId +raw tenantId +objectId +cursor +token +connection_init payload +arbitrary full field path +``` + +Trace: + +```text +HTTP/WebSocket receive +→ GraphQL request +→ resolver +→ DataLoader +→ Application Use Case +→ DB/HTTP/Messaging +``` + +## 23. Configuration과 startup validation + +```yaml +backend: + graphql: + execution-profile: BLOCKING_MVC + http-profile: V1 + schema: + mapping-inspection: FAIL + compatibility-policy: stable-v1 + security: + production-named-operation-required: true + graphiql-enabled: false + clients: + first-party: + max-document-bytes: 65536 + max-variables-bytes: 65536 + max-depth: 12 + max-fields: 500 + max-aliases: 50 + max-fragments: 50 + default-page-size: 20 + max-page-size: 100 + max-complexity: 10000 + max-response-nodes: 10000 + max-response-bytes: 5242880 + max-execution-time: 5s +``` + +위 숫자는 예시이며 benchmark 후 profile manifest에서 확정한다. + +Startup 실패 조건: + +- schema mapping mismatch +- duplicate scalar/directive +- forbidden `Upload` scalar +- Stable에서 HTTP GET, batch, multipart 활성화 +- Production anonymous operation 허용 +- Production GraphiQL 활성화 +- unknown client policy +- resolver execution type과 runtime profile 충돌 +- unsigned cursor codec +- response cache 활성화 +- Stable Starter에 Advanced module 자동 포함 + +## 24. 테스트 전략 + +### 24.1 Contract + +- SDL parse, assembly, mapping, compatibility +- HTTP media, request error 4xx, execution error 200, partial data +- immutable request context와 actor/tenant isolation +- resolver return/input type와 direct repository access 금지 +- DataLoader batch, missing, per-key error, cache scope +- signed cursor와 forward/backward pagination +- mutation transaction, optimistic conflict, idempotency +- error masking과 null propagation +- parser, alias, fragment, complexity, response budget +- observation cardinality + +### 24.2 Storage·Integration + +- JPA query count, entity load, fetch profile +- Mongo examined documents, query count, keyset +- HTTP batch downstream와 bulkhead +- Fileserver upload ticket only + +### 24.3 Performance·Fault + +- named high concurrency +- deep valid query +- wide alias/fragment bomb +- nested connection +- DataLoader saturation +- DB pool saturation +- downstream timeout +- large response serialization +- virtual thread saturation +- event-loop blocking +- cancellation leak + +### 24.4 Realtime Advanced + +- connection-init auth +- token expiry +- slow consumer +- source failure +- ordered/low-latency +- cancel storm +- rolling deployment +- graceful drain +- 1k baseline과 목표 connection soak + +## 25. 지원 등급 + +| 기능 | 등급 | +|---|---| +| SDL, Query/Mutation, HTTP POST | Stable | +| Partial data/error | Stable | +| Context·Security | Stable | +| DataLoader | Stable | +| signed Cursor Connection | Stable | +| Cost Control | Stable | +| Preparsed Cache | Stable | +| MVC VT / WebFlux profile | Stable | +| Fetch Profile | Stable platform capability | +| Persisted Operation | Advanced Stable | +| WebSocket Subscription | Advanced Stable | +| SSE | Advanced | +| Federation Subgraph | Advanced | +| Codegen | Optional | +| DataLoader chaining | Advanced | +| RSocket·HTTP GET | Experimental | +| Incremental Delivery | Experimental/disabled | +| Multipart Upload·HTTP batch | Unsupported | +| Entity/Document auto exposure | Unsupported default | +| request-wide DB transaction | Unsupported default | +| Response Cache | Initial unsupported | + +## 26. 구현 단계 + +```text +Foundation +→ Schema / HTTP / Context / Resolver / Error / Security + +Execution Safety +→ DataLoader / Pagination / Cost / Timeout / Observability + +Storage Integration +→ Fetch Profile / JPA / Mongo / Downstream Batch + +Governance +→ Persisted Operation / Usage / Admin + +Realtime +→ WebSocket / SSE / Backpressure / Auth Lifecycle + +Extension +→ Federation / Codegen / RSocket / HTTP Draft / Incremental +``` + +## 27. Definition of Done + +Stable 플랫폼은 다음 조건을 모두 만족한다. + +- Entity·Document가 GraphQL wire type으로 노출되지 않는다. +- Schema mapping mismatch가 startup/CI에서 실패한다. +- Schema breaking change가 Release Gate에서 차단된다. +- HTTP 4xx/200 partial-error 계약이 고정된다. +- N+1 회귀 테스트가 실제 PostgreSQL·MongoDB에서 통과한다. +- Cursor 변조, filter/profile mismatch가 거부된다. +- Query cost와 response size가 실행 전·중 제한된다. +- internal exception, raw query, variables, PII가 응답·metric에 노출되지 않는다. +- timeout 이후 하위 작업이 취소되고 resource가 누수되지 않는다. +- Stable Starter가 Advanced 기능을 자동 활성화하지 않는다. +- 실제 부하·장애 증거와 운영 Runbook이 존재한다. + +## 28. 명시적 비지원 + +```text +GraphQL multipart upload +HTTP array batching +Arbitrary JSON input gateway +Persistence entity auto exposure +GraphQL request-wide transaction +Durable subscription guarantee +Exactly-once subscription delivery +Raw GraphQL engine access for application code +Unbounded list and totalCount-by-default +Response cache without actor/tenant/permission model +``` + +--- + +# 부록 A. 요구사항 추적표 + +| 리서치 영역 | 설계 반영 | +|---|---| +| 조사 결론과 지원 기준 | §2~§6 | +| Schema 계약과 진화 정책 | §7~§8 | +| Transport와 실행·데이터 접근 계약 | §9~§12 | +| DataLoader·Pagination·Mutation·Error | §13~§16 | +| 보안·비용 통제·Persisted Operation | §17~§19 | +| Subscription·Federation·Codegen·관측성 | §20~§22 | +| 테스트·지원 등급·구현 순서 | §24~§27 | + +# 부록 B. 입력 심층 리서치 원문 + +> 아래 원문은 설계 결정의 근거와 세부 제약을 보존하기 위해 첨부한다. 상단 설계 계약이 구현 기준이다. + +# GraphQL API 실행 플랫폼 심층 리서치 + +## 조사 결론과 지원 기준 + +이번 `graphql` 모듈의 적절한 정체성은 **GraphQL Java/Spring for GraphQL의 편의 Wrapper가 아니라, 외부 Schema 계약이 실제 Application Use Case로 실행되는 전 과정을 통제하는 API 실행 플랫폼**입니다. GraphQL Java는 `GraphQLSchema`, `DataFetcher`, 실행 전략과 `ExecutionResult`를 제공하는 실행 엔진이고, Spring for GraphQL은 이를 Spring의 transport, annotated controller, context propagation, exception resolution, DataLoader, Spring Data 통합과 연결합니다. Spring for GraphQL의 현재 Data Integration 문서 역시 GraphQL을 Selection Set을 SQL이나 JSON query로 일대일 번역하는 데이터 게이트웨이로 보지 않으며, client selection과 server-side projection을 상호 보완적인 것으로 설명합니다. citeturn16search4turn24view0 + +따라서 핵심 경계는 다음으로 확정하는 것이 가장 안전합니다. + +```text +Client + ↓ +GraphQL Transport + ↓ +Authentication + GraphQlRequestContext + ↓ +Parse / Validation + ↓ +Operation Policy / Cost / Persisted Operation + ↓ +Resolver / DataFetcher + ↓ +DataLoader + ↓ +Application Use Case + ↓ +JPA / MongoDB / HTTP Client / Messaging / Fileserver + ↓ +Read Model / DTO + ↓ +GraphQL Completion + Partial Data + Error + ↓ +HTTP Response / Subscription Stream +``` + +```text +GraphQL Platform owns +├─ SDL contract +├─ transport profile +├─ execution policy +├─ request context +├─ resolver conventions +├─ DataLoader contract +├─ cursor envelope +├─ error wire contract +├─ cost / abuse control +├─ persisted operation +├─ subscription delivery contract +├─ schema governance +├─ observability +└─ test / release gates + +Domain/Application owns +├─ use cases +├─ authorization decision requiring domain state +├─ DTO / read models +├─ transaction requirements +├─ pagination query semantics +├─ filter / sort semantics +└─ integration-event meaning + +Persistence/Integration modules own +├─ JPA fetch/query/transaction +├─ Mongo consistency/aggregation/index +├─ HTTP timeout/retry/TLS +├─ Messaging ACK/replay/DLQ +├─ Fileserver binary lifecycle +└─ Object Storage bytes +``` + +### 기술 기준선 + +2026년 8월 12일 기준 공식 문서에서 Spring Boot의 현재 stable은 `4.1.0`, Spring for GraphQL의 최신 stable은 `2.0.4`, GraphQL Java 공식 v25 문서는 `25.0`을 사용 버전으로 제시합니다. 완성된 GraphQL 언어·실행 규격은 **September 2025 Edition**입니다. 반면 GraphQL over HTTP는 현재도 **Stage 2 Draft**이며, 문서 자체가 production에서 draft를 고정 규격처럼 의존하는 것을 경고합니다. 따라서 HTTP 표준은 “지원 Profile”로 고정하고 사양 변화에 대한 별도 compatibility lane을 운영해야 합니다. citeturn19search4turn14search3turn16search12turn15search0turn22view0 + +| 영역 | 권장 기준 | 플랫폼 판정 | +|---|---|---| +| Java | 21 | Stable baseline | +| Spring Boot | 프로젝트 4.1 BOM | Source of Truth | +| Spring for GraphQL | 2.0 계열, 현재 2.0.4 | Stable | +| GraphQL Java | Boot 조합 우선, 현재 v25 계열 | Stable | +| GraphQL Specification | September 2025 | Contract | +| GraphQL over HTTP | Stage 2 Draft | Compatibility Profile | +| HTTP | Spring MVC / WebFlux | Stable | +| WebSocket | Spring GraphQL + `graphql-ws` protocol | Advanced Stable | +| SSE | Spring `GraphQlSseHandler` | Advanced | +| RSocket | Spring 전용 Extension | Experimental | +| Federation Subgraph | `federation-jvm` 통합 | Advanced | +| Federation Router | Core 밖 | Experimental/별도 프로젝트 | +| Multipart Upload | 지원하지 않음 | Unsupported | +| HTTP multi-operation batch | 초기 지원하지 않음 | Unsupported | +| Reactive | Reactor | Stable profile | +| Virtual Thread | Java 21 + Spring executor | Stable profile | + +**중요한 최신 조사 결과가 하나 있습니다.** 현재 GraphQL over HTTP Draft의 상태 코드 부분에는 partial `data + errors`에 `294`를 제안하는 새 문구가 들어가 있는 반면, Spring for GraphQL 2.0.4는 GraphQL request가 validation을 통과한 뒤 발생한 execution/field error를 HTTP `200`으로 반환한다고 명시합니다. 같은 Draft의 field-error 설명에도 실행된 operation의 field error는 `200`이라는 규칙이 남아 있어, 이 영역은 현재 이동 중인 표준입니다. 따라서 **Stable 플랫폼에서 294를 선제 도입하지 말고 Spring 2.0.4의 실제 동작을 계약으로 고정**한 뒤, HTTP Draft 호환 Job에서 변화만 추적하는 것이 맞습니다. citeturn16search10turn23view1 + +### 공개 기능 계층 + +| 계층 | 공개 대상 | 범위 | 정책 | +|---|---|---|---| +| **G1 Standard GraphQL API** | 일반 도메인 개발자 | SDL, Query/Mutation, annotated resolver, HTTP, validation, error, DataLoader, cursor pagination | 기본 Starter | +| **G2 Advanced Execution** | 복잡한 API 개발자 | persisted operation, fetch profile, custom directive/scalar, advanced cost, subscription, SSE | 명시 opt-in | +| **G3 GraphQL Extension** | 전문 통합 코드 | Federation, RSocket, provider codegen, custom WebSocket extension | 별도 모듈 | +| **G4 Admin Plane** | 운영·CI/CD | schema diff, persisted-op registry, operation block, cost profile, usage, composition | 애플리케이션 resolver와 분리 | + +일반 애플리케이션에는 `graphql.GraphQL`이나 자유형 `GraphQLCodeRegistry`를 기본 API로 공개하지 않는 편이 좋습니다. Spring Boot 자체도 일반적인 애플리케이션은 직접 `DataFetcher`를 작성하기보다 annotated controller를 사용하도록 안내하고, `RuntimeWiringConfigurer`는 scalar, directive, type resolver 같은 infrastructure extension을 위한 진입점으로 제공합니다. citeturn19search1turn18search2 + +권장 의존 구조는 다음과 같습니다. + +```text +graphql-core-api + ↑ +graphql-schema + ↑ +graphql-execution + ↑ +graphql-controller + ↑ +graphql-spring-boot-starter + +graphql-http ─────────┐ +graphql-websocket ────┤ +graphql-sse ──────────┤→ graphql-execution +graphql-dataloader ───┤ +graphql-pagination ───┤ +graphql-security ─────┤ +graphql-cost-control ─┤ +graphql-error ────────┤ +graphql-observability ┘ + +Optional: +graphql-persisted-operation +graphql-codegen +graphql-federation +graphql-spring-data-compat +graphql-testkit-* +``` + +특히 `graphql-spring-data-compat`를 별도 모듈로 두는 근거가 분명합니다. Spring for GraphQL 2.0.4는 `@GraphQlRepository`로 Querydsl/QBE repository를 자동 DataFetcher 등록할 수 있고, 자동 pagination은 **offset 기반, 기본 20개**입니다. 이 기능은 편리하지만 Persistence Model과 외부 Schema의 결합 및 pagination 정책 고착 가능성이 있으므로 Skeleton의 주류 API로 삼기보다 allowlist 기반 호환 기능으로 한정하는 것이 적절합니다. citeturn24view0 + + +## Schema 계약과 진화 정책 + +GraphQL Java는 programmatic schema와 SDL을 모두 제공하지만, 어느 방식을 선택해야 할지 확신이 없다면 SDL을 권장합니다. Spring Boot는 기본적으로 `src/main/resources/graphql/**`의 `.graphqls`, `.gqls`를 읽고, `classpath*:graphql/**/` 형태로 여러 모듈의 schema fragment도 조립할 수 있습니다. 따라서 **SDL First + module-owned fragment + platform-governed assembly**를 기본 계약으로 확정하는 것이 적절합니다. citeturn17search5turn19search1 + +```text +modules/order/src/main/resources/graphql/order/ +├── order-type.graphqls +├── order-query.graphqls +└── order-mutation.graphqls + +modules/graphql-schema/src/main/resources/graphql/common/ +├── scalar.graphqls +├── error.graphqls +├── connection.graphqls +└── directive.graphqls +``` + +플랫폼은 Schema 자체를 독점하지 않습니다. **도메인 모듈이 자기 Schema coordinate와 resolver를 소유하고**, `graphql-schema`는 이름 규칙, scalar, directive, schema assembly, validation, compatibility checker를 소유해야 합니다. + +### Type System 계약 + +**Output과 Input은 분리**합니다. + +```graphql +type Order { + id: ID! + status: OrderStatus! + createdAt: Instant! +} + +input CreateOrderInput { + customerId: ID! + items: [CreateOrderItemInput!]! +} +``` + +JPA Entity, Mongo Document 또는 provider SDK model을 Input/Output으로 재사용하지 않습니다. 이는 Persistence refactoring을 API breaking change로 만드는 것을 방지하고, 입력에 공개해서는 안 될 persistence field가 포함되는 것을 차단합니다. + +**Nullability는 단순 Java null annotation이 아니라 장애 격리 경계**로 취급해야 합니다. GraphQL에서 nullable이 기본이고 `!`가 Non-Null이며, Non-Null field가 실행 중 null이 되면 오류가 부모 Non-Null 경계를 따라 전파됩니다. 따라서 `String!`은 “평상시 DB에서 null이 아니다”라는 의미가 아니라 **정상 데이터, 권한 처리, dependency failure, resolver mapping을 포함해 이 field를 항상 제공할 수 있다**는 API 보장이어야 합니다. citeturn15search0 + +권장 규칙은 다음과 같습니다. + +| 상황 | 권장 | +|---|---| +| Aggregate identity | `ID!` | +| 반드시 존재하는 immutable value | Non-Null 후보 | +| 외부 서비스가 채우는 enrichment | Nullable 우선 | +| 권한에 따라 값을 반환하지 못할 수 있음 | Nullable 또는 별도 권한 모델 | +| Resolver가 부분 실패할 수 있는 expensive child | Nullable boundary | +| List 자체가 항상 존재 | `[T!]!` 후보 | +| List element 자체가 실패 가능 | `[T]` 또는 구조 재설계 | +| 신규 field | 기본 nullable로 도입 후 보장 검증 | +| Non-Null 전환 | 데이터 + resolver contract gate 필수 | + +GraphQL `ID`는 외부 API 관점에서 opaque identifier로 사용해야 하며, 데이터베이스 PK 형식을 GraphQL contract로 약속하지 않는 것이 좋습니다. DB가 `Long`, `UUID`, Mongo `ObjectId`여도 resolver mapper에서 external ID로 변환하면 됩니다. GraphQL 규격 자체도 ID를 식별자용 scalar로 정의하며 string 형태로 직렬화합니다. citeturn15search0 + +**September 2025 규격의 `@oneOf`**는 정확히 하나의 input field가 제공되고 그 값이 non-null이어야 하는 Input Object를 표현합니다. 각 구성 field 자체는 nullable이고 default value를 가질 수 없습니다. 따라서 상호 배타적인 여러 selector를 임의 validator보다 Schema 자체로 표현하는 데 적합합니다. citeturn15search0 + +```graphql +input OrderSelector @oneOf { + id: ID + orderNumber: String + externalReference: String +} +``` + +플랫폼에서는 `@oneOf`를 **Spec-Stable 기능**으로 분류하되 GraphQL Java/Spring 조합의 schema build 및 coercion contract test를 release gate에 포함하는 것이 좋습니다. + +**Scalar 정책**은 다음처럼 제한하는 것이 적절합니다. + +| Scalar | 판정 | 계약 | +|---|---|---| +| `ID` | Stable | opaque | +| `UUID` | Stable custom | canonical string | +| `Instant` | Stable custom | UTC timestamp | +| `Date` | Stable custom | calendar date | +| `BigDecimal` | Stable custom | 정확도 손실 없는 문자열/명시 coercion | +| `Long` | Stable custom | JS client 범위 고려 | +| `URL` | Advanced | parser/normalization 명시 | +| `Email` | Advanced | format validation과 ownership validation 분리 | +| `JSON` | Restricted | 명시 필드에만 allowlist | +| `Upload` | Unsupported | Fileserver 사용 | + +자유형 `JSON`은 typed GraphQL validation을 우회하므로 “모든 것을 넣는 escape hatch”로 제공해서는 안 됩니다. Custom scalar에는 명확한 serialization/coercion contract를 두고, 필요하면 GraphQL 규격의 `@specifiedBy`를 사용해 의미를 명시할 수 있습니다. citeturn15search0 + +Spring for GraphQL은 `graphql-multipart-request-spec`을 직접 지원하지 않으며 공식 문서도 GraphQL이 텍스트 데이터 교환을 중심으로 하고 별도의 비공식 multipart 규격이 존재한다고 설명합니다. 따라서 `Upload` scalar, multipart parser, binary streaming을 Core에 넣지 않고 기존 Fileserver에서 upload reservation/ticket을 발행하는 구조가 적합합니다. citeturn23view2 + +```text +GraphQL createFileUpload(...) + ↓ +Fileserver Application Use Case + ↓ +fileId + upload URL/ticket + +Binary +Client ──────────→ Fileserver/Object Storage + +GraphQL + ↓ +metadata / status / reference only +``` + +### Schema assembly과 startup 검증 + +Spring for GraphQL 2.0.4의 `SchemaMappingInspector`는 Schema field에 DataFetcher 또는 Java property mapping이 있는지, 존재하지 않는 Schema field에 DataFetcher가 등록됐는지, argument와 nullness가 Schema와 일치하는지 등을 startup에서 검사할 수 있습니다. 이를 단순 INFO report로 남기지 말고 Stable 플랫폼에서는 **CI 실패 또는 startup failure policy**로 승격하는 것이 좋습니다. citeturn18search3 + +Schema build gate는 최소한 다음 계약을 검사해야 합니다. + +```text +SDL parse +→ GraphQL schema validation +→ duplicate type / field / directive +→ scalar wiring +→ interface / union TypeResolver +→ resolver mapping inspection +→ argument mapping +→ nullability mapping +→ forbidden scalar/directive +→ schema compatibility +→ schema fingerprint +``` + +### Schema Evolution과 breaking-change 기준 + +September 2025 규격에서는 field뿐 아니라 argument, input field, enum value에도 `@deprecated`를 적용할 수 있습니다. 다만 default가 없는 required non-null argument/input field는 바로 deprecate할 수 없으며, 먼저 nullable로 만들거나 default를 부여해야 합니다. citeturn15search0 + +플랫폼 compatibility checker는 단순 “SDL diff”와 **wire compatibility**, **generated-client source compatibility**를 구분해야 합니다. + +| 변경 | Wire 판정 | Generated Client 위험 | 기본 정책 | +|---|---|---|---| +| nullable output field 추가 | 호환 | 낮음 | 허용 | +| non-null output field 추가 | 기존 operation에는 호환 | 생성 모델 변경 | 데이터 보장 검증 | +| field 삭제/rename | Breaking | 높음 | 금지 | +| output `T! → T` | Breaking | 높음 | 금지 | +| output `T → T!` | 대체로 강화 | source type 변경 가능 | Review | +| optional argument 추가 | 호환 | 낮음 | 허용 | +| required argument 추가 | Breaking | 높음 | 금지 | +| input `T → T!` | Breaking | 높음 | 금지 | +| input `T! → T` | 호환 방향 | 생성 모델 변경 | 허용+Review | +| optional input field 추가 | 호환 | 낮음 | 허용 | +| input field 삭제 | Breaking | 높음 | 금지 | +| enum value 삭제 | Breaking | 높음 | 금지 | +| output enum value 추가 | protocol additive | exhaustive switch 위험 | Client impact review | +| union/interface possible type 추가 | protocol additive | exhaustive codegen 위험 | Client impact review | +| scalar coercion 변경 | 사실상 Breaking | 높음 | 새 Scalar/version | +| directive 의미 변경 | Behavioral Breaking 가능 | 다양 | Review | +| deprecated element 유지 | 호환 | 경고 | Usage gate 적용 | + +삭제 정책은 다음과 같이 운영하는 편이 좋습니다. + +```text +@deprecated(reason: "Use ...") + ↓ +Schema Usage Observation + ↓ +Persisted Operation Reference Scan + ↓ +지원 종료 기간 + ↓ +Client owner 승인 + ↓ +Breaking Schema Release +``` + +Schema hash 하나만으로는 충분하지 않습니다. `GraphQlSchemaContract`에는 최소 `schemaHash`, `breakingPolicyVersion`, `scalarManifestVersion`, `directiveManifestVersion`을 포함시키는 것이 좋습니다. + + +## Transport와 실행·데이터 접근 계약 + +Spring for GraphQL의 핵심 실행 추상화는 `ExecutionGraphQlService`이고, HTTP·WebSocket 등 transport가 여기에 요청을 위임합니다. 이 구조를 플랫폼의 실제 내부 경계로 그대로 활용하면 transport policy와 GraphQL execution policy를 분리하기 좋습니다. citeturn18search0 + +권장 파이프라인은 다음입니다. + +```text +Transport Adapter + ↓ +Request Envelope Validation + ↓ +Authentication + ↓ +GraphQlRequestContext creation + ↓ +WebGraphQlInterceptor + ↓ +Persisted Operation lookup + ↓ +Parse / Validate + ↓ +OperationPolicy + ↓ +CostPolicy + ↓ +ExecutionGraphQlService + ↓ +Annotated Controller / DataFetcher + ↓ +Application Service + ↓ +Completion / Error Mapping +``` + +### Transport 지원 매트릭스 + +GraphQL over HTTP Draft는 POST를 MUST로 하고 GET을 MAY로 정의하지만, 현재 Spring for GraphQL의 server HTTP profile은 JSON body를 사용하는 POST를 기본 계약으로 합니다. Spring은 `application/graphql-response+json`에서 parse/validation failure에 4xx를 사용하고, validation을 통과해 execution이 시작된 뒤의 오류는 GraphQL `errors`와 HTTP 200으로 반환합니다. citeturn22view0turn23view1 + +| Transport | Spring 기능 | 등급 | 플랫폼 계약 | +|---|---|---|---| +| HTTP POST JSON | 기본 지원 | **Stable** | Query/Mutation | +| HTTP GET Query | HTTP Draft는 허용, Spring 기본 server profile과 차이 | Experimental | 초기 비지원 | +| `application/graphql-response+json` | 지원 | **Stable Preferred** | 새 client 기본 | +| legacy `application/json` response | 지원 | Stable Compat | 이전 client | +| WebSocket | `graphql-ws` 기반 | **Advanced Stable** | Subscription 중심 | +| SSE | Distinct Connection | Advanced | Subscription-only | +| RSocket | request-response/request-stream | Experimental | 내부 시스템 한정 | +| multipart upload | Spring 직접 미지원 | Unsupported | Fileserver | +| HTTP array batch | GraphQL core가 아님 | Unsupported | 필요 시 별도 Extension | + +Spring의 SSE 구현은 POST `application/json` + `Accept: text/event-stream`을 사용하고 **Distinct connections mode**만 구현하며, Query/Mutation이 아니라 Subscription의 대안으로 문서화되어 있습니다. WebSocket은 현재 `graphql-ws` 계열 protocol을 사용하고 과거 `subscriptions-transport-ws`는 inactive/superseded 상태입니다. RSocket에서는 Query/Mutation이 request-response, Subscription이 request-stream으로 처리됩니다. citeturn23view1turn23view3 + +HTTP Stable Profile은 다음처럼 명시하는 것이 좋습니다. + +```text +Method: + POST only + +Content-Type: + application/json + +Accept: + application/graphql-response+json preferred + application/json compatibility + +Body: + query + operationName + variables + extensions + +Policies: + requestBytes + variableBytes + extensions allowlist + named-operation requirement in production + CORS allowlist + CSRF profile according to credential mode + compression threshold + responseBytes + request timeout +``` + +GraphQL over HTTP Draft도 `query`, `operationName`, `variables`, `extensions`라는 요청 parameter를 정의하고, request media type은 `application/json`, response media type은 `application/graphql-response+json`으로 규정하고 있습니다. 다만 Stage 2 Draft이므로 `GraphQlHttpProfile.V1`처럼 플랫폼 Profile을 명시적으로 versioning하는 것이 중요합니다. citeturn22view0 + +### 실행 Profile + +Spring for GraphQL은 기본적으로 GraphQL Java의 비동기 실행 모델을 활용하며, reactive resolver는 `CompletionStage` 형태로 execution에 결합되고 Subscription에서는 `Publisher`가 유지됩니다. Java 21에서는 `@SchemaMapping`, `@BatchMapping` 등의 blocking `Callable`을 Virtual Thread executor에 보낼 수 있고, Spring Boot는 `spring.threads.virtual.enabled` 설정 시 annotated controller용 virtual-thread executor를 구성합니다. citeturn18search0turn18search2 + +권장 Profile은 세 가지입니다. + +| Profile | 적합한 workload | 규칙 | +|---|---|---| +| `BLOCKING_MVC` | JPA, blocking Mongo, blocking SDK | Java 21 VT 또는 bounded executor | +| `REACTIVE_WEBFLUX` | Reactive Mongo, WebClient, 높은 Subscription 수 | blocking 금지, Reactor Context | +| `MIXED_CONTROLLED` | 기존 blocking + 일부 reactive | 명시 adapter 필수, event-loop blocking 검출 | + +`MIXED`를 “아무 반환 타입이나 허용”이라는 의미로 사용해서는 안 됩니다. Resolver catalog에 `BLOCKING`, `ASYNC`, `REACTIVE`, `STREAM` 실행 유형을 등록하고 platform test가 WebFlux event loop에서 blocking repository가 호출되지 않는지 검증하는 편이 안전합니다. + +Spring은 GraphQL 전체 요청에 `TimeoutWebGraphQlInterceptor`를 제공하며 timeout 시 reactive data fetcher 쪽으로 cancellation 신호를 전달합니다. Streaming request에서는 stream이 성립될 때까지만 이 request timeout이 적용되고, 장기 Subscription에는 transport별 timeout을 별도로 구성해야 합니다. citeturn18search0turn18search4 + +따라서 timeout은 한 값이 아니라 다음 계층으로 나누어야 합니다. + +```text +transportHandshakeTimeout +requestExecutionTimeout +resolverBudget +databaseDeadline +httpClientDeadline +dataLoaderBatchTimeout +subscriptionIdleTimeout +subscriptionMaxAge +shutdownDrainTimeout +``` + +### Resolver와 Application Service 경계 + +Annotated controller는 Spring for GraphQL에서 Schema field와 DataFetcher를 연결하는 표준적 고수준 진입점입니다. `@QueryMapping`, `@MutationMapping`, `@SubscriptionMapping`, `@SchemaMapping`, `@BatchMapping`을 주류 API로 사용하고, raw `DataFetcher` 등록은 custom scalar/directive/federation 등 명시된 SPI로 제한하는 것이 적절합니다. citeturn18search2 + +```java +@Controller +final class OrderGraphQlController { + + private final FindOrderUseCase findOrder; + private final CreateOrderUseCase createOrder; + + @QueryMapping + OrderView order(@Argument String id, GraphQlRequestContext context) { + return findOrder.find(new FindOrderQuery(context.actor(), id)); + } + + @MutationMapping + CreateOrderPayload createOrder( + @Argument CreateOrderInput input, + GraphQlRequestContext context) { + + return createOrder.execute(input.toCommand(context.actor())); + } +} +``` + +```text +Allowed in Resolver +├─ GraphQL input coercion 이후 transport DTO 변환 +├─ Bean Validation +├─ Actor/Tenant/Locale context 전달 +├─ Application Use Case 호출 +└─ GraphQL DTO/Payload mapping + +Not Allowed +├─ EntityManager query +├─ MongoTemplate query +├─ HTTP retry/circuit breaker 구현 +├─ Kafka/Rabbit ACK 처리 +├─ ObjectStorage binary IO +├─ multi-step domain state transition +└─ provider exception을 그대로 client에 노출 +``` + +반환 기본형은 `DTO`, `ReadModel`, `Connection`, `MutationPayload`, `Publisher`이고 `Entity`, `Document`, provider SDK model, 자유형 `Map`은 금지 후보입니다. + +### Selection Set과 Fetch Profile + +Spring for GraphQL의 공식 Data Integration 문서는 Selection Set을 DB query로 직접 번역하는 gateway가 아니라고 명시합니다. 또한 DTO/interface projection과 Selection Set을 함께 사용할 수 있다고 설명합니다. citeturn24view0 + +따라서 다음 방식이 가장 안전합니다. + +```text +GraphQL Selection + ↓ +SelectionClassifier + ↓ +registered FetchProfile + ↓ +Application Query + ↓ +JPA/Mongo Repository +``` + +예를 들면: + +```text +Order.BASIC + id status createdAt + +Order.WITH_ITEMS + BASIC + items + +Order.WITH_CUSTOMER + BASIC + customer + +Order.FULL_DETAIL + BASIC + items + customer + paymentSummary +``` + +GraphQL 플랫폼은 `DataFetchingFieldSelectionSet`을 보고 **유한 집합의 Fetch Profile 중 하나를 선택**할 수 있지만, 실제 SQL join fetch, EntityGraph, DTO projection, Mongo aggregation은 JPA/Mongo 모듈에 남겨둡니다. + +이 구조는 다음 실패를 동시에 막습니다. + +```text +Selection 조합마다 SQL plan 생성 +필드 추가가 즉시 DB column exposure로 연결 +computed field를 DB column으로 오인 +권한 field를 projection에 잘못 포함 +association lazy access로 N+1 발생 +Mongo/JPA의 서로 다른 fetch semantics를 GraphQL이 흡수 +``` + +`@GraphQlRepository` 자동 노출은 이 원칙을 우회할 수 있으므로 `graphql-spring-data-compat`에서 등록 가능한 repository와 argument/filter를 명시적으로 allowlist해야 합니다. Spring의 자동 등록 기능 자체가 GraphQL arguments를 Querydsl predicate나 QBE로 바꾸고 offset pagination까지 수행하기 때문에, 플랫폼의 기본 추상화로 사용하면 저장소 세부가 Schema 계약 쪽으로 빠르게 올라옵니다. citeturn24view0 + + +## DataLoader·Pagination·Mutation·Error 계약 + +### DataLoader는 요청 단위 Batch Planner + +GraphQL Java/Spring의 DataLoader는 Graph 탐색 중 발생하는 반복 fetch를 모아 batch load하고, 요청 범위 cache를 이용해 동일 key의 중복 load를 줄이는 수단입니다. Spring for GraphQL은 `BatchLoaderRegistry`와 `@BatchMapping`을 제공하고, DataLoader cache는 요청 내부에서 동작합니다. citeturn14search5turn18search2 + +따라서 다음 규칙을 Stable contract로 삼는 것이 좋습니다. + +```text +DataLoader instance +→ 반드시 request scoped + +DataLoader cache +→ 한 GraphQL execution 내부만 + +Cross-request caching +→ Redis / Application Cache responsibility + +Authorization +→ BatchLoader도 동일 Actor/Tenant Context 사용 + +Batch I/O +→ JPA IN / Mongo $in / HTTP batch API 한도에 맞춰 chunk + +Result +→ ordered loader면 key와 동일 순서 +→ mapped loader면 key 기반 명시 대응 +``` + +요청 간 DataLoader singleton을 공유하지 않는 것이 특히 중요합니다. 사용자·tenant별 데이터가 cache에 남는 경우 교차 사용자 데이터 유출까지 이어질 수 있기 때문입니다. GraphQL Java의 DataLoader 문서도 사용자별 데이터를 다루는 경우 per-request DataLoader 사용을 권장합니다. citeturn10search3 + +Batch 계약은 다음 데이터를 보존해야 합니다. + +```java +record GraphQlBatchPolicy( + String loaderName, + int maxBatchSize, + Duration timeout, + MissingKeyPolicy missingKeyPolicy, + BatchErrorPolicy errorPolicy +) {} +``` + +`@BatchMapping`에서 ordered collection을 반환할 경우 source/parent와 같은 순서여야 하고, `Map` 반환형을 이용하면 key별 대응을 명시할 수 있습니다. citeturn18search2 + +GraphQL Java 25에는 chained DataLoader의 자동 dispatch 기능이 추가됐지만 opt-in이고 dispatch ordering을 변화시킬 수 있으므로 **G2 Advanced + 별도 회귀 테스트**가 적절합니다. 기존 N+1 해결만을 위해 Stable 기본값으로 켜지 않는 것이 좋습니다. citeturn10search3 + +DataLoader와 저장소 기술의 선택 기준은 다음과 같습니다. + +| 문제 | 우선 수단 | +|---|---| +| 동일 parent type의 child ID 반복 조회 | DataLoader | +| Root query 자체가 과도한 Entity graph 적재 | DTO Projection / Fetch Profile | +| JPA 단일 aggregate에서 반드시 함께 읽음 | JPA fetch plan | +| Mongo 내 `$lookup`이 본질적으로 적합 | Mongo Aggregation | +| Downstream이 batch API 제공 | HTTP batch DataLoader | +| Downstream이 batch API 없음 | application aggregator + concurrency/bulkhead | +| unbounded child collection | DataLoader가 아니라 Pagination | + +### Cursor Connection + +Spring for GraphQL 2.0.4는 Connection/Edge/PageInfo 패턴과 `first`, `after`, `last`, `before` 입력을 지원하며 Spring Data `Window`와 `Slice`를 Connection으로 adapter할 수 있습니다. 또한 keyset cursor를 JSON으로 직렬화하고 Base64 encoding하는 전략도 제공합니다. citeturn24view0 + +다만 **Base64는 encoding이지 무결성 보호가 아닙니다.** Backend Skeleton에서 cursor를 security-sensitive server state token으로 취급한다면 다음 envelope를 권장합니다. + +```json +{ + "v": 1, + "profile": "orders-by-created-at", + "direction": "FORWARD", + "keyset": { + "createdAt": "2026-08-12T01:00:00Z", + "id": "..." + }, + "filter": "sha256:...", + "kid": "cursor-key-2026-01", + "mac": "..." +} +``` + +Cursor 계약: + +```text +Opaque to client +Versioned +Query profile bound +Filter fingerprint bound +Sort/tie-breaker included +HMAC authenticated +Unknown version rejected +Page size revalidated server-side +Sensitive raw values 최소화 +``` + +저장소별 실제 pagination은 GraphQL이 아닌 storage/application layer가 소유합니다. + +```text +JPA +→ keyset / ScrollPosition + +MongoDB +→ range predicate + _id tie-breaker + +External API +→ upstream opaque cursor를 signed envelope 안에 보관 +``` + +`totalCount`는 기본 Connection field로 강제하지 않는 편이 좋습니다. 큰 relation에서는 page fetch보다 count가 더 비쌀 수 있기 때문에, 필요한 Connection profile에만 explicit resolver로 제공합니다. + +### Mutation과 Transaction + +GraphQL 규격은 Mutation root field를 문서 순서대로 serial execution하지만, **그 순차성이 데이터베이스 transaction을 의미하지는 않습니다**. Spring for GraphQL 2.0.4의 현재 Data Integration 문서도 GraphQL 자체에 transaction semantics가 없다고 명시하고, transaction-per-controller-method를 가장 단순한 권장 방식으로 설명합니다. 여러 DataFetcher 전체에 request-wide transaction을 걸려면 execution을 serial하게 만드는 등 훨씬 큰 제약이 필요합니다. citeturn15search0turn24view0 + +따라서 플랫폼 기본 계약은 다음으로 확정하는 것이 좋습니다. + +```text +Mutation Resolver + ↓ +one Application Use Case + ↓ +one explicit transaction boundary +``` + +더 구체적으로는 transaction annotation 자체도 Resolver보다 Application Service에 두는 것이 아키텍처 경계를 더 잘 유지합니다. + +```java +@Service +final class UpdateOrderService implements UpdateOrderUseCase { + + @Transactional + public UpdateOrderResult execute(UpdateOrderCommand command) { + // domain operation + } +} +``` + +```text +mutation { + updateOrder(...) + createInvoice(...) +} +``` + +위 두 root field는 **순차 실행될 뿐 독립 Use Case Transaction**입니다. 두 작업이 반드시 atomic해야 한다면 client가 root mutation 두 개를 조합하도록 두지 말고: + +```graphql +type Mutation { + confirmOrderAndCreateInvoice( + input: ConfirmOrderInput! + ): ConfirmOrderPayload! +} +``` + +처럼 **하나의 Application Use Case를 표현하는 Mutation**을 제공하는 것이 맞습니다. Spring 문서 역시 여러 변경을 하나의 transaction으로 유지해야 한다면 필요한 모든 input을 하나의 mutation method가 받도록 설계하는 방식을 권고합니다. citeturn24view0 + +Optimistic concurrency도 GraphQL 자체 기능이 아니라 Use Case contract로 전달합니다. + +```graphql +input UpdateOrderInput { + orderId: ID! + expectedVersion: Long! + status: OrderStatus! +} +``` + +Idempotency 역시 플랫폼 Extension입니다. 권장 범위는 HTTP request 전체가 아니라 **side-effecting Mutation Use Case**입니다. + +```text +idempotencyKey ++ actor/client identity ++ mutation coordinate ++ normalized business input hash +→ Idempotency Record +``` + +HTTP `Idempotency-Key`를 수용하더라도 이를 Mutation Context로 변환해 Application Use Case의 idempotency mechanism에 전달해야 하며, GraphQL transport 자체가 DB replay policy를 구현해서는 안 됩니다. + +### Error contract + +GraphQL의 중요한 장점은 실행 중 field error가 발생해도 가능한 data를 함께 반환할 수 있다는 점입니다. Non-Null field error는 부모 경계로 전파될 수 있으므로 Error model과 Nullability policy는 함께 설계해야 합니다. citeturn15search0 + +권장 네 계층은 다음과 같습니다. + +| 계층 | 발생 시점 | `path` | HTTP Stable Profile | 표현 | +|---|---|---:|---|---| +| `REQUEST_ERROR` | parse/validation/coercion | 보통 없음 | 4xx with new media type | `errors` | +| `FIELD_ERROR` | resolver execution | 있음 | 200 | partial `data` + `errors` | +| `BUSINESS_RESULT` | 예상 가능한 업무 결과 | 보통 data | 200 | typed payload/union 우선 | +| `INTERNAL_ERROR` | 예상 밖 장애 | 있음/없음 | 실행 후면 200 | opaque `errors` | + +Spring for GraphQL에서 unresolved DataFetcher exception은 기본적으로 `INTERNAL_ERROR`와 `executionId`가 들어간 의도적으로 불투명한 message로 바뀌며, request execution 전에 발생한 global parse/validation error는 `DataFetcherExceptionResolver`가 처리할 수 없습니다. Subscription publisher의 사후 오류에는 별도 `SubscriptionExceptionResolver`가 있습니다. citeturn18search0turn23view0 + +권장 Wire Error는 다음과 같습니다. + +```json +{ + "message": "요청을 처리할 수 없습니다.", + "path": ["order", "payment"], + "extensions": { + "code": "PAYMENT_DEPENDENCY_UNAVAILABLE", + "category": "DEPENDENCY", + "retryable": true, + "executionId": "..." + } +} +``` + +공개 가능한 `extensions`는 allowlist로 고정해야 합니다. + +```text +code +category +retryable +executionId +constraint +field // safe logical input field only +``` + +다음은 공개 금지입니다. + +```text +Java exception class +stack trace +SQL +JPQL +Mongo query +HTTP downstream URL with credentials +provider error body +database identifier +tenant id raw value +access token +internal host +``` + +예상 가능한 업무 상태는 가능하면 GraphQL errors보다 typed data로 표현하는 방식을 병행할 수 있습니다. + +```graphql +union CreateOrderResult = + CreateOrderSuccess + | OrderAlreadyExists + | InvalidOrderState +``` + +단, 모든 validation을 union으로 바꾸는 것도 바람직하지 않습니다. **Schema/input coercion 문제는 request error, 예상 가능한 업무 분기는 typed result, 예상 밖 실행 실패는 GraphQL error**라는 기준이 가장 일관됩니다. + + +## 보안·비용 통제·Persisted Operation + +GraphQL endpoint는 URL 하나를 공유하므로 HTTP URL security만으로 operation이나 field별 접근권한을 구분할 수 없습니다. Spring for GraphQL도 이 점을 명시하며 서비스나 data-fetching 계층에서 `@PreAuthorize`, `@Secured` 같은 fine-grained security를 적용하도록 안내합니다. citeturn18search1 + +보안 모델은 다음 계층으로 분리해야 합니다. + +```text +Transport Authentication + ↓ +Client Profile Authorization + ↓ +Operation Authorization + ↓ +Field / Use Case Authorization + ↓ +Object Authorization + ↓ +Tenant Isolation +``` + +**Schema visibility와 authorization은 별개**입니다. GraphQL Java의 `GraphqlFieldVisibility`는 Schema에서 특정 field를 보이지 않게 할 수 있지만, object instance에 대해 사용자가 실제로 접근해도 되는지를 검증하는 수단은 아닙니다. 따라서 “introspection에서 숨겼다 = 보호됐다”라는 설계를 금지해야 합니다. citeturn17search18turn18search1 + +권장 request context는 transport DTO와 domain context를 뒤섞지 않고 immutable하게 구성합니다. + +```java +record GraphQlRequestContext( + Actor actor, + TenantContext tenant, + ClientProfile clientProfile, + Locale locale, + String operationId, + String traceId, + Deadline deadline +) {} +``` + +`tenantId`를 GraphQL argument에서 받아 신뢰하지 않고 authentication/session으로 결정된 `TenantContext`를 Application Service와 BatchLoader까지 전달해야 합니다. + +### Introspection·GraphiQL + +Spring Boot에서 Schema introspection은 기본 허용이며 설정으로 비활성화할 수 있고, GraphiQL은 기본 비활성입니다. citeturn19search1 + +권장 운영 정책은 다음과 같습니다. + +| 환경 | Introspection | GraphiQL | Schema Printer | +|---|---|---|---| +| Local | Allow | Allow | Allow | +| Test | Allow | Optional | CI only | +| Dev | Authenticated | Authenticated | Admin | +| Staging | Admin/CI profile | Off | Admin | +| Prod internal | Client profile 기반 | Off | G4 Admin | +| Prod public | 제한 또는 Off | Off | Off | + +Introspection을 꺼도 resolver authorization이나 cost defense가 대체되지 않습니다. 실제 방어는 Field/Object Authorization, Operation Policy, Cost Limit, request size limit, Persisted Operation 등에서 이루어져야 합니다. + +### Operation cost와 DoS 방어 + +GraphQL Java 25에는 parser 수준의 query character/token/whitespace/grammar-depth 제한과 query depth/complexity instrumentation이 있습니다. 공식 문서의 library 기본 ceiling은 query characters 약 1 MiB, token 15,000, whitespace token 200,000, grammar rule depth 500 등으로 상당히 넓으므로, 이를 그대로 public API의 business limit로 사용하는 대신 플랫폼에서 더 작은 profile을 별도로 두는 것이 좋습니다. citeturn10search1turn10search2 + +방어는 “Depth 한 개”가 아니라 다음 순서가 적절합니다. + +```text +HTTP body bytes + ↓ +variables bytes + ↓ +persisted-operation lookup / request format + ↓ +parser character/token/grammar limits + ↓ +GraphQL parse + ↓ +GraphQL validation + ↓ +operation count/name/type + ↓ +introspection policy + ↓ +selection depth + ↓ +field/alias/fragment count + ↓ +input list/string limits + ↓ +complexity + ↓ +estimated response node budget + ↓ +execution timeout + ↓ +runtime response-byte limit +``` + +Complexity는 단순 field count보다 **cardinality와 resolver 특성**을 포함해야 합니다. + +```text +FieldCost = + baseFieldCost + + resolverWeight + + childCost × effectiveCardinality +``` + +예를 들면 다음처럼 catalog를 관리할 수 있습니다. + +| Resolver class | 상대 weight 예 | +|---|---:| +| in-memory scalar/property | 1 | +| indexed DB lookup | 2 | +| batched relation | 3 | +| bounded aggregation | 8 | +| external service batch | 10 | +| external per-object request | 20 | +| search/heavy aggregation | 별도 승인 | + +위 숫자는 표준값이 아니라 **초기 calibration용 상대 weight**입니다. Release benchmark에서 실제 latency, DB statement 수, examined-row/document 수, downstream call 수와 비교해 조정해야 합니다. + +특히 List/Connection은 다음처럼 계산해야 합니다. + +```text +requested first = 50 +child subtree cost = 10 + +connection cost +≈ root cost + 50 × child cost +``` + +Client가 `first`를 생략했다고 비용을 1로 계산해서는 안 되고 `defaultPageSize`를 사용해야 하며, `first > maxPageSize`는 execution 전에 거부해야 합니다. + +Cost Profile은 다음처럼 구성할 수 있습니다. + +```java +record GraphQlClientPolicy( + int maxDocumentBytes, + int maxVariablesBytes, + int maxDepth, + int maxFields, + int maxAliases, + int maxFragments, + int maxInputListElements, + int maxPageSize, + long maxComplexity, + long maxResponseNodes, + Duration maxExecutionTime, + boolean introspectionAllowed, + boolean persistedOperationOnly +) {} +``` + +```text +PUBLIC +PARTNER +FIRST_PARTY +ADMIN +INTROSPECTION +``` + +각 실제 숫자는 부하 시험을 통과한 **환경별 manifest**에 두고 애플리케이션 코드에 산재시키지 않는 것이 좋습니다. + +### Operation name + +GraphQL 규격상 단일 anonymous operation은 유효할 수 있지만, 운영 환경에서 anonymous operation은 tracing, cost exception, persisted registry, usage 분석을 어렵게 합니다. 따라서 다음 정책이 실용적입니다. + +```text +Local +→ anonymous 허용 + +Dev/Test +→ 경고 + +Production FIRST_PARTY/PARTNER +→ named operation 필수 + +PUBLIC +→ named operation 또는 persisted-only +``` + +### Preparsed cache와 Persisted Operation 분리 + +GraphQL Java의 `PreparsedDocumentProvider`는 parsing/validation 결과인 Document를 재사용할 뿐 **실행 결과를 cache하지 않습니다**. 공식 문서도 이 점을 명확히 구분합니다. citeturn16search4 + +따라서 플랫폼의 세 cache를 절대 합치지 않아야 합니다. + +| 기능 | Cache 대상 | 권한 민감도 | 기본 | +|---|---|---|---| +| Preparsed Document Cache | parse/validation 결과 | schema/profile 의존 | Enabled bounded | +| Persisted Operation Registry | 승인된 operation text | client/profile 의존 | Advanced Stable | +| Response Cache | 실행 결과 | actor/tenant/permission 매우 민감 | Disabled | + +Preparsed key는 raw query 하나만으로 끝내기보다 적어도 다음을 고려해야 합니다. + +```text +documentHash +schemaContractHash +validationPolicyVersion +clientSchemaProfile +``` + +Field visibility나 validation rule이 client profile별로 달라지는데 query 문자열만 cache key로 사용하면 잘못 검증된 Document를 재사용할 가능성이 있기 때문입니다. + +Persisted Operation registry는 다음 형태가 적절합니다. + +```text +PersistedOperation +├─ operationId +├─ operationName +├─ sha256Document +├─ canonicalDocument +├─ schemaContractHash +├─ allowedClientProfiles +├─ maximumComplexity +├─ maximumVariablesBytes +├─ status: ACTIVE | DEPRECATED | BLOCKED +├─ registeredAt +└─ expiresAt? +``` + +실행은 다음처럼 합니다. + +```text +operationId + ↓ +registry lookup + ↓ +client allowlist + ↓ +document hash / schema compatibility + ↓ +variable validation + ↓ +cost policy + ↓ +execute +``` + +이렇게 하면 incident 시 특정 operation만 G4 Admin Plane에서 `BLOCKED` 처리할 수 있습니다. + +### WebSocket 인증과 장기 권한 + +Spring for GraphQL에는 WebSocket `connection_init` payload에서 인증 정보를 꺼내 인증한 후 SecurityContext를 이후 request로 전파하는 interceptor가 있습니다. citeturn18search12 + +그러나 장기 Subscription에서 인증은 connection 시점 한 번으로 끝내면 안 됩니다. Stable 정책 후보는 **credential expiry 시 connection 종료**입니다. + +```text +connection_init +→ authenticate +→ capture actor / tenant / credential expiry +→ connection_ack +→ subscribe +→ initial authorization +→ events +→ token expiry or revocation signal +→ complete/close +``` + +resource ownership이 수시로 바뀌는 매우 민감한 Subscription이라면 각 event를 Integration Event에서 GraphQL DTO로 바꾸기 전에 Application Authorization Service에 재검증하도록 별도 Profile을 둡니다. + + +## Subscription·Federation·Codegen·관측성 + +GraphQL Specification은 Subscription을 source event stream으로부터 response stream을 만드는 장기 operation으로 정의하지만, **transport protocol, ACK, buffering, replay, resend, QoS는 정의하지 않습니다**. 따라서 GraphQL Subscription을 Kafka/RabbitMQ 등의 durable messaging으로 간주할 수 없습니다. citeturn6search0turn22view0 + +경계는 다음과 같습니다. + +```text +Messaging Platform +├─ persistence +├─ offset +├─ ACK +├─ replay +├─ retry +└─ DLQ + +GraphQL Subscription +├─ client selection +├─ connected actor context +├─ authorization +├─ event → GraphQL DTO +├─ live delivery +└─ cancellation +``` + +권장 source 구조는 다음입니다. + +```text +Kafka / Rabbit / Application Publisher + ↓ + Subscription Source Adapter + ↓ + authorization / filtering + ↓ + Publisher + ↓ +GraphQL selection completion + ↓ +WebSocket or SSE +``` + +외부 Integration Event를 그대로 GraphQL type으로 반환하지 말고 stable GraphQL DTO로 변환해야 합니다. Messaging schema와 GraphQL schema의 lifecycle이 달라야 하기 때문입니다. + +### Subscription 상태 모델 + +```text +CONNECTING +→ AUTHENTICATING +→ READY +→ SUBSCRIBED +→ STREAMING +→ CANCELLING +→ COMPLETED + +Exceptional: +AUTH_EXPIRED +SLOW_CONSUMER +SOURCE_FAILED +SERVER_DRAINING +PROTOCOL_ERROR +``` + +관리할 connection policy: + +```text +connectionInitTimeout +heartbeat/ping-pong +idleTimeout +maxConnectionAge +maxSubscriptionsPerConnection +maxBufferedEvents +slowConsumerPolicy +subscriptionAuthPolicy +shutdownDrain +sourceCancellation +``` + +Spring WebFlux WebSocket handler는 non-blocking I/O와 backpressure를 사용하며 Subscription은 Reactive Streams `Publisher`로 처리됩니다. 따라서 수천~수만 장기 connection을 주요 목표로 한다면 Subscription transport는 `REACTIVE_WEBFLUX`가 기본 release lane이 되는 것이 타당합니다. citeturn23view1 + +기본 slow-consumer 정책은 **무음 event drop보다 connection 종료**를 권장합니다. GraphQL 자체에는 replay 표준이 없기 때문에 drop하면 client가 어느 event를 잃었는지 알 수 없습니다. Event loss가 업무상 허용되는 telemetry 성격의 기능만 별도 `LOW_LATENCY_DROP_ALLOWED` profile을 사용할 수 있습니다. + +### Event ordering + +Spring for GraphQL 2.0.4 문서는 nested asynchronous field fetch 때문에 Subscription item이 source 순서와 다르게 완료될 수 있음을 설명하며, `SubscriptionExecutionStrategy.KEEP_SUBSCRIPTION_EVENTS_ORDERED` flag로 buffering해 source ordering을 유지할 수 있습니다. citeturn23view0 + +따라서 두 Profile을 분리하는 것이 좋습니다. + +| Profile | 보장 | 대가 | +|---|---|---| +| `LOW_LATENCY` | 완료되는 즉시 전달 | event 순서 변화 가능 | +| `ORDERED` | upstream ordering 보존 | head-of-line blocking/버퍼 증가 | + +### Replay Extension + +GraphQL 표준 자체에는 resume token이 없으므로 다음 기능을 GraphQL Stable Core의 보장으로 광고하지 않습니다. citeturn6search0 + +내구성 있는 재연결이 실제 요구라면 G2/G3 Extension으로: + +```text +snapshotSequence +eventSequence +messagingOffset +subscriptionCursor +snapshot + live handoff +``` + +를 정의하고, 실제 replay guarantee는 Messaging Platform이 소유하도록 해야 합니다. + +### SSE와 WebSocket + +Spring SSE는 Distinct Connection mode이므로 Subscription 한 개당 HTTP streaming connection이라는 운영적 특성을 고려해야 합니다. HTTP/2가 connection 비용을 줄일 수 있지만 WebSocket multiplexing과 운영 특성이 다릅니다. citeturn23view1 + +권장 선택은: + +```text +브라우저 양방향 프로토콜·여러 subscription multiplexing +→ WebSocket + +단순 server→client stream, proxy 친화성 중요 +→ SSE Advanced + +Spring 내부 ecosystem의 RSocket 요구 +→ RSocket Experimental +``` + +### Federation + +Spring for GraphQL 2.0.4는 `federation-jvm` 통합과 `FederationSchemaFactory`, `@EntityMapping`을 제공하고 federated entity batch loading도 지원합니다. citeturn17search1 + +그러나 **Federation 지원 가능 = Skeleton 기본 architecture여야 함**은 아닙니다. + +권장 등급: + +```text +Single executable schema +→ Stable Default + +Federation Subgraph +→ Advanced Optional + +Federation Router/Supergraph 운영 +→ 별도 프로젝트 또는 Experimental + +Schema Stitching +→ Core 비지원 +``` + +Federation activation gate는 다음 조건을 요구하는 것이 좋습니다. + +```text +독립 배포되는 서비스 ++ schema ownership이 실제로 팀별 분리 ++ composition CI ++ router 운영 owner ++ distributed trace ++ cross-subgraph latency budget ++ entity key lifecycle policy ++ partial-failure policy +``` + +그렇지 않으면 Federation은 단순 Schema 분리보다 cross-subgraph N+1, network amplification, 배포 순서, entity key 변경, 권한 중복 같은 훨씬 큰 운영 비용을 가져옵니다. + +### Code Generation + +Spring for GraphQL 2.0.4의 Code Generation 문서는 DGS Codegen을 통해 client request/input/response selection types와 Schema data type을 생성할 수 있다고 설명하면서, **애플리케이션 자체의 data type은 로직을 넣어야 할 경우 code generation이 이상적이지 않을 수 있고 client type은 좋은 후보**라고 명시합니다. citeturn17search0 + +따라서 정책은 다음처럼 명확합니다. + +```text +Schema validation +→ 적극 사용 + +Operation validation +→ 적극 사용 + +Client request/response model generation +→ 지원 + +Transport-only DTO generation +→ 선택 지원 + +Domain Entity generation +→ 금지 + +Application Use Case interface generation +→ 금지 + +Repository generation from GraphQL schema +→ 금지 +``` + +즉: + +```text +SDL + ↓ +Generated GraphQL Transport Types (optional) + ↓ +Mapper + ↓ +Human-owned Application DTO / Use Case +``` + +를 유지합니다. + +### 관측성 + +Spring for GraphQL은 Micrometer 기반으로 `graphql.request`, non-trivial `graphql.datafetcher`, `graphql.dataloader` observation을 제공합니다. DataFetcher에는 field name/outcome/error type 등이, DataLoader에는 loader name/outcome/size 등이 관측 정보로 제공됩니다. citeturn14search2 + +플랫폼은 Spring observation을 재구현하지 말고 **명명 규칙과 cardinality policy를 추가**해야 합니다. + +```text +Request +graphql.request +├─ operationName +├─ operationType +├─ clientProfile +├─ persisted +├─ outcome +├─ errorCategory +├─ complexityBucket +├─ depthBucket +├─ duration +└─ responseBytes + +Resolver +graphql.datafetcher +├─ schemaCoordinate +├─ resolverCatalog +├─ outcome +└─ duration + +DataLoader +graphql.dataloader +├─ loaderName +├─ batchSize +├─ requestedKeys +├─ cacheEffect +└─ duration +``` + +저 cardinality catalog에 등록된 `operationName`, `schemaCoordinate`, `loaderName`은 사용할 수 있지만 raw query와 argument를 metric label로 사용해서는 안 됩니다. + +```text +Metric/Trace에 금지 +├─ raw GraphQL document +├─ variables +├─ userId +├─ raw tenantId +├─ object ID +├─ cursor +├─ Authorization header +├─ connection_init token +└─ arbitrary full field path +``` + +Trace에는 다음 구간이 연결되어야 합니다. + +```text +HTTP/WebSocket receive +→ GraphQL request +→ resolver +→ DataLoader +→ Application Use Case +→ DB/HTTP/Messaging +``` + +GraphQL Java v25의 Profiler는 DataFetcher, DataLoader와 execution timing 분석 기능을 제공하므로 Local/Dev 성능 분석이나 G4 Admin diagnostic에 유용하지만, public GraphQL response의 일반 extension으로 노출하지 않는 편이 적절합니다. citeturn17search15 + + +## 테스트·지원 등급·구현 순서와 Release Gate + +Spring의 `GraphQlTester`는 transport-independent 테스트 workflow를 제공하고 `HttpGraphQlTester`, `WebSocketGraphQlTester`, `RSocketGraphQlTester`, `ExecutionGraphQlServiceTester`, `WebGraphQlTester` 등의 변형을 제공합니다. 따라서 같은 operation document를 execution-level과 실제 transport-level에서 반복 검증하는 구조를 만들 수 있습니다. citeturn14search8 + +### 지원 범위 최종안 + +| Capability | 등급 | Release 조건 | +|---|---|---| +| SDL-first schema | **Stable** | schema/build compatibility gate | +| Query/Mutation annotated resolver | **Stable** | application boundary test | +| HTTP POST | **Stable** | media/status contract | +| `application/graphql-response+json` | **Stable preferred** | client compatibility | +| Partial data/error | **Stable** | null propagation test | +| Request context/security | **Stable** | actor/tenant isolation | +| DataLoader/BatchMapping | **Stable** | N+1/batch isolation | +| Cursor Connection | **Stable** | signed cursor/keyset tests | +| Cost control | **Stable** | attack + load tests | +| Preparsed cache | **Stable** | bounded/cache-key test | +| Persisted Operation | **Advanced Stable** | registry/admin tooling | +| Selection→registered Fetch Profile | **Advanced Stable** | plan/query regression | +| Virtual-thread MVC | **Stable Profile** | blocking workload load test | +| Reactive WebFlux | **Stable Profile** | BlockHound/equivalent gate | +| WebSocket Subscription | **Advanced Stable** | soak/reconnect/auth/cancel | +| SSE Subscription | **Advanced** | connection scalability | +| DataLoader chaining v25 | **Advanced** | dispatch regression | +| Federation Subgraph | **Advanced** | composition/trace/failure gate | +| Client Codegen | **Optional Stable tooling** | generated-source compatibility | +| Server transport DTO Codegen | Advanced | mapping policy | +| RSocket | **Experimental** | explicit consumer | +| HTTP GET | **Experimental** | HTTP draft compatibility | +| Federation Router | **Experimental/외부** | independent ops | +| Incremental delivery | **Experimental/초기 비지원** | spec/framework profile 확정 후 | +| Response Cache | **초기 비지원** | actor/tenant/cache-key 모델 확정 전 | +| Multipart Upload | **Unsupported** | Fileserver 사용 | +| HTTP array batch | **Unsupported** | 별도 extension 없이는 금지 | +| Entity/Document 자동 API 노출 | **Unsupported as default** | compat allowlist만 | +| GraphQL request-wide DB TX | **Unsupported as default** | 특수 instrumentation만 | + +### 계약 테스트 매트릭스 + +| 영역 | 반드시 검증할 계약 | +|---|---| +| Schema | SDL parse, duplicate, scalar, interface/union, mapping inspection, golden snapshot | +| Compatibility | field/arg/input/nullability/enum/union/directive diff | +| HTTP | media type, malformed JSON, parse/validation 4xx, execution error 200 | +| Resolver | DTO return, direct repository ban, context propagation | +| Query | variables, fragment, alias, directive, partial data | +| DataLoader | query count, duplicate key, missing key, ordering, cache scope, tenant isolation | +| Pagination | forward/backward, tie value, concurrent insert/delete, cursor HMAC, version | +| Mutation | transaction, second root failure, optimistic conflict, idempotency | +| Error | request/field/business/internal, null propagation, masking | +| Security | unauthenticated, field/object/tenant, introspection, WS auth | +| Cost | chars, token, depth, alias bomb, fragment bomb, nested list, variable size | +| Persisted | hash mismatch, schema mismatch, client deny, blocked operation | +| Subscription | connect, cancel, auth expiry, ordering, slow consumer, source failure | +| Federation | composition, entity key, batch entity load, partial subgraph failure | +| Observability | no raw query/PII tag, trace correlation, cardinality | +| Shutdown | in-flight query, WS drain, cancellation propagation | + +### 성능·장애 Release Gate + +Stable 선언 전에 다음 benchmark는 실제 storage/integration test 환경과 결합해야 합니다. + +```text +Named query high concurrency +Deep-but-valid query +Wide alias query +Nested Connection +DataLoader batch saturation +JPA connection pool saturation +Mongo pool saturation +HTTP downstream bulkhead saturation +Dependency timeout +Partial dependency failure +Large response serialization +Virtual thread saturation +Event-loop blocking detection +``` + +Subscription lane은 별도로: + +```text +1k persistent connections +→ 10k target connection test +→ multiple subscriptions per connection +→ event burst +→ slow consumer +→ cancel storm +→ auth expiry +→ server restart +→ rolling deployment +→ graceful drain +→ source restart +``` + +Spring WebSocket의 reactive path가 backpressure를 제공한다고 하더라도 **GraphQL 플랫폼의 bounded buffer와 downstream event source가 자동으로 안전해지는 것은 아니므로**, 실제 slow consumer 시 memory profile과 cancellation propagation을 계측해야 합니다. citeturn23view1 + +### 실무 실패 사례와 설계 규칙 + +| 실패 상황 | 직접 원인 | 플랫폼 규칙 | 회귀 테스트 | +|---|---|---|---| +| JPA Entity 직접 GraphQL 반환 | Persistence/API 결합, lazy access | DTO/ReadModel only | persistence association access 검사 | +| Field마다 repository query | N+1 | BatchMapping/DataLoader | DB query count | +| 전역 DataLoader | request 간 cache leakage | per-request loader | actor/tenant cross-request test | +| unbounded child list | response 폭증 | Connection + maxPage | complexity/node budget | +| depth만 제한 | wide alias attack | depth + fields + alias + complexity | alias bomb | +| selection→SQL 직접 생성 | plan 조합 폭증 | finite FetchProfile | query-plan snapshot | +| root mutations를 하나의 TX로 오인 | serial ≠ atomic | Use Case TX | second mutation failure | +| 모든 GraphQL error를 HTTP 500 | partial-data semantics 훼손 | request/field error 분리 | partial response | +| 내부 exception message 노출 | 정보 유출 | opaque INTERNAL_ERROR | SQL/stack leak | +| 자유형 JSON 입력 | Schema validation 우회 | typed input | unexpected-field/injection | +| `@GraphQlRepository` 무제한 사용 | persistence exposure | compat allowlist | forbidden filter/sort | +| multipart upload | fileserver 기능 중복 | upload ticket pattern | Upload scalar absence | +| Subscription을 durable queue로 간주 | replay/ACK 표준 없음 | Messaging + live adapter | disconnect loss | +| WS init 시점만 auth | 장기 권한 회수 미반영 | expiry/revalidation | role revoke/token expiry | +| raw query를 metric tag | cardinality/PII 폭증 | named operation/catalog | cardinality budget | +| usage 없이 field 삭제 | client breaking | deprecation + usage gate | schema diff | +| Federation 선제 도입 | distributed complexity | single schema default | composition opt-in gate | +| Base64 cursor를 신뢰 | client 변조 가능 | HMAC/version/fingerprint | tamper test | +| `totalCount` 항상 계산 | expensive count | explicit opt-in | count query regression | +| page size를 complexity에 미반영 | cheap-score bypass | list cardinality multiplier | `first=max` cost test | +| resolver timeout만 둠 | downstream work 지속 | cancellation/deadline propagation | timeout leak | +| reactive resolver에서 blocking DB | event-loop starvation | execution profile 검사 | event-loop blocking test | +| request-wide transaction | long TX·병렬성 상실 | mutation use-case TX | concurrency/lock test | +| field visibility를 auth로 사용 | 실제 object 권한 누락 | service/object auth | hidden-but-direct access | +| operation cache와 response cache 혼동 | stale/permission leak | cache 계층 분리 | cross-user test | +| Subscription unordered completion | async child fetch | explicit ORDERED profile | sequence test | +| slow client에서 무한 buffering | OOM | bounded buffer/close policy | slow consumer load | +| Schema와 resolver mapping 불일치 | silent null | startup inspector | application startup failure | + +### 단계별 구현 순서 + +**Foundation 단계**에서는 `graphql-core-api`, `graphql-schema`, `graphql-controller`, `graphql-http`, `graphql-error`, `graphql-security`, `graphql-testkit-core/http`를 구현합니다. 완료 조건은 SDL assembly, mapping inspection, HTTP media/status contract, immutable request context, DTO-only resolver convention, error masking, schema snapshot/compatibility test가 모두 CI에서 통과하는 것입니다. GraphQL Java의 SDL 권장 방식과 Spring의 schema resource/mapping inspection을 그대로 활용하고 재구현하지 않습니다. citeturn17search5turn18search3 + +```text +Foundation DONE += +Schema Contract ++ HTTP POST ++ Context ++ Resolver Boundary ++ Error Contract ++ Security ++ Testkit +``` + +**Execution Safety 단계**에서는 `graphql-dataloader`, `graphql-pagination`, `graphql-cost-control`, `graphql-observability`를 추가합니다. 완료 조건은 N+1 회귀 테스트, signed keyset cursor, max-page enforcement, depth/alias/complexity attack test, request timeout/cancellation, operation-name 기반 low-cardinality observation이 통과하는 것입니다. Spring의 DataLoader, connection adapter, Micrometer observation을 활용하되 플랫폼은 policy와 manifest만 추가합니다. citeturn14search5turn24view0turn14search2 + +```text +Execution Safety DONE += +No N+1 baseline regression ++ bounded pagination ++ cost budget ++ timeout/cancel ++ observability +``` + +**Operation Governance 단계**에서는 `graphql-persisted-operation`과 G4 Admin Plane을 구현합니다. 완료 조건은 immutable operation registry, schema hash 연동, client allowlist, operation block, usage measurement, preparsed cache와 registry 분리가 검증되는 것입니다. GraphQL Java의 `PreparsedDocumentProvider`는 parsed document cache일 뿐이라는 의미를 유지해야 합니다. citeturn16search4 + +```text +Governance DONE += +Persisted Registry ++ Operation Block ++ Schema Usage ++ Deprecation Gate ++ Cost Profile Management +``` + +**Realtime 단계**에서 `graphql-websocket`, 이후 필요할 때 `graphql-sse`를 활성화합니다. 완료 조건은 connection-init authentication, auth expiry, max connection age, cancellation, slow consumer, bounded buffer, source error, rolling shutdown, ordered/low-latency profile, 1k→목표 connection soak test입니다. Spring은 WebSocket/SSE transport와 ordering hook을 이미 제공하므로 플랫폼이 wire protocol을 새로 만들 필요는 없습니다. citeturn23view0turn23view1turn18search12 + +```text +Realtime DONE += +Auth lifecycle ++ bounded streaming ++ cancel ++ ordering profile ++ shutdown drain ++ load/soak test +``` + +**Extension 단계**에서만 `graphql-federation`, `graphql-codegen`, RSocket 등 G3 기능을 추가합니다. Federation Subgraph는 실제 독립 배포와 Schema ownership이 확인될 때만 Advanced로 승격하고, generated code는 GraphQL transport/client boundary에 한정합니다. Spring for GraphQL은 현재 Federation과 Codegen 양쪽 모두 공식 통합 지점을 제공하므로 Core에 별도 범용 framework를 만들 필요는 없습니다. citeturn17search1turn17search0 + +최종적으로 권장되는 플랫폼의 중심 API는 `GraphQL` 엔진 Wrapper가 아니라 다음 계약군입니다. + +```text +GraphQlSchemaContract +GraphQlRequestContext +GraphQlClientPolicy +GraphQlOperationPolicy +GraphQlCostPolicy +GraphQlFetchProfile +GraphQlBatchPolicy +GraphQlCursorCodec +GraphQlErrorContract +PersistedOperationRegistry +GraphQlSubscriptionPolicy +GraphQlObservationConvention +SchemaCompatibilityPolicy +``` + +그리고 도메인 개발자에게 보이는 일상적인 코드는 가능한 한 평범하게 유지합니다. + +```text +SDL fragment ++ +@QueryMapping / @MutationMapping / @SchemaMapping / @BatchMapping ++ +Application Use Case ++ +DTO / Read Model +``` + +이것이 Spring for GraphQL과 GraphQL Java가 이미 잘하는 부분을 다시 추상화하지 않으면서도, **Schema 계약 → transport → 인증/context → parse/validation → operation/cost → resolver → DataLoader → application → partial result/error → streaming**이라는 전체 실행 경로에 일관된 안전 정책을 부여하는 가장 적절한 구조입니다. Spring의 현재 설계도 transport가 `ExecutionGraphQlService`를 호출하고, annotated controller가 DataFetcher로 연결되며, Spring Data 통합은 선택 사항으로 제공되는 계층 구조를 취하고 있습니다. citeturn18search0turn18search2turn24view0 \ No newline at end of file diff --git a/graphql-superpowers-package/validate_graphql_docs.py b/graphql-superpowers-package/validate_graphql_docs.py new file mode 100755 index 00000000..7759f090 --- /dev/null +++ b/graphql-superpowers-package/validate_graphql_docs.py @@ -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 `1–48`", +] +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) diff --git a/jpa-superpowers-package/MANIFEST.sha256 b/jpa-superpowers-package/MANIFEST.sha256 new file mode 100644 index 00000000..f29e95ca --- /dev/null +++ b/jpa-superpowers-package/MANIFEST.sha256 @@ -0,0 +1,6 @@ +72113b04239cd397787fec5cdc9ac5aa309e85c53767042fc7019338afb884d0 ./README.md +2ae49f02d38b32dbd660ae3957f97912f469c0ff404e0e665d4986fe21e33963 ./VALIDATION.md +1ac376309b161cf95b6b3def89284e509ce0191131a268833ca13bd50814ff09 ./docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md +bb5d0d876e9a3232a3661cd50b3a4c0da0ea4c68f820526d8b34cb3bdcb43d86 ./docs/superpowers/plans/2026-08-11-jpa-persistence-platform-implementation-plan.md +a28046eb1451d87e3ac9c3b1922c134b99fa9155d32c09c0f5bddf80f588aaec ./docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md +4e53da920430d5a82a744475a30b8036d2412627c2488d182daf6d3c1b013eb6 ./validate_jpa_docs.py diff --git a/jpa-superpowers-package/README.md b/jpa-superpowers-package/README.md new file mode 100644 index 00000000..cf814817 --- /dev/null +++ b/jpa-superpowers-package/README.md @@ -0,0 +1,68 @@ +# JPA 관계형 영속성 플랫폼 Superpowers 패키지 + +이 패키지는 Java/Spring Backend Skeleton의 JPA 관계형 영속성 플랫폼을 구현하기 위한 설계서, Stable 구현 계획서, Experimental 확장 계획서와 정적 검증 도구를 포함한다. + +## 기준 + +- 기준일: 2026-08-11 +- Java 21 +- Gradle Kotlin DSL +- Spring Boot 4.1 dependency management +- Spring Data JPA 4.1 +- Jakarta Persistence 3.2 +- Hibernate ORM 7.4 +- PostgreSQL 16·17·18 Stable Matrix +- H2는 Local Convenience이며 PostgreSQL 호환성 증거로 사용하지 않는다. + +## 문서 + +```text +docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md +docs/superpowers/plans/2026-08-11-jpa-persistence-platform-implementation-plan.md +docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md +``` + +## 구현 경계 + +Stable 계획은 다음 원칙을 고정한다. + +```text +도메인이 Entity·Embeddable·Repository·Query를 소유한다. +GenericRepository 또는 Spring Data CRUD 재포장을 만들지 않는다. +Application Service가 Transaction 경계를 소유한다. +Retry는 새 Persistence Context에서 전체 Use Case를 재실행한다. +Commit 결과 불명은 자동 Retry하지 않고 reconciliation으로 처리한다. +Flyway가 운영 Schema 변경의 Source of Truth다. +PostgreSQL 16·17·18 실제 계약 테스트를 Release Gate로 사용한다. +``` + +Experimental 계획은 다음 범위를 Stable과 분리한다. + +```text +Shared-schema Multi-tenancy +PostgreSQL RLS +Schema-per-tenant +Database-per-tenant +Consistency-aware Read Replica +Jakarta Persistence 4.0 +Hibernate ORM 8 +PostgreSQL 19 +``` + +## 실행 순서 + +1. Stable 구현 계획 Task 1~53을 순서대로 수행한다. +2. 각 Task에서 실패 테스트 → 실패 확인 → 최소 구현 → 통과 확인 → 커밋 순서를 따른다. +3. Stable Release Gate가 통과한 뒤 Experimental Task 1~9를 시작한다. +4. Experimental 기능은 별도 feature flag와 승격 ADR 없이 Stable Starter에 포함하지 않는다. + +## 검증 + +압축을 해제한 패키지 루트에서 실행한다. + +```bash +python validate_jpa_docs.py +sha256sum -c MANIFEST.sha256 +``` + +현재 `VALIDATION.md`는 문서 구조·Task 완결성·타입 일관성·경로 충돌·핵심 계약을 대상으로 한 정적 검증 결과다. 실제 Backend Skeleton 저장소의 Gradle compile, PostgreSQL integration, Flyway upgrade, failure injection, 성능 및 보안 테스트 결과는 아니다. diff --git a/jpa-superpowers-package/VALIDATION.md b/jpa-superpowers-package/VALIDATION.md new file mode 100644 index 00000000..e8d0b2fb --- /dev/null +++ b/jpa-superpowers-package/VALIDATION.md @@ -0,0 +1,829 @@ +# JPA Superpowers 문서 정적 검증 + +- 결과: **PASS** +- 실행 검사: **808개** +- 통과: **808개** +- 실패: **0개** +- 설계서: **3,276행** +- Stable 구현 계획서: **4,716행** +- Experimental 확장 계획서: **771행** +- Stable Task: **53개** +- Experimental Task: **9개** +- Stable Create 경로: **235개** +- Experimental Create 경로: **37개** +- 설계 SHA-256: `a28046eb1451d87e3ac9c3b1922c134b99fa9155d32c09c0f5bddf80f588aaec` +- Stable 계획 SHA-256: `bb5d0d876e9a3232a3661cd50b3a4c0da0ea4c68f820526d8b34cb3bdcb43d86` +- Experimental 계획 SHA-256: `1ac376309b161cf95b6b3def89284e509ce0191131a268833ca13bd50814ff09` + +## 검사 결과 + +| 검사 | 결과 | 상세 | +|---|---:|---| +| 설계서 존재 | PASS | /mnt/data/jpa-superpowers-package/docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md | +| Stable 계획서 존재 | PASS | /mnt/data/jpa-superpowers-package/docs/superpowers/plans/2026-08-11-jpa-persistence-platform-implementation-plan.md | +| Experimental 계획서 존재 | PASS | /mnt/data/jpa-superpowers-package/docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md | +| 설계서 최소 상세도 | PASS | 3276 lines | +| Stable 계획서 최소 상세도 | PASS | 4716 lines | +| Experimental 계획서 최소 상세도 | PASS | 771 lines | +| 설계서 코드 fence 균형 | PASS | 262 | +| 설계서 placeholder 부재 | PASS | | +| Stable 계획서 코드 fence 균형 | PASS | 540 | +| Stable 계획서 placeholder 부재 | PASS | | +| Experimental 계획서 코드 fence 균형 | PASS | 94 | +| Experimental 계획서 placeholder 부재 | PASS | | +| 설계 핵심 계약: GenericRepository | PASS | | +| 설계 핵심 계약: TransactionCompletionUnknownException | PASS | | +| 설계 핵심 계약: EvidenceAwareJpaTransactionManager | PASS | | +| 설계 핵심 계약: Application Service | PASS | | +| 설계 핵심 계약: OSIV | PASS | | +| 설계 핵심 계약: PostgreSQL 16·17·18 | PASS | | +| 설계 핵심 계약: Hibernate 7.4 Collection Fetch Pagination | PASS | | +| 설계 핵심 계약: FOR UPDATE SKIP LOCKED | PASS | | +| 설계 핵심 계약: CREATE INDEX CONCURRENTLY | PASS | | +| 설계 핵심 계약: Flyway | PASS | | +| 설계 핵심 계약: Runtime·Migration·Admin | PASS | | +| 설계 핵심 계약: H2는 Local Convenience | PASS | | +| 설계 핵심 계약: 전체 Transaction Retry | PASS | | +| 설계 핵심 계약: J1 Standard Persistence | PASS | | +| 설계 핵심 계약: J4 Admin / Operations | PASS | | +| 설계 핵심 계약: 완료 정의 | PASS | | +| GenericRepository 실제 선언 부재 | PASS | | +| 운영 ddl auto update 금지 | PASS | | +| Completion Unknown 자동 Retry 금지 | PASS | | +| OSIV false 강제 | PASS | | +| PG16·17·18 Matrix | PASS | | +| Hibernate 7.4 fetch pagination gate | PASS | | +| Flyway snapshot upgrade gate | PASS | | +| Runtime role no DDL gate | PASS | | +| Stable 계획에 Experimental create 경로 부재 | PASS | | +| Stable Task 1~53 연속성 | PASS | [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53] | +| Experimental Task 1~9 연속성 | PASS | [1, 2, 3, 4, 5, 6, 7, 8, 9] | +| Stable Task chunk 수 | PASS | 53 | +| Experimental Task chunk 수 | PASS | 9 | +| Stable Task 1 필수 항목: **Files:** | PASS | | +| Stable Task 1 필수 항목: **Interfaces:** | PASS | | +| Stable Task 1 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 1 필수 항목: **Step 1: | PASS | | +| Stable Task 1 필수 항목: **Step 2: | PASS | | +| Stable Task 1 필수 항목: **Step 3: | PASS | | +| Stable Task 1 필수 항목: **Step 4: | PASS | | +| Stable Task 1 필수 항목: **Step 5: | PASS | | +| Stable Task 1 필수 항목: Expected: | PASS | | +| Stable Task 1 필수 항목: git commit -m | PASS | | +| Stable Task 1 Gradle focused command | PASS | | +| Stable Task 1 exact path | PASS | | +| Stable Task 2 필수 항목: **Files:** | PASS | | +| Stable Task 2 필수 항목: **Interfaces:** | PASS | | +| Stable Task 2 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 2 필수 항목: **Step 1: | PASS | | +| Stable Task 2 필수 항목: **Step 2: | PASS | | +| Stable Task 2 필수 항목: **Step 3: | PASS | | +| Stable Task 2 필수 항목: **Step 4: | PASS | | +| Stable Task 2 필수 항목: **Step 5: | PASS | | +| Stable Task 2 필수 항목: Expected: | PASS | | +| Stable Task 2 필수 항목: git commit -m | PASS | | +| Stable Task 2 Gradle focused command | PASS | | +| Stable Task 2 exact path | PASS | | +| Stable Task 3 필수 항목: **Files:** | PASS | | +| Stable Task 3 필수 항목: **Interfaces:** | PASS | | +| Stable Task 3 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 3 필수 항목: **Step 1: | PASS | | +| Stable Task 3 필수 항목: **Step 2: | PASS | | +| Stable Task 3 필수 항목: **Step 3: | PASS | | +| Stable Task 3 필수 항목: **Step 4: | PASS | | +| Stable Task 3 필수 항목: **Step 5: | PASS | | +| Stable Task 3 필수 항목: Expected: | PASS | | +| Stable Task 3 필수 항목: git commit -m | PASS | | +| Stable Task 3 Gradle focused command | PASS | | +| Stable Task 3 exact path | PASS | | +| Stable Task 4 필수 항목: **Files:** | PASS | | +| Stable Task 4 필수 항목: **Interfaces:** | PASS | | +| Stable Task 4 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 4 필수 항목: **Step 1: | PASS | | +| Stable Task 4 필수 항목: **Step 2: | PASS | | +| Stable Task 4 필수 항목: **Step 3: | PASS | | +| Stable Task 4 필수 항목: **Step 4: | PASS | | +| Stable Task 4 필수 항목: **Step 5: | PASS | | +| Stable Task 4 필수 항목: Expected: | PASS | | +| Stable Task 4 필수 항목: git commit -m | PASS | | +| Stable Task 4 Gradle focused command | PASS | | +| Stable Task 4 exact path | PASS | | +| Stable Task 5 필수 항목: **Files:** | PASS | | +| Stable Task 5 필수 항목: **Interfaces:** | PASS | | +| Stable Task 5 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 5 필수 항목: **Step 1: | PASS | | +| Stable Task 5 필수 항목: **Step 2: | PASS | | +| Stable Task 5 필수 항목: **Step 3: | PASS | | +| Stable Task 5 필수 항목: **Step 4: | PASS | | +| Stable Task 5 필수 항목: **Step 5: | PASS | | +| Stable Task 5 필수 항목: Expected: | PASS | | +| Stable Task 5 필수 항목: git commit -m | PASS | | +| Stable Task 5 Gradle focused command | PASS | | +| Stable Task 5 exact path | PASS | | +| Stable Task 6 필수 항목: **Files:** | PASS | | +| Stable Task 6 필수 항목: **Interfaces:** | PASS | | +| Stable Task 6 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 6 필수 항목: **Step 1: | PASS | | +| Stable Task 6 필수 항목: **Step 2: | PASS | | +| Stable Task 6 필수 항목: **Step 3: | PASS | | +| Stable Task 6 필수 항목: **Step 4: | PASS | | +| Stable Task 6 필수 항목: **Step 5: | PASS | | +| Stable Task 6 필수 항목: Expected: | PASS | | +| Stable Task 6 필수 항목: git commit -m | PASS | | +| Stable Task 6 Gradle focused command | PASS | | +| Stable Task 6 exact path | PASS | | +| Stable Task 7 필수 항목: **Files:** | PASS | | +| Stable Task 7 필수 항목: **Interfaces:** | PASS | | +| Stable Task 7 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 7 필수 항목: **Step 1: | PASS | | +| Stable Task 7 필수 항목: **Step 2: | PASS | | +| Stable Task 7 필수 항목: **Step 3: | PASS | | +| Stable Task 7 필수 항목: **Step 4: | PASS | | +| Stable Task 7 필수 항목: **Step 5: | PASS | | +| Stable Task 7 필수 항목: Expected: | PASS | | +| Stable Task 7 필수 항목: git commit -m | PASS | | +| Stable Task 7 Gradle focused command | PASS | | +| Stable Task 7 exact path | PASS | | +| Stable Task 8 필수 항목: **Files:** | PASS | | +| Stable Task 8 필수 항목: **Interfaces:** | PASS | | +| Stable Task 8 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 8 필수 항목: **Step 1: | PASS | | +| Stable Task 8 필수 항목: **Step 2: | PASS | | +| Stable Task 8 필수 항목: **Step 3: | PASS | | +| Stable Task 8 필수 항목: **Step 4: | PASS | | +| Stable Task 8 필수 항목: **Step 5: | PASS | | +| Stable Task 8 필수 항목: Expected: | PASS | | +| Stable Task 8 필수 항목: git commit -m | PASS | | +| Stable Task 8 Gradle focused command | PASS | | +| Stable Task 8 exact path | PASS | | +| Stable Task 9 필수 항목: **Files:** | PASS | | +| Stable Task 9 필수 항목: **Interfaces:** | PASS | | +| Stable Task 9 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 9 필수 항목: **Step 1: | PASS | | +| Stable Task 9 필수 항목: **Step 2: | PASS | | +| Stable Task 9 필수 항목: **Step 3: | PASS | | +| Stable Task 9 필수 항목: **Step 4: | PASS | | +| Stable Task 9 필수 항목: **Step 5: | PASS | | +| Stable Task 9 필수 항목: Expected: | PASS | | +| Stable Task 9 필수 항목: git commit -m | PASS | | +| Stable Task 9 Gradle focused command | PASS | | +| Stable Task 9 exact path | PASS | | +| Stable Task 10 필수 항목: **Files:** | PASS | | +| Stable Task 10 필수 항목: **Interfaces:** | PASS | | +| Stable Task 10 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 10 필수 항목: **Step 1: | PASS | | +| Stable Task 10 필수 항목: **Step 2: | PASS | | +| Stable Task 10 필수 항목: **Step 3: | PASS | | +| Stable Task 10 필수 항목: **Step 4: | PASS | | +| Stable Task 10 필수 항목: **Step 5: | PASS | | +| Stable Task 10 필수 항목: Expected: | PASS | | +| Stable Task 10 필수 항목: git commit -m | PASS | | +| Stable Task 10 Gradle focused command | PASS | | +| Stable Task 10 exact path | PASS | | +| Stable Task 11 필수 항목: **Files:** | PASS | | +| Stable Task 11 필수 항목: **Interfaces:** | PASS | | +| Stable Task 11 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 11 필수 항목: **Step 1: | PASS | | +| Stable Task 11 필수 항목: **Step 2: | PASS | | +| Stable Task 11 필수 항목: **Step 3: | PASS | | +| Stable Task 11 필수 항목: **Step 4: | PASS | | +| Stable Task 11 필수 항목: **Step 5: | PASS | | +| Stable Task 11 필수 항목: Expected: | PASS | | +| Stable Task 11 필수 항목: git commit -m | PASS | | +| Stable Task 11 Gradle focused command | PASS | | +| Stable Task 11 exact path | PASS | | +| Stable Task 12 필수 항목: **Files:** | PASS | | +| Stable Task 12 필수 항목: **Interfaces:** | PASS | | +| Stable Task 12 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 12 필수 항목: **Step 1: | PASS | | +| Stable Task 12 필수 항목: **Step 2: | PASS | | +| Stable Task 12 필수 항목: **Step 3: | PASS | | +| Stable Task 12 필수 항목: **Step 4: | PASS | | +| Stable Task 12 필수 항목: **Step 5: | PASS | | +| Stable Task 12 필수 항목: Expected: | PASS | | +| Stable Task 12 필수 항목: git commit -m | PASS | | +| Stable Task 12 Gradle focused command | PASS | | +| Stable Task 12 exact path | PASS | | +| Stable Task 13 필수 항목: **Files:** | PASS | | +| Stable Task 13 필수 항목: **Interfaces:** | PASS | | +| Stable Task 13 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 13 필수 항목: **Step 1: | PASS | | +| Stable Task 13 필수 항목: **Step 2: | PASS | | +| Stable Task 13 필수 항목: **Step 3: | PASS | | +| Stable Task 13 필수 항목: **Step 4: | PASS | | +| Stable Task 13 필수 항목: **Step 5: | PASS | | +| Stable Task 13 필수 항목: Expected: | PASS | | +| Stable Task 13 필수 항목: git commit -m | PASS | | +| Stable Task 13 Gradle focused command | PASS | | +| Stable Task 13 exact path | PASS | | +| Stable Task 14 필수 항목: **Files:** | PASS | | +| Stable Task 14 필수 항목: **Interfaces:** | PASS | | +| Stable Task 14 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 14 필수 항목: **Step 1: | PASS | | +| Stable Task 14 필수 항목: **Step 2: | PASS | | +| Stable Task 14 필수 항목: **Step 3: | PASS | | +| Stable Task 14 필수 항목: **Step 4: | PASS | | +| Stable Task 14 필수 항목: **Step 5: | PASS | | +| Stable Task 14 필수 항목: Expected: | PASS | | +| Stable Task 14 필수 항목: git commit -m | PASS | | +| Stable Task 14 Gradle focused command | PASS | | +| Stable Task 14 exact path | PASS | | +| Stable Task 15 필수 항목: **Files:** | PASS | | +| Stable Task 15 필수 항목: **Interfaces:** | PASS | | +| Stable Task 15 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 15 필수 항목: **Step 1: | PASS | | +| Stable Task 15 필수 항목: **Step 2: | PASS | | +| Stable Task 15 필수 항목: **Step 3: | PASS | | +| Stable Task 15 필수 항목: **Step 4: | PASS | | +| Stable Task 15 필수 항목: **Step 5: | PASS | | +| Stable Task 15 필수 항목: Expected: | PASS | | +| Stable Task 15 필수 항목: git commit -m | PASS | | +| Stable Task 15 Gradle focused command | PASS | | +| Stable Task 15 exact path | PASS | | +| Stable Task 16 필수 항목: **Files:** | PASS | | +| Stable Task 16 필수 항목: **Interfaces:** | PASS | | +| Stable Task 16 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 16 필수 항목: **Step 1: | PASS | | +| Stable Task 16 필수 항목: **Step 2: | PASS | | +| Stable Task 16 필수 항목: **Step 3: | PASS | | +| Stable Task 16 필수 항목: **Step 4: | PASS | | +| Stable Task 16 필수 항목: **Step 5: | PASS | | +| Stable Task 16 필수 항목: Expected: | PASS | | +| Stable Task 16 필수 항목: git commit -m | PASS | | +| Stable Task 16 Gradle focused command | PASS | | +| Stable Task 16 exact path | PASS | | +| Stable Task 17 필수 항목: **Files:** | PASS | | +| Stable Task 17 필수 항목: **Interfaces:** | PASS | | +| Stable Task 17 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 17 필수 항목: **Step 1: | PASS | | +| Stable Task 17 필수 항목: **Step 2: | PASS | | +| Stable Task 17 필수 항목: **Step 3: | PASS | | +| Stable Task 17 필수 항목: **Step 4: | PASS | | +| Stable Task 17 필수 항목: **Step 5: | PASS | | +| Stable Task 17 필수 항목: Expected: | PASS | | +| Stable Task 17 필수 항목: git commit -m | PASS | | +| Stable Task 17 Gradle focused command | PASS | | +| Stable Task 17 exact path | PASS | | +| Stable Task 18 필수 항목: **Files:** | PASS | | +| Stable Task 18 필수 항목: **Interfaces:** | PASS | | +| Stable Task 18 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 18 필수 항목: **Step 1: | PASS | | +| Stable Task 18 필수 항목: **Step 2: | PASS | | +| Stable Task 18 필수 항목: **Step 3: | PASS | | +| Stable Task 18 필수 항목: **Step 4: | PASS | | +| Stable Task 18 필수 항목: **Step 5: | PASS | | +| Stable Task 18 필수 항목: Expected: | PASS | | +| Stable Task 18 필수 항목: git commit -m | PASS | | +| Stable Task 18 Gradle focused command | PASS | | +| Stable Task 18 exact path | PASS | | +| Stable Task 19 필수 항목: **Files:** | PASS | | +| Stable Task 19 필수 항목: **Interfaces:** | PASS | | +| Stable Task 19 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 19 필수 항목: **Step 1: | PASS | | +| Stable Task 19 필수 항목: **Step 2: | PASS | | +| Stable Task 19 필수 항목: **Step 3: | PASS | | +| Stable Task 19 필수 항목: **Step 4: | PASS | | +| Stable Task 19 필수 항목: **Step 5: | PASS | | +| Stable Task 19 필수 항목: Expected: | PASS | | +| Stable Task 19 필수 항목: git commit -m | PASS | | +| Stable Task 19 Gradle focused command | PASS | | +| Stable Task 19 exact path | PASS | | +| Stable Task 20 필수 항목: **Files:** | PASS | | +| Stable Task 20 필수 항목: **Interfaces:** | PASS | | +| Stable Task 20 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 20 필수 항목: **Step 1: | PASS | | +| Stable Task 20 필수 항목: **Step 2: | PASS | | +| Stable Task 20 필수 항목: **Step 3: | PASS | | +| Stable Task 20 필수 항목: **Step 4: | PASS | | +| Stable Task 20 필수 항목: **Step 5: | PASS | | +| Stable Task 20 필수 항목: Expected: | PASS | | +| Stable Task 20 필수 항목: git commit -m | PASS | | +| Stable Task 20 Gradle focused command | PASS | | +| Stable Task 20 exact path | PASS | | +| Stable Task 21 필수 항목: **Files:** | PASS | | +| Stable Task 21 필수 항목: **Interfaces:** | PASS | | +| Stable Task 21 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 21 필수 항목: **Step 1: | PASS | | +| Stable Task 21 필수 항목: **Step 2: | PASS | | +| Stable Task 21 필수 항목: **Step 3: | PASS | | +| Stable Task 21 필수 항목: **Step 4: | PASS | | +| Stable Task 21 필수 항목: **Step 5: | PASS | | +| Stable Task 21 필수 항목: Expected: | PASS | | +| Stable Task 21 필수 항목: git commit -m | PASS | | +| Stable Task 21 Gradle focused command | PASS | | +| Stable Task 21 exact path | PASS | | +| Stable Task 22 필수 항목: **Files:** | PASS | | +| Stable Task 22 필수 항목: **Interfaces:** | PASS | | +| Stable Task 22 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 22 필수 항목: **Step 1: | PASS | | +| Stable Task 22 필수 항목: **Step 2: | PASS | | +| Stable Task 22 필수 항목: **Step 3: | PASS | | +| Stable Task 22 필수 항목: **Step 4: | PASS | | +| Stable Task 22 필수 항목: **Step 5: | PASS | | +| Stable Task 22 필수 항목: Expected: | PASS | | +| Stable Task 22 필수 항목: git commit -m | PASS | | +| Stable Task 22 Gradle focused command | PASS | | +| Stable Task 22 exact path | PASS | | +| Stable Task 23 필수 항목: **Files:** | PASS | | +| Stable Task 23 필수 항목: **Interfaces:** | PASS | | +| Stable Task 23 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 23 필수 항목: **Step 1: | PASS | | +| Stable Task 23 필수 항목: **Step 2: | PASS | | +| Stable Task 23 필수 항목: **Step 3: | PASS | | +| Stable Task 23 필수 항목: **Step 4: | PASS | | +| Stable Task 23 필수 항목: **Step 5: | PASS | | +| Stable Task 23 필수 항목: Expected: | PASS | | +| Stable Task 23 필수 항목: git commit -m | PASS | | +| Stable Task 23 Gradle focused command | PASS | | +| Stable Task 23 exact path | PASS | | +| Stable Task 24 필수 항목: **Files:** | PASS | | +| Stable Task 24 필수 항목: **Interfaces:** | PASS | | +| Stable Task 24 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 24 필수 항목: **Step 1: | PASS | | +| Stable Task 24 필수 항목: **Step 2: | PASS | | +| Stable Task 24 필수 항목: **Step 3: | PASS | | +| Stable Task 24 필수 항목: **Step 4: | PASS | | +| Stable Task 24 필수 항목: **Step 5: | PASS | | +| Stable Task 24 필수 항목: Expected: | PASS | | +| Stable Task 24 필수 항목: git commit -m | PASS | | +| Stable Task 24 Gradle focused command | PASS | | +| Stable Task 24 exact path | PASS | | +| Stable Task 25 필수 항목: **Files:** | PASS | | +| Stable Task 25 필수 항목: **Interfaces:** | PASS | | +| Stable Task 25 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 25 필수 항목: **Step 1: | PASS | | +| Stable Task 25 필수 항목: **Step 2: | PASS | | +| Stable Task 25 필수 항목: **Step 3: | PASS | | +| Stable Task 25 필수 항목: **Step 4: | PASS | | +| Stable Task 25 필수 항목: **Step 5: | PASS | | +| Stable Task 25 필수 항목: Expected: | PASS | | +| Stable Task 25 필수 항목: git commit -m | PASS | | +| Stable Task 25 Gradle focused command | PASS | | +| Stable Task 25 exact path | PASS | | +| Stable Task 26 필수 항목: **Files:** | PASS | | +| Stable Task 26 필수 항목: **Interfaces:** | PASS | | +| Stable Task 26 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 26 필수 항목: **Step 1: | PASS | | +| Stable Task 26 필수 항목: **Step 2: | PASS | | +| Stable Task 26 필수 항목: **Step 3: | PASS | | +| Stable Task 26 필수 항목: **Step 4: | PASS | | +| Stable Task 26 필수 항목: **Step 5: | PASS | | +| Stable Task 26 필수 항목: Expected: | PASS | | +| Stable Task 26 필수 항목: git commit -m | PASS | | +| Stable Task 26 Gradle focused command | PASS | | +| Stable Task 26 exact path | PASS | | +| Stable Task 27 필수 항목: **Files:** | PASS | | +| Stable Task 27 필수 항목: **Interfaces:** | PASS | | +| Stable Task 27 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 27 필수 항목: **Step 1: | PASS | | +| Stable Task 27 필수 항목: **Step 2: | PASS | | +| Stable Task 27 필수 항목: **Step 3: | PASS | | +| Stable Task 27 필수 항목: **Step 4: | PASS | | +| Stable Task 27 필수 항목: **Step 5: | PASS | | +| Stable Task 27 필수 항목: Expected: | PASS | | +| Stable Task 27 필수 항목: git commit -m | PASS | | +| Stable Task 27 Gradle focused command | PASS | | +| Stable Task 27 exact path | PASS | | +| Stable Task 28 필수 항목: **Files:** | PASS | | +| Stable Task 28 필수 항목: **Interfaces:** | PASS | | +| Stable Task 28 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 28 필수 항목: **Step 1: | PASS | | +| Stable Task 28 필수 항목: **Step 2: | PASS | | +| Stable Task 28 필수 항목: **Step 3: | PASS | | +| Stable Task 28 필수 항목: **Step 4: | PASS | | +| Stable Task 28 필수 항목: **Step 5: | PASS | | +| Stable Task 28 필수 항목: Expected: | PASS | | +| Stable Task 28 필수 항목: git commit -m | PASS | | +| Stable Task 28 Gradle focused command | PASS | | +| Stable Task 28 exact path | PASS | | +| Stable Task 29 필수 항목: **Files:** | PASS | | +| Stable Task 29 필수 항목: **Interfaces:** | PASS | | +| Stable Task 29 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 29 필수 항목: **Step 1: | PASS | | +| Stable Task 29 필수 항목: **Step 2: | PASS | | +| Stable Task 29 필수 항목: **Step 3: | PASS | | +| Stable Task 29 필수 항목: **Step 4: | PASS | | +| Stable Task 29 필수 항목: **Step 5: | PASS | | +| Stable Task 29 필수 항목: Expected: | PASS | | +| Stable Task 29 필수 항목: git commit -m | PASS | | +| Stable Task 29 Gradle focused command | PASS | | +| Stable Task 29 exact path | PASS | | +| Stable Task 30 필수 항목: **Files:** | PASS | | +| Stable Task 30 필수 항목: **Interfaces:** | PASS | | +| Stable Task 30 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 30 필수 항목: **Step 1: | PASS | | +| Stable Task 30 필수 항목: **Step 2: | PASS | | +| Stable Task 30 필수 항목: **Step 3: | PASS | | +| Stable Task 30 필수 항목: **Step 4: | PASS | | +| Stable Task 30 필수 항목: **Step 5: | PASS | | +| Stable Task 30 필수 항목: Expected: | PASS | | +| Stable Task 30 필수 항목: git commit -m | PASS | | +| Stable Task 30 Gradle focused command | PASS | | +| Stable Task 30 exact path | PASS | | +| Stable Task 31 필수 항목: **Files:** | PASS | | +| Stable Task 31 필수 항목: **Interfaces:** | PASS | | +| Stable Task 31 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 31 필수 항목: **Step 1: | PASS | | +| Stable Task 31 필수 항목: **Step 2: | PASS | | +| Stable Task 31 필수 항목: **Step 3: | PASS | | +| Stable Task 31 필수 항목: **Step 4: | PASS | | +| Stable Task 31 필수 항목: **Step 5: | PASS | | +| Stable Task 31 필수 항목: Expected: | PASS | | +| Stable Task 31 필수 항목: git commit -m | PASS | | +| Stable Task 31 Gradle focused command | PASS | | +| Stable Task 31 exact path | PASS | | +| Stable Task 32 필수 항목: **Files:** | PASS | | +| Stable Task 32 필수 항목: **Interfaces:** | PASS | | +| Stable Task 32 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 32 필수 항목: **Step 1: | PASS | | +| Stable Task 32 필수 항목: **Step 2: | PASS | | +| Stable Task 32 필수 항목: **Step 3: | PASS | | +| Stable Task 32 필수 항목: **Step 4: | PASS | | +| Stable Task 32 필수 항목: **Step 5: | PASS | | +| Stable Task 32 필수 항목: Expected: | PASS | | +| Stable Task 32 필수 항목: git commit -m | PASS | | +| Stable Task 32 Gradle focused command | PASS | | +| Stable Task 32 exact path | PASS | | +| Stable Task 33 필수 항목: **Files:** | PASS | | +| Stable Task 33 필수 항목: **Interfaces:** | PASS | | +| Stable Task 33 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 33 필수 항목: **Step 1: | PASS | | +| Stable Task 33 필수 항목: **Step 2: | PASS | | +| Stable Task 33 필수 항목: **Step 3: | PASS | | +| Stable Task 33 필수 항목: **Step 4: | PASS | | +| Stable Task 33 필수 항목: **Step 5: | PASS | | +| Stable Task 33 필수 항목: Expected: | PASS | | +| Stable Task 33 필수 항목: git commit -m | PASS | | +| Stable Task 33 Gradle focused command | PASS | | +| Stable Task 33 exact path | PASS | | +| Stable Task 34 필수 항목: **Files:** | PASS | | +| Stable Task 34 필수 항목: **Interfaces:** | PASS | | +| Stable Task 34 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 34 필수 항목: **Step 1: | PASS | | +| Stable Task 34 필수 항목: **Step 2: | PASS | | +| Stable Task 34 필수 항목: **Step 3: | PASS | | +| Stable Task 34 필수 항목: **Step 4: | PASS | | +| Stable Task 34 필수 항목: **Step 5: | PASS | | +| Stable Task 34 필수 항목: Expected: | PASS | | +| Stable Task 34 필수 항목: git commit -m | PASS | | +| Stable Task 34 Gradle focused command | PASS | | +| Stable Task 34 exact path | PASS | | +| Stable Task 35 필수 항목: **Files:** | PASS | | +| Stable Task 35 필수 항목: **Interfaces:** | PASS | | +| Stable Task 35 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 35 필수 항목: **Step 1: | PASS | | +| Stable Task 35 필수 항목: **Step 2: | PASS | | +| Stable Task 35 필수 항목: **Step 3: | PASS | | +| Stable Task 35 필수 항목: **Step 4: | PASS | | +| Stable Task 35 필수 항목: **Step 5: | PASS | | +| Stable Task 35 필수 항목: Expected: | PASS | | +| Stable Task 35 필수 항목: git commit -m | PASS | | +| Stable Task 35 Gradle focused command | PASS | | +| Stable Task 35 exact path | PASS | | +| Stable Task 36 필수 항목: **Files:** | PASS | | +| Stable Task 36 필수 항목: **Interfaces:** | PASS | | +| Stable Task 36 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 36 필수 항목: **Step 1: | PASS | | +| Stable Task 36 필수 항목: **Step 2: | PASS | | +| Stable Task 36 필수 항목: **Step 3: | PASS | | +| Stable Task 36 필수 항목: **Step 4: | PASS | | +| Stable Task 36 필수 항목: **Step 5: | PASS | | +| Stable Task 36 필수 항목: Expected: | PASS | | +| Stable Task 36 필수 항목: git commit -m | PASS | | +| Stable Task 36 Gradle focused command | PASS | | +| Stable Task 36 exact path | PASS | | +| Stable Task 37 필수 항목: **Files:** | PASS | | +| Stable Task 37 필수 항목: **Interfaces:** | PASS | | +| Stable Task 37 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 37 필수 항목: **Step 1: | PASS | | +| Stable Task 37 필수 항목: **Step 2: | PASS | | +| Stable Task 37 필수 항목: **Step 3: | PASS | | +| Stable Task 37 필수 항목: **Step 4: | PASS | | +| Stable Task 37 필수 항목: **Step 5: | PASS | | +| Stable Task 37 필수 항목: Expected: | PASS | | +| Stable Task 37 필수 항목: git commit -m | PASS | | +| Stable Task 37 Gradle focused command | PASS | | +| Stable Task 37 exact path | PASS | | +| Stable Task 38 필수 항목: **Files:** | PASS | | +| Stable Task 38 필수 항목: **Interfaces:** | PASS | | +| Stable Task 38 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 38 필수 항목: **Step 1: | PASS | | +| Stable Task 38 필수 항목: **Step 2: | PASS | | +| Stable Task 38 필수 항목: **Step 3: | PASS | | +| Stable Task 38 필수 항목: **Step 4: | PASS | | +| Stable Task 38 필수 항목: **Step 5: | PASS | | +| Stable Task 38 필수 항목: Expected: | PASS | | +| Stable Task 38 필수 항목: git commit -m | PASS | | +| Stable Task 38 Gradle focused command | PASS | | +| Stable Task 38 exact path | PASS | | +| Stable Task 39 필수 항목: **Files:** | PASS | | +| Stable Task 39 필수 항목: **Interfaces:** | PASS | | +| Stable Task 39 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 39 필수 항목: **Step 1: | PASS | | +| Stable Task 39 필수 항목: **Step 2: | PASS | | +| Stable Task 39 필수 항목: **Step 3: | PASS | | +| Stable Task 39 필수 항목: **Step 4: | PASS | | +| Stable Task 39 필수 항목: **Step 5: | PASS | | +| Stable Task 39 필수 항목: Expected: | PASS | | +| Stable Task 39 필수 항목: git commit -m | PASS | | +| Stable Task 39 Gradle focused command | PASS | | +| Stable Task 39 exact path | PASS | | +| Stable Task 40 필수 항목: **Files:** | PASS | | +| Stable Task 40 필수 항목: **Interfaces:** | PASS | | +| Stable Task 40 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 40 필수 항목: **Step 1: | PASS | | +| Stable Task 40 필수 항목: **Step 2: | PASS | | +| Stable Task 40 필수 항목: **Step 3: | PASS | | +| Stable Task 40 필수 항목: **Step 4: | PASS | | +| Stable Task 40 필수 항목: **Step 5: | PASS | | +| Stable Task 40 필수 항목: Expected: | PASS | | +| Stable Task 40 필수 항목: git commit -m | PASS | | +| Stable Task 40 Gradle focused command | PASS | | +| Stable Task 40 exact path | PASS | | +| Stable Task 41 필수 항목: **Files:** | PASS | | +| Stable Task 41 필수 항목: **Interfaces:** | PASS | | +| Stable Task 41 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 41 필수 항목: **Step 1: | PASS | | +| Stable Task 41 필수 항목: **Step 2: | PASS | | +| Stable Task 41 필수 항목: **Step 3: | PASS | | +| Stable Task 41 필수 항목: **Step 4: | PASS | | +| Stable Task 41 필수 항목: **Step 5: | PASS | | +| Stable Task 41 필수 항목: Expected: | PASS | | +| Stable Task 41 필수 항목: git commit -m | PASS | | +| Stable Task 41 Gradle focused command | PASS | | +| Stable Task 41 exact path | PASS | | +| Stable Task 42 필수 항목: **Files:** | PASS | | +| Stable Task 42 필수 항목: **Interfaces:** | PASS | | +| Stable Task 42 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 42 필수 항목: **Step 1: | PASS | | +| Stable Task 42 필수 항목: **Step 2: | PASS | | +| Stable Task 42 필수 항목: **Step 3: | PASS | | +| Stable Task 42 필수 항목: **Step 4: | PASS | | +| Stable Task 42 필수 항목: **Step 5: | PASS | | +| Stable Task 42 필수 항목: Expected: | PASS | | +| Stable Task 42 필수 항목: git commit -m | PASS | | +| Stable Task 42 Gradle focused command | PASS | | +| Stable Task 42 exact path | PASS | | +| Stable Task 43 필수 항목: **Files:** | PASS | | +| Stable Task 43 필수 항목: **Interfaces:** | PASS | | +| Stable Task 43 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 43 필수 항목: **Step 1: | PASS | | +| Stable Task 43 필수 항목: **Step 2: | PASS | | +| Stable Task 43 필수 항목: **Step 3: | PASS | | +| Stable Task 43 필수 항목: **Step 4: | PASS | | +| Stable Task 43 필수 항목: **Step 5: | PASS | | +| Stable Task 43 필수 항목: Expected: | PASS | | +| Stable Task 43 필수 항목: git commit -m | PASS | | +| Stable Task 43 Gradle focused command | PASS | | +| Stable Task 43 exact path | PASS | | +| Stable Task 44 필수 항목: **Files:** | PASS | | +| Stable Task 44 필수 항목: **Interfaces:** | PASS | | +| Stable Task 44 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 44 필수 항목: **Step 1: | PASS | | +| Stable Task 44 필수 항목: **Step 2: | PASS | | +| Stable Task 44 필수 항목: **Step 3: | PASS | | +| Stable Task 44 필수 항목: **Step 4: | PASS | | +| Stable Task 44 필수 항목: **Step 5: | PASS | | +| Stable Task 44 필수 항목: Expected: | PASS | | +| Stable Task 44 필수 항목: git commit -m | PASS | | +| Stable Task 44 Gradle focused command | PASS | | +| Stable Task 44 exact path | PASS | | +| Stable Task 45 필수 항목: **Files:** | PASS | | +| Stable Task 45 필수 항목: **Interfaces:** | PASS | | +| Stable Task 45 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 45 필수 항목: **Step 1: | PASS | | +| Stable Task 45 필수 항목: **Step 2: | PASS | | +| Stable Task 45 필수 항목: **Step 3: | PASS | | +| Stable Task 45 필수 항목: **Step 4: | PASS | | +| Stable Task 45 필수 항목: **Step 5: | PASS | | +| Stable Task 45 필수 항목: Expected: | PASS | | +| Stable Task 45 필수 항목: git commit -m | PASS | | +| Stable Task 45 Gradle focused command | PASS | | +| Stable Task 45 exact path | PASS | | +| Stable Task 46 필수 항목: **Files:** | PASS | | +| Stable Task 46 필수 항목: **Interfaces:** | PASS | | +| Stable Task 46 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 46 필수 항목: **Step 1: | PASS | | +| Stable Task 46 필수 항목: **Step 2: | PASS | | +| Stable Task 46 필수 항목: **Step 3: | PASS | | +| Stable Task 46 필수 항목: **Step 4: | PASS | | +| Stable Task 46 필수 항목: **Step 5: | PASS | | +| Stable Task 46 필수 항목: Expected: | PASS | | +| Stable Task 46 필수 항목: git commit -m | PASS | | +| Stable Task 46 Gradle focused command | PASS | | +| Stable Task 46 exact path | PASS | | +| Stable Task 47 필수 항목: **Files:** | PASS | | +| Stable Task 47 필수 항목: **Interfaces:** | PASS | | +| Stable Task 47 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 47 필수 항목: **Step 1: | PASS | | +| Stable Task 47 필수 항목: **Step 2: | PASS | | +| Stable Task 47 필수 항목: **Step 3: | PASS | | +| Stable Task 47 필수 항목: **Step 4: | PASS | | +| Stable Task 47 필수 항목: **Step 5: | PASS | | +| Stable Task 47 필수 항목: Expected: | PASS | | +| Stable Task 47 필수 항목: git commit -m | PASS | | +| Stable Task 47 Gradle focused command | PASS | | +| Stable Task 47 exact path | PASS | | +| Stable Task 48 필수 항목: **Files:** | PASS | | +| Stable Task 48 필수 항목: **Interfaces:** | PASS | | +| Stable Task 48 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 48 필수 항목: **Step 1: | PASS | | +| Stable Task 48 필수 항목: **Step 2: | PASS | | +| Stable Task 48 필수 항목: **Step 3: | PASS | | +| Stable Task 48 필수 항목: **Step 4: | PASS | | +| Stable Task 48 필수 항목: **Step 5: | PASS | | +| Stable Task 48 필수 항목: Expected: | PASS | | +| Stable Task 48 필수 항목: git commit -m | PASS | | +| Stable Task 48 Gradle focused command | PASS | | +| Stable Task 48 exact path | PASS | | +| Stable Task 49 필수 항목: **Files:** | PASS | | +| Stable Task 49 필수 항목: **Interfaces:** | PASS | | +| Stable Task 49 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 49 필수 항목: **Step 1: | PASS | | +| Stable Task 49 필수 항목: **Step 2: | PASS | | +| Stable Task 49 필수 항목: **Step 3: | PASS | | +| Stable Task 49 필수 항목: **Step 4: | PASS | | +| Stable Task 49 필수 항목: **Step 5: | PASS | | +| Stable Task 49 필수 항목: Expected: | PASS | | +| Stable Task 49 필수 항목: git commit -m | PASS | | +| Stable Task 49 Gradle focused command | PASS | | +| Stable Task 49 exact path | PASS | | +| Stable Task 50 필수 항목: **Files:** | PASS | | +| Stable Task 50 필수 항목: **Interfaces:** | PASS | | +| Stable Task 50 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 50 필수 항목: **Step 1: | PASS | | +| Stable Task 50 필수 항목: **Step 2: | PASS | | +| Stable Task 50 필수 항목: **Step 3: | PASS | | +| Stable Task 50 필수 항목: **Step 4: | PASS | | +| Stable Task 50 필수 항목: **Step 5: | PASS | | +| Stable Task 50 필수 항목: Expected: | PASS | | +| Stable Task 50 필수 항목: git commit -m | PASS | | +| Stable Task 50 Gradle focused command | PASS | | +| Stable Task 50 exact path | PASS | | +| Stable Task 51 필수 항목: **Files:** | PASS | | +| Stable Task 51 필수 항목: **Interfaces:** | PASS | | +| Stable Task 51 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 51 필수 항목: **Step 1: | PASS | | +| Stable Task 51 필수 항목: **Step 2: | PASS | | +| Stable Task 51 필수 항목: **Step 3: | PASS | | +| Stable Task 51 필수 항목: **Step 4: | PASS | | +| Stable Task 51 필수 항목: **Step 5: | PASS | | +| Stable Task 51 필수 항목: Expected: | PASS | | +| Stable Task 51 필수 항목: git commit -m | PASS | | +| Stable Task 51 Gradle focused command | PASS | | +| Stable Task 51 exact path | PASS | | +| Stable Task 52 필수 항목: **Files:** | PASS | | +| Stable Task 52 필수 항목: **Interfaces:** | PASS | | +| Stable Task 52 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 52 필수 항목: **Step 1: | PASS | | +| Stable Task 52 필수 항목: **Step 2: | PASS | | +| Stable Task 52 필수 항목: **Step 3: | PASS | | +| Stable Task 52 필수 항목: **Step 4: | PASS | | +| Stable Task 52 필수 항목: **Step 5: | PASS | | +| Stable Task 52 필수 항목: Expected: | PASS | | +| Stable Task 52 필수 항목: git commit -m | PASS | | +| Stable Task 52 Gradle focused command | PASS | | +| Stable Task 52 exact path | PASS | | +| Stable Task 53 필수 항목: **Files:** | PASS | | +| Stable Task 53 필수 항목: **Interfaces:** | PASS | | +| Stable Task 53 필수 항목: **Implementation requirements:** | PASS | | +| Stable Task 53 필수 항목: **Step 1: | PASS | | +| Stable Task 53 필수 항목: **Step 2: | PASS | | +| Stable Task 53 필수 항목: **Step 3: | PASS | | +| Stable Task 53 필수 항목: **Step 4: | PASS | | +| Stable Task 53 필수 항목: **Step 5: | PASS | | +| Stable Task 53 필수 항목: Expected: | PASS | | +| Stable Task 53 필수 항목: git commit -m | PASS | | +| Stable Task 53 Gradle focused command | PASS | | +| Stable Task 53 exact path | PASS | | +| Experimental Task 1 필수 항목: **Files:** | PASS | | +| Experimental Task 1 필수 항목: **Interfaces:** | PASS | | +| Experimental Task 1 필수 항목: **Implementation requirements:** | PASS | | +| Experimental Task 1 필수 항목: **Step 1: | PASS | | +| Experimental Task 1 필수 항목: **Step 2: | PASS | | +| Experimental Task 1 필수 항목: **Step 3: | PASS | | +| Experimental Task 1 필수 항목: **Step 4: | PASS | | +| Experimental Task 1 필수 항목: **Step 5: | PASS | | +| Experimental Task 1 필수 항목: Expected: | PASS | | +| Experimental Task 1 필수 항목: git commit -m | PASS | | +| Experimental Task 1 Gradle focused command | PASS | | +| Experimental Task 1 exact path | PASS | | +| Experimental Task 2 필수 항목: **Files:** | PASS | | +| Experimental Task 2 필수 항목: **Interfaces:** | PASS | | +| Experimental Task 2 필수 항목: **Implementation requirements:** | PASS | | +| Experimental Task 2 필수 항목: **Step 1: | PASS | | +| Experimental Task 2 필수 항목: **Step 2: | PASS | | +| Experimental Task 2 필수 항목: **Step 3: | PASS | | +| Experimental Task 2 필수 항목: **Step 4: | PASS | | +| Experimental Task 2 필수 항목: **Step 5: | PASS | | +| Experimental Task 2 필수 항목: Expected: | PASS | | +| Experimental Task 2 필수 항목: git commit -m | PASS | | +| Experimental Task 2 Gradle focused command | PASS | | +| Experimental Task 2 exact path | PASS | | +| Experimental Task 3 필수 항목: **Files:** | PASS | | +| Experimental Task 3 필수 항목: **Interfaces:** | PASS | | +| Experimental Task 3 필수 항목: **Implementation requirements:** | PASS | | +| Experimental Task 3 필수 항목: **Step 1: | PASS | | +| Experimental Task 3 필수 항목: **Step 2: | PASS | | +| Experimental Task 3 필수 항목: **Step 3: | PASS | | +| Experimental Task 3 필수 항목: **Step 4: | PASS | | +| Experimental Task 3 필수 항목: **Step 5: | PASS | | +| Experimental Task 3 필수 항목: Expected: | PASS | | +| Experimental Task 3 필수 항목: git commit -m | PASS | | +| Experimental Task 3 Gradle focused command | PASS | | +| Experimental Task 3 exact path | PASS | | +| Experimental Task 4 필수 항목: **Files:** | PASS | | +| Experimental Task 4 필수 항목: **Interfaces:** | PASS | | +| Experimental Task 4 필수 항목: **Implementation requirements:** | PASS | | +| Experimental Task 4 필수 항목: **Step 1: | PASS | | +| Experimental Task 4 필수 항목: **Step 2: | PASS | | +| Experimental Task 4 필수 항목: **Step 3: | PASS | | +| Experimental Task 4 필수 항목: **Step 4: | PASS | | +| Experimental Task 4 필수 항목: **Step 5: | PASS | | +| Experimental Task 4 필수 항목: Expected: | PASS | | +| Experimental Task 4 필수 항목: git commit -m | PASS | | +| Experimental Task 4 Gradle focused command | PASS | | +| Experimental Task 4 exact path | PASS | | +| Experimental Task 5 필수 항목: **Files:** | PASS | | +| Experimental Task 5 필수 항목: **Interfaces:** | PASS | | +| Experimental Task 5 필수 항목: **Implementation requirements:** | PASS | | +| Experimental Task 5 필수 항목: **Step 1: | PASS | | +| Experimental Task 5 필수 항목: **Step 2: | PASS | | +| Experimental Task 5 필수 항목: **Step 3: | PASS | | +| Experimental Task 5 필수 항목: **Step 4: | PASS | | +| Experimental Task 5 필수 항목: **Step 5: | PASS | | +| Experimental Task 5 필수 항목: Expected: | PASS | | +| Experimental Task 5 필수 항목: git commit -m | PASS | | +| Experimental Task 5 Gradle focused command | PASS | | +| Experimental Task 5 exact path | PASS | | +| Experimental Task 6 필수 항목: **Files:** | PASS | | +| Experimental Task 6 필수 항목: **Interfaces:** | PASS | | +| Experimental Task 6 필수 항목: **Implementation requirements:** | PASS | | +| Experimental Task 6 필수 항목: **Step 1: | PASS | | +| Experimental Task 6 필수 항목: **Step 2: | PASS | | +| Experimental Task 6 필수 항목: **Step 3: | PASS | | +| Experimental Task 6 필수 항목: **Step 4: | PASS | | +| Experimental Task 6 필수 항목: **Step 5: | PASS | | +| Experimental Task 6 필수 항목: Expected: | PASS | | +| Experimental Task 6 필수 항목: git commit -m | PASS | | +| Experimental Task 6 Gradle focused command | PASS | | +| Experimental Task 6 exact path | PASS | | +| Experimental Task 7 필수 항목: **Files:** | PASS | | +| Experimental Task 7 필수 항목: **Interfaces:** | PASS | | +| Experimental Task 7 필수 항목: **Implementation requirements:** | PASS | | +| Experimental Task 7 필수 항목: **Step 1: | PASS | | +| Experimental Task 7 필수 항목: **Step 2: | PASS | | +| Experimental Task 7 필수 항목: **Step 3: | PASS | | +| Experimental Task 7 필수 항목: **Step 4: | PASS | | +| Experimental Task 7 필수 항목: **Step 5: | PASS | | +| Experimental Task 7 필수 항목: Expected: | PASS | | +| Experimental Task 7 필수 항목: git commit -m | PASS | | +| Experimental Task 7 Gradle focused command | PASS | | +| Experimental Task 7 exact path | PASS | | +| Experimental Task 8 필수 항목: **Files:** | PASS | | +| Experimental Task 8 필수 항목: **Interfaces:** | PASS | | +| Experimental Task 8 필수 항목: **Implementation requirements:** | PASS | | +| Experimental Task 8 필수 항목: **Step 1: | PASS | | +| Experimental Task 8 필수 항목: **Step 2: | PASS | | +| Experimental Task 8 필수 항목: **Step 3: | PASS | | +| Experimental Task 8 필수 항목: **Step 4: | PASS | | +| Experimental Task 8 필수 항목: **Step 5: | PASS | | +| Experimental Task 8 필수 항목: Expected: | PASS | | +| Experimental Task 8 필수 항목: git commit -m | PASS | | +| Experimental Task 8 Gradle focused command | PASS | | +| Experimental Task 8 exact path | PASS | | +| Experimental Task 9 필수 항목: **Files:** | PASS | | +| Experimental Task 9 필수 항목: **Interfaces:** | PASS | | +| Experimental Task 9 필수 항목: **Implementation requirements:** | PASS | | +| Experimental Task 9 필수 항목: **Step 1: | PASS | | +| Experimental Task 9 필수 항목: **Step 2: | PASS | | +| Experimental Task 9 필수 항목: **Step 3: | PASS | | +| Experimental Task 9 필수 항목: **Step 4: | PASS | | +| Experimental Task 9 필수 항목: **Step 5: | PASS | | +| Experimental Task 9 필수 항목: Expected: | PASS | | +| Experimental Task 9 필수 항목: git commit -m | PASS | | +| Experimental Task 9 Gradle focused command | PASS | | +| Experimental Task 9 exact path | PASS | | +| Stable Create 경로 중복 부재 | PASS | 235 | +| Experimental Create 경로 중복 부재 | PASS | 37 | +| Stable·Experimental Create 경로 충돌 부재 | PASS | set() | +| Experimental 계획은 Stable Task 1~53 이후 시작 | PASS | | +| 공통 타입 일관성: PersistenceOperationName | PASS | 21 | +| 공통 타입 일관성: TransactionProfile | PASS | 14 | +| 공통 타입 일관성: RetryProfile | PASS | 5 | +| 공통 타입 일관성: JpaTransactionExecutor | PASS | 14 | +| 공통 타입 일관성: JpaRetryPolicy | PASS | 7 | +| 공통 타입 일관성: RetryDecision | PASS | 8 | +| 공통 타입 일관성: JpaFailureContext | PASS | 11 | +| 공통 타입 일관성: QueryName | PASS | 22 | +| 공통 타입 일관성: KeysetPageRequest | PASS | 4 | +| 공통 타입 일관성: KeysetSlice | PASS | 7 | +| 공통 타입 일관성: TransactionCompletionUnknownException | PASS | 7 | +| 공통 타입 일관성: PostgreSqlWorkClaimExecutor | PASS | 2 | +| 공통 타입 일관성: FlywayValidationGate | PASS | 8 | +| 공통 타입 일관성: JpaPlatformEndpoint | PASS | 3 | +| Experimental Gradle 경로 정확성 | PASS | | +| Replica annotation-only routing 금지 | PASS | | +| RLS connection reuse 검증 | PASS | | +| Stable 승격 ADR gate | PASS | | +| 심층 리서치 원문 부록 포함 | PASS | | diff --git a/jpa-superpowers-package/docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md b/jpa-superpowers-package/docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md new file mode 100644 index 00000000..bc9c1ae1 --- /dev/null +++ b/jpa-superpowers-package/docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md @@ -0,0 +1,771 @@ +# JPA Experimental Expansion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stable JPA 플랫폼을 변경하지 않고 Multi-tenancy, PostgreSQL RLS, schema/database tenant 분리, consistency-aware Read Replica, Jakarta Persistence 4.0, Hibernate ORM 8, PostgreSQL 19 호환성을 독립 Experimental 모듈과 승격 Gate로 검증한다. + +**Architecture:** Experimental module은 Stable `jpa-core-api` 계약만 소비하며 Stable starter에 자동 포함되지 않는다. 각 기능은 명시적 feature flag와 별도 compatibility/failure suite를 요구한다. 실험 결과가 Stable 의미론과 충돌하면 Core를 왜곡하지 않고 capability 또는 별도 profile로 유지한다. + +**Tech Stack:** Stable 계획의 Java 21·Spring Boot 4.1·PostgreSQL Testcontainers 기반, PostgreSQL RLS, AbstractRoutingDataSource, tenant-specific DataSource registry, Jakarta Persistence 4.0 preview/final compatibility lane, Hibernate ORM 8 compatibility lane, PostgreSQL 19 compatibility lane. + +## Global Constraints + +- Stable 계획 Task 1~53이 완료되고 Release Gate가 통과한 뒤 시작한다. +- 모듈 루트는 `modules/jpa-experimental`이다. +- Experimental module은 `jpa-spring-boot-starter`의 기본 dependency가 아니다. +- 모든 기능은 `backend.jpa.experimental.*` feature flag를 요구한다. +- Tenant ID와 consistency token은 metric label에 기록하지 않는다. +- Tenant context 누락은 fail-closed다. +- `readOnly=true`만으로 replica routing하지 않는다. +- Lock query, write transaction, read-after-write pin은 primary를 사용한다. +- JPA4/Hibernate8/PG19 결과로 Stable 3.2/7.4/PG16~18 contract를 수정하지 않는다. +- 승격 전 별도 security, failure, migration and compatibility evidence가 필요하다. + +--- + +## 1. Experimental 파일 구조 + +```text +modules/jpa-experimental/ +├── jpa-experimental-core/ +├── jpa-multitenancy-column/ +├── jpa-multitenancy-rls/ +├── jpa-multitenancy-schema/ +├── jpa-multitenancy-database/ +├── jpa-read-replica/ +└── jpa-next-compatibility/ +``` + +--- +### Task 1: Experimental Module·Feature Gate·Dependency Isolation 구성 + +**Files:** +- Create: `modules/jpa-experimental/jpa-experimental-core/build.gradle.kts` +- Create: `modules/jpa-experimental/jpa-multitenancy-column/build.gradle.kts` +- Create: `modules/jpa-experimental/jpa-multitenancy-rls/build.gradle.kts` +- Create: `modules/jpa-experimental/jpa-multitenancy-schema/build.gradle.kts` +- Create: `modules/jpa-experimental/jpa-multitenancy-database/build.gradle.kts` +- Create: `modules/jpa-experimental/jpa-read-replica/build.gradle.kts` +- Create: `modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts` +- Create: `modules/jpa-experimental/jpa-experimental-core/src/main/java/io/backend/skeleton/jpa/experimental/ExperimentalFeature.java` +- Create: `modules/jpa-experimental/jpa-experimental-core/src/main/java/io/backend/skeleton/jpa/experimental/ExperimentalFeatureGate.java` +- Modify: `settings.gradle.kts` +- Test: `modules/jpa-experimental/jpa-experimental-core/src/test/java/io/backend/skeleton/jpa/experimental/ExperimentalFeatureGateTest.java` + +**Interfaces:** +- Consumes: Stable `jpa-core-api` and explicit environment feature flags. +- Produces: Isolated experimental projects that cannot enter the Stable starter transitively. + +**Implementation requirements:** +- Every module depends only on Stable public contracts, never on Stable internal packages. +- Feature gate fails startup when module is present but flag is absent. +- Add a dependency graph test proving the Stable starter has no experimental dependency. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.experimental; + +class ExperimentalFeatureGateTest { + @Test + void featureIsDisabledUnlessExplicitlyEnabled() { + assertThatThrownBy(() -> gate.requireEnabled(MULTITENANCY_COLUMN, Map.of())) + .hasMessageContaining("backend.jpa.experimental.multitenancy-column=true"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-experimental-core:test --tests 'io.backend.skeleton.jpa.experimental.ExperimentalFeatureGateTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.experimental; + +public final class ExperimentalFeatureGate { + public void requireEnabled( + ExperimentalFeature feature, + Map flags) { + if (!Boolean.TRUE.equals(flags.get(feature.property()))) { + throw new IllegalStateException(feature.property() + "=true is required"); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-experimental-core:test --tests 'io.backend.skeleton.jpa.experimental.ExperimentalFeatureGateTest' +./gradlew :modules:jpa-experimental:jpa-experimental-core:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa-experimental/jpa-experimental-core/build.gradle.kts' 'modules/jpa-experimental/jpa-multitenancy-column/build.gradle.kts' 'modules/jpa-experimental/jpa-multitenancy-rls/build.gradle.kts' 'modules/jpa-experimental/jpa-multitenancy-schema/build.gradle.kts' 'modules/jpa-experimental/jpa-multitenancy-database/build.gradle.kts' 'modules/jpa-experimental/jpa-read-replica/build.gradle.kts' 'modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts' 'modules/jpa-experimental/jpa-experimental-core/src/main/java/io/backend/skeleton/jpa/experimental/ExperimentalFeature.java' 'modules/jpa-experimental/jpa-experimental-core/src/main/java/io/backend/skeleton/jpa/experimental/ExperimentalFeatureGate.java' 'settings.gradle.kts' 'modules/jpa-experimental/jpa-experimental-core/src/test/java/io/backend/skeleton/jpa/experimental/ExperimentalFeatureGateTest.java' +git commit -m "build: isolate jpa experimental modules" +``` + +### Task 2: Shared-schema Tenant Context와 Column Guard 구현 + +**Files:** +- Create: `modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantId.java` +- Create: `modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantContext.java` +- Create: `modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantAwareRepositoryGuard.java` +- Create: `modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantEntityListenerGuard.java` +- Test: `modules/jpa-experimental/jpa-multitenancy-column/src/integrationTest/java/io/backend/skeleton/jpa/experimental/tenant/TenantColumnIsolationTest.java` + +**Interfaces:** +- Consumes: Explicit request/job tenant context and domain Entity tenant-column contracts. +- Produces: Fail-closed tenant context propagation and query/write isolation evidence. + +**Implementation requirements:** +- Reject Repository access when tenant context is absent outside an audited admin scope. +- Require tenant column in unique/index requirements where isolation depends on it. +- Test async job context propagation and cleanup. +- Do not rely on Hibernate filter alone as the final security boundary. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.experimental.tenant; + +class TenantColumnIsolationTest { + @Test + void tenantARepositoryCannotReadTenantBRows() { + insertFor(TENANT_A, "a"); + insertFor(TENANT_B, "b"); + + assertThat(withTenant(TENANT_A, repository::findAll)) + .extracting(Item::value) + .containsExactly("a"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-multitenancy-column:integrationTest --tests 'io.backend.skeleton.jpa.experimental.tenant.TenantColumnIsolationTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.experimental.tenant; + +public final class TenantContext { + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + + public static TenantId require() { + TenantId tenant = CURRENT.get(); + if (tenant == null) throw new IllegalStateException("tenant context is required"); + return tenant; + } + + public static void clear() { CURRENT.remove(); } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-multitenancy-column:integrationTest --tests 'io.backend.skeleton.jpa.experimental.tenant.TenantColumnIsolationTest' +./gradlew :modules:jpa-experimental:jpa-multitenancy-column:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantId.java' 'modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantContext.java' 'modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantAwareRepositoryGuard.java' 'modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantEntityListenerGuard.java' 'modules/jpa-experimental/jpa-multitenancy-column/src/integrationTest/java/io/backend/skeleton/jpa/experimental/tenant/TenantColumnIsolationTest.java' +git commit -m "feat: add experimental tenant column isolation" +``` + +### Task 3: PostgreSQL RLS Tenant Policy와 Connection Reuse Guard 구현 + +**Files:** +- Create: `modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsTenantSessionBinder.java` +- Create: `modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsPolicyVerifier.java` +- Create: `modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsAdminBypassToken.java` +- Create: `modules/jpa-experimental/jpa-multitenancy-rls/src/main/resources/db/experimental-rls/V1__tenant_rls.sql` +- Test: `modules/jpa-experimental/jpa-multitenancy-rls/src/failureTest/java/io/backend/skeleton/jpa/experimental/rls/RlsIsolationFailureTest.java` + +**Interfaces:** +- Consumes: TenantContext, PostgreSQL transaction-local settings and restricted runtime role. +- Produces: Database-enforced tenant isolation that resets safely across pooled connections. + +**Implementation requirements:** +- Set tenant context with transaction-local `set_config` before tenant queries. +- Prove a pooled connection cannot leak the prior tenant into the next transaction. +- Runtime role must not own tables or bypass RLS. +- Admin bypass requires a separate DataSource and audit token. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.experimental.rls; + +class RlsIsolationFailureTest { + @Test + void pooledConnectionDoesNotLeakPriorTenantSetting() { + withTenant(TENANT_A, () -> assertThat(repository.count()).isEqualTo(1)); + withTenant(TENANT_B, () -> assertThat(repository.count()).isEqualTo(1)); + withoutTenant(() -> assertThatThrownBy(repository::count).isInstanceOf(DataAccessException.class)); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-multitenancy-rls:failureTest --tests 'io.backend.skeleton.jpa.experimental.rls.RlsIsolationFailureTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.experimental.rls; + +public final class RlsTenantSessionBinder { + public void bind(EntityManager entityManager, TenantId tenant) { + entityManager.createNativeQuery( + "select set_config('app.tenant_id', :tenant, true)") + .setParameter("tenant", tenant.value()) + .getSingleResult(); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-multitenancy-rls:failureTest --tests 'io.backend.skeleton.jpa.experimental.rls.RlsIsolationFailureTest' +./gradlew :modules:jpa-experimental:jpa-multitenancy-rls:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsTenantSessionBinder.java' 'modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsPolicyVerifier.java' 'modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsAdminBypassToken.java' 'modules/jpa-experimental/jpa-multitenancy-rls/src/main/resources/db/experimental-rls/V1__tenant_rls.sql' 'modules/jpa-experimental/jpa-multitenancy-rls/src/failureTest/java/io/backend/skeleton/jpa/experimental/rls/RlsIsolationFailureTest.java' +git commit -m "feat: add experimental postgresql rls isolation" +``` + +### Task 4: Schema-per-tenant Connection Provider와 Migration Orchestrator 구현 + +**Files:** +- Create: `modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantRegistry.java` +- Create: `modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaMultiTenantConnectionProvider.java` +- Create: `modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantMigrationOrchestrator.java` +- Test: `modules/jpa-experimental/jpa-multitenancy-schema/src/migrationTest/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantMigrationContractTest.java` + +**Interfaces:** +- Consumes: Validated tenant→schema catalog and Flyway migration gate. +- Produces: Bounded schema selection and per-tenant migration status without accepting raw schema names. + +**Implementation requirements:** +- Map TenantId to a pre-registered schema identifier; no user-provided SQL identifier. +- Reset schema/search_path when returning pooled connections. +- Track migration version and failure per tenant. +- Rate-limit tenant migrations and support resume without auto-repair. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.experimental.schema; + +class SchemaTenantMigrationContractTest { + @Test + void migratesOnlyRegisteredSchemasAndResumesAfterFailure() { + orchestrator.migrateAll(List.of(TENANT_A, TENANT_B)); + assertThat(status(TENANT_A).version()).isEqualTo(LATEST); + assertThatThrownBy(() -> orchestrator.migrate(new TenantId("../public"))) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-multitenancy-schema:migrationTest --tests 'io.backend.skeleton.jpa.experimental.schema.SchemaTenantMigrationContractTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.experimental.schema; + +public final class SchemaTenantRegistry { + public String requireSchema(TenantId tenant) { + return Optional.ofNullable(schemaByTenant.get(tenant)) + .orElseThrow(() -> new IllegalArgumentException("unregistered tenant schema")); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-multitenancy-schema:migrationTest --tests 'io.backend.skeleton.jpa.experimental.schema.SchemaTenantMigrationContractTest' +./gradlew :modules:jpa-experimental:jpa-multitenancy-schema:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantRegistry.java' 'modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaMultiTenantConnectionProvider.java' 'modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantMigrationOrchestrator.java' 'modules/jpa-experimental/jpa-multitenancy-schema/src/migrationTest/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantMigrationContractTest.java' +git commit -m "feat: add experimental schema per tenant persistence" +``` + +### Task 5: Database-per-tenant DataSource Registry와 Capacity Guard 구현 + +**Files:** +- Create: `modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantDataSourceRegistry.java` +- Create: `modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantEntityManagerFactoryRegistry.java` +- Create: `modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantPoolBudget.java` +- Create: `modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantDataSourceLifecycle.java` +- Test: `modules/jpa-experimental/jpa-multitenancy-database/src/performanceTest/java/io/backend/skeleton/jpa/experimental/database/TenantPoolCapacityContractTest.java` + +**Interfaces:** +- Consumes: Secret-backed tenant connection profiles and global DB connection budget. +- Produces: Lazy bounded per-tenant pools with eviction, credential rotation and migration status. + +**Implementation requirements:** +- Never create an unbounded Hikari pool per tenant. +- Enforce global maximum pools and connections before creating a DataSource. +- Drain and close pools on tenant removal or credential rotation. +- Do not expose tenant JDBC URLs or credentials in diagnostics. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.experimental.database; + +class TenantPoolCapacityContractTest { + @Test + void refusesNewTenantPoolWhenGlobalConnectionBudgetIsExhausted() { + registry.openTenants(globalBudget().maxTenants()); + assertThatThrownBy(() -> registry.require(ANOTHER_TENANT)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("tenant pool budget"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-multitenancy-database:performanceTest --tests 'io.backend.skeleton.jpa.experimental.database.TenantPoolCapacityContractTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.experimental.database; + +public record TenantPoolBudget( + int maxOpenPools, + int maxConnectionsAcrossPools) { + public void requireCapacity(int openPools, int allocatedConnections) { + if (openPools >= maxOpenPools || allocatedConnections >= maxConnectionsAcrossPools) { + throw new IllegalStateException("tenant pool budget exhausted"); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-multitenancy-database:performanceTest --tests 'io.backend.skeleton.jpa.experimental.database.TenantPoolCapacityContractTest' +./gradlew :modules:jpa-experimental:jpa-multitenancy-database:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantDataSourceRegistry.java' 'modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantEntityManagerFactoryRegistry.java' 'modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantPoolBudget.java' 'modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantDataSourceLifecycle.java' 'modules/jpa-experimental/jpa-multitenancy-database/src/performanceTest/java/io/backend/skeleton/jpa/experimental/database/TenantPoolCapacityContractTest.java' +git commit -m "feat: add experimental database per tenant registry" +``` + +### Task 6: Consistency-aware Read Replica Routing 구현 + +**Files:** +- Create: `modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReadConsistency.java` +- Create: `modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ConsistencyToken.java` +- Create: `modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReplicaRoutingDecision.java` +- Create: `modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ConsistencyAwareDataSourceRouter.java` +- Create: `modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReplicaLagMonitor.java` +- Test: `modules/jpa-experimental/jpa-read-replica/src/failureTest/java/io/backend/skeleton/jpa/experimental/replica/ReadAfterWriteRoutingContractTest.java` + +**Interfaces:** +- Consumes: Primary/replica DataSources, transaction state, lock intent and replica lag evidence. +- Produces: Routing decisions for PRIMARY_REQUIRED, BOUNDED_STALENESS and EVENTUAL reads. + +**Implementation requirements:** +- Writes, lock queries, REQUIRES_NEW writes and active write transactions always use primary. +- Read-after-write uses a consistency token or primary pin, not `readOnly=true` alone. +- Fallback to primary when replica lag exceeds policy or evidence is unavailable. +- Keep routing fixed for the life of one transaction. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.experimental.replica; + +class ReadAfterWriteRoutingContractTest { + @Test + void immediateReadAfterWriteUsesPrimaryUntilConsistencyTokenIsSatisfied() { + var token = service.writeAndReturnConsistencyToken(); + var decision = router.route(readOnlyTransaction(), ReadConsistency.after(token)); + assertThat(decision.target()).isEqualTo(PRIMARY); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-read-replica:failureTest --tests 'io.backend.skeleton.jpa.experimental.replica.ReadAfterWriteRoutingContractTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.experimental.replica; + +public final class ConsistencyAwareDataSourceRouter { + public ReplicaRoutingDecision route( + TransactionContext transaction, + ReadConsistency consistency) { + if (transaction.write() || transaction.locking() || + !lagMonitor.satisfies(consistency)) { + return ReplicaRoutingDecision.primary(); + } + return ReplicaRoutingDecision.replica(); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-read-replica:failureTest --tests 'io.backend.skeleton.jpa.experimental.replica.ReadAfterWriteRoutingContractTest' +./gradlew :modules:jpa-experimental:jpa-read-replica:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReadConsistency.java' 'modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ConsistencyToken.java' 'modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReplicaRoutingDecision.java' 'modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ConsistencyAwareDataSourceRouter.java' 'modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReplicaLagMonitor.java' 'modules/jpa-experimental/jpa-read-replica/src/failureTest/java/io/backend/skeleton/jpa/experimental/replica/ReadAfterWriteRoutingContractTest.java' +git commit -m "feat: add experimental consistency aware replica routing" +``` + +### Task 7: Jakarta Persistence 4.0 Compatibility Lane 구현 + +**Files:** +- Create: `modules/jpa-experimental/jpa-next-compatibility/src/compatibilityJpa4/java/io/backend/skeleton/jpa/experimental/next/Jpa4CompatibilityTest.java` +- Create: `.github/workflows/jpa-next-jpa4.yml` +- Modify: `modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts` +- Test: `modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/CompatibilityLaneDefinitionTest.java` + +**Interfaces:** +- Consumes: Published Jakarta Persistence 4.0 milestone/final artifact when available and the Stable contract suite. +- Produces: A non-blocking compatibility report that does not alter Stable JPA 3.2 APIs. + +**Implementation requirements:** +- Run the Stable public API compilation and selected mapping contracts against JPA 4. +- Record removed/changed APIs and provider support separately. +- Do not publish JPA4 compiled artifacts under Stable coordinates. + +- [ ] **Step 1: Write the failing test** + +```kotlin +package io.backend.skeleton.jpa.experimental.next; + +class CompatibilityLaneDefinitionTest { + @Test + void jpaFourLaneIsExperimentalAndSeparateFromStablePublication() { + assertThat(lane("jpa4").publicationEnabled()).isFalse(); + assertThat(lane("jpa4").supportLevel()).isEqualTo(EXPERIMENTAL); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.CompatibilityLaneDefinitionTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```kotlin +testing { + suites { + register("compatibilityJpa4") { + useJUnitJupiter() + dependencies { + implementation(project(":modules:jpa:jpa-core-api")) + implementation(libs.jakarta.persistence.next) + } + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.CompatibilityLaneDefinitionTest' +./gradlew :modules:jpa-experimental:jpa-next-compatibility:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa-experimental/jpa-next-compatibility/src/compatibilityJpa4/java/io/backend/skeleton/jpa/experimental/next/Jpa4CompatibilityTest.java' '.github/workflows/jpa-next-jpa4.yml' 'modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts' 'modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/CompatibilityLaneDefinitionTest.java' +git commit -m "test: add jakarta persistence four compatibility lane" +``` + +### Task 8: Hibernate ORM 8 Compatibility Lane 구현 + +**Files:** +- Create: `modules/jpa-experimental/jpa-next-compatibility/src/compatibilityHibernate8/java/io/backend/skeleton/jpa/experimental/next/Hibernate8CompatibilityTest.java` +- Create: `.github/workflows/jpa-next-hibernate8.yml` +- Modify: `modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts` +- Test: `modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/HibernateCompatibilityPolicyTest.java` + +**Interfaces:** +- Consumes: Hibernate ORM 8 milestone/final artifact and Stable Hibernate 7.4 regression suites. +- Produces: Generated SQL, fetch pagination, statistics, batch and extension compatibility evidence. + +**Implementation requirements:** +- Re-run collection fetch pagination, StatementInspector, Statistics, JSONB, Batch and StatelessSession contracts. +- Record SQL and performance differences without weakening the 7.4 Stable gate. +- Do not allow Hibernate 8 dependencies in Stable published modules. + +- [ ] **Step 1: Write the failing test** + +```kotlin +package io.backend.skeleton.jpa.experimental.next; + +class HibernateCompatibilityPolicyTest { + @Test + void hibernateEightCannotReplaceStableProviderWithoutPromotion() { + assertThat(policy.stableProvider()).isEqualTo("7.4"); + assertThat(policy.experimentalProviders()).contains("8"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.HibernateCompatibilityPolicyTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```kotlin +testing { + suites { + register("compatibilityHibernate8") { + useJUnitJupiter() + dependencies { + implementation(project(":modules:jpa:jpa-testkit-postgresql")) + implementation(libs.hibernate.orm.next) + } + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.HibernateCompatibilityPolicyTest' +./gradlew :modules:jpa-experimental:jpa-next-compatibility:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa-experimental/jpa-next-compatibility/src/compatibilityHibernate8/java/io/backend/skeleton/jpa/experimental/next/Hibernate8CompatibilityTest.java' '.github/workflows/jpa-next-hibernate8.yml' 'modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts' 'modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/HibernateCompatibilityPolicyTest.java' +git commit -m "test: add hibernate eight compatibility lane" +``` + +### Task 9: PostgreSQL 19 Compatibility와 Stable 승격 Gate 구현 + +**Files:** +- Create: `modules/jpa-experimental/jpa-next-compatibility/src/compatibilityPostgresql19/java/io/backend/skeleton/jpa/experimental/next/PostgreSql19CompatibilityTest.java` +- Create: `docs/jpa/experimental-support-matrix.md` +- Create: `docs/jpa/experimental-promotion-checklist.md` +- Create: `.github/workflows/jpa-next-postgresql19.yml` +- Modify: `modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts` +- Test: `modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/ExperimentalPromotionGateTest.java` + +**Interfaces:** +- Consumes: PG19 image when GA, all Stable contracts, experimental security/failure/migration/performance reports. +- Produces: A promotion decision that requires evidence rather than version availability alone. + +**Implementation requirements:** +- Run mapping, SQLSTATE, lock, batch, Flyway, plan and native extension contracts on PG19. +- Promotion requires two supported patch runs and no unresolved semantic regression. +- Multi-tenancy/replica promotion requires tenant leakage, failover, lag and pool-capacity evidence. +- Update Stable support matrix only through a reviewed ADR. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.experimental.next; + +class ExperimentalPromotionGateTest { + @Test + void promotionRequiresAllEvidenceAndReviewedAdr() { + var evidence = evidence().withCompatibility(true).withSecurity(true).withFailure(true) + .withMigration(true).withPerformance(true).withReviewedAdr(false); + assertThat(gate.evaluate(evidence)).isEqualTo(BLOCKED_MISSING_ADR); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.ExperimentalPromotionGateTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.experimental.next; + +public final class ExperimentalPromotionGate { + public PromotionDecision evaluate(PromotionEvidence evidence) { + if (!evidence.allTechnicalGatesPassed()) return BLOCKED_TECHNICAL; + if (!evidence.reviewedAdr()) return BLOCKED_MISSING_ADR; + return ELIGIBLE_FOR_STABLE_REVIEW; + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.ExperimentalPromotionGateTest' +./gradlew :modules:jpa-experimental:jpa-next-compatibility:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa-experimental/jpa-next-compatibility/src/compatibilityPostgresql19/java/io/backend/skeleton/jpa/experimental/next/PostgreSql19CompatibilityTest.java' 'docs/jpa/experimental-support-matrix.md' 'docs/jpa/experimental-promotion-checklist.md' '.github/workflows/jpa-next-postgresql19.yml' 'modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts' 'modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/ExperimentalPromotionGateTest.java' +git commit -m "docs: add jpa experimental promotion gates" +``` +## 2. Experimental 완료 조건 + +```text +Stable starter가 Experimental module에 의존하지 않는다. +Tenant context 누락과 connection reuse에서 fail-closed다. +RLS runtime role이 policy를 bypass하지 못한다. +Schema/database tenant migration과 pool capacity가 bounded다. +Replica routing이 read-after-write와 lock query를 primary에 고정한다. +JPA4/Hibernate8/PG19 lane이 Stable artifacts를 변경하지 않는다. +승격은 ADR와 compatibility/security/failure/migration/performance 증거를 요구한다. +``` diff --git a/jpa-superpowers-package/docs/superpowers/plans/2026-08-11-jpa-persistence-platform-implementation-plan.md b/jpa-superpowers-package/docs/superpowers/plans/2026-08-11-jpa-persistence-platform-implementation-plan.md new file mode 100644 index 00000000..43d000df --- /dev/null +++ b/jpa-superpowers-package/docs/superpowers/plans/2026-08-11-jpa-persistence-platform-implementation-plan.md @@ -0,0 +1,4716 @@ +# JPA 관계형 영속성 플랫폼 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Java/Spring Backend Skeleton에 도메인 Repository 소유권, Application Use Case Transaction, SQLSTATE 기반 오류, 전체 Transaction Retry, Fetch·Pagination·Batch 검증, PostgreSQL Native Extension, Flyway Schema Gate, 관측성·보안·실제 PostgreSQL Release Matrix를 갖춘 JPA 관계형 영속성 플랫폼을 구현한다. + +**Architecture:** `jpa-core-api`는 Spring·JPA 비종속 안정 계약을 소유하고, `jpa-transaction`, `jpa-spring-data`, `jpa-hibernate`, `jpa-postgresql`, `jpa-migration-flyway`가 이를 구현한다. 도메인 모듈은 Entity와 Repository를 직접 소유하며 플랫폼은 Generic CRUD Repository를 만들지 않는다. Retry는 새 Persistence Context의 전체 Use Case 단위이고 Commit 결과 불명은 자동 Retry하지 않는다. + +**Tech Stack:** Java 21, Gradle Kotlin DSL, Spring Boot 4.1 dependency management, Spring Data JPA 4.1, Jakarta Persistence 3.2, Hibernate ORM 7.4, PostgreSQL 16·17·18, HikariCP, Flyway, Micrometer, Spring Observation, JUnit 5, AssertJ, ArchUnit, Testcontainers, Toxiproxy. + +## Global Constraints + +- Root package는 `io.backend.skeleton.jpa`이다. +- 모듈 루트는 `modules/jpa`이다. +- Java 21과 Spring Boot 4.1 BOM 조합을 사용하며 개별 Hibernate·Flyway·Hikari 버전을 임의로 override하지 않는다. +- Stable JPA 규격은 Jakarta Persistence 3.2, Stable Provider는 Hibernate ORM 7.4다. +- Stable DB Matrix는 PostgreSQL 16·17·18이다. +- H2는 Local Convenience이며 PostgreSQL 계약 증거로 사용하지 않는다. +- 도메인 모듈이 Entity, Embeddable, Repository, Query, Index Requirement, Lock·Soft Delete·Audit 정책을 소유한다. +- `GenericRepository` 또는 Spring Data CRUD를 재구현하는 Base Repository를 만들지 않는다. +- 일반 애플리케이션의 Transaction 경계는 Application Service다. +- OSIV는 모든 운영 profile에서 명시적으로 false다. +- 운영 Schema 변경의 Source of Truth는 Flyway이고 Hibernate는 validate만 수행한다. +- 운영에서 `ddl-auto=update`, `create`, `create-drop`을 허용하지 않는다. +- Optimistic Conflict·Deadlock·Serialization Failure Retry는 새 Persistence Context와 새 DB Transaction에서 전체 Use Case를 재실행한다. +- `TransactionCompletionUnknownException`은 자동 Retry하지 않는다. +- 외부 HTTP, Object Storage, Messaging 호출을 DB Transaction 안에서 대기하지 않는다. +- PostgreSQL write-heavy Entity의 기본 ID 전략은 Sequence이며 IDENTITY는 제한한다. +- Entity를 Controller 응답, Message payload, Redis Java serialization 값으로 직접 노출하지 않는다. +- Fetch 전략은 Use Case별 EntityGraph·Fetch Join·Projection·Batch Fetch로 결정한다. +- Hibernate 7.4 collection fetch pagination은 PG16·17·18 generated SQL과 row amplification을 계약 테스트한다. +- Dynamic Sort는 allowlist를 사용하고 Native SQL 값은 parameter binding한다. +- JDBC Batch 완료는 실제 batch 통계로 증명한다. +- Bulk DML은 flush → bulk → clear 규칙을 따른다. +- Runtime·Migration·Admin DB credential을 분리한다. +- SQL parameter, Entity ID, Tenant ID 원문, PII를 metric label과 일반 로그에 기록하지 않는다. +- Multi-tenancy, Read Replica, JPA 4, Hibernate 8, PostgreSQL 19는 별도 Experimental 계획으로 구현한다. +- 각 Task는 실패 테스트 → 실패 확인 → 최소 구현 → 통과 확인 → 커밋 순서로 수행한다. +- 각 Task는 독립적으로 검토 가능한 하나의 커밋으로 종료한다. + +--- + +## 1. 확정 파일 구조 + +```text +backend-skeleton/ +├── settings.gradle.kts +├── build-logic/src/main/kotlin/jpa-library-conventions.gradle.kts +├── modules/jpa/ +│ ├── jpa-core-api/ +│ ├── jpa-transaction/ +│ ├── jpa-spring-data/ +│ ├── jpa-querydsl/ +│ ├── jpa-hibernate/ +│ ├── jpa-postgresql/ +│ ├── jpa-postgresql-copy/ +│ ├── jpa-migration-flyway/ +│ ├── jpa-auditing/ +│ ├── jpa-envers/ +│ ├── jpa-cache-hibernate/ +│ ├── jpa-observability/ +│ ├── jpa-security/ +│ ├── jpa-spring-boot-starter/ +│ ├── jpa-testkit/ +│ ├── jpa-testkit-postgresql/ +│ ├── jpa-testkit-migration/ +│ └── jpa-testkit-queryplan/ +├── infra/jpa/ +│ ├── postgres/ +│ ├── roles/ +│ └── toxiproxy/ +├── docs/jpa/ +│ ├── support-matrix.md +│ ├── entity-mapping-guide.md +│ ├── transaction-guide.md +│ ├── query-fetch-guide.md +│ ├── migration-guide.md +│ ├── postgresql-extensions.md +│ ├── observability.md +│ ├── security.md +│ └── runbooks.md +└── docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md +``` + +## 2. 핵심 package + +```text +io.backend.skeleton.jpa.api +io.backend.skeleton.jpa.api.capability +io.backend.skeleton.jpa.api.error +io.backend.skeleton.jpa.api.query +io.backend.skeleton.jpa.api.transaction +io.backend.skeleton.jpa.transaction +io.backend.skeleton.jpa.springdata +io.backend.skeleton.jpa.querydsl +io.backend.skeleton.jpa.hibernate +io.backend.skeleton.jpa.postgresql +io.backend.skeleton.jpa.migration +io.backend.skeleton.jpa.auditing +io.backend.skeleton.jpa.envers +io.backend.skeleton.jpa.cache +io.backend.skeleton.jpa.observation +io.backend.skeleton.jpa.security +io.backend.skeleton.jpa.autoconfigure +io.backend.skeleton.jpa.testkit +``` + +## 3. Module dependency map + +```text +jpa-core-api + → no project dependency + +jpa-transaction + → jpa-core-api + +jpa-spring-data + → jpa-core-api + +jpa-querydsl + → jpa-core-api + → jpa-spring-data + +jpa-hibernate + → jpa-core-api + +jpa-postgresql + → jpa-core-api + → jpa-hibernate + +jpa-postgresql-copy + → jpa-core-api + → jpa-postgresql + +jpa-migration-flyway + → jpa-core-api + +jpa-auditing + → jpa-core-api + +jpa-envers + → jpa-core-api + → jpa-hibernate + +jpa-cache-hibernate + → jpa-core-api + → jpa-hibernate + +jpa-observability + → jpa-core-api + → jpa-hibernate + +jpa-security + → jpa-core-api + +jpa-spring-boot-starter + → jpa-core-api + → jpa-transaction + → jpa-spring-data + → jpa-hibernate + → jpa-postgresql + → jpa-migration-flyway + → jpa-auditing + → jpa-observability + → jpa-security + +jpa-testkit + → jpa-core-api + +jpa-testkit-postgresql + → jpa-testkit + → jpa-postgresql + +jpa-testkit-migration + → jpa-testkit-postgresql + → jpa-migration-flyway + +jpa-testkit-queryplan + → jpa-testkit-postgresql + → jpa-observability +``` + +Provider SDK, Spring Data, Hibernate, Flyway, Querydsl, PostgreSQL JDBC dependencies are added only in the owning module. `jpa-core-api` remains framework-free. + +--- +### Task 1: Gradle 멀티모듈과 JPA 품질 Test Suite 구성 + +**Files:** +- Create: `build-logic/src/main/kotlin/jpa-library-conventions.gradle.kts` +- Create: `modules/jpa/jpa-core-api/build.gradle.kts` +- Create: `modules/jpa/jpa-transaction/build.gradle.kts` +- Create: `modules/jpa/jpa-spring-data/build.gradle.kts` +- Create: `modules/jpa/jpa-querydsl/build.gradle.kts` +- Create: `modules/jpa/jpa-hibernate/build.gradle.kts` +- Create: `modules/jpa/jpa-postgresql/build.gradle.kts` +- Create: `modules/jpa/jpa-postgresql-copy/build.gradle.kts` +- Create: `modules/jpa/jpa-migration-flyway/build.gradle.kts` +- Create: `modules/jpa/jpa-auditing/build.gradle.kts` +- Create: `modules/jpa/jpa-envers/build.gradle.kts` +- Create: `modules/jpa/jpa-cache-hibernate/build.gradle.kts` +- Create: `modules/jpa/jpa-observability/build.gradle.kts` +- Create: `modules/jpa/jpa-security/build.gradle.kts` +- Create: `modules/jpa/jpa-spring-boot-starter/build.gradle.kts` +- Create: `modules/jpa/jpa-testkit/build.gradle.kts` +- Create: `modules/jpa/jpa-testkit-postgresql/build.gradle.kts` +- Create: `modules/jpa/jpa-testkit-migration/build.gradle.kts` +- Create: `modules/jpa/jpa-testkit-queryplan/build.gradle.kts` +- Modify: `settings.gradle.kts` +- Test: `build-logic/src/test/kotlin/JpaModuleBoundaryTest.kt` + +**Interfaces:** +- Consumes: Host repository version catalog and Spring Boot 4.1 dependency management. +- Produces: 18 isolated JPA modules and `test`, `integrationTest`, `contractTest`, `migrationTest`, `failureTest`, `performanceTest`, `compatibilityTest` suites. + +**Implementation requirements:** +- Register every module under `:modules:jpa:*` and apply Java 21 toolchains. +- Do not pin Hibernate, Flyway, Hikari, Spring Data versions outside the Boot BOM. +- Expose integration suites only in modules that own external resources. +- Make `check` depend on unit and architecture tests; release aggregates are added in Task 53. +- Ensure experimental modules are not included in this Stable dependency graph. + +- [ ] **Step 1: Write the failing test** + +```kotlin +class JpaModuleBoundaryTest { + @Test + fun `core api has no framework dependency`() { + val core = project(":modules:jpa:jpa-core-api") + assertThat(core.directDependencies()) + .noneMatch { it.startsWith("org.springframework") || + it.startsWith("org.hibernate") || + it.startsWith("jakarta.persistence") } + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'JpaModuleBoundaryTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```kotlin +plugins { + `java-library` + `jvm-test-suite` +} + +java { + toolchain.languageVersion.set(JavaLanguageVersion.of(21)) +} + +testing { + suites { + named("test") { useJUnitJupiter() } + register("contractTest") { + useJUnitJupiter() + dependencies { implementation(project()) } + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'JpaModuleBoundaryTest' +./gradlew :modules:jpa:jpa-core-api:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'build-logic/src/main/kotlin/jpa-library-conventions.gradle.kts' 'modules/jpa/jpa-core-api/build.gradle.kts' 'modules/jpa/jpa-transaction/build.gradle.kts' 'modules/jpa/jpa-spring-data/build.gradle.kts' 'modules/jpa/jpa-querydsl/build.gradle.kts' 'modules/jpa/jpa-hibernate/build.gradle.kts' 'modules/jpa/jpa-postgresql/build.gradle.kts' 'modules/jpa/jpa-postgresql-copy/build.gradle.kts' 'modules/jpa/jpa-migration-flyway/build.gradle.kts' 'modules/jpa/jpa-auditing/build.gradle.kts' 'modules/jpa/jpa-envers/build.gradle.kts' 'modules/jpa/jpa-cache-hibernate/build.gradle.kts' 'modules/jpa/jpa-observability/build.gradle.kts' 'modules/jpa/jpa-security/build.gradle.kts' 'modules/jpa/jpa-spring-boot-starter/build.gradle.kts' 'modules/jpa/jpa-testkit/build.gradle.kts' 'modules/jpa/jpa-testkit-postgresql/build.gradle.kts' 'modules/jpa/jpa-testkit-migration/build.gradle.kts' 'modules/jpa/jpa-testkit-queryplan/build.gradle.kts' 'settings.gradle.kts' 'build-logic/src/test/kotlin/JpaModuleBoundaryTest.kt' +git commit -m "build: add jpa platform modules and test suites" +``` + +### Task 2: Core Operation Name과 Capability 계약 구현 + +**Files:** +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/PersistenceOperationName.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/capability/JpaCapability.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/capability/SupportLevel.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/capability/CapabilitySupport.java` +- Test: `modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/PersistenceOperationNameTest.java` + +**Interfaces:** +- Consumes: Only Java 21 standard library. +- Produces: Bounded operation names and explicit Stable/Advanced/Experimental capability metadata. + +**Implementation requirements:** +- Operation names must match `[a-z][a-z0-9.-]{2,95}`. +- Capability constraints must be immutable and must not store provider objects. +- Include capabilities for transaction retry, completion evidence, keyset pagination, batch, PostgreSQL native write, schema gate, L2 cache, Envers. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.api; + +class PersistenceOperationNameTest { + @Test + void rejectsDynamicIdentifiers() { + assertThatThrownBy(() -> new PersistenceOperationName("order/" + UUID.randomUUID())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void acceptsRegisteredLowCardinalityName() { + assertThat(new PersistenceOperationName("order.place").value()) + .isEqualTo("order.place"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.PersistenceOperationNameTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.api; + +public record PersistenceOperationName(String value) { + private static final Pattern FORMAT = + Pattern.compile("[a-z][a-z0-9.-]{2,95}"); + + public PersistenceOperationName { + if (value == null || !FORMAT.matcher(value).matches()) { + throw new IllegalArgumentException("invalid persistence operation name"); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.PersistenceOperationNameTest' +./gradlew :modules:jpa:jpa-core-api:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/PersistenceOperationName.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/capability/JpaCapability.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/capability/SupportLevel.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/capability/CapabilitySupport.java' 'modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/PersistenceOperationNameTest.java' +git commit -m "feat: add jpa operation and capability contracts" +``` + +### Task 3: 안정 JPA 오류 계층과 Failure Context 구현 + +**Files:** +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/JpaPersistenceException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/JpaFailureContext.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/FailureCategory.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/OptimisticConflictException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/PessimisticLockTimeoutException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/DeadlockDetectedException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/SerializationFailureException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/ConstraintViolationDetails.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/UniqueConstraintViolationException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/ForeignKeyViolationException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/CheckConstraintViolationException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/QueryTimeoutException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/TransactionTimeoutException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/ConnectionUnavailableException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/SchemaMismatchException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/DataCorruptionException.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/TransactionCompletionUnknownException.java` +- Test: `modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/error/JpaFailureContextTest.java` + +**Interfaces:** +- Consumes: `PersistenceOperationName` from Task 2. +- Produces: Provider-independent, structured, sanitized persistence exceptions. + +**Implementation requirements:** +- Every exception preserves operation, SQLSTATE, attempt, retryable, completionUnknown, elapsed and trace ID. +- Constraint exceptions preserve a registered constraint code and optional bounded database constraint name. +- Exception messages must never contain SQL parameter values, Entity IDs or PII. +- `TransactionCompletionUnknownException` must always report `completionUnknown=true` and `retryable=false`. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.api.error; + +class JpaFailureContextTest { + @Test + void completionUnknownCanNeverBeMarkedRetryable() { + var context = JpaFailureContext.completionUnknown( + new PersistenceOperationName("payment.commit"), "40003", 1, Duration.ofMillis(50), "trace"); + + assertThat(context.retryable()).isFalse(); + assertThat(context.completionUnknown()).isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.error.JpaFailureContextTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.api.error; + +public record JpaFailureContext( + PersistenceOperationName operation, + String sqlState, + int transactionAttempt, + boolean retryable, + boolean completionUnknown, + Duration elapsed, + String traceId) { + + public static JpaFailureContext completionUnknown( + PersistenceOperationName operation, + String sqlState, + int attempt, + Duration elapsed, + String traceId) { + return new JpaFailureContext( + operation, sqlState, attempt, false, true, elapsed, traceId); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.error.JpaFailureContextTest' +./gradlew :modules:jpa:jpa-core-api:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/JpaPersistenceException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/JpaFailureContext.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/FailureCategory.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/OptimisticConflictException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/PessimisticLockTimeoutException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/DeadlockDetectedException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/SerializationFailureException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/ConstraintViolationDetails.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/UniqueConstraintViolationException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/ForeignKeyViolationException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/CheckConstraintViolationException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/QueryTimeoutException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/TransactionTimeoutException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/ConnectionUnavailableException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/SchemaMismatchException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/DataCorruptionException.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/error/TransactionCompletionUnknownException.java' 'modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/error/JpaFailureContextTest.java' +git commit -m "feat: add stable jpa persistence error model" +``` + +### Task 4: PostgreSQL SQLSTATE 분류와 예외 변환 구현 + +**Files:** +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlState.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlFailureClassifier.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlExceptionTranslator.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/ConstraintCatalog.java` +- Test: `modules/jpa/jpa-postgresql/src/test/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlFailureClassifierTest.java` + +**Interfaces:** +- Consumes: Stable exceptions from Task 3 and PostgreSQL `PSQLException` structured fields. +- Produces: Message-text-independent SQLSTATE classification for `40001`, `40003`, `40P01`, `23505`, `23503`, `23514`, `55P03`. + +**Implementation requirements:** +- Unwrap Spring, Hibernate, JDBC and PostgreSQL exception chains without parsing localized message text. +- Map constraint names through a bounded `ConstraintCatalog` before exposing them. +- Unknown SQLSTATE must remain an explicit UNKNOWN category, not an optimistic guess. +- Do not classify every connection exception as completion unknown; commit phase evidence is required by Task 6. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.postgresql.error; + +class PostgreSqlFailureClassifierTest { + @ParameterizedTest + @CsvSource({ + "40001,SERIALIZATION_FAILURE", + "40003,COMPLETION_UNKNOWN", + "40P01,DEADLOCK", + "23505,UNIQUE_CONSTRAINT", + "55P03,LOCK_NOT_AVAILABLE" + }) + void classifiesBySqlState(String state, FailureCategory expected) { + assertThat(new PostgreSqlFailureClassifier().classify(state)) + .isEqualTo(expected); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:test --tests 'io.backend.skeleton.jpa.postgresql.error.PostgreSqlFailureClassifierTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.postgresql.error; + +public final class PostgreSqlFailureClassifier { + public FailureCategory classify(String sqlState) { + return switch (sqlState) { + case "40001" -> FailureCategory.SERIALIZATION_FAILURE; + case "40003" -> FailureCategory.COMPLETION_UNKNOWN; + case "40P01" -> FailureCategory.DEADLOCK; + case "23505" -> FailureCategory.UNIQUE_CONSTRAINT; + case "23503" -> FailureCategory.FOREIGN_KEY_CONSTRAINT; + case "23514" -> FailureCategory.CHECK_CONSTRAINT; + case "55P03" -> FailureCategory.LOCK_NOT_AVAILABLE; + default -> FailureCategory.UNKNOWN; + }; + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:test --tests 'io.backend.skeleton.jpa.postgresql.error.PostgreSqlFailureClassifierTest' +./gradlew :modules:jpa:jpa-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlState.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlFailureClassifier.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlExceptionTranslator.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/ConstraintCatalog.java' 'modules/jpa/jpa-postgresql/src/test/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlFailureClassifierTest.java' +git commit -m "feat: translate postgresql sqlstate failures" +``` + +### Task 5: Transaction Profile과 Retry Profile Core 계약 구현 + +**Files:** +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/PropagationMode.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/IsolationLevel.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/JitterMode.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/RetryProfile.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/TransactionProfile.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/TransactionAttempt.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/RetryDisposition.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/RetryDecision.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/JpaRetryPolicy.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/JpaTransactionExecutor.java` +- Test: `modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/transaction/TransactionProfileTest.java` + +**Interfaces:** +- Consumes: `PersistenceOperationName`, `JpaPersistenceException` and `FailureCategory`. +- Produces: Framework-free transaction, attempt and retry contracts. + +**Implementation requirements:** +- Stable propagation values are REQUIRED, MANDATORY and explicitly opt-in REQUIRES_NEW. +- Expose DEFAULT, READ_COMMITTED, REPEATABLE_READ and SERIALIZABLE isolation. +- Require positive finite timeout for write profiles. +- Require `maxAttempts >= 1`; completion unknown is never a retryable failure category. +- RetryDecision must distinguish full transaction retry, reconciliation and fail. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.api.transaction; + +class TransactionProfileTest { + @Test + void writeProfileRequiresFiniteTimeout() { + assertThatThrownBy(() -> new TransactionProfile( + "write", PropagationMode.REQUIRED, IsolationLevel.READ_COMMITTED, + Duration.ZERO, false, RetryProfile.none())) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.transaction.TransactionProfileTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.api.transaction; + +public record TransactionProfile( + String name, + PropagationMode propagation, + IsolationLevel isolation, + Duration timeout, + boolean readOnly, + RetryProfile retryProfile) { + + public TransactionProfile { + if (!readOnly && (timeout == null || timeout.isZero() || timeout.isNegative())) { + throw new IllegalArgumentException("write transaction requires positive timeout"); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.transaction.TransactionProfileTest' +./gradlew :modules:jpa:jpa-core-api:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/PropagationMode.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/IsolationLevel.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/JitterMode.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/RetryProfile.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/TransactionProfile.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/TransactionAttempt.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/RetryDisposition.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/RetryDecision.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/JpaRetryPolicy.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/transaction/JpaTransactionExecutor.java' 'modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/transaction/TransactionProfileTest.java' +git commit -m "feat: define jpa transaction and retry profiles" +``` + +### Task 6: Commit Evidence를 추적하는 JpaTransactionManager 구현 + +**Files:** +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionCompletionEvidence.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionEvidenceContext.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/EvidenceAwareJpaTransactionManager.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/CommitFailureClassifier.java` +- Test: `modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/EvidenceAwareJpaTransactionManagerTest.java` + +**Interfaces:** +- Consumes: Spring ORM `JpaTransactionManager`, Task 3 error model and Task 4 classifier SPI. +- Produces: Transaction phase evidence and commit-phase-only completion unknown translation. + +**Implementation requirements:** +- Track NOT_STARTED, ACTIVE, COMMITTING, COMMITTED, ROLLED_BACK and UNKNOWN per transaction. +- Set COMMITTING immediately before delegating to the provider commit. +- Only convert transport/SQLSTATE failures during COMMITTING to completion unknown. +- Clear ThreadLocal evidence in every success and failure path. +- Preserve the original provider exception as cause without leaking parameters. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.transaction; + +class EvidenceAwareJpaTransactionManagerTest { + @Test + void connectionLossDuringCommitBecomesCompletionUnknown() { + var manager = fixtureThatCommitsThenDropsResponse(); + + assertThatThrownBy(() -> inTransaction(manager, () -> repository.insert("key-1"))) + .isInstanceOf(TransactionCompletionUnknownException.class) + .satisfies(error -> assertThat(((JpaPersistenceException) error) + .context().completionUnknown()).isTrue()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.EvidenceAwareJpaTransactionManagerTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.transaction; + +public final class EvidenceAwareJpaTransactionManager extends JpaTransactionManager { + private final CommitFailureClassifier classifier; + + @Override + protected void doCommit(DefaultTransactionStatus status) { + TransactionEvidenceContext.mark(TransactionCompletionEvidence.COMMITTING); + try { + super.doCommit(status); + TransactionEvidenceContext.mark(TransactionCompletionEvidence.COMMITTED); + } catch (RuntimeException failure) { + TransactionEvidenceContext.mark(TransactionCompletionEvidence.UNKNOWN); + throw classifier.translateCommitFailure(failure); + } finally { + TransactionEvidenceContext.clear(); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.EvidenceAwareJpaTransactionManagerTest' +./gradlew :modules:jpa:jpa-transaction:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionCompletionEvidence.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionEvidenceContext.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/EvidenceAwareJpaTransactionManager.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/CommitFailureClassifier.java' 'modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/EvidenceAwareJpaTransactionManagerTest.java' +git commit -m "feat: track jpa transaction completion evidence" +``` + +### Task 7: Programmatic JpaTransactionExecutor 구현 + +**Files:** +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/SpringJpaTransactionExecutor.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionDefinitionMapper.java` +- Test: `modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/SpringJpaTransactionExecutorTest.java` + +**Interfaces:** +- Consumes: Task 5 transaction contracts and Spring `PlatformTransactionManager`. +- Produces: A programmatic transaction boundary that maps profile propagation, isolation, timeout and read-only exactly. + +**Implementation requirements:** +- Use a fresh `TransactionTemplate` definition per call without mutable global state. +- Map timeout to whole seconds only after rejecting sub-second truncation or documenting rounding. +- Propagate `PersistenceOperationName` into observation context. +- Do not implement retry in this class; Task 8 owns retry coordination. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.transaction; + +class SpringJpaTransactionExecutorTest { + @Test + void mapsSerializableReadOnlyProfile() { + var profile = profile(SERIALIZABLE, Duration.ofSeconds(3), true); + executor.execute(OPERATION, profile, () -> null); + + assertThat(transactionProbe.isolation()).isEqualTo(Connection.TRANSACTION_SERIALIZABLE); + assertThat(transactionProbe.readOnly()).isTrue(); + assertThat(transactionProbe.timeoutSeconds()).isEqualTo(3); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.SpringJpaTransactionExecutorTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.transaction; + +public final class SpringJpaTransactionExecutor implements JpaTransactionExecutor { + private final PlatformTransactionManager transactionManager; + + @Override + public T execute( + PersistenceOperationName operation, + TransactionProfile profile, + Supplier work) { + var template = new TransactionTemplate(transactionManager); + TransactionDefinitionMapper.apply(template, profile); + return template.execute(status -> work.get()); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.SpringJpaTransactionExecutorTest' +./gradlew :modules:jpa:jpa-transaction:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/SpringJpaTransactionExecutor.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionDefinitionMapper.java' 'modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/SpringJpaTransactionExecutorTest.java' +git commit -m "feat: execute jpa transaction profiles" +``` + +### Task 8: 전체 Transaction Retry Coordinator 구현 + +**Files:** +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/FullTransactionRetryCoordinator.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/BackoffCalculator.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/RetryBudget.java` +- Test: `modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/FullTransactionRetryCoordinatorTest.java` + +**Interfaces:** +- Consumes: `JpaTransactionExecutor`, `JpaRetryPolicy`, `RetryProfile` and stable exceptions. +- Produces: Bounded retry that calls the transaction executor anew for every attempt. + +**Implementation requirements:** +- Every attempt must create a new transaction and new Persistence Context. +- Never retry completion unknown, constraint, schema or data corruption failures. +- Apply exponential backoff, configured jitter, max elapsed deadline and attempt budget. +- Emit one logical operation result and attempt-level events without logging every retry as WARN. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.transaction; + +class FullTransactionRetryCoordinatorTest { + @Test + void retriesWholeUseCaseWithFreshPersistenceContext() { + var contexts = new ArrayList(); + var result = coordinator.execute(OPERATION, RETRY_PROFILE, () -> { + contexts.add(entityManagerIdentity()); + if (contexts.size() == 1) throw optimisticConflict(); + return "ok"; + }); + + assertThat(result).isEqualTo("ok"); + assertThat(contexts).hasSize(2).doesNotHaveDuplicates(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.FullTransactionRetryCoordinatorTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.transaction; + +public final class FullTransactionRetryCoordinator { + public T execute( + PersistenceOperationName operation, + TransactionProfile profile, + Supplier work) { + for (int attempt = 1; ; attempt++) { + try { + return transactionExecutor.execute(operation, profile, work); + } catch (JpaPersistenceException failure) { + RetryDecision decision = retryPolicy.classify( + failure, new TransactionAttempt(attempt, clock.instant())); + if (decision.disposition() != RETRY_FULL_TRANSACTION) throw failure; + sleeper.sleep(decision.delay()); + } + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.FullTransactionRetryCoordinatorTest' +./gradlew :modules:jpa:jpa-transaction:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/FullTransactionRetryCoordinator.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/BackoffCalculator.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/RetryBudget.java' 'modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/FullTransactionRetryCoordinatorTest.java' +git commit -m "feat: retry complete jpa transactions safely" +``` + +### Task 9: RetryableJpaTransaction Annotation과 AOP ordering 구현 + +**Files:** +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/RetryableJpaTransaction.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/RetryableJpaTransactionInterceptor.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionProfileRegistry.java` +- Test: `modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/RetryableJpaTransactionInterceptorTest.java` + +**Interfaces:** +- Consumes: Task 8 coordinator and named transaction profiles. +- Produces: An opt-in public-method annotation whose retry interceptor wraps the Spring transaction interceptor. + +**Implementation requirements:** +- Require a registered operation name and profile name in the annotation. +- Order retry advice outside transaction advice so each attempt creates a transaction. +- Reject self-invocation in documentation and architecture tests. +- Reject methods that return reactive types because JPA is blocking. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.transaction; + +class RetryableJpaTransactionInterceptorTest { + @Test + void retryAdviceRunsOutsideTransactionAdvice() { + service.failOnceWithSerializationFailure(); + service.execute(); + + assertThat(probe.transactionIds()).containsExactly("tx-1", "tx-2"); + assertThat(probe.retryAdviceOrder()).isLessThan(probe.transactionAdviceOrder()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.RetryableJpaTransactionInterceptorTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.transaction; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface RetryableJpaTransaction { + String operation(); + String profile(); +} + +@Order(Ordered.HIGHEST_PRECEDENCE + 100) +public final class RetryableJpaTransactionInterceptor implements MethodInterceptor { + public Object invoke(MethodInvocation invocation) { + var policy = annotation(invocation.getMethod()); + return coordinator.execute( + new PersistenceOperationName(policy.operation()), + profiles.require(policy.profile()), + () -> proceed(invocation)); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.RetryableJpaTransactionInterceptorTest' +./gradlew :modules:jpa:jpa-transaction:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/RetryableJpaTransaction.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/RetryableJpaTransactionInterceptor.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionProfileRegistry.java' 'modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/RetryableJpaTransactionInterceptorTest.java' +git commit -m "feat: add retryable jpa transaction advice" +``` + +### Task 10: Completion Unknown Reconciliation SPI와 Audit 구현 + +**Files:** +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionCompletionResolver.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/CompletionResolution.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/CompletionUnknownRecord.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/CompletionUnknownRecorder.java` +- Test: `modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/CompletionUnknownRecorderTest.java` + +**Interfaces:** +- Consumes: `TransactionCompletionUnknownException` and domain-provided transaction keys. +- Produces: A durable/auditable handoff for domain-specific committed/not-committed/unknown reconciliation. + +**Implementation requirements:** +- Core resolver returns COMMITTED, NOT_COMMITTED or STILL_UNKNOWN without guessing. +- Recording must happen outside the unknown transaction using a separate durable channel chosen by the application. +- Preserve operation, transaction key, SQLSTATE, trace ID and occurrence time; never persist SQL parameters. +- Do not automatically call the original use case from the resolver. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.transaction; + +class CompletionUnknownRecorderTest { + @Test + void recordsUnknownWithoutRetryingOriginalWork() { + recorder.record(exception("payment-42")); + + assertThat(audit.last().transactionKey()).isEqualTo("payment-42"); + assertThat(originalUseCase.invocations()).isZero(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.CompletionUnknownRecorderTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.transaction; + +public interface TransactionCompletionResolver { + CompletionResolution resolve(K transactionKey); +} + +public enum CompletionResolution { + COMMITTED, + NOT_COMMITTED, + STILL_UNKNOWN +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:test --tests 'io.backend.skeleton.jpa.transaction.CompletionUnknownRecorderTest' +./gradlew :modules:jpa:jpa-transaction:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/TransactionCompletionResolver.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/CompletionResolution.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/CompletionUnknownRecord.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/CompletionUnknownRecorder.java' 'modules/jpa/jpa-transaction/src/test/java/io/backend/skeleton/jpa/transaction/CompletionUnknownRecorderTest.java' +git commit -m "feat: add transaction completion reconciliation contracts" +``` + +### Task 11: OSIV와 DDL Auto 위험 설정 Startup Guard 구현 + +**Files:** +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaSafetyProperties.java` +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaDangerousConfigurationGuard.java` +- Modify: `modules/jpa/jpa-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` +- Test: `modules/jpa/jpa-spring-boot-starter/src/test/java/io/backend/skeleton/jpa/autoconfigure/JpaDangerousConfigurationGuardTest.java` + +**Interfaces:** +- Consumes: Spring Boot Environment and the design global constraints. +- Produces: Fail-fast startup validation for OSIV and production schema mutation settings. + +**Implementation requirements:** +- Fail when `spring.jpa.open-in-view=true` outside an explicit local convenience profile. +- Fail in dev/staging/prod when ddl-auto is update/create/create-drop. +- Allow validate or none according to schema-management policy. +- Error messages must name the unsafe property and approved alternatives. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.autoconfigure; + +class JpaDangerousConfigurationGuardTest { + @Test + void productionRejectsOpenSessionInViewAndDdlUpdate() { + context.withPropertyValues( + "spring.profiles.active=prod", + "spring.jpa.open-in-view=true", + "spring.jpa.hibernate.ddl-auto=update") + .run(result -> assertThat(result).hasFailed()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-boot-starter:test --tests 'io.backend.skeleton.jpa.autoconfigure.JpaDangerousConfigurationGuardTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.autoconfigure; + +public final class JpaDangerousConfigurationGuard { + public void validate(Environment environment) { + boolean osiv = environment.getProperty( + "spring.jpa.open-in-view", Boolean.class, false); + String ddl = environment.getProperty( + "spring.jpa.hibernate.ddl-auto", "none"); + if (osiv) throw new IllegalStateException("spring.jpa.open-in-view must be false"); + if (Set.of("update", "create", "create-drop").contains(ddl)) { + throw new IllegalStateException("Flyway owns schema changes; use validate or none"); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-boot-starter:test --tests 'io.backend.skeleton.jpa.autoconfigure.JpaDangerousConfigurationGuardTest' +./gradlew :modules:jpa:jpa-spring-boot-starter:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaSafetyProperties.java' 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaDangerousConfigurationGuard.java' 'modules/jpa/jpa-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports' 'modules/jpa/jpa-spring-boot-starter/src/test/java/io/backend/skeleton/jpa/autoconfigure/JpaDangerousConfigurationGuardTest.java' +git commit -m "feat: reject unsafe jpa startup configuration" +``` + +### Task 12: Hikari·PostgreSQL Runtime Profile 검증 구현 + +**Files:** +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaDataSourceProperties.java` +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaDataSourceProfileValidator.java` +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/PostgreSqlVersionPolicy.java` +- Test: `modules/jpa/jpa-spring-boot-starter/src/test/java/io/backend/skeleton/jpa/autoconfigure/JpaDataSourceProfileValidatorTest.java` + +**Interfaces:** +- Consumes: Configured DataSource metadata, Hikari configuration and Stable PG16·17·18 policy. +- Produces: Runtime validation for database product/version, explicit pool limits and finite acquisition timeout. + +**Implementation requirements:** +- Reject non-PostgreSQL production datasource unless a future profile is explicitly installed. +- Accept PostgreSQL 16, 17 and 18; report but do not Stable-enable 19. +- Require explicit maximumPoolSize and connectionTimeout in production properties. +- Do not impose a universal pool size; validate consistency with positive bounds only. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.autoconfigure; + +class JpaDataSourceProfileValidatorTest { + @Test + void rejectsPostgreSqlNineteenFromStableProfile() { + var metadata = metadata("PostgreSQL", 19); + assertThatThrownBy(() -> validator.validateStable(metadata, properties())) + .hasMessageContaining("PostgreSQL 16, 17 or 18"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-boot-starter:test --tests 'io.backend.skeleton.jpa.autoconfigure.JpaDataSourceProfileValidatorTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.autoconfigure; + +public final class PostgreSqlVersionPolicy { + private static final Set STABLE = Set.of(16, 17, 18); + + public void requireStable(DatabaseMetaData metadata) throws SQLException { + if (!"PostgreSQL".equals(metadata.getDatabaseProductName()) || + !STABLE.contains(metadata.getDatabaseMajorVersion())) { + throw new IllegalStateException("Stable JPA profile requires PostgreSQL 16, 17 or 18"); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-boot-starter:test --tests 'io.backend.skeleton.jpa.autoconfigure.JpaDataSourceProfileValidatorTest' +./gradlew :modules:jpa:jpa-spring-boot-starter:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaDataSourceProperties.java' 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaDataSourceProfileValidator.java' 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/PostgreSqlVersionPolicy.java' 'modules/jpa/jpa-spring-boot-starter/src/test/java/io/backend/skeleton/jpa/autoconfigure/JpaDataSourceProfileValidatorTest.java' +git commit -m "feat: validate jpa datasource and postgresql profile" +``` + +### Task 13: Entity Mapping ArchUnit Rule Pack 구현 + +**Files:** +- Create: `modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/JpaArchitectureRules.java` +- Create: `modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/EntityMappingCondition.java` +- Create: `modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/EntityExposureCondition.java` +- Test: `modules/jpa/jpa-security/src/test/java/io/backend/skeleton/jpa/security/JpaArchitectureRulesTest.java` + +**Interfaces:** +- Consumes: ArchUnit and Jakarta Persistence annotations in the inspected application. +- Produces: Reusable rules for field access, non-final Entity, protected no-arg constructor, no web exposure and no Hibernate dependency in domain packages. + +**Implementation requirements:** +- Detect Controller methods returning an `@Entity` type or collection of Entity. +- Detect Entity classes in web/controller packages. +- Detect `org.hibernate` dependencies from domain packages. +- Detect final Entity classes and missing protected/public no-arg constructors. +- Provide separate warning-level rules for Cascade.ALL and EAGER associations rather than silently rewriting them. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.security; + +class JpaArchitectureRulesTest { + @Test + void controllerMayNotReturnEntity() { + var classes = new ClassFileImporter().importClasses(BadOrderController.class, OrderEntity.class); + assertThatThrownBy(() -> JpaArchitectureRules.noEntityFromWeb().check(classes)) + .hasMessageContaining("OrderEntity"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-security:test --tests 'io.backend.skeleton.jpa.security.JpaArchitectureRulesTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.security; + +public final class JpaArchitectureRules { + public static ArchRule noEntityFromWeb() { + return methods().that().areDeclaredInClassesThat() + .resideInAPackage("..web..") + .should(new EntityExposureCondition()); + } + + public static ArchRule entitiesFollowPortableMappingRules() { + return classes().that().areAnnotatedWith(Entity.class) + .should(new EntityMappingCondition()); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-security:test --tests 'io.backend.skeleton.jpa.security.JpaArchitectureRulesTest' +./gradlew :modules:jpa:jpa-security:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/JpaArchitectureRules.java' 'modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/EntityMappingCondition.java' 'modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/EntityExposureCondition.java' 'modules/jpa/jpa-security/src/test/java/io/backend/skeleton/jpa/security/JpaArchitectureRulesTest.java' +git commit -m "feat: enforce jpa entity architecture rules" +``` + +### Task 14: Sequence·UUID ID Strategy Contract Testkit 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/id/UuidV7Generator.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/id/SequenceEntity.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/id/IdentityEntity.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/contractTest/java/io/backend/skeleton/jpa/testkit/id/IdStrategyContractTest.java` +- Test: `modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/id/UuidV7GeneratorTest.java` + +**Interfaces:** +- Consumes: PostgreSQL Testcontainer foundation and Hibernate statistics. +- Produces: Application UUIDv7 generator and evidence that Sequence batches while IDENTITY is classified as limited. + +**Implementation requirements:** +- UUIDv7 output must be monotonic enough for the test clock and set RFC variant/version bits. +- Sequence fixture must align allocationSize with the migration sequence increment. +- Contract test records actual prepared statements and JDBC batches. +- Do not expose PostgreSQL 18 `uuidv7()` as PG16·17 common behavior. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.id; + +class UuidV7GeneratorTest { + @Test + void producesVersionSevenUuidInTimeOrder() { + var first = generator.next(Instant.parse("2026-08-11T00:00:00Z")); + var second = generator.next(Instant.parse("2026-08-11T00:00:01Z")); + + assertThat(first.version()).isEqualTo(7); + assertThat(first.compareTo(second)).isLessThan(0); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.id.UuidV7GeneratorTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.testkit.id; + +public final class UuidV7Generator { + public UUID next(Instant instant) { + long unixMillis = instant.toEpochMilli() & 0x0000_FFFF_FFFF_FFFFL; + long most = (unixMillis << 16) | 0x7000L | random.nextLong(0x1000L); + long least = (random.nextLong() & 0x3FFF_FFFF_FFFF_FFFFL) | + 0x8000_0000_0000_0000L; + return new UUID(most, least); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.id.UuidV7GeneratorTest' +./gradlew :modules:jpa:jpa-testkit-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/id/UuidV7Generator.java' 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/id/SequenceEntity.java' 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/id/IdentityEntity.java' 'modules/jpa/jpa-testkit-postgresql/src/contractTest/java/io/backend/skeleton/jpa/testkit/id/IdStrategyContractTest.java' 'modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/id/UuidV7GeneratorTest.java' +git commit -m "test: add jpa id strategy contracts" +``` + +### Task 15: JPA 3.2 Value Mapping Contract Fixture 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/mapping/Money.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/mapping/MappingEntity.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/mapping/DurationMillisConverter.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/contractTest/java/io/backend/skeleton/jpa/testkit/mapping/JpaValueMappingContractTest.java` +- Test: `modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/mapping/DurationMillisConverterTest.java` + +**Interfaces:** +- Consumes: JPA 3.2, Hibernate 7.4 and PostgreSQL round-trip test infrastructure. +- Produces: Round-trip contracts for Instant, OffsetDateTime, LocalDate, UUID, String Enum, record Embeddable and Duration converter. + +**Implementation requirements:** +- Use STRING or explicit converter for Enum; never ORDINAL. +- Verify record Embeddable construction and dirty checking under Hibernate 7.4. +- Specify timezone and precision assertions explicitly. +- Malformed database values must produce stable data corruption errors. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.mapping; + +class DurationMillisConverterTest { + @Test + void roundTripsDurationAsMilliseconds() { + var duration = Duration.ofSeconds(42).plusMillis(7); + assertThat(converter.convertToEntityAttribute( + converter.convertToDatabaseColumn(duration))).isEqualTo(duration); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.mapping.DurationMillisConverterTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.testkit.mapping; + +@Converter(autoApply = false) +public final class DurationMillisConverter + implements AttributeConverter { + public Long convertToDatabaseColumn(Duration value) { + return value == null ? null : value.toMillis(); + } + public Duration convertToEntityAttribute(Long value) { + return value == null ? null : Duration.ofMillis(value); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.mapping.DurationMillisConverterTest' +./gradlew :modules:jpa:jpa-testkit-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/mapping/Money.java' 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/mapping/MappingEntity.java' 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/mapping/DurationMillisConverter.java' 'modules/jpa/jpa-testkit-postgresql/src/contractTest/java/io/backend/skeleton/jpa/testkit/mapping/JpaValueMappingContractTest.java' 'modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/mapping/DurationMillisConverterTest.java' +git commit -m "test: define jpa value mapping contracts" +``` + +### Task 16: Entity Lifecycle·Association Persistence Context Contract 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/lifecycle/LifecycleParent.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/lifecycle/LifecycleChild.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/lifecycle/EntityStateProbe.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/contractTest/java/io/backend/skeleton/jpa/testkit/lifecycle/JpaLifecycleAssociationContractTest.java` +- Test: `modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/lifecycle/EntityStateProbeTest.java` + +**Interfaces:** +- Consumes: Jakarta Persistence 3.2 EntityManager lifecycle and domain-style parent/child fixtures. +- Produces: Explicit persist, merge, find, getReference, dirty-check, flush, clear, detach, refresh, owning-side, cascade and orphan-removal contracts. + +**Implementation requirements:** +- Prove `merge` returns the managed copy and does not attach the passed detached instance. +- Prove flush writes SQL but does not imply transaction commit. +- Prove clear/detach stop dirty checking and refresh reloads database state. +- Prove only the owning side updates the foreign key and helper methods synchronize both sides. +- Test cascade/orphan removal only on an aggregate-owned child fixture; do not define a platform-wide default. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.lifecycle; + +class EntityStateProbeTest { + @Test + void distinguishesManagedDetachedAndMergedInstances() { + var original = new LifecycleParent("p-1"); + entityManager.persist(original); + entityManager.flush(); + entityManager.detach(original); + + var merged = entityManager.merge(original); + assertThat(entityManager.contains(original)).isFalse(); + assertThat(entityManager.contains(merged)).isTrue(); + assertThat(merged).isNotSameAs(original); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.lifecycle.EntityStateProbeTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.testkit.lifecycle; + +public final class EntityStateProbe { + private final EntityManager entityManager; + + public EntityState stateOf(Object entity) { + if (entityManager.contains(entity)) return EntityState.MANAGED; + Object id = entityManager.getEntityManagerFactory() + .getPersistenceUnitUtil().getIdentifier(entity); + return id == null ? EntityState.TRANSIENT : EntityState.DETACHED; + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.lifecycle.EntityStateProbeTest' +./gradlew :modules:jpa:jpa-testkit-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/lifecycle/LifecycleParent.java' 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/lifecycle/LifecycleChild.java' 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/lifecycle/EntityStateProbe.java' 'modules/jpa/jpa-testkit-postgresql/src/contractTest/java/io/backend/skeleton/jpa/testkit/lifecycle/JpaLifecycleAssociationContractTest.java' 'modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/lifecycle/EntityStateProbeTest.java' +git commit -m "test: add jpa lifecycle and association contracts" +``` + +### Task 17: Spring Data Auditing Opt-in 모듈 구현 + +**Files:** +- Create: `modules/jpa/jpa-auditing/src/main/java/io/backend/skeleton/jpa/auditing/AuditMetadata.java` +- Create: `modules/jpa/jpa-auditing/src/main/java/io/backend/skeleton/jpa/auditing/JpaAuditorProvider.java` +- Create: `modules/jpa/jpa-auditing/src/main/java/io/backend/skeleton/jpa/auditing/JpaAuditingConfiguration.java` +- Test: `modules/jpa/jpa-auditing/src/test/java/io/backend/skeleton/jpa/auditing/JpaAuditingContractTest.java` + +**Interfaces:** +- Consumes: Spring Data auditing and application-provided current actor resolver. +- Produces: Embeddable technical auditing without a mandatory BaseEntity. + +**Implementation requirements:** +- Provide createdAt, createdBy, modifiedAt and modifiedBy as an opt-in Embeddable. +- Use `Instant` and a bounded opaque actor identifier. +- Do not confuse technical auditing with business audit or Entity history. +- Allow system/background jobs to use an explicit system actor. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.auditing; + +class JpaAuditingContractTest { + @Test + void persistsTechnicalAuditWhenEntityOptsIn() { + var saved = repository.save(new AuditedFixture("value")); + entityManager.flush(); + + assertThat(saved.audit().createdAt()).isEqualTo(clock.instant()); + assertThat(saved.audit().createdBy()).isEqualTo("user-42"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-auditing:test --tests 'io.backend.skeleton.jpa.auditing.JpaAuditingContractTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.auditing; + +@Embeddable +public class AuditMetadata { + @CreatedDate private Instant createdAt; + @CreatedBy private String createdBy; + @LastModifiedDate private Instant modifiedAt; + @LastModifiedBy private String modifiedBy; + + protected AuditMetadata() {} +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-auditing:test --tests 'io.backend.skeleton.jpa.auditing.JpaAuditingContractTest' +./gradlew :modules:jpa:jpa-auditing:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-auditing/src/main/java/io/backend/skeleton/jpa/auditing/AuditMetadata.java' 'modules/jpa/jpa-auditing/src/main/java/io/backend/skeleton/jpa/auditing/JpaAuditorProvider.java' 'modules/jpa/jpa-auditing/src/main/java/io/backend/skeleton/jpa/auditing/JpaAuditingConfiguration.java' 'modules/jpa/jpa-auditing/src/test/java/io/backend/skeleton/jpa/auditing/JpaAuditingContractTest.java' +git commit -m "feat: add opt in spring data jpa auditing" +``` + +### Task 18: QueryName과 QueryObservation Core 구현 + +**Files:** +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/QueryName.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/QueryObservation.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/QueryScope.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/NoopQueryObservation.java` +- Test: `modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/query/QueryNameTest.java` + +**Interfaces:** +- Consumes: Java 21 only and the operation-name validation pattern. +- Produces: Low-cardinality query identity and framework-neutral observation scopes. + +**Implementation requirements:** +- Query names use a bounded registry format and never contain IDs or raw SQL. +- QueryScope records row count, failure and close exactly once. +- Provide a no-op implementation for modules that do not install observability. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.api.query; + +class QueryNameTest { + @Test + void rejectsRawSqlAsMetricIdentity() { + assertThatThrownBy(() -> new QueryName("select * from orders where id=42")) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.query.QueryNameTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.api.query; + +public record QueryName(String value) { + public QueryName { + if (value == null || !value.matches("[a-z][a-z0-9.-]{2,95}")) { + throw new IllegalArgumentException("invalid query name"); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.query.QueryNameTest' +./gradlew :modules:jpa:jpa-core-api:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/QueryName.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/QueryObservation.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/QueryScope.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/NoopQueryObservation.java' 'modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/query/QueryNameTest.java' +git commit -m "feat: add bounded jpa query observation contract" +``` + +### Task 19: Custom Repository Fragment 지원과 Generic Repository 금지 규칙 구현 + +**Files:** +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/JpaRepositoryFragmentSupport.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/EntityManagerAccess.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/RegisteredQuery.java` +- Modify: `modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/JpaArchitectureRules.java` +- Test: `modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/JpaRepositoryFragmentSupportTest.java` + +**Interfaces:** +- Consumes: Spring Data JPA custom fragment model and Task 18 query names. +- Produces: A helper base for domain-owned custom implementations, not a CRUD repository. + +**Implementation requirements:** +- Do not declare save, findById, findAll or delete methods in platform interfaces. +- Expose EntityManager only to custom repository implementation packages. +- Require a registered QueryName for helper-created typed/native queries. +- Add an architecture test that fails if a platform type named GenericRepository or BaseRepository extends CrudRepository. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.springdata; + +class JpaRepositoryFragmentSupportTest { + @Test + void platformDoesNotReimplementCrudRepository() { + assertThat(JpaRepositoryFragmentSupport.class.getMethods()) + .extracting(Method::getName) + .doesNotContain("save", "findById", "findAll", "delete"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.JpaRepositoryFragmentSupportTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.springdata; + +public abstract class JpaRepositoryFragmentSupport { + private final EntityManager entityManager; + + protected JpaRepositoryFragmentSupport(EntityManager entityManager) { + this.entityManager = entityManager; + } + + protected final TypedQuery typedQuery( + QueryName name, String jpql, Class resultType) { + return entityManager.createQuery(jpql, resultType) + .setHint("org.hibernate.comment", name.value()); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.JpaRepositoryFragmentSupportTest' +./gradlew :modules:jpa:jpa-spring-data:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/JpaRepositoryFragmentSupport.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/EntityManagerAccess.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/RegisteredQuery.java' 'modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/JpaArchitectureRules.java' 'modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/JpaRepositoryFragmentSupportTest.java' +git commit -m "feat: support domain owned jpa repository fragments" +``` + +### Task 20: Specification과 Querydsl 선택 Integration 구현 + +**Files:** +- Create: `modules/jpa/jpa-querydsl/src/main/java/io/backend/skeleton/jpa/querydsl/QuerydslJpaSupport.java` +- Create: `modules/jpa/jpa-querydsl/src/main/java/io/backend/skeleton/jpa/querydsl/PredicatePolicy.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/SpecificationPolicy.java` +- Test: `modules/jpa/jpa-querydsl/src/test/java/io/backend/skeleton/jpa/querydsl/QuerydslJpaSupportTest.java` + +**Interfaces:** +- Consumes: Optional Querydsl JPA dependency, Spring Data Specification and registered QueryName. +- Produces: Explicit Q2 dynamic query helpers without changing J1 repository contracts. + +**Implementation requirements:** +- Keep Querydsl as an optional module; starter must not pull it transitively unless enabled. +- Reject an unbounded query when no predicate and no explicit allow-all token is present. +- Require page size and sort allowlist for collection queries. +- Do not accept user-provided path expressions. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.querydsl; + +class QuerydslJpaSupportTest { + @Test + void rejectsUnboundedPredicateForCollectionQuery() { + assertThatThrownBy(() -> support.select(ORDER_QUERY, order, null, page(100))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("bounded predicate"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-querydsl:test --tests 'io.backend.skeleton.jpa.querydsl.QuerydslJpaSupportTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.querydsl; + +public final class QuerydslJpaSupport { + public JPAQuery select( + QueryName name, + EntityPath root, + Predicate predicate, + QueryPage page) { + PredicatePolicy.requireBounded(predicate, page); + return queryFactory.selectFrom(root) + .where(predicate) + .limit(page.size()) + .setHint("org.hibernate.comment", name.value()); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-querydsl:test --tests 'io.backend.skeleton.jpa.querydsl.QuerydslJpaSupportTest' +./gradlew :modules:jpa:jpa-querydsl:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-querydsl/src/main/java/io/backend/skeleton/jpa/querydsl/QuerydslJpaSupport.java' 'modules/jpa/jpa-querydsl/src/main/java/io/backend/skeleton/jpa/querydsl/PredicatePolicy.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/SpecificationPolicy.java' 'modules/jpa/jpa-querydsl/src/test/java/io/backend/skeleton/jpa/querydsl/QuerydslJpaSupportTest.java' +git commit -m "feat: add optional jpa specification and querydsl support" +``` + +### Task 21: Dynamic Sort Allowlist와 Safe Sort Mapper 구현 + +**Files:** +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/SafeSortField.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/SafeSortRegistry.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/SafeSortMapper.java` +- Test: `modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/SafeSortMapperTest.java` + +**Interfaces:** +- Consumes: Spring Data `Sort` and a domain-registered field catalog. +- Produces: Injection-safe sort mapping with deterministic tie-breakers. + +**Implementation requirements:** +- Reject unknown field, function expression, whitespace and punctuation from user input. +- Map public sort names to fixed entity paths. +- Append the configured stable tie-breaker when absent. +- Do not use `JpaSort.unsafe` for user-controlled values. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.springdata; + +class SafeSortMapperTest { + @Test + void rejectsSqlExpressionAndAddsTieBreaker() { + assertThatThrownBy(() -> mapper.map(List.of("name desc nulls last; drop table"))) + .isInstanceOf(IllegalArgumentException.class); + + assertThat(mapper.map(List.of("createdAt,desc"))) + .extracting(Sort.Order::getProperty) + .containsExactly("createdAt", "id"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.SafeSortMapperTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.springdata; + +public final class SafeSortMapper { + public Sort map(List requested) { + var orders = requested.stream() + .map(value -> registry.require(value.field()).toOrder(value.direction())) + .collect(Collectors.toCollection(ArrayList::new)); + if (orders.stream().noneMatch(order -> order.getProperty().equals(registry.tieBreaker()))) { + orders.add(Sort.Order.desc(registry.tieBreaker())); + } + return Sort.by(orders); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.SafeSortMapperTest' +./gradlew :modules:jpa:jpa-spring-data:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/SafeSortField.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/SafeSortRegistry.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/SafeSortMapper.java' 'modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/SafeSortMapperTest.java' +git commit -m "feat: enforce allowlisted deterministic jpa sorting" +``` + +### Task 22: Hibernate Statement Inspector와 Statistics Snapshot 구현 + +**Files:** +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/QueryNameContext.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/NamedStatementInspector.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/HibernateStatisticsSnapshot.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/HibernateStatisticsCollector.java` +- Test: `modules/jpa/jpa-hibernate/src/test/java/io/backend/skeleton/jpa/hibernate/HibernateStatisticsCollectorTest.java` + +**Interfaces:** +- Consumes: Hibernate 7.4 StatementInspector/Statistics and `QueryName`. +- Produces: Per-scope statement, entity, collection, flush and batch statistics without SQL parameter capture. + +**Implementation requirements:** +- Use query-name comments or context metadata without including dynamic values. +- Snapshot entity load/fetch and collection load/fetch separately. +- Record prepared statement count, flush count and JDBC batch execution count. +- Clear query context in finally blocks. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.hibernate; + +class HibernateStatisticsCollectorTest { + @Test + void separatesEntityLoadFromEntityFetch() { + var before = collector.snapshot(); + fixture.loadOrdersWithSharedUser(); + var delta = collector.snapshot().minus(before); + + assertThat(delta.entityLoadCount()).isPositive(); + assertThat(delta.entityFetchCount()).isGreaterThanOrEqualTo(0); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-hibernate:test --tests 'io.backend.skeleton.jpa.hibernate.HibernateStatisticsCollectorTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.hibernate; + +public record HibernateStatisticsSnapshot( + long preparedStatements, + long entityLoads, + long entityFetches, + long collectionLoads, + long collectionFetches, + long flushes, + long jdbcBatches) { + + public HibernateStatisticsSnapshot minus(HibernateStatisticsSnapshot before) { + return new HibernateStatisticsSnapshot( + preparedStatements - before.preparedStatements, + entityLoads - before.entityLoads, + entityFetches - before.entityFetches, + collectionLoads - before.collectionLoads, + collectionFetches - before.collectionFetches, + flushes - before.flushes, + jdbcBatches - before.jdbcBatches); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-hibernate:test --tests 'io.backend.skeleton.jpa.hibernate.HibernateStatisticsCollectorTest' +./gradlew :modules:jpa:jpa-hibernate:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/QueryNameContext.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/NamedStatementInspector.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/HibernateStatisticsSnapshot.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/HibernateStatisticsCollector.java' 'modules/jpa/jpa-hibernate/src/test/java/io/backend/skeleton/jpa/hibernate/HibernateStatisticsCollectorTest.java' +git commit -m "feat: collect hibernate query and fetch statistics" +``` + +### Task 23: Query Count·N+1 Assertion Testkit 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/query/QueryExpectation.java` +- Create: `modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/query/FetchExpectation.java` +- Create: `modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/query/JpaQueryAssertions.java` +- Create: `modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/query/QueryMeasurement.java` +- Test: `modules/jpa/jpa-testkit/src/test/java/io/backend/skeleton/jpa/testkit/query/JpaQueryAssertionsTest.java` + +**Interfaces:** +- Consumes: Task 22 statistics snapshots and a statement/row measurement adapter. +- Produces: Assertions for statement count, fetch count, hydrated entities, rows and bounded execution time. + +**Implementation requirements:** +- Do not reduce N+1 verification to statement count only. +- Allow upper bounds and exact expectations separately. +- Error output must show queryName and each measured dimension. +- Support skewed and shared-association fixtures in PG contract suites. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.query; + +class JpaQueryAssertionsTest { + @Test + void reportsCartesianAmplificationEvenForOneStatement() { + var measurement = new QueryMeasurement(1, 100, 2000, 2000, Duration.ofMillis(40)); + assertThatThrownBy(() -> assertions.assertMatches( + measurement, QueryExpectation.maxRows(500))) + .hasMessageContaining("rows=2000"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit:test --tests 'io.backend.skeleton.jpa.testkit.query.JpaQueryAssertionsTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.testkit.query; + +public final class JpaQueryAssertions { + public void assertMatches( + QueryMeasurement actual, + QueryExpectation expected) { + if (!expected.matches(actual)) { + throw new AssertionError("JPA query expectation failed: " + actual.summary()); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit:test --tests 'io.backend.skeleton.jpa.testkit.query.JpaQueryAssertionsTest' +./gradlew :modules:jpa:jpa-testkit:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/query/QueryExpectation.java' 'modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/query/FetchExpectation.java' 'modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/query/JpaQueryAssertions.java' 'modules/jpa/jpa-testkit/src/main/java/io/backend/skeleton/jpa/testkit/query/QueryMeasurement.java' 'modules/jpa/jpa-testkit/src/test/java/io/backend/skeleton/jpa/testkit/query/JpaQueryAssertionsTest.java' +git commit -m "test: add quantitative jpa query assertions" +``` + +### Task 24: Use Case Fetch Plan과 EntityGraph Helper 구현 + +**Files:** +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/FetchPlanName.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/EntityGraphCatalog.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/FetchPlanApplier.java` +- Test: `modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/FetchPlanApplierTest.java` + +**Interfaces:** +- Consumes: EntityManager graphs, registered QueryName and domain-defined graph names. +- Produces: Use-case-specific EntityGraph selection without changing mapping fetch defaults. + +**Implementation requirements:** +- Require a registered fetch-plan name; no arbitrary attribute strings from API input. +- Support fetchgraph and loadgraph semantics explicitly. +- Do not mutate global Entity mapping or turn associations EAGER. +- Expose applied fetch plan to observation context. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.springdata; + +class FetchPlanApplierTest { + @Test + void appliesRegisteredGraphAndRejectsUnknownGraph() { + var query = fixtureQuery(); + applier.apply(query, new FetchPlanName("order.detail")); + assertThat(query.getHints()).containsKey("jakarta.persistence.fetchgraph"); + + assertThatThrownBy(() -> applier.apply(query, new FetchPlanName("order.secret"))) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.FetchPlanApplierTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.springdata; + +public final class FetchPlanApplier { + public TypedQuery apply(TypedQuery query, FetchPlanName name) { + EntityGraph graph = catalog.require(name); + return query.setHint("jakarta.persistence.fetchgraph", graph); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.FetchPlanApplierTest' +./gradlew :modules:jpa:jpa-spring-data:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/FetchPlanName.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/EntityGraphCatalog.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/FetchPlanApplier.java' 'modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/FetchPlanApplierTest.java' +git commit -m "feat: add use case specific entity graph support" +``` + +### Task 25: Hibernate 7.4 Collection Fetch Pagination 회귀 Suite 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/fetch/PagedParent.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/fetch/PagedChild.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/compatibilityTest/java/io/backend/skeleton/jpa/testkit/fetch/HibernateCollectionFetchPaginationContractTest.java` +- Test: `modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/fetch/FetchPaginationExpectationTest.java` + +**Interfaces:** +- Consumes: Hibernate 7.4, PG16·17·18, Task 23 measurement and a parent/child skew fixture. +- Produces: A version-specific gate for SQL limit/subquery behavior, parent count, row amplification and count correctness. + +**Implementation requirements:** +- Test one fetched collection with Page and exact parent limit. +- Capture generated SQL and prove DB-level bounded selection under Hibernate 7.4. +- Keep a negative multiple-collection Cartesian test. +- Run on all Stable PostgreSQL versions and every Boot/Hibernate patch upgrade. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.fetch; + +class FetchPaginationExpectationTest { + @Test + void oneCollectionPageRequiresBoundedParentSelection() { + var expected = FetchPaginationExpectation.hibernate74PostgreSql(20); + assertThat(expected.maxReturnedParents()).isEqualTo(20); + assertThat(expected.requiresDatabaseLimit()).isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.fetch.FetchPaginationExpectationTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.testkit.fetch; + +public record FetchPaginationExpectation( + int maxReturnedParents, + boolean requiresDatabaseLimit, + int maxRowAmplification) { + + public static FetchPaginationExpectation hibernate74PostgreSql(int pageSize) { + return new FetchPaginationExpectation(pageSize, true, pageSize * 100); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.fetch.FetchPaginationExpectationTest' +./gradlew :modules:jpa:jpa-testkit-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/fetch/PagedParent.java' 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/fetch/PagedChild.java' 'modules/jpa/jpa-testkit-postgresql/src/compatibilityTest/java/io/backend/skeleton/jpa/testkit/fetch/HibernateCollectionFetchPaginationContractTest.java' 'modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/fetch/FetchPaginationExpectationTest.java' +git commit -m "test: certify hibernate collection fetch pagination" +``` + +### Task 26: Keyset Pagination Core Cursor 계약 구현 + +**Files:** +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/SortDirection.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/KeysetPageRequest.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/KeysetSlice.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/CursorCodec.java` +- Create: `modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/SignedJsonCursorCodec.java` +- Test: `modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/query/SignedJsonCursorCodecTest.java` + +**Interfaces:** +- Consumes: Java JSON codec adapter and an application-provided HMAC key. +- Produces: Versioned, bounded, tamper-evident cursor API independent of Spring Data. + +**Implementation requirements:** +- Require page size between 1 and a configured maximum. +- Cursor payload includes version and all ordering tie-breakers. +- Do not place JPQL, SQL fragments or raw entity paths in cursor data. +- Reject signature mismatch and unknown cursor version. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.api.query; + +class SignedJsonCursorCodecTest { + @Test + void detectsTamperingAndRoundTripsTieBreaker() { + var cursor = new OrderCursor(Instant.parse("2026-08-11T00:00:00Z"), UUID.randomUUID()); + var encoded = codec.encode(cursor); + assertThat(codec.decode(encoded)).isEqualTo(cursor); + assertThatThrownBy(() -> codec.decode(encoded + "x")) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.query.SignedJsonCursorCodecTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.api.query; + +public record KeysetPageRequest( + Optional after, + int size, + SortDirection direction) { + public KeysetPageRequest { + if (size < 1 || size > 500) throw new IllegalArgumentException("invalid page size"); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-core-api:test --tests 'io.backend.skeleton.jpa.api.query.SignedJsonCursorCodecTest' +./gradlew :modules:jpa:jpa-core-api:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/SortDirection.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/KeysetPageRequest.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/KeysetSlice.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/CursorCodec.java' 'modules/jpa/jpa-core-api/src/main/java/io/backend/skeleton/jpa/api/query/SignedJsonCursorCodec.java' 'modules/jpa/jpa-core-api/src/test/java/io/backend/skeleton/jpa/api/query/SignedJsonCursorCodecTest.java' +git commit -m "feat: add signed keyset cursor contracts" +``` + +### Task 27: Spring Data Keyset Query Support 구현 + +**Files:** +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/JpaKeysetQuerySupport.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/KeysetPredicateBuilder.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/KeysetSliceAssembler.java` +- Test: `modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/JpaKeysetQuerySupportTest.java` + +**Interfaces:** +- Consumes: Task 26 cursor types, Criteria API and domain-provided keyset adapters. +- Produces: Deterministic size+1 keyset query execution and next-cursor assembly. + +**Implementation requirements:** +- Use lexicographic predicates matching the exact sort direction and null policy. +- Require a unique tie-breaker. +- Fetch at most `size + 1` rows and return only `size`. +- Do not execute a count query. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.springdata; + +class JpaKeysetQuerySupportTest { + @Test + void duplicateCreatedAtUsesIdTieBreakerWithoutGap() { + var first = repository.findRecent(request(Optional.empty(), 2)); + var second = repository.findRecent(request(first.nextCursor(), 2)); + + assertThat(Stream.concat(first.items().stream(), second.items().stream())) + .extracting(OrderSummary::id) + .doesNotHaveDuplicates(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.JpaKeysetQuerySupportTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.springdata; + +public final class KeysetSliceAssembler { + public KeysetSlice assemble( + List fetched, + int requestedSize, + Function cursorExtractor) { + boolean hasNext = fetched.size() > requestedSize; + List items = List.copyOf(fetched.subList(0, Math.min(fetched.size(), requestedSize))); + Optional next = hasNext ? Optional.of(cursorExtractor.apply(items.getLast())) : Optional.empty(); + return new KeysetSlice<>(items, next, hasNext); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.JpaKeysetQuerySupportTest' +./gradlew :modules:jpa:jpa-spring-data:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/JpaKeysetQuerySupport.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/KeysetPredicateBuilder.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/KeysetSliceAssembler.java' 'modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/JpaKeysetQuerySupportTest.java' +git commit -m "feat: implement deterministic jpa keyset pagination" +``` + +### Task 28: Scroll·Stream Resource Guard 구현 + +**Files:** +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/JpaStreamScope.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/JpaStreamExecutor.java` +- Create: `modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/ScrollPolicy.java` +- Test: `modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/JpaStreamExecutorTest.java` + +**Interfaces:** +- Consumes: Spring Data Scroll/Stream APIs, Transaction synchronization and QueryName. +- Produces: A bounded resource scope that closes Stream/ResultSet and forbids returning it beyond the transaction. + +**Implementation requirements:** +- Require an active read-only transaction for stream execution. +- Close the stream in normal, exception and cancellation paths. +- Require fetch size, maximum rows or explicit admin token. +- Reject WebFlux/Reactor return types in this blocking module. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.springdata; + +class JpaStreamExecutorTest { + @Test + void closesStreamWhenConsumerFails() { + assertThatThrownBy(() -> executor.consume(QUERY, policy(100), stream -> { + stream.findFirst(); + throw new IllegalStateException("boom"); + })).isInstanceOf(IllegalStateException.class); + + assertThat(resourceProbe.closed()).isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.JpaStreamExecutorTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.springdata; + +public final class JpaStreamExecutor { + public R consume( + QueryName query, + ScrollPolicy policy, + Supplier> supplier, + Function, R> consumer) { + TransactionGuard.requireActiveReadOnly(); + try (Stream stream = supplier.get()) { + return consumer.apply(stream.limit(policy.maxRows())); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-data:test --tests 'io.backend.skeleton.jpa.springdata.JpaStreamExecutorTest' +./gradlew :modules:jpa:jpa-spring-data:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/JpaStreamScope.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/JpaStreamExecutor.java' 'modules/jpa/jpa-spring-data/src/main/java/io/backend/skeleton/jpa/springdata/ScrollPolicy.java' 'modules/jpa/jpa-spring-data/src/test/java/io/backend/skeleton/jpa/springdata/JpaStreamExecutorTest.java' +git commit -m "feat: guard jpa scroll and stream resources" +``` + +### Task 29: Optimistic Lock 오류 변환과 전체 Use Case Retry 계약 구현 + +**Files:** +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/OptimisticConflictTranslator.java` +- Create: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/DefaultJpaRetryPolicy.java` +- Modify: `modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/FullTransactionRetryCoordinator.java` +- Test: `modules/jpa/jpa-transaction/src/integrationTest/java/io/backend/skeleton/jpa/transaction/OptimisticRetryIntegrationTest.java` + +**Interfaces:** +- Consumes: JPA `OptimisticLockException`, Spring optimistic locking exceptions and Task 8 coordinator. +- Produces: Stable `OptimisticConflictException` and bounded full-transaction recomputation. + +**Implementation requirements:** +- Translate conflicts thrown at flush or commit. +- Ensure retry reloads the entity and reruns domain rules. +- Do not retry when the use case declared external irreversible side effects. +- Record conflict entity type only from a bounded catalog, never Entity ID. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.transaction; + +class OptimisticRetryIntegrationTest { + @Test + void secondAttemptReloadsAndRecomputesAggregate() { + concurrentWriterUpdatesVersion(); + var result = retryingService.increaseQuantity(orderId, 2); + + assertThat(result.attempts()).isEqualTo(2); + assertThat(repository.findById(orderId).orElseThrow().quantity()).isEqualTo(5); + assertThat(probe.persistenceContextIds()).doesNotHaveDuplicates(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:integrationTest --tests 'io.backend.skeleton.jpa.transaction.OptimisticRetryIntegrationTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.transaction; + +public final class DefaultJpaRetryPolicy implements JpaRetryPolicy { + public RetryDecision classify( + JpaPersistenceException failure, + TransactionAttempt attempt) { + if (failure instanceof TransactionCompletionUnknownException) { + return RetryDecision.reconcile("transaction completion is unknown"); + } + if (failure instanceof OptimisticConflictException || + failure instanceof SerializationFailureException || + failure instanceof DeadlockDetectedException) { + return RetryDecision.retry(backoff.forAttempt(attempt.number())); + } + return RetryDecision.fail("non-retryable persistence failure"); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-transaction:integrationTest --tests 'io.backend.skeleton.jpa.transaction.OptimisticRetryIntegrationTest' +./gradlew :modules:jpa:jpa-transaction:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/OptimisticConflictTranslator.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/DefaultJpaRetryPolicy.java' 'modules/jpa/jpa-transaction/src/main/java/io/backend/skeleton/jpa/transaction/FullTransactionRetryCoordinator.java' 'modules/jpa/jpa-transaction/src/integrationTest/java/io/backend/skeleton/jpa/transaction/OptimisticRetryIntegrationTest.java' +git commit -m "feat: retry optimistic conflicts as complete transactions" +``` + +### Task 30: Pessimistic Lock Timeout과 Deadlock 변환 구현 + +**Files:** +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlLockOptions.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlLockExceptionTranslator.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/LockWaitObservation.java` +- Test: `modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlPessimisticLockContractTest.java` + +**Interfaces:** +- Consumes: JPA Pessimistic lock hints, SQLSTATE classifier and PostgreSQL Testcontainers. +- Produces: Distinct lock-timeout, NOWAIT and deadlock errors with lock-wait metrics. + +**Implementation requirements:** +- Distinguish statement-level lock timeout from transaction-aborting deadlock. +- Map `55P03` to lock-not-available/timeout and `40P01` to deadlock. +- Require finite lock timeout for pessimistic lock profiles. +- Hold locks only inside the Application Transaction. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.postgresql.lock; + +class PostgreSqlPessimisticLockContractTest { + @Test + void nowaitFailsImmediatelyWhileBlockingLockTimesOutSeparately() { + lockRowInOtherTransaction(); + + assertThatThrownBy(() -> repository.findForUpdateNowait(id)) + .isInstanceOf(PessimisticLockTimeoutException.class); + assertThat(lockProbe.lastWait()).isLessThan(Duration.ofSeconds(1)); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.lock.PostgreSqlPessimisticLockContractTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.postgresql.lock; + +public record PostgreSqlLockOptions( + LockModeType mode, + Duration timeout, + boolean nowait) { + public PostgreSqlLockOptions { + if (timeout == null || timeout.isNegative()) { + throw new IllegalArgumentException("lock timeout must be finite"); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.lock.PostgreSqlPessimisticLockContractTest' +./gradlew :modules:jpa:jpa-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlLockOptions.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlLockExceptionTranslator.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/LockWaitObservation.java' 'modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlPessimisticLockContractTest.java' +git commit -m "feat: classify postgresql pessimistic lock failures" +``` + +### Task 31: PostgreSQL NOWAIT·SKIP LOCKED Work Claim Extension 구현 + +**Files:** +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/WorkQueueName.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/WorkClaimExecutor.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlWorkClaimExecutor.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/WorkClaim.java` +- Test: `modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlWorkClaimContractTest.java` + +**Interfaces:** +- Consumes: EntityManager native query, registered queue SQL and PostgreSQL `FOR UPDATE SKIP LOCKED`. +- Produces: Queue-specific batch claim semantics instead of a generic inconsistent-read API. + +**Implementation requirements:** +- Require a registered queue name and fixed SQL template. +- Claim rows in deterministic priority/id order. +- Return lease owner and lease-until evidence in the same transaction. +- Do not expose `skipLocked=true` on arbitrary repository methods. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.postgresql.lock; + +class PostgreSqlWorkClaimContractTest { + @Test + void competingWorkersClaimDisjointRows() { + var first = workerA.claimNextBatch(QUEUE, 10, Duration.ofMinutes(1)); + var second = workerB.claimNextBatch(QUEUE, 10, Duration.ofMinutes(1)); + + assertThat(first).extracting(WorkClaim::id) + .doesNotContainAnyElementsOf(second.stream().map(WorkClaim::id).toList()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.lock.PostgreSqlWorkClaimContractTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.postgresql.lock; + +public interface WorkClaimExecutor { + List> claimNextBatch( + WorkQueueName queue, + int size, + Duration lease); +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.lock.PostgreSqlWorkClaimContractTest' +./gradlew :modules:jpa:jpa-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/WorkQueueName.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/WorkClaimExecutor.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlWorkClaimExecutor.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/lock/WorkClaim.java' 'modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/lock/PostgreSqlWorkClaimContractTest.java' +git commit -m "feat: add postgresql skip locked work claims" +``` + +### Task 32: Constraint Violation Catalog와 Race-safe 오류 변환 구현 + +**Files:** +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/constraint/ConstraintCode.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/constraint/PostgreSqlConstraintCatalog.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/constraint/PostgreSqlConstraintViolationTranslator.java` +- Modify: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlExceptionTranslator.java` +- Test: `modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/constraint/ConstraintRaceContractTest.java` + +**Interfaces:** +- Consumes: Structured PostgreSQL server error fields and design-time constraint registry. +- Produces: Stable application constraint codes for unique, foreign-key, not-null and check violations. + +**Implementation requirements:** +- Two concurrent inserts of the same logical key must result in one commit and one unique exception. +- Do not rely on a prior `exists` query for correctness. +- Unknown constraint names map to a generic bounded code and secure diagnostic metadata. +- Support partial unique index and `NULLS NOT DISTINCT` migration names. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.postgresql.constraint; + +class ConstraintRaceContractTest { + @Test + void concurrentCreateIsResolvedByDatabaseConstraint() { + var results = runConcurrently( + () -> service.create("same@example.test"), + () -> service.create("same@example.test")); + + assertThat(results.successCount()).isEqualTo(1); + assertThat(results.failure()).isInstanceOf(UniqueConstraintViolationException.class); + assertThat(((UniqueConstraintViolationException) results.failure()) + .details().code()).isEqualTo(new ConstraintCode("user.active-email.unique")); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.constraint.ConstraintRaceContractTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.postgresql.constraint; + +public final class PostgreSqlConstraintCatalog { + private final Map byDatabaseName; + + public ConstraintCode resolve(String databaseName) { + return byDatabaseName.getOrDefault( + databaseName, new ConstraintCode("database.constraint.unknown")); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.constraint.ConstraintRaceContractTest' +./gradlew :modules:jpa:jpa-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/constraint/ConstraintCode.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/constraint/PostgreSqlConstraintCatalog.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/constraint/PostgreSqlConstraintViolationTranslator.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/error/PostgreSqlExceptionTranslator.java' 'modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/constraint/ConstraintRaceContractTest.java' +git commit -m "feat: map database constraints to stable error codes" +``` + +### Task 33: Hibernate JDBC Batch Profile과 Configuration Guard 구현 + +**Files:** +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/JpaBatchProfile.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/JpaBatchProfileRegistry.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/HibernateBatchConfigurationGuard.java` +- Test: `modules/jpa/jpa-hibernate/src/test/java/io/backend/skeleton/jpa/hibernate/batch/HibernateBatchConfigurationGuardTest.java` + +**Interfaces:** +- Consumes: Hibernate batch settings and Entity identifier metadata. +- Produces: Named batch profiles and startup diagnostics for IDENTITY and sequence mismatch. + +**Implementation requirements:** +- Require positive batch, flush and clear sizes for enabled profiles. +- Warn/fail when a write-heavy batch profile targets IDENTITY entities. +- Validate sequence allocation size against migration metadata in the contract suite. +- Treat `order_inserts` and `order_updates` as profile options, not universal defaults. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.hibernate.batch; + +class HibernateBatchConfigurationGuardTest { + @Test + void rejectsIdentityEntityInRequiredBatchProfile() { + var profile = new JpaBatchProfile("import", 50, 50, 50, true, true, true); + assertThatThrownBy(() -> guard.validate(profile, IdentityEntity.class)) + .hasMessageContaining("IDENTITY disables insert batching"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-hibernate:test --tests 'io.backend.skeleton.jpa.hibernate.batch.HibernateBatchConfigurationGuardTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.hibernate.batch; + +public record JpaBatchProfile( + String name, + int jdbcBatchSize, + int flushSize, + int clearSize, + boolean orderInserts, + boolean orderUpdates, + boolean batchingRequired) { + public JpaBatchProfile { + if (jdbcBatchSize < 1 || flushSize < 1 || clearSize < 1) { + throw new IllegalArgumentException("batch sizes must be positive"); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-hibernate:test --tests 'io.backend.skeleton.jpa.hibernate.batch.HibernateBatchConfigurationGuardTest' +./gradlew :modules:jpa:jpa-hibernate:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/JpaBatchProfile.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/JpaBatchProfileRegistry.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/HibernateBatchConfigurationGuard.java' 'modules/jpa/jpa-hibernate/src/test/java/io/backend/skeleton/jpa/hibernate/batch/HibernateBatchConfigurationGuardTest.java' +git commit -m "feat: define verified hibernate batch profiles" +``` + +### Task 34: Chunked Batch Persist Executor 구현 + +**Files:** +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/JpaBatchExecutor.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/HibernateJpaBatchExecutor.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/BatchExecutionResult.java` +- Test: `modules/jpa/jpa-hibernate/src/integrationTest/java/io/backend/skeleton/jpa/hibernate/batch/HibernateJpaBatchExecutorIntegrationTest.java` + +**Interfaces:** +- Consumes: Task 33 profile, EntityManager and Hibernate statistics. +- Produces: Flush/clear bounded batch persistence with measured JDBC batch execution. + +**Implementation requirements:** +- Persist each item exactly once inside a caller-owned transaction. +- Flush and clear at configured boundaries and once at the end. +- Reject a Stream that cannot report or enforce a maximum input count unless admin capability is present. +- Return processed rows, flush count, statement count and actual batch count. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.hibernate.batch; + +class HibernateJpaBatchExecutorIntegrationTest { + @Test + void executesActualJdbcBatchesAndBoundsPersistenceContext() { + var result = executor.persist(BATCH_PROFILE, fixtures(1_000), entityManager::persist); + + assertThat(result.processed()).isEqualTo(1_000); + assertThat(result.jdbcBatches()).isGreaterThan(1); + assertThat(result.maxManagedEntities()).isLessThanOrEqualTo(50); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-hibernate:integrationTest --tests 'io.backend.skeleton.jpa.hibernate.batch.HibernateJpaBatchExecutorIntegrationTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.hibernate.batch; + +public final class HibernateJpaBatchExecutor implements JpaBatchExecutor { + public BatchExecutionResult persist( + JpaBatchProfile profile, + Iterable items, + Consumer persister) { + int processed = 0; + for (T item : items) { + persister.accept(item); + processed++; + if (processed % profile.flushSize() == 0) { + entityManager.flush(); + entityManager.clear(); + } + } + entityManager.flush(); + entityManager.clear(); + return measurements.result(processed); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-hibernate:integrationTest --tests 'io.backend.skeleton.jpa.hibernate.batch.HibernateJpaBatchExecutorIntegrationTest' +./gradlew :modules:jpa:jpa-hibernate:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/JpaBatchExecutor.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/HibernateJpaBatchExecutor.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/batch/BatchExecutionResult.java' 'modules/jpa/jpa-hibernate/src/integrationTest/java/io/backend/skeleton/jpa/hibernate/batch/HibernateJpaBatchExecutorIntegrationTest.java' +git commit -m "feat: execute bounded hibernate jdbc batches" +``` + +### Task 35: Bulk DML flush-clear Executor 구현 + +**Files:** +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/bulk/BulkOperationName.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/bulk/BulkDmlExecutor.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/bulk/HibernateBulkDmlExecutor.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/bulk/BulkDmlResult.java` +- Test: `modules/jpa/jpa-hibernate/src/integrationTest/java/io/backend/skeleton/jpa/hibernate/bulk/HibernateBulkDmlExecutorIntegrationTest.java` + +**Interfaces:** +- Consumes: EntityManager, registered bulk operation and Task 18 QueryObservation. +- Produces: Explicit flush → bulk SQL → clear execution with affected-row guard. + +**Implementation requirements:** +- Require an active transaction and registered operation name. +- Flush before query execution and clear immediately after it. +- Require minimum/maximum expected affected rows; fail on unexpected blast radius. +- Document that callbacks and optimistic version checks are bypassed. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.hibernate.bulk; + +class HibernateBulkDmlExecutorIntegrationTest { + @Test + void clearsStaleManagedEntitiesAfterBulkUpdate() { + var managed = repository.findById(id).orElseThrow(); + executor.execute(OPERATION, () -> query.executeUpdate(), expectedRows(1)); + + assertThat(entityManager.contains(managed)).isFalse(); + assertThat(repository.findById(id).orElseThrow().status()).isEqualTo("ARCHIVED"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-hibernate:integrationTest --tests 'io.backend.skeleton.jpa.hibernate.bulk.HibernateBulkDmlExecutorIntegrationTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.hibernate.bulk; + +public final class HibernateBulkDmlExecutor implements BulkDmlExecutor { + public BulkDmlResult execute( + BulkOperationName name, + IntSupplier statement, + AffectedRowsExpectation expectation) { + entityManager.flush(); + int affected = statement.getAsInt(); + entityManager.clear(); + expectation.verify(affected); + return new BulkDmlResult(name, affected); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-hibernate:integrationTest --tests 'io.backend.skeleton.jpa.hibernate.bulk.HibernateBulkDmlExecutorIntegrationTest' +./gradlew :modules:jpa:jpa-hibernate:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/bulk/BulkOperationName.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/bulk/BulkDmlExecutor.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/bulk/HibernateBulkDmlExecutor.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/bulk/BulkDmlResult.java' 'modules/jpa/jpa-hibernate/src/integrationTest/java/io/backend/skeleton/jpa/hibernate/bulk/HibernateBulkDmlExecutorIntegrationTest.java' +git commit -m "feat: execute safe jpa bulk dml with context clearing" +``` + +### Task 36: Hibernate StatelessSession Advanced Runner 구현 + +**Files:** +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/stateless/StatelessWorkName.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/stateless/StatelessSessionRunner.java` +- Create: `modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/stateless/HibernateStatelessSessionRunner.java` +- Test: `modules/jpa/jpa-hibernate/src/integrationTest/java/io/backend/skeleton/jpa/hibernate/stateless/HibernateStatelessSessionRunnerIntegrationTest.java` + +**Interfaces:** +- Consumes: Hibernate SessionFactory and J4/Advanced authorization token. +- Produces: An opt-in bulk session with explicit no-dirty-checking/no-cascade semantics. + +**Implementation requirements:** +- Do not register this runner as the default Repository implementation. +- Require a named operation, row cap and explicit transaction mode. +- Document that returned objects are not managed and aliases may occur. +- Measure rows, statements and memory independent of persistence-context size. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.hibernate.stateless; + +class HibernateStatelessSessionRunnerIntegrationTest { + @Test + void insertsWithoutGrowingPersistenceContext() { + var result = runner.execute(WORK, 10_000, session -> { + fixtures(10_000).forEach(session::insert); + return 10_000; + }); + + assertThat(result).isEqualTo(10_000); + assertThat(hibernateSessionStatistics.managedEntityCount()).isZero(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-hibernate:integrationTest --tests 'io.backend.skeleton.jpa.hibernate.stateless.HibernateStatelessSessionRunnerIntegrationTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.hibernate.stateless; + +public final class HibernateStatelessSessionRunner implements StatelessSessionRunner { + public T execute( + StatelessWorkName name, + long maxRows, + Function work) { + try (StatelessSession session = sessionFactory.openStatelessSession()) { + Transaction tx = session.beginTransaction(); + try { + T result = work.apply(session); + tx.commit(); + return result; + } catch (RuntimeException failure) { + tx.rollback(); + throw failure; + } + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-hibernate:integrationTest --tests 'io.backend.skeleton.jpa.hibernate.stateless.HibernateStatelessSessionRunnerIntegrationTest' +./gradlew :modules:jpa:jpa-hibernate:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/stateless/StatelessWorkName.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/stateless/StatelessSessionRunner.java' 'modules/jpa/jpa-hibernate/src/main/java/io/backend/skeleton/jpa/hibernate/stateless/HibernateStatelessSessionRunner.java' 'modules/jpa/jpa-hibernate/src/integrationTest/java/io/backend/skeleton/jpa/hibernate/stateless/HibernateStatelessSessionRunnerIntegrationTest.java' +git commit -m "feat: add opt in hibernate stateless session runner" +``` + +### Task 37: PostgreSQL JSONB Mapping과 Query Contract 구현 + +**Files:** +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/json/JsonDocument.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/json/JsonDocumentCodec.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/json/PostgreSqlJsonQuerySupport.java` +- Test: `modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/json/PostgreSqlJsonbContractTest.java` + +**Interfaces:** +- Consumes: Hibernate JSON JDBC type, Jackson adapter and PostgreSQL JSONB operators. +- Produces: Versioned JSONB value mapping and parameter-bound JSON path/containment queries. + +**Implementation requirements:** +- Do not store Java class names in JSON payload. +- Require schema name/version in `JsonDocument`. +- Use parameters for values and a registered catalog for JSON paths. +- Test GIN index plan separately in Task 44. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.postgresql.json; + +class PostgreSqlJsonbContractTest { + @Test + void roundTripsVersionedDocumentAndQueriesByRegisteredPath() { + repository.save(entity(json("profile", 2, Map.of("tier", "pro")))); + entityManager.flush(); + + assertThat(querySupport.contains(PATH_TIER, "pro")) + .extracting(Result::schemaVersion) + .containsExactly(2); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.json.PostgreSqlJsonbContractTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.postgresql.json; + +public record JsonDocument( + String schema, + int version, + JsonNode payload) { + public JsonDocument { + if (schema == null || schema.isBlank() || version < 1) { + throw new IllegalArgumentException("invalid json document envelope"); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.json.PostgreSqlJsonbContractTest' +./gradlew :modules:jpa:jpa-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/json/JsonDocument.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/json/JsonDocumentCodec.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/json/PostgreSqlJsonQuerySupport.java' 'modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/json/PostgreSqlJsonbContractTest.java' +git commit -m "feat: add postgresql jsonb persistence support" +``` + +### Task 38: PostgreSQL Array·Range Mapping Contract 구현 + +**Files:** +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/array/PostgreSqlArraySupport.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/range/PgRange.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/range/PgRangeJdbcType.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/range/PostgreSqlRangeQuerySupport.java` +- Test: `modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/range/PostgreSqlArrayRangeContractTest.java` + +**Interfaces:** +- Consumes: Hibernate JDBC type SPI and PostgreSQL array/range types. +- Produces: Typed array and bounded/unbounded range round-trip and overlap/containment query support. + +**Implementation requirements:** +- Represent open/closed and unbounded endpoints explicitly. +- Reject invalid ranges in Java before sending them. +- Do not flatten ranges into two unrelated columns in this extension. +- Run identical contracts on PG16·17·18. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.postgresql.range; + +class PostgreSqlArrayRangeContractTest { + @Test + void roundTripsClosedOpenRangeAndArray() { + var saved = repository.save(fixture( + List.of("a", "b"), PgRange.closedOpen(Instant.EPOCH, Instant.EPOCH.plusSeconds(60)))); + entityManager.flush(); + entityManager.clear(); + + var loaded = repository.findById(saved.id()).orElseThrow(); + assertThat(loaded.tags()).containsExactly("a", "b"); + assertThat(loaded.window().upperInclusive()).isFalse(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.range.PostgreSqlArrayRangeContractTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.postgresql.range; + +public record PgRange>( + Optional lower, + boolean lowerInclusive, + Optional upper, + boolean upperInclusive) { + public PgRange { + if (lower.isPresent() && upper.isPresent() && + lower.get().compareTo(upper.get()) > 0) { + throw new IllegalArgumentException("range lower bound exceeds upper bound"); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.range.PostgreSqlArrayRangeContractTest' +./gradlew :modules:jpa:jpa-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/array/PostgreSqlArraySupport.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/range/PgRange.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/range/PgRangeJdbcType.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/range/PostgreSqlRangeQuerySupport.java' 'modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/range/PostgreSqlArrayRangeContractTest.java' +git commit -m "feat: add postgresql array and range mappings" +``` + +### Task 39: PostgreSQL ON CONFLICT·RETURNING Native Write 구현 + +**Files:** +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/write/NativeWriteName.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/write/UpsertConflictTarget.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/write/PostgreSqlUpsertExecutor.java` +- Create: `modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/write/RegisteredPostgreSqlUpsertExecutor.java` +- Test: `modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/write/PostgreSqlUpsertContractTest.java` + +**Interfaces:** +- Consumes: Registered native SQL, parameter binder, QueryObservation and Persistence Context clear policy. +- Produces: Explicit upsert result with inserted/updated disposition and returned projection. + +**Implementation requirements:** +- Require a registered conflict target and fixed update column set. +- Parameter-bind all values; dynamic table/column names are forbidden. +- Return whether insert or conflict-update occurred when SQL can expose it. +- Clear or refresh affected managed Entity state before returning to JPA code. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.postgresql.write; + +class PostgreSqlUpsertContractTest { + @Test + void concurrentUpsertReturnsOneLogicalRow() { + runConcurrently( + () -> executor.execute(UPSERT, command("key", 1)), + () -> executor.execute(UPSERT, command("key", 2))); + + assertThat(jdbc.queryForObject("select count(*) from counters where key='key'", Long.class)) + .isEqualTo(1L); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.write.PostgreSqlUpsertContractTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.postgresql.write; + +public interface PostgreSqlUpsertExecutor { + UpsertResult execute(NativeWriteName operation, C command); +} + +public record UpsertResult(WriteDisposition disposition, R value) {} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql:contractTest --tests 'io.backend.skeleton.jpa.postgresql.write.PostgreSqlUpsertContractTest' +./gradlew :modules:jpa:jpa-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/write/NativeWriteName.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/write/UpsertConflictTarget.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/write/PostgreSqlUpsertExecutor.java' 'modules/jpa/jpa-postgresql/src/main/java/io/backend/skeleton/jpa/postgresql/write/RegisteredPostgreSqlUpsertExecutor.java' 'modules/jpa/jpa-postgresql/src/contractTest/java/io/backend/skeleton/jpa/postgresql/write/PostgreSqlUpsertContractTest.java' +git commit -m "feat: add registered postgresql upsert writes" +``` + +### Task 40: PostgreSQL COPY Bulk Loader J4 Extension 구현 + +**Files:** +- Create: `modules/jpa/jpa-postgresql-copy/src/main/java/io/backend/skeleton/jpa/postgresql/copy/CopyOperationName.java` +- Create: `modules/jpa/jpa-postgresql-copy/src/main/java/io/backend/skeleton/jpa/postgresql/copy/PostgreSqlCopyLoader.java` +- Create: `modules/jpa/jpa-postgresql-copy/src/main/java/io/backend/skeleton/jpa/postgresql/copy/CopyFormat.java` +- Create: `modules/jpa/jpa-postgresql-copy/src/main/java/io/backend/skeleton/jpa/postgresql/copy/CopyResult.java` +- Test: `modules/jpa/jpa-postgresql-copy/src/integrationTest/java/io/backend/skeleton/jpa/postgresql/copy/PostgreSqlCopyLoaderIntegrationTest.java` + +**Interfaces:** +- Consumes: PostgreSQL JDBC `CopyManager`, admin capability token and bounded input stream. +- Produces: Explicit J4 bulk load with row/byte limits, transaction policy and audit identity. + +**Implementation requirements:** +- Require a registered COPY statement; no caller-provided table or column strings. +- Enforce max rows, max bytes and finite timeout. +- Run only under a configured bulk/admin role. +- Return rows and bytes; never use Entity callbacks or Persistence Context. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.postgresql.copy; + +class PostgreSqlCopyLoaderIntegrationTest { + @Test + void loadsBoundedCsvWithoutEntityHydration() { + var result = loader.load(IMPORT, csvOf(10_000), limits(10_000, 5_000_000)); + + assertThat(result.rows()).isEqualTo(10_000); + assertThat(hibernateStatistics.entityLoadCount()).isZero(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql-copy:integrationTest --tests 'io.backend.skeleton.jpa.postgresql.copy.PostgreSqlCopyLoaderIntegrationTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.postgresql.copy; + +public interface PostgreSqlCopyLoader { + CopyResult load( + CopyOperationName operation, + InputStream source, + CopyLimits limits); +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-postgresql-copy:integrationTest --tests 'io.backend.skeleton.jpa.postgresql.copy.PostgreSqlCopyLoaderIntegrationTest' +./gradlew :modules:jpa:jpa-postgresql-copy:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-postgresql-copy/src/main/java/io/backend/skeleton/jpa/postgresql/copy/CopyOperationName.java' 'modules/jpa/jpa-postgresql-copy/src/main/java/io/backend/skeleton/jpa/postgresql/copy/PostgreSqlCopyLoader.java' 'modules/jpa/jpa-postgresql-copy/src/main/java/io/backend/skeleton/jpa/postgresql/copy/CopyFormat.java' 'modules/jpa/jpa-postgresql-copy/src/main/java/io/backend/skeleton/jpa/postgresql/copy/CopyResult.java' 'modules/jpa/jpa-postgresql-copy/src/integrationTest/java/io/backend/skeleton/jpa/postgresql/copy/PostgreSqlCopyLoaderIntegrationTest.java' +git commit -m "feat: add guarded postgresql copy bulk loader" +``` + +### Task 41: Flyway Schema Policy와 Hibernate Validate Gate 구현 + +**Files:** +- Create: `modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/SchemaManagementMode.java` +- Create: `modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/FlywaySchemaPolicy.java` +- Create: `modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/FlywayValidationGate.java` +- Create: `modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/SchemaVersionSnapshot.java` +- Test: `modules/jpa/jpa-migration-flyway/src/test/java/io/backend/skeleton/jpa/migration/FlywayValidationGateTest.java` + +**Interfaces:** +- Consumes: Flyway validate/migrate information and environment profile. +- Produces: Environment-specific migration policy that never auto-repairs or allows runtime DDL mutation. + +**Implementation requirements:** +- Local/test/dev may migrate with migration credential; staging/prod support deployment-owned migration. +- Hibernate validate must run after migration in tests and runtime startup. +- Checksum mismatch, missing migration and schema mismatch fail closed. +- Repair is represented only as an admin operation descriptor, not startup behavior. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.migration; + +class FlywayValidationGateTest { + @Test + void checksumMismatchFailsAndNeverRepairsAutomatically() { + var result = validationResultWithChecksumMismatch(); + assertThatThrownBy(() -> gate.requireValid(result)) + .isInstanceOf(SchemaMismatchException.class); + assertThat(flywayProbe.repairInvocations()).isZero(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-migration-flyway:test --tests 'io.backend.skeleton.jpa.migration.FlywayValidationGateTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.migration; + +public final class FlywayValidationGate { + public void requireValid(ValidateResult result) { + if (!result.validationSuccessful) { + throw new SchemaMismatchException( + "Flyway validation failed: " + sanitizedErrorCodes(result)); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-migration-flyway:test --tests 'io.backend.skeleton.jpa.migration.FlywayValidationGateTest' +./gradlew :modules:jpa:jpa-migration-flyway:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/SchemaManagementMode.java' 'modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/FlywaySchemaPolicy.java' 'modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/FlywayValidationGate.java' 'modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/SchemaVersionSnapshot.java' 'modules/jpa/jpa-migration-flyway/src/test/java/io/backend/skeleton/jpa/migration/FlywayValidationGateTest.java' +git commit -m "feat: enforce flyway schema validation policy" +``` + +### Task 42: Migration Snapshot Upgrade Testkit 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit-migration/src/main/java/io/backend/skeleton/jpa/testkit/migration/MigrationSnapshot.java` +- Create: `modules/jpa/jpa-testkit-migration/src/main/java/io/backend/skeleton/jpa/testkit/migration/MigrationScenario.java` +- Create: `modules/jpa/jpa-testkit-migration/src/main/java/io/backend/skeleton/jpa/testkit/migration/MigrationContractRunner.java` +- Create: `modules/jpa/jpa-testkit-migration/src/migrationTest/java/io/backend/skeleton/jpa/testkit/migration/FlywayUpgradeContractTest.java` +- Test: `modules/jpa/jpa-testkit-migration/src/test/java/io/backend/skeleton/jpa/testkit/migration/MigrationScenarioTest.java` + +**Interfaces:** +- Consumes: PostgreSQL containers, schema snapshots and Task 41 validation gate. +- Produces: Repeatable empty, N-1 and oldest-supported upgrade scenarios plus checksum/missing migration failures. + +**Implementation requirements:** +- Restore snapshots into a clean database before each scenario. +- Run migrations and Hibernate validate after upgrade. +- Assert data invariants as well as schema version. +- Persist recovery instructions for non-transactional migration failures. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.migration; + +class MigrationScenarioTest { + @Test + void requiresEmptyPreviousAndOldestSupportedScenarios() { + assertThat(MigrationScenario.required()) + .extracting(MigrationScenario::name) + .containsExactlyInAnyOrder("empty", "previous-release", "oldest-supported"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-migration:test --tests 'io.backend.skeleton.jpa.testkit.migration.MigrationScenarioTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.testkit.migration; + +public record MigrationScenario( + String name, + MigrationSnapshot snapshot, + Consumer invariant) { + public static List required() { + return List.of(empty(), previousRelease(), oldestSupported()); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-migration:test --tests 'io.backend.skeleton.jpa.testkit.migration.MigrationScenarioTest' +./gradlew :modules:jpa:jpa-testkit-migration:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit-migration/src/main/java/io/backend/skeleton/jpa/testkit/migration/MigrationSnapshot.java' 'modules/jpa/jpa-testkit-migration/src/main/java/io/backend/skeleton/jpa/testkit/migration/MigrationScenario.java' 'modules/jpa/jpa-testkit-migration/src/main/java/io/backend/skeleton/jpa/testkit/migration/MigrationContractRunner.java' 'modules/jpa/jpa-testkit-migration/src/migrationTest/java/io/backend/skeleton/jpa/testkit/migration/FlywayUpgradeContractTest.java' 'modules/jpa/jpa-testkit-migration/src/test/java/io/backend/skeleton/jpa/testkit/migration/MigrationScenarioTest.java' +git commit -m "test: add flyway upgrade snapshot contracts" +``` + +### Task 43: Non-transactional Concurrent Index Migration Guard 구현 + +**Files:** +- Create: `modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/NonTransactionalMigrationPolicy.java` +- Create: `modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/ConcurrentIndexMigrationInspector.java` +- Create: `modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/FailedConcurrentIndexRecovery.java` +- Test: `modules/jpa/jpa-migration-flyway/src/test/java/io/backend/skeleton/jpa/migration/ConcurrentIndexMigrationInspectorTest.java` + +**Interfaces:** +- Consumes: Flyway migration resource metadata and PostgreSQL index catalog. +- Produces: A gate ensuring `CREATE INDEX CONCURRENTLY` is explicitly non-transactional and recoverable. + +**Implementation requirements:** +- Detect concurrent index SQL in transactional migrations and fail validation. +- Require a companion `.conf` or registered policy marking execute-in-transaction false. +- Detect invalid indexes after failed migration and generate a bounded recovery report. +- Do not auto-drop invalid indexes in application startup. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.migration; + +class ConcurrentIndexMigrationInspectorTest { + @Test + void concurrentIndexMustBeMarkedNonTransactional() { + var migration = sql("V42__order_index.sql", "create index concurrently ix_order on orders(created_at)"); + assertThatThrownBy(() -> inspector.validate(migration, transactionEnabled())) + .hasMessageContaining("executeInTransaction=false"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-migration-flyway:test --tests 'io.backend.skeleton.jpa.migration.ConcurrentIndexMigrationInspectorTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.migration; + +public final class ConcurrentIndexMigrationInspector { + public void validate(MigrationResource migration, boolean executeInTransaction) { + if (migration.sql().toLowerCase(Locale.ROOT).contains("create index concurrently") && + executeInTransaction) { + throw new IllegalStateException( + migration.name() + " must set executeInTransaction=false"); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-migration-flyway:test --tests 'io.backend.skeleton.jpa.migration.ConcurrentIndexMigrationInspectorTest' +./gradlew :modules:jpa:jpa-migration-flyway:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/NonTransactionalMigrationPolicy.java' 'modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/ConcurrentIndexMigrationInspector.java' 'modules/jpa/jpa-migration-flyway/src/main/java/io/backend/skeleton/jpa/migration/FailedConcurrentIndexRecovery.java' 'modules/jpa/jpa-migration-flyway/src/test/java/io/backend/skeleton/jpa/migration/ConcurrentIndexMigrationInspectorTest.java' +git commit -m "feat: guard concurrent index migrations" +``` + +### Task 44: PostgreSQL Query Plan Testkit 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit-queryplan/src/main/java/io/backend/skeleton/jpa/testkit/queryplan/QueryPlanExpectation.java` +- Create: `modules/jpa/jpa-testkit-queryplan/src/main/java/io/backend/skeleton/jpa/testkit/queryplan/PostgreSqlExplainRunner.java` +- Create: `modules/jpa/jpa-testkit-queryplan/src/main/java/io/backend/skeleton/jpa/testkit/queryplan/NormalizedPlan.java` +- Create: `modules/jpa/jpa-testkit-queryplan/src/main/java/io/backend/skeleton/jpa/testkit/queryplan/QueryPlanAssertions.java` +- Test: `modules/jpa/jpa-testkit-queryplan/src/test/java/io/backend/skeleton/jpa/testkit/queryplan/QueryPlanAssertionsTest.java` + +**Interfaces:** +- Consumes: Registered SQL/parameters under a test/admin role and `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)`. +- Produces: Structural plan assertions for node types, row-estimate ratio, sort spill and buffer use. + +**Implementation requirements:** +- Do not globally fail every sequential scan. +- Normalize volatile cost/time fields before snapshot comparison. +- Require representative parameters and fixture statistics. +- Never run ANALYZE write queries outside isolated test databases. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.queryplan; + +class QueryPlanAssertionsTest { + @Test + void detectsUnexpectedSortSpillAndEstimateError() { + var plan = planWithDiskSortAndEstimateRatio(100.0); + assertThatThrownBy(() -> assertions.assertMatches(plan, + expectation().maxEstimateRatio(10).forbidDiskSort())) + .hasMessageContaining("Disk Sort"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-queryplan:test --tests 'io.backend.skeleton.jpa.testkit.queryplan.QueryPlanAssertionsTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.testkit.queryplan; + +public record QueryPlanExpectation( + Set requiredNodeTypes, + Set forbiddenNodeTypes, + double maxEstimateRatio, + boolean forbidDiskSort) { +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-queryplan:test --tests 'io.backend.skeleton.jpa.testkit.queryplan.QueryPlanAssertionsTest' +./gradlew :modules:jpa:jpa-testkit-queryplan:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit-queryplan/src/main/java/io/backend/skeleton/jpa/testkit/queryplan/QueryPlanExpectation.java' 'modules/jpa/jpa-testkit-queryplan/src/main/java/io/backend/skeleton/jpa/testkit/queryplan/PostgreSqlExplainRunner.java' 'modules/jpa/jpa-testkit-queryplan/src/main/java/io/backend/skeleton/jpa/testkit/queryplan/NormalizedPlan.java' 'modules/jpa/jpa-testkit-queryplan/src/main/java/io/backend/skeleton/jpa/testkit/queryplan/QueryPlanAssertions.java' 'modules/jpa/jpa-testkit-queryplan/src/test/java/io/backend/skeleton/jpa/testkit/queryplan/QueryPlanAssertionsTest.java' +git commit -m "test: add postgresql query plan regression toolkit" +``` + +### Task 45: Database Role·search_path Security Verifier 구현 + +**Files:** +- Create: `modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/DatabaseRolePolicy.java` +- Create: `modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/PostgreSqlRuntimeRoleVerifier.java` +- Create: `modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/SearchPathPolicy.java` +- Create: `modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/DatabasePrivilegeReport.java` +- Test: `modules/jpa/jpa-security/src/integrationTest/java/io/backend/skeleton/jpa/security/PostgreSqlRuntimeRoleVerifierIntegrationTest.java` + +**Interfaces:** +- Consumes: Runtime DataSource, `current_user`, `current_setting(search_path)` and privilege functions. +- Produces: Fail-fast proof that runtime role has DML but lacks DDL and untrusted schema CREATE privilege. + +**Implementation requirements:** +- Verify current user and schema against configured allowlists. +- Reject runtime role with CREATE on application schema or database. +- Reject untrusted writable schemas in search_path. +- Do not expose usernames or JDBC URLs in Actuator output beyond bounded profile names. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.security; + +class PostgreSqlRuntimeRoleVerifierIntegrationTest { + @Test + void runtimeRoleCanWriteRowsButCannotCreateTable() { + verifier.requireSafe(runtimeDataSource, policy()); + assertThatThrownBy(() -> jdbc.execute("create table forbidden(id bigint)")) + .isInstanceOf(DataAccessException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-security:integrationTest --tests 'io.backend.skeleton.jpa.security.PostgreSqlRuntimeRoleVerifierIntegrationTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.security; + +public final class PostgreSqlRuntimeRoleVerifier { + public DatabasePrivilegeReport verify(DataSource dataSource, DatabaseRolePolicy policy) { + return jdbc(dataSource).queryForObject(""" + select current_user, + current_setting('search_path'), + has_schema_privilege(current_user, current_schema(), 'CREATE') + """, reportMapper); + } + + public void requireSafe(DataSource dataSource, DatabaseRolePolicy policy) { + DatabasePrivilegeReport report = verify(dataSource, policy); + policy.requireSafe(report); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-security:integrationTest --tests 'io.backend.skeleton.jpa.security.PostgreSqlRuntimeRoleVerifierIntegrationTest' +./gradlew :modules:jpa:jpa-security:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/DatabaseRolePolicy.java' 'modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/PostgreSqlRuntimeRoleVerifier.java' 'modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/SearchPathPolicy.java' 'modules/jpa/jpa-security/src/main/java/io/backend/skeleton/jpa/security/DatabasePrivilegeReport.java' 'modules/jpa/jpa-security/src/integrationTest/java/io/backend/skeleton/jpa/security/PostgreSqlRuntimeRoleVerifierIntegrationTest.java' +git commit -m "feat: verify postgresql runtime role safety" +``` + +### Task 46: Hibernate Second-level Cache Opt-in 모듈 구현 + +**Files:** +- Create: `modules/jpa/jpa-cache-hibernate/src/main/java/io/backend/skeleton/jpa/cache/HibernateCachePolicy.java` +- Create: `modules/jpa/jpa-cache-hibernate/src/main/java/io/backend/skeleton/jpa/cache/CacheRegionCatalog.java` +- Create: `modules/jpa/jpa-cache-hibernate/src/main/java/io/backend/skeleton/jpa/cache/HibernateCacheGuard.java` +- Test: `modules/jpa/jpa-cache-hibernate/src/test/java/io/backend/skeleton/jpa/cache/HibernateCacheGuardTest.java` + +**Interfaces:** +- Consumes: Hibernate L2 cache settings and Entity metadata. +- Produces: ENABLE_SELECTIVE, Entity-by-Entity cache enrollment while keeping Query Cache disabled by default. + +**Implementation requirements:** +- Fail if Query Cache is enabled without an explicit experimental approval. +- Require registered cache region and concurrency strategy for each cached Entity. +- Require a Bulk DML eviction strategy. +- Document external DB writer and cluster invalidation assumptions. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.cache; + +class HibernateCacheGuardTest { + @Test + void queryCacheIsOffAndOnlyRegisteredEntitiesAreCacheable() { + assertThatThrownBy(() -> guard.validate(settings(queryCacheEnabled()), catalog())) + .hasMessageContaining("Query Cache is disabled by default"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-cache-hibernate:test --tests 'io.backend.skeleton.jpa.cache.HibernateCacheGuardTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.cache; + +public final class HibernateCacheGuard { + public void validate(HibernateCacheSettings settings, CacheRegionCatalog catalog) { + if (settings.queryCacheEnabled()) { + throw new IllegalStateException("Query Cache is disabled by default"); + } + if (settings.sharedCacheMode() != SharedCacheMode.ENABLE_SELECTIVE) { + throw new IllegalStateException("Use ENABLE_SELECTIVE for L2 cache"); + } + catalog.validate(settings.cacheableEntities()); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-cache-hibernate:test --tests 'io.backend.skeleton.jpa.cache.HibernateCacheGuardTest' +./gradlew :modules:jpa:jpa-cache-hibernate:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-cache-hibernate/src/main/java/io/backend/skeleton/jpa/cache/HibernateCachePolicy.java' 'modules/jpa/jpa-cache-hibernate/src/main/java/io/backend/skeleton/jpa/cache/CacheRegionCatalog.java' 'modules/jpa/jpa-cache-hibernate/src/main/java/io/backend/skeleton/jpa/cache/HibernateCacheGuard.java' 'modules/jpa/jpa-cache-hibernate/src/test/java/io/backend/skeleton/jpa/cache/HibernateCacheGuardTest.java' +git commit -m "feat: add opt in hibernate second level cache guard" +``` + +### Task 47: Hibernate Envers Entity History Opt-in 모듈 구현 + +**Files:** +- Create: `modules/jpa/jpa-envers/src/main/java/io/backend/skeleton/jpa/envers/EnversHistoryPolicy.java` +- Create: `modules/jpa/jpa-envers/src/main/java/io/backend/skeleton/jpa/envers/EnversRevisionMetadata.java` +- Create: `modules/jpa/jpa-envers/src/main/java/io/backend/skeleton/jpa/envers/EnversHistoryReader.java` +- Create: `modules/jpa/jpa-envers/src/main/java/io/backend/skeleton/jpa/envers/EnversConfigurationGuard.java` +- Test: `modules/jpa/jpa-envers/src/integrationTest/java/io/backend/skeleton/jpa/envers/EnversHistoryContractTest.java` + +**Interfaces:** +- Consumes: Hibernate Envers and application-provided revision actor/context. +- Produces: Entity-specific history without conflating it with technical or business audit. + +**Implementation requirements:** +- Require explicit `@Audited` or catalog enrollment. +- Record bounded actor/correlation metadata, not entire security principals. +- Require retention and PII deletion policy before production enablement. +- Do not enable Envers for every Entity through a global base class. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.envers; + +class EnversHistoryContractTest { + @Test + void storesHistoryOnlyForOptedInEntity() { + updateAuditedEntity(); + updateNonAuditedEntity(); + + assertThat(reader.revisions(AuditedFixture.class, auditedId)).hasSize(2); + assertThat(reader.revisions(PlainFixture.class, plainId)).isEmpty(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-envers:integrationTest --tests 'io.backend.skeleton.jpa.envers.EnversHistoryContractTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.envers; + +public interface EnversHistoryReader { + List> revisions(Class entityType, Object id); +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-envers:integrationTest --tests 'io.backend.skeleton.jpa.envers.EnversHistoryContractTest' +./gradlew :modules:jpa:jpa-envers:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-envers/src/main/java/io/backend/skeleton/jpa/envers/EnversHistoryPolicy.java' 'modules/jpa/jpa-envers/src/main/java/io/backend/skeleton/jpa/envers/EnversRevisionMetadata.java' 'modules/jpa/jpa-envers/src/main/java/io/backend/skeleton/jpa/envers/EnversHistoryReader.java' 'modules/jpa/jpa-envers/src/main/java/io/backend/skeleton/jpa/envers/EnversConfigurationGuard.java' 'modules/jpa/jpa-envers/src/integrationTest/java/io/backend/skeleton/jpa/envers/EnversHistoryContractTest.java' +git commit -m "feat: add opt in hibernate envers history" +``` + +### Task 48: JPA Metrics·Tracing·Log Redaction 구현 + +**Files:** +- Create: `modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/MicrometerQueryObservation.java` +- Create: `modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/JpaTransactionObservation.java` +- Create: `modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/JpaRetryObservation.java` +- Create: `modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/JpaMetricTags.java` +- Create: `modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/SqlDiagnosticRedactor.java` +- Test: `modules/jpa/jpa-observability/src/test/java/io/backend/skeleton/jpa/observation/JpaObservabilityContractTest.java` + +**Interfaces:** +- Consumes: Micrometer, Spring Observation, QueryName, PersistenceOperationName and Hibernate statistics. +- Produces: Logical transaction/query/retry metrics with bounded tags and PII-safe diagnostics. + +**Implementation requirements:** +- Measure transaction count/duration/rollback/timeout/retry/completion-unknown. +- Measure query count/duration/rows/fetch metrics and JDBC batch count. +- Allow only registered operation/query/entity type tags. +- Reject SQL parameters, IDs, tenant values and dynamic exception messages from metric tags. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.observation; + +class JpaObservabilityContractTest { + @Test + void metricsNeverUseEntityIdOrSqlParameterAsTag() { + observation.recordFailure(OPERATION, QUERY, uniqueViolation("secret@example.test")); + + assertThat(registry.getMeters()) + .flatExtracting(meter -> meter.getId().getTags()) + .extracting(Tag::getValue) + .noneMatch(value -> value.contains("secret@example.test") || value.contains("entity-42")); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-observability:test --tests 'io.backend.skeleton.jpa.observation.JpaObservabilityContractTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.observation; + +public record JpaMetricTags( + String persistenceUnit, + String operationName, + String queryName, + String outcome, + String failureCategory) { + public JpaMetricTags { + LowCardinality.requireRegistered(operationName, queryName, failureCategory); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-observability:test --tests 'io.backend.skeleton.jpa.observation.JpaObservabilityContractTest' +./gradlew :modules:jpa:jpa-observability:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/MicrometerQueryObservation.java' 'modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/JpaTransactionObservation.java' 'modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/JpaRetryObservation.java' 'modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/JpaMetricTags.java' 'modules/jpa/jpa-observability/src/main/java/io/backend/skeleton/jpa/observation/SqlDiagnosticRedactor.java' 'modules/jpa/jpa-observability/src/test/java/io/backend/skeleton/jpa/observation/JpaObservabilityContractTest.java' +git commit -m "feat: add safe jpa observability contracts" +``` + +### Task 49: PostgreSQL 16·17·18 공통 Contract Suite 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/postgresql/PostgreSqlVersion.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/postgresql/PostgreSqlContainerFactory.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/postgresql/PostgreSqlContractExtension.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/contractTest/java/io/backend/skeleton/jpa/testkit/postgresql/StablePostgreSqlMatrixContractTest.java` +- Test: `modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/postgresql/PostgreSqlVersionTest.java` + +**Interfaces:** +- Consumes: All Stable mapping, transaction, query, fetch, batch, extension and security contracts. +- Produces: A parameterized release matrix over real PostgreSQL 16, 17 and 18 containers. + +**Implementation requirements:** +- PR profile runs 16 and 18; release profile runs 16, 17 and 18. +- Pin image digests or approved tags and record exact server version. +- Run Flyway before Hibernate validate. +- H2 results must not satisfy this suite. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.postgresql; + +class PostgreSqlVersionTest { + @Test + void stableVersionsAreExactlySixteenSeventeenAndEighteen() { + assertThat(PostgreSqlVersion.stable()) + .containsExactly(PG_16, PG_17, PG_18); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.postgresql.PostgreSqlVersionTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.testkit.postgresql; + +public enum PostgreSqlVersion { + PG_16("postgres:16"), + PG_17("postgres:17"), + PG_18("postgres:18"); + + public static List stable() { + return List.of(PG_16, PG_17, PG_18); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.postgresql.PostgreSqlVersionTest' +./gradlew :modules:jpa:jpa-testkit-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/postgresql/PostgreSqlVersion.java' 'modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/postgresql/PostgreSqlContainerFactory.java' 'modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/postgresql/PostgreSqlContractExtension.java' 'modules/jpa/jpa-testkit-postgresql/src/contractTest/java/io/backend/skeleton/jpa/testkit/postgresql/StablePostgreSqlMatrixContractTest.java' 'modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/postgresql/PostgreSqlVersionTest.java' +git commit -m "test: add postgresql stable compatibility matrix" +``` + +### Task 50: Deadlock·Serialization·Commit Ambiguity Failure Injection Suite 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/failure/PostgreSqlFailureScenario.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/failure/CommitAmbiguityProxy.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/failureTest/java/io/backend/skeleton/jpa/testkit/failure/PostgreSqlConcurrencyFailureContractTest.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/failureTest/java/io/backend/skeleton/jpa/testkit/failure/CommitAmbiguityContractTest.java` +- Modify: `infra/jpa/toxiproxy/docker-compose.yml` +- Test: `modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/failure/PostgreSqlFailureScenarioTest.java` + +**Interfaces:** +- Consumes: Toxiproxy, deterministic transaction barriers, Task 6 evidence manager and Task 8 retry coordinator. +- Produces: Reproducible `40P01`, `40001` and commit-response-loss scenarios. + +**Implementation requirements:** +- Deadlock uses opposite lock order and confirms bounded full-TX retry. +- Serialization uses SERIALIZABLE invariant contention. +- Commit ambiguity distinguishes before-COMMIT, during-COMMIT and after-server-commit response loss. +- After-server-commit loss must emit completion unknown and must not rerun the original mutation. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.failure; + +class PostgreSqlFailureScenarioTest { + @Test + void commitAmbiguityHasThreeDistinctInjectionPoints() { + assertThat(PostgreSqlFailureScenario.commitPoints()) + .containsExactly(BEFORE_COMMIT, DURING_COMMIT, AFTER_SERVER_COMMIT_BEFORE_RESPONSE); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.failure.PostgreSqlFailureScenarioTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.testkit.failure; + +public enum PostgreSqlFailureScenario { + BEFORE_COMMIT, + DURING_COMMIT, + AFTER_SERVER_COMMIT_BEFORE_RESPONSE; + + public static List commitPoints() { + return List.of(values()); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.failure.PostgreSqlFailureScenarioTest' +./gradlew :modules:jpa:jpa-testkit-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/failure/PostgreSqlFailureScenario.java' 'modules/jpa/jpa-testkit-postgresql/src/main/java/io/backend/skeleton/jpa/testkit/failure/CommitAmbiguityProxy.java' 'modules/jpa/jpa-testkit-postgresql/src/failureTest/java/io/backend/skeleton/jpa/testkit/failure/PostgreSqlConcurrencyFailureContractTest.java' 'modules/jpa/jpa-testkit-postgresql/src/failureTest/java/io/backend/skeleton/jpa/testkit/failure/CommitAmbiguityContractTest.java' 'infra/jpa/toxiproxy/docker-compose.yml' 'modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/failure/PostgreSqlFailureScenarioTest.java' +git commit -m "test: add jpa concurrency and commit ambiguity failures" +``` + +### Task 51: Hikari Pool·REQUIRES_NEW Saturation Contract 구현 + +**Files:** +- Create: `modules/jpa/jpa-testkit-postgresql/src/performanceTest/java/io/backend/skeleton/jpa/testkit/pool/HikariPoolSaturationContractTest.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/performanceTest/java/io/backend/skeleton/jpa/testkit/pool/RequiresNewPoolPressureContractTest.java` +- Create: `modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/pool/PoolMeasurement.java` +- Test: `modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/pool/PoolMeasurementTest.java` + +**Interfaces:** +- Consumes: Hikari metrics, bounded executor and nested transaction fixtures. +- Produces: Evidence for pending/acquire latency, connection timeout and outer+inner connection pressure. + +**Implementation requirements:** +- Test finite pool saturation without changing production defaults. +- Show that concurrent REQUIRED uses one connection per transaction while REQUIRES_NEW can require two. +- Ensure rejected/acquire-timeout work releases all connections. +- Record transaction duration and pending acquire latency together. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.testkit.pool; + +class PoolMeasurementTest { + @Test + void reportsPendingAndAcquireLatencyTogether() { + var measurement = new PoolMeasurement(4, 2, 3, Duration.ofMillis(80)); + assertThat(measurement.pending()).isEqualTo(3); + assertThat(measurement.acquireLatency()).isEqualTo(Duration.ofMillis(80)); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.pool.PoolMeasurementTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.testkit.pool; + +public record PoolMeasurement( + int active, + int idle, + int pending, + Duration acquireLatency) { +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.pool.PoolMeasurementTest' +./gradlew :modules:jpa:jpa-testkit-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-testkit-postgresql/src/performanceTest/java/io/backend/skeleton/jpa/testkit/pool/HikariPoolSaturationContractTest.java' 'modules/jpa/jpa-testkit-postgresql/src/performanceTest/java/io/backend/skeleton/jpa/testkit/pool/RequiresNewPoolPressureContractTest.java' 'modules/jpa/jpa-testkit-postgresql/src/testFixtures/java/io/backend/skeleton/jpa/testkit/pool/PoolMeasurement.java' 'modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/pool/PoolMeasurementTest.java' +git commit -m "test: certify hikari and requires new pool behavior" +``` + +### Task 52: Spring Boot Starter·Actuator·Capability Report 완성 + +**Files:** +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaPlatformAutoConfiguration.java` +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaTransactionAutoConfiguration.java` +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaObservabilityAutoConfiguration.java` +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaPlatformEndpoint.java` +- Create: `modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaPlatformReport.java` +- Modify: `modules/jpa/jpa-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` +- Test: `modules/jpa/jpa-spring-boot-starter/src/test/java/io/backend/skeleton/jpa/autoconfigure/JpaPlatformAutoConfigurationTest.java` + +**Interfaces:** +- Consumes: Tasks 6~12, 18, 22~25, 41, 45 and 48. +- Produces: Conditional Stable auto-configuration and a sanitized actuator endpoint. + +**Implementation requirements:** +- Back off when the application supplies its own transaction manager or observation implementation. +- Auto-configure only Stable modules; Querydsl, Envers, L2 and COPY require explicit dependencies/properties. +- Endpoint reports DB major version, provider version, schema version, OSIV, role verification and capabilities. +- Do not expose JDBC URL, username, SQL, credentials or Entity catalog. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.jpa.autoconfigure; + +class JpaPlatformAutoConfigurationTest { + @Test + void configuresStablePlatformAndSanitizesEndpoint() { + context.withUserConfiguration(TestJpaApplication.class) + .run(result -> { + assertThat(result).hasSingleBean(JpaTransactionExecutor.class); + assertThat(result.getBean(JpaPlatformEndpoint.class).platform()) + .doesNotHaveToString(".*jdbc:.*|.*password.*"); + }); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-boot-starter:test --tests 'io.backend.skeleton.jpa.autoconfigure.JpaPlatformAutoConfigurationTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +package io.backend.skeleton.jpa.autoconfigure; + +@AutoConfiguration +@EnableConfigurationProperties({JpaSafetyProperties.class, JpaDataSourceProperties.class}) +public class JpaPlatformAutoConfiguration { + @Bean + JpaPlatformReport jpaPlatformReport( + DatabaseMetadata metadata, + FlywaySchemaPolicy schema, + DatabasePrivilegeReport privileges) { + return JpaPlatformReport.sanitized(metadata, schema, privileges); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-spring-boot-starter:test --tests 'io.backend.skeleton.jpa.autoconfigure.JpaPlatformAutoConfigurationTest' +./gradlew :modules:jpa:jpa-spring-boot-starter:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaPlatformAutoConfiguration.java' 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaTransactionAutoConfiguration.java' 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaObservabilityAutoConfiguration.java' 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaPlatformEndpoint.java' 'modules/jpa/jpa-spring-boot-starter/src/main/java/io/backend/skeleton/jpa/autoconfigure/JpaPlatformReport.java' 'modules/jpa/jpa-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports' 'modules/jpa/jpa-spring-boot-starter/src/test/java/io/backend/skeleton/jpa/autoconfigure/JpaPlatformAutoConfigurationTest.java' +git commit -m "feat: complete jpa spring boot starter and actuator" +``` + +### Task 53: CI Matrix·문서·ADR·Release Gate 완성 + +**Files:** +- Create: `.github/workflows/jpa-pr.yml` +- Create: `.github/workflows/jpa-nightly.yml` +- Create: `.github/workflows/jpa-release.yml` +- Create: `docs/jpa/support-matrix.md` +- Create: `docs/jpa/entity-mapping-guide.md` +- Create: `docs/jpa/transaction-guide.md` +- Create: `docs/jpa/query-fetch-guide.md` +- Create: `docs/jpa/migration-guide.md` +- Create: `docs/jpa/postgresql-extensions.md` +- Create: `docs/jpa/observability.md` +- Create: `docs/jpa/security.md` +- Create: `docs/jpa/runbooks.md` +- Create: `docs/adr/ADR-JPA-001-domain-owns-persistence-model.md` +- Create: `docs/adr/ADR-JPA-002-full-transaction-retry.md` +- Create: `docs/adr/ADR-JPA-003-completion-unknown.md` +- Create: `docs/adr/ADR-JPA-004-flyway-schema-source-of-truth.md` +- Create: `docs/adr/ADR-JPA-005-postgresql-real-contract.md` +- Modify: `build.gradle.kts` +- Test: `modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/release/JpaReleaseManifestTest.java` + +**Interfaces:** +- Consumes: All Stable modules, test suites, design decisions and support matrix. +- Produces: PR/nightly/release aggregation, operator documentation and a machine-readable release manifest. + +**Implementation requirements:** +- PR runs unit, architecture, PG16·18 contract and migration smoke. +- Nightly runs PG16·17·18, failure, plan, pool and security suites. +- Release runs all Stable contracts, upgrade snapshots, performance and artifact compatibility checks. +- Document Stable/Advanced/Experimental/Unsupported features exactly as the design. +- Release fails if H2 is the only database test, OSIV is on, ddl-auto mutates schema, completion unknown retry exists or runtime DDL succeeds. + +- [ ] **Step 1: Write the failing test** + +```kotlin +package io.backend.skeleton.jpa.testkit.release; + +class JpaReleaseManifestTest { + @Test + void manifestContainsAllStableVersionsAndMandatoryGates() { + var manifest = JpaReleaseManifest.load("docs/jpa/support-matrix.md"); + assertThat(manifest.postgreSqlVersions()).containsExactly(16, 17, 18); + assertThat(manifest.gates()).contains( + "completion-unknown-no-retry", + "osiv-disabled", + "flyway-validate", + "runtime-role-no-ddl", + "hibernate-7.4-fetch-pagination"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.release.JpaReleaseManifestTest' +``` + +Expected: FAIL because the production type or behavior does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```kotlin +plugins { + base +} + +tasks.register("jpaReleaseGate") { + dependsOn( + ":modules:jpa:jpa-testkit-postgresql:contractTest", + ":modules:jpa:jpa-testkit-postgresql:failureTest", + ":modules:jpa:jpa-testkit-postgresql:performanceTest", + ":modules:jpa:jpa-testkit-migration:migrationTest", + ":modules:jpa:jpa-testkit-queryplan:test" + ) +} +``` + +Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements. + +- [ ] **Step 4: Run the focused test and the module test suite** + +Run: + +```bash +./gradlew :modules:jpa:jpa-testkit-postgresql:test --tests 'io.backend.skeleton.jpa.testkit.release.JpaReleaseManifestTest' +./gradlew :modules:jpa:jpa-testkit-postgresql:test +``` + +Expected: PASS with all assertions green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add '.github/workflows/jpa-pr.yml' '.github/workflows/jpa-nightly.yml' '.github/workflows/jpa-release.yml' 'docs/jpa/support-matrix.md' 'docs/jpa/entity-mapping-guide.md' 'docs/jpa/transaction-guide.md' 'docs/jpa/query-fetch-guide.md' 'docs/jpa/migration-guide.md' 'docs/jpa/postgresql-extensions.md' 'docs/jpa/observability.md' 'docs/jpa/security.md' 'docs/jpa/runbooks.md' 'docs/adr/ADR-JPA-001-domain-owns-persistence-model.md' 'docs/adr/ADR-JPA-002-full-transaction-retry.md' 'docs/adr/ADR-JPA-003-completion-unknown.md' 'docs/adr/ADR-JPA-004-flyway-schema-source-of-truth.md' 'docs/adr/ADR-JPA-005-postgresql-real-contract.md' 'build.gradle.kts' 'modules/jpa/jpa-testkit-postgresql/src/test/java/io/backend/skeleton/jpa/testkit/release/JpaReleaseManifestTest.java' +git commit -m "docs: add jpa release matrix and runbooks" +``` +## 4. 최종 실행 순서와 Review Gate + +```text +Task 1~12 +→ 모듈·Core·오류·Transaction·Starter Guard + +Task 13~28 +→ Mapping·Persistence Context·Repository·Query·Fetch·Pagination + +Task 29~32 +→ Optimistic/Pessimistic·Constraint + +Task 33~40 +→ Batch·Bulk·Hibernate·PostgreSQL Native + +Task 41~45 +→ Flyway·Migration·Plan·Security + +Task 46~48 +→ L2 Cache·Envers·Observability + +Task 49~53 +→ PostgreSQL Matrix·Failure·Pool·Starter·Release +``` + +각 Task 뒤에는 두 단계 review를 수행한다. + +1. **Specification review:** 설계서의 계약과 exact type/signature가 일치하는가. +2. **Quality review:** 테스트가 failure mode를 실제로 재현하고 위험한 우회 경로를 남기지 않는가. + +Stable 계획이 끝나기 전 Experimental module을 구현하지 않는다. + +## 5. 계획 완료 기준 + +```text +53개 Task가 순서대로 존재한다. +각 Task에 정확한 파일 경로와 public interface가 있다. +각 Task가 failing test와 예상 실패를 포함한다. +각 Task가 최소 구현 코드와 pass command를 포함한다. +각 Task가 독립 commit으로 종료한다. +Generic Repository 재구현 Task가 없다. +Commit Unknown 자동 Retry가 없다. +PG16·17·18 Release Matrix가 있다. +Flyway, Security, Fetch, Batch, Pool, Failure Gate가 구현 순서에 포함된다. +``` diff --git a/jpa-superpowers-package/docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md b/jpa-superpowers-package/docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md new file mode 100644 index 00000000..a907c6b0 --- /dev/null +++ b/jpa-superpowers-package/docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md @@ -0,0 +1,3276 @@ +# JPA 관계형 영속성 플랫폼 설계서 + +- 문서 상태: 구현 기준 설계 +- 기준일: 2026-08-11 +- 대상 저장소: `backend-skeleton` +- 설계 경로: `docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md` +- 요구사항 원본: `붙여넣은 마크다운(1)(20260811-071252).md` + +--- + +## 1. 문서 목적 + +이 문서는 Java/Spring Backend Skeleton에서 사용할 JPA 관계형 영속성 플랫폼의 공개 계약, 모듈 경계, 트랜잭션 의미론, Hibernate·PostgreSQL 확장, Flyway 스키마 관리, 오류·Retry·관측성·보안·검증 기준을 구현 가능한 수준으로 확정한다. + +이 플랫폼은 `JpaRepository`를 다시 감싸는 CRUD 라이브러리가 아니다. 도메인 모듈이 Entity, Embeddable, Repository, 업무 Query, Index Requirement, Lock·Soft Delete·Audit 정책을 소유하고, 플랫폼은 다음 기술적 기반을 제공한다. + +```text +도메인 소유 +├─ Entity / Embeddable +├─ Repository Interface +├─ 도메인 Query +├─ 도메인 Constraint·Index 요구 +└─ 도메인 Lock·Soft-delete·Audit 정책 + +플랫폼 소유 +├─ Persistence Context·Transaction 정책 +├─ SQLSTATE 기반 오류 모델 +├─ 전체 Use Case Retry +├─ Fetch·Query·Pagination 검증 도구 +├─ Hibernate Batch·Statistics 확장 +├─ PostgreSQL Native Capability +├─ Flyway Migration·Schema Gate +├─ 관측성·보안 규칙 +└─ PostgreSQL 실제 계약 Testkit +``` + +구현자가 이 문서를 읽은 뒤 다시 결정하지 않아야 하는 핵심 질문은 다음과 같다. + +```text +어디에 Transaction을 시작하는가? +어떤 실패에서 전체 업무를 다시 실행할 수 있는가? +Commit 결과를 모르면 무엇을 하는가? +어떤 Fetch Plan을 선택하고 어떻게 N+1을 검증하는가? +어떤 Query는 JPQL이고 어떤 Query는 Native SQL인가? +Batch가 실제 JDBC Batch인지 어떻게 증명하는가? +Entity Mapping과 Schema 중 무엇이 Source of Truth인가? +PostgreSQL 고유 기능을 어디까지 공개하는가? +어떤 DB 계정이 어떤 권한을 갖는가? +어떤 PostgreSQL 버전에서 Stable을 선언하는가? +``` + +--- + +## 2. 목표와 성공 기준 + +### 2.1 목표 + +1. 도메인 Repository를 보존하면서 JPA·Hibernate·PostgreSQL 사용 규칙을 일관되게 제공한다. +2. Application Use Case 단위 Transaction과 전체 Transaction Retry를 구현한다. +3. Optimistic Conflict, Deadlock, Serialization Failure, Lock Timeout, Constraint Violation, Commit 결과 불명을 안정 오류로 변환한다. +4. OSIV, 전역 EAGER, 전역 Cascade, 전역 Soft Delete, 운영 `ddl-auto=update` 같은 위험한 기본값을 구조적으로 차단한다. +5. EntityGraph, Fetch Join, Projection, Batch Fetch, Keyset Pagination을 Use Case별 Fetch·Query 전략으로 제공한다. +6. JDBC Batch, Bulk DML, StatelessSession, PostgreSQL Native Write를 서로 다른 Capability로 제공한다. +7. Flyway를 운영 Schema 변경의 Source of Truth로 고정하고 빈 DB·이전 Release Snapshot·최장 지원 Snapshot 업그레이드를 검증한다. +8. H2가 아닌 PostgreSQL 16·17·18 실제 의미론으로 Stable을 인증한다. +9. Query Count, Entity/Collection Fetch, Row Load, Query Plan, Pool·Transaction·Retry를 관측한다. +10. 일반 애플리케이션이 Hibernate Session·Native SQL·운영 DDL을 무제한으로 사용하지 못하게 한다. + +### 2.2 성공 기준 + +| 영역 | 완료 기준 | +|---|---| +| Repository | 플랫폼에 `GenericRepository` 재구현이 없고 도메인 Repository가 Spring Data를 직접 확장할 수 있다. | +| Mapping | Field Access, protected no-arg constructor, Entity 직렬화 금지, association 규칙이 정적·통합 테스트로 검증된다. | +| Transaction | Application Service 경계, propagation, isolation, timeout, rollback rule이 계약 테스트로 고정된다. | +| Retry | 새 Persistence Context와 새 DB Transaction에서 전체 Use Case만 재실행된다. | +| Completion Unknown | Commit 단계 연결 손실이 일반 transient 오류와 분리되고 자동 Retry되지 않는다. | +| Fetch | N+1, Multiple Collection Cartesian Product, Collection Fetch Pagination을 정량 검증한다. | +| Pagination | Page·Slice·Keyset·Scroll의 사용 기준과 stable ordering이 코드로 제공된다. | +| Batch | SQL log가 아니라 Hibernate/JDBC 통계로 실제 batch 실행을 증명한다. | +| Migration | `Flyway migrate + Hibernate validate`, checksum·missing migration 실패, N-1/oldest snapshot 업그레이드가 CI에 연결된다. | +| PostgreSQL | JSONB·Array·Range·`ON CONFLICT`·`NOWAIT`·`SKIP LOCKED`가 PG16·17·18에서 검증된다. | +| Security | Runtime·Migration·Admin 역할이 분리되고 Runtime 역할의 DDL이 실패한다. | +| Observability | queryName 기반 저카디널리티 지표를 제공하고 SQL parameter·PII를 기록하지 않는다. | +| Release | Stable·Advanced·Experimental 경계가 문서, 의존성, CI lane에서 일치한다. | + +--- + +## 3. 입력 자료와 명시적 구현 가정 + +### 3.1 요구사항 원본이 확정한 사항 + +- Java 21을 Stable baseline으로 사용한다. +- Spring Boot BOM이 관리하는 Spring Data JPA·Hibernate·Flyway·Hikari 조합을 사용한다. +- Spring Data JPA 4.1, Jakarta Persistence 3.2, Hibernate ORM 7.4를 Stable 기준으로 삼는다. +- PostgreSQL 16·17·18을 Stable DB Matrix로 삼는다. +- H2는 Local Convenience이며 PostgreSQL 호환성 증거가 아니다. +- Jakarta Persistence 4.0, Hibernate ORM 8, PostgreSQL 19는 별도 compatibility lane이다. +- J1 Standard, J2 Advanced, J3 Provider/DB Extension, J4 Admin/Operations 계층을 사용한다. +- 도메인이 Entity와 Repository를 소유하고 플랫폼은 Generic CRUD Repository를 만들지 않는다. +- Persistence Context는 transaction-scoped이며 OSIV를 명시적으로 비활성화한다. +- Transaction 경계는 Application Service에 둔다. +- Optimistic Conflict·Deadlock·Serialization Failure Retry는 전체 Transaction 재실행이다. +- Commit 결과 불명은 `TransactionCompletionUnknown`으로 분류하고 자동 Retry하지 않는다. +- PostgreSQL write-heavy Entity의 기본 ID 전략은 Sequence이며 IDENTITY는 JDBC Batch 제약 때문에 제한한다. +- Fetch 전략은 Use Case별 Fetch Plan으로 관리한다. +- Hibernate 7.4의 Collection Fetch Join + Pagination은 과거 금지 규칙을 복사하지 않고 실제 SQL·row amplification을 검증한다. +- Flyway가 실제 Schema 변경의 Source of Truth이며 운영 `ddl-auto=update`를 금지한다. +- Application·Migration·Admin DB credential을 분리한다. +- Multi-tenancy와 Read Replica는 초기 Experimental이다. + +### 3.2 실제 저장소가 제공되지 않아 고정한 가정 + +| 항목 | 설계 가정 | +|---|---| +| 저장소 | Gradle Kotlin DSL 멀티모듈 `backend-skeleton` | +| 모듈 루트 | `modules/jpa` | +| Root package | `io.backend.skeleton.jpa` | +| Spring Boot | 4.1 계열 BOM. 정확한 patch는 host 저장소 version catalog가 소유한다. | +| Runtime DB | PostgreSQL 16 이상 | +| 기본 Provider | Hibernate ORM 7.4 | +| Migration | Flyway | +| Connection Pool | HikariCP | +| 테스트 | JUnit 5, AssertJ, ArchUnit, Testcontainers, Toxiproxy | +| 관측성 | Micrometer, Spring Observation, OpenTelemetry exporter adapter | +| CI | PR: PG16·18, Release: PG16·17·18 | + +연구 자료가 범용 numeric timeout, pool size, batch size를 확정하지 않았으므로 플랫폼은 이를 보편 상수로 하드코딩하지 않는다. Production profile은 명시적 값을 요구하고, Testkit만 결정적인 fixture 값을 제공한다. + +### 3.3 우선순위 + +```text +사용자 지시 +→ 이 설계서의 명시적 계약 +→ 심층 리서치 원본 +→ host 저장소의 기존 convention +→ Spring Boot BOM 기본값 +``` + +기존 저장소 구조가 다르면 경로와 convention plugin 이름은 매핑할 수 있지만, 공개 계약과 불변 조건은 유지한다. + +--- + +## 4. 범위 + +### 4.1 Stable 범위 + +```text +Spring Data domain repository +Jakarta Persistence 3.2 +Hibernate ORM 7.4 +PostgreSQL 16·17·18 +REQUIRED transaction +READ COMMITTED 기본 isolation +read-only·timeout +Optimistic Lock +표준 Pessimistic Lock +Derived Query·JPQL·Projection +EntityGraph·Fetch Join +Page·Slice·Keyset +JDBC Batch +Flyway migrate·validate +SQLSTATE 기반 오류 +bounded full-transaction retry +OSIV off +L1 Persistence Context +Spring Data auditing opt-in +PG Testcontainers contract +``` + +### 4.2 Advanced opt-in 범위 + +```text +MANDATORY·REQUIRES_NEW +Specification·Querydsl +Query Hint·Scroll·Stream +NOWAIT·SKIP LOCKED +Batch Fetch·Subselect Fetch +Bulk DML +StatelessSession +PostgreSQL JSONB·Array·Range·INET +ON CONFLICT·RETURNING +COPY 기반 대량 import +Envers +Hibernate L2 Cache +Concurrent Index migration +Query Plan regression +``` + +### 4.3 Experimental 범위 + +```text +Shared schema tenant column +PostgreSQL RLS +Schema-per-tenant +Database-per-tenant +Read Replica routing +Jakarta Persistence 4.0 +Hibernate ORM 8 +PostgreSQL 19 +``` + +Experimental 기능은 별도 모듈과 CI lane에서만 활성화하며 Stable Core의 공개 API를 변경하지 않는다. + +### 4.4 명시적 비지원 + +```text +GenericRepository CRUD 재구현 +Entity를 Web/API DTO로 직접 반환 +Extended Persistence Context 일반 사용 +OSIV +전역 EAGER +전역 Cascade.ALL +전역 implicit Soft Delete +운영 ddl-auto update/create/create-drop +Repository method 단위 부분 Retry +Commit 결과 불명 자동 Retry +Remote distributed transaction 기본화 +임의 XA 기본 지원 +무제한 findAll +자유로운 raw SQL +H2 결과로 PostgreSQL Stable 선언 +annotation 하나만으로 Read Replica 자동 routing +Hibernate Query Cache 기본 활성화 +``` + +### 4.5 Reactive 경계 + +JPA와 JDBC는 Blocking 기술이다. 이 플랫폼은 Reactor 타입을 공개 API에 넣지 않는다. WebFlux 애플리케이션이 JPA를 사용할 경우 애플리케이션 또는 별도 execution adapter가 bounded blocking executor로 격리해야 하며, Reactor event-loop에서 Repository를 호출하는 것은 금지한다. Reactive relational persistence가 필요하면 별도 R2DBC 모듈을 설계한다. + +--- + +## 5. 핵심 설계 원칙 + +1. **도메인 소유권 유지:** Entity·Embeddable·Repository·업무 Query·Index Requirement는 도메인이 소유한다. +2. **추상화 중복 금지:** Spring Data의 CRUD 추상화를 다시 감싸지 않는다. +3. **Use Case Transaction:** Transaction은 Application Use Case 단위다. +4. **전체 Transaction Retry:** Retry는 새 Persistence Context와 새 Transaction에서 전체 작업을 다시 실행한다. +5. **불명확성 보존:** Commit 결과를 모르면 성공 또는 실패로 추정하지 않는다. +6. **Fetch Plan 명시:** Mapping annotation 하나로 모든 Use Case의 Fetch를 결정하지 않는다. +7. **Schema Source of Truth 분리:** Entity Mapping은 객체-관계 매핑 계약이고 실제 Schema 변경은 Flyway가 소유한다. +8. **PostgreSQL 실제 검증:** H2나 mock으로 Lock·Constraint·SQLSTATE·Plan 의미론을 증명하지 않는다. +9. **Provider 차이 노출:** Hibernate·PostgreSQL 고유 기능은 J3 Extension으로 명시한다. +10. **위험 기능 opt-in:** REQUIRES_NEW, Native SQL, Bulk DML, StatelessSession, L2 Cache, Envers는 선택 모듈이다. +11. **정량 성능 검증:** Query 수뿐 아니라 rows, hydrated entity, collection fetch, batch, pool wait를 측정한다. +12. **권한 최소화:** Runtime 계정은 DML만, Migration·Admin 계정은 별도다. + +--- + +## 6. 전체 아키텍처 + +```text +Domain / Application +├─ Entity +├─ Embeddable +├─ Repository Interface +├─ Custom Repository Contract +├─ Projection / Read Model +└─ Application Service @Transactional + │ + ▼ +┌──────────────────────────────────────────────────┐ +│ JPA Persistence Platform │ +│ │ +│ J1 Standard │ +│ ├─ Spring Data integration │ +│ ├─ Transaction defaults │ +│ ├─ Stable error model │ +│ └─ Auditing opt-in │ +│ │ +│ J2 Advanced │ +│ ├─ Fetch / Query support │ +│ ├─ Keyset / Scroll │ +│ ├─ Full-TX retry │ +│ ├─ Batch / Bulk │ +│ └─ Pessimistic lock │ +│ │ +│ J3 Provider / DB Extension │ +│ ├─ Hibernate Session / Statistics │ +│ ├─ StatelessSession │ +│ ├─ PostgreSQL types │ +│ ├─ ON CONFLICT / RETURNING │ +│ └─ NOWAIT / SKIP LOCKED / COPY │ +│ │ +│ J4 Admin / Operations │ +│ ├─ Flyway │ +│ ├─ Index / Backfill │ +│ ├─ Plan regression │ +│ └─ Role / Schema validation │ +└───────────────────────┬──────────────────────────┘ + │ + ▼ + PostgreSQL 16~18 +``` + +### 6.1 일반 Write 흐름 + +```text +Controller +→ Application Service +→ @Transactional 시작 +→ Domain Repository +→ Entity persist/update +→ flush +→ DB constraint/lock 검증 +→ commit +→ 결과 반환 +``` + +외부 HTTP, Object Storage, Messaging 호출은 DB Transaction 밖으로 이동한다. DB 변경과 메시지 발행은 기존 Messaging Platform의 Transactional Outbox를 사용한다. + +### 6.2 Retry 흐름 + +```text +Application Use Case +→ Attempt 1: 새 EntityManager + 새 Transaction +→ OptimisticConflict / Deadlock / SerializationFailure +→ Retry Policy 분류 +→ bounded backoff + jitter +→ Attempt 2: 새 EntityManager + 새 Transaction +→ commit +``` + +부분 SQL만 다시 실행하거나 동일 Persistence Context를 재사용하지 않는다. + +### 6.3 Completion Unknown 흐름 + +```text +Application +→ COMMIT 전송 +→ PostgreSQL commit 가능 +→ 응답 전에 connection loss +→ EvidenceAwareJpaTransactionManager +→ TransactionCompletionUnknown +→ 자동 Retry 금지 +→ transactionKey / unique key / outbox / 상태 조회 +→ domain-specific reconciliation +``` + +### 6.4 Read 흐름 + +```text +Application Query +→ QueryName +→ Projection / EntityGraph / Fetch Join / Native Query +→ QueryObservation +→ Statement + Hibernate statistics +→ DTO / Projection 반환 +``` + +Entity를 Controller에 반환하지 않는다. + +--- + +## 7. 모듈 구조 + +```text +backend-skeleton/ +├── modules/jpa/ +│ ├── jpa-core-api/ +│ ├── jpa-transaction/ +│ ├── jpa-spring-data/ +│ ├── jpa-querydsl/ +│ ├── jpa-hibernate/ +│ ├── jpa-postgresql/ +│ ├── jpa-postgresql-copy/ +│ ├── jpa-migration-flyway/ +│ ├── jpa-auditing/ +│ ├── jpa-envers/ +│ ├── jpa-cache-hibernate/ +│ ├── jpa-observability/ +│ ├── jpa-security/ +│ ├── jpa-spring-boot-starter/ +│ ├── jpa-testkit/ +│ ├── jpa-testkit-postgresql/ +│ ├── jpa-testkit-migration/ +│ └── jpa-testkit-queryplan/ +├── modules/jpa-experimental/ +│ ├── jpa-multitenancy-column/ +│ ├── jpa-multitenancy-rls/ +│ ├── jpa-multitenancy-schema/ +│ ├── jpa-multitenancy-database/ +│ ├── jpa-read-replica/ +│ └── jpa-next-compatibility/ +├── infra/jpa/ +│ ├── postgres/ +│ ├── toxiproxy/ +│ └── roles/ +└── docs/jpa/ + ├── entity-mapping-guide.md + ├── transaction-guide.md + ├── query-fetch-guide.md + ├── migration-guide.md + ├── postgresql-extensions.md + ├── observability.md + ├── security.md + ├── support-matrix.md + └── runbooks.md +``` + +### 7.1 모듈 책임 + +| 모듈 | 책임 | +|---|---| +| `jpa-core-api` | Spring/JPA 비종속 안정 오류·Transaction Profile·Query Name·Capability 계약 | +| `jpa-transaction` | Spring Transaction Adapter, full-TX retry, completion evidence | +| `jpa-spring-data` | Custom Fragment 기반 지원, Safe Sort, Projection·EntityGraph helper | +| `jpa-querydsl` | 선택 Querydsl integration | +| `jpa-hibernate` | Statistics, Fetch·Batch·Bulk·StatelessSession extension | +| `jpa-postgresql` | SQLSTATE, JSONB·Array·Range, native write, lock extension | +| `jpa-postgresql-copy` | J4 대량 import/backfill COPY | +| `jpa-migration-flyway` | Migration policy, validate, snapshot upgrade gate | +| `jpa-auditing` | Spring Data auditing opt-in | +| `jpa-envers` | Entity history opt-in | +| `jpa-cache-hibernate` | Hibernate L2 Cache opt-in; Query Cache 기본 비활성 | +| `jpa-observability` | queryName·transaction·retry·Hibernate statistics 관측 | +| `jpa-security` | ArchUnit rule, DB role/search_path validation, log redaction | +| `jpa-spring-boot-starter` | AutoConfiguration·Properties·Actuator·startup guard | +| `jpa-testkit*` | PostgreSQL·Migration·Query Plan·Concurrency 계약 테스트 | + +### 7.2 의존 방향 + +```text +jpa-core-api +↑ +├─ jpa-transaction +├─ jpa-spring-data +├─ jpa-hibernate +├─ jpa-postgresql +├─ jpa-migration-flyway +├─ jpa-auditing +├─ jpa-observability +└─ jpa-security + +jpa-spring-boot-starter +→ 위 Stable 모듈 조합 + +jpa-testkit* +→ 테스트 대상 모듈 +``` + +`jpa-core-api`는 `jakarta.persistence`, Spring, Hibernate, PostgreSQL JDBC, Flyway에 의존하지 않는다. + +### 7.3 ArchUnit 경계 + +```text +jpa-core-api → provider/framework dependency 금지 +platform → domain Entity 정의 금지 +domain → org.hibernate 직접 의존 금지 +web/controller → @Entity 반환 금지 +@Entity → web DTO annotation 금지 +application → J4 admin package 접근 금지 +``` + +--- + +## 8. 공개 계층 J1~J4 + +### 8.1 J1 Standard Persistence + +일반 애플리케이션이 기본으로 사용한다. + +```text +Spring Data Repository +Derived Query +JPQL +DTO / Interface Projection +Application Service @Transactional +@Version Optimistic Lock +Spring Data Auditing opt-in +Page / Slice +``` + +도메인 Repository 예시: + +```java +public interface OrderRepository + extends JpaRepository, OrderRepositoryCustom { + + Optional findByOrderNumber(OrderNumber orderNumber); +} + +public interface OrderRepositoryCustom { + KeysetSlice findRecent( + OrderSearchCondition condition, + KeysetPageRequest page); +} +``` + +### 8.2 J2 Advanced Persistence + +```text +Specification +Querydsl +EntityGraph +Query Hint +Pessimistic Lock +Keyset / Scroll / Stream +JDBC Batch +Bulk DML +Full Transaction Retry +``` + +J2 사용은 명시적 모듈 의존성과 Query Name 등록을 요구한다. + +### 8.3 J3 Provider / Database Extension + +```text +Hibernate Session +Hibernate Fetch Profile +StatelessSession +PostgreSQL JSONB·Array·Range·INET +ON CONFLICT·RETURNING +NOWAIT·SKIP LOCKED +Native SQL +``` + +J3 API는 `io.backend.skeleton.jpa.postgresql` 또는 `io.backend.skeleton.jpa.hibernate` package에 격리하고 application service가 provider type을 직접 받지 않게 한다. + +### 8.4 J4 Admin / Operations + +```text +Flyway migrate·validate·repair 승인 +Concurrent Index +Backfill +COPY +Partition +Maintenance SQL +Schema Drift +Plan Regression +Role Verification +``` + +J4는 일반 Runtime credential로 실행하지 않는다. `repair`, purge, destructive migration은 operation ID, operator, reason, dry-run 또는 승인 절차를 요구한다. + +--- + +## 9. Core 공개 계약 + +### 9.1 Operation Name + +```java +public record PersistenceOperationName(String value) { + public PersistenceOperationName { + if (value == null || !value.matches("[a-z][a-z0-9.-]{2,95}")) { + throw new IllegalArgumentException("invalid persistence operation name"); + } + } +} +``` + +Operation Name은 metric·trace·retry policy의 bounded key이다. 동적 SQL이나 Entity ID를 넣지 않는다. + +### 9.2 Transaction Profile + +```java +public record TransactionProfile( + String name, + PropagationMode propagation, + IsolationLevel isolation, + Duration timeout, + boolean readOnly, + RetryProfile retryProfile) { +} + +public enum PropagationMode { + REQUIRED, + MANDATORY, + REQUIRES_NEW +} + +public enum IsolationLevel { + DEFAULT, + READ_COMMITTED, + REPEATABLE_READ, + SERIALIZABLE +} +``` + +Stable 기본은 `REQUIRED + READ_COMMITTED`. `REQUIRES_NEW`는 별도 opt-in profile과 pool pressure test를 요구한다. + +### 9.3 Transaction Executor + +```java +public interface JpaTransactionExecutor { + T execute( + PersistenceOperationName operation, + TransactionProfile profile, + Supplier work); +} +``` + +일반 Use Case는 `@Transactional`을 사용할 수 있다. Programmatic retry·동적 profile이 필요한 Use Case는 executor를 사용한다. + +### 9.4 Retry Policy + +```java +public interface JpaRetryPolicy { + RetryDecision classify( + JpaPersistenceException failure, + TransactionAttempt attempt); +} + +public record RetryDecision( + RetryDisposition disposition, + Duration delay, + String reason) { +} + +public enum RetryDisposition { + RETRY_FULL_TRANSACTION, + RECONCILE, + FAIL +} +``` + +### 9.5 Query Observation + +```java +public interface QueryObservation { + QueryScope start(QueryName queryName); +} + +public interface QueryScope extends AutoCloseable { + void rows(long count); + void failure(Throwable failure); + @Override void close(); +} +``` + +--- + +## 10. Entity 소유권과 Mapping 규칙 + +### 10.1 소유권 + +플랫폼은 업무 Entity를 정의하지 않는다. 도메인 모듈이 다음을 소유한다. + +```text +@Table 이름 +@Column 의미 +PK·FK·Unique·Check 요구 +Association +Cascade +Soft Delete +Audit +Index Requirement +Lock 정책 +``` + +플랫폼은 규칙, annotation helper, test fixture, static check만 제공한다. + +### 10.2 기본 규칙 + +| 항목 | 기본 계약 | +|---|---| +| Access | Field Access | +| Constructor | `protected` no-arg | +| Entity class | non-final | +| Persistent field | proxy 호환성을 해치지 않게 설계 | +| API 반환 | Entity 금지, DTO·Projection 사용 | +| `toString` | LAZY association 제외 | +| equals/hashCode | mutable association·mutable business field 제외 | +| Callback | 외부 HTTP·Messaging·File I/O 금지 | +| BaseEntity | 전역 강제 금지 | +| Soft Delete | 전역 강제 금지 | +| Audit | opt-in | + +### 10.3 equals/hashCode + +ID가 DB 생성이면 transient 상태에서 ID가 없음을 고려한다. mutable generated ID를 hash-based collection에 넣은 뒤 hashCode가 바뀌는 설계를 피한다. 권장 패턴은 domain-assigned immutable ID 또는 class + stable immutable key를 사용하되 각 Aggregate가 계약을 명시하는 것이다. + +### 10.4 Entity 외부 노출 금지 + +다음은 금지한다. + +```text +Controller method 반환형이 @Entity +Entity에 Jackson API contract annotation 사용 +Lazy collection을 JSON serializer가 탐색 +Entity를 Message payload로 직접 사용 +Entity를 Redis value로 직접 Java serialize +``` + +--- + +## 11. ID 생성 전략 + +### 11.1 기본 선택 + +| 전략 | 등급 | 계약 | +|---|---|---| +| PostgreSQL Sequence | Stable 기본 | write-heavy Entity, JDBC Batch와 호환 | +| JPA UUID | Stable | 분산 ID, insert 전 identity 확보 | +| Application-assigned UUID/UUIDv7 | Stable | PG16~18 공통 방식 | +| PostgreSQL 18 `uuidv7()` | J3 PG18 전용 | Stable Matrix 공통 기본으로 사용하지 않음 | +| IDENTITY | 제한 | insert batching 제약; 소규모 write만 | +| Composite ID | Domain-specific | 실제 composite identity일 때만 | +| Natural ID | 별도 unique index | PK와 혼동하지 않음 | + +### 11.2 Sequence 규칙 + +```java +@SequenceGenerator( + name = "order_seq", + sequenceName = "order_seq", + allocationSize = 50 +) +@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_seq") +``` + +`allocationSize=50`은 universal constant가 아니라 reference profile이다. 실제 workload benchmark와 sequence increment가 일치해야 하며 플랫폼은 mismatch를 테스트한다. + +### 11.3 UUIDv7 + +PG16·17·18 공통 지원을 위해 application-generated UUIDv7을 기본 extension으로 제공할 수 있다. DB-generated PG18 UUIDv7은 별도 Capability로 노출한다. + +--- + +## 12. Value Mapping + +| 타입 | 기본 계약 | +|---|---| +| `Instant` | 서버 간 절대 시점 | +| `OffsetDateTime` | offset 자체가 업무 의미일 때 | +| `LocalDate` | 날짜 | +| `LocalDateTime` | timezone 없는 업무 시간에만 | +| `Duration` | converter/provider mapping contract test | +| `UUID` | Stable | +| Enum | STRING 또는 명시적 converter; ordinal 금지 | +| Money | Embeddable value object | +| Record Embeddable | JPA 3.2 Stable, provider round-trip test 필수 | +| JSONB·Array·Range·INET | `jpa-postgresql` | +| LOB | 일반 목록 fetch에서 제한 | +| 암호화 값 | key rotation·queryability 포함 별도 capability | + +### 12.1 Converter 규칙 + +- Converter는 null, unknown version, malformed value를 명확히 처리한다. +- Java class name을 wire/schema 값으로 저장하지 않는다. +- Enum rename은 DB migration 없이 수행하지 않는다. +- `AttributeConverter` 내부에서 외부 I/O를 수행하지 않는다. + +--- + +## 13. Association·Cascade·Collection + +### 13.1 ToOne + +- 기본적으로 명시적 LAZY를 검토한다. +- 실제 lazy proxy 동작을 Hibernate contract test로 보증한다. +- FK nullable과 `optional`을 일치시킨다. +- 목록 조회에서 필요한 ToOne은 EntityGraph·Fetch Join·Projection으로 가져온다. + +### 13.2 ToMany + +- LAZY가 기본이다. +- `List`, `Set`, `Map` 선택은 중복·순서 의미를 반영한다. +- `List` 두 개를 동시에 join fetch하는 설계를 피한다. +- collection 전체를 항상 필요한 aggregate가 아니면 Projection 또는 별도 Query를 사용한다. + +### 13.3 Cascade + +```text +Cascade.ALL +→ 전역 기본값 금지 + +orphanRemoval +→ Parent가 Child lifecycle을 독점 소유할 때만 + +ManyToMany +→ 단순 연결 외에는 Join Entity 우선 +``` + +### 13.4 양방향 관계 + +Owning side가 DB 변경을 결정한다. `addChild/removeChild` helper가 양쪽 in-memory graph를 항상 동기화해야 한다. + +--- + +## 14. Persistence Context 계약 + +```text +Transient +Managed +Detached +Removed +``` + +### 14.1 기본 계약 + +```text +persist != merge +find != getReference +save != immediate INSERT +flush != commit +Entity mutation != immediate UPDATE +``` + +### 14.2 Scope + +- transaction-scoped Persistence Context만 Stable이다. +- Extended Persistence Context는 지원하지 않는다. +- EntityManager는 thread-safe로 취급하지 않는다. +- OSIV는 false다. +- Lazy association 접근은 Application Transaction 내부에서만 허용한다. + +### 14.3 Flush + +- Query 전에 AUTO flush가 발생할 수 있다. +- 명시적 flush는 SQL 동기화 지점이지 commit 증거가 아니다. +- Batch는 chunk마다 flush·clear한다. +- Bulk DML 전 flush, 후 clear 또는 refresh한다. + +### 14.4 Merge + +`merge()` 반환값이 managed instance다. 전달한 detached instance가 managed로 변한다고 가정하지 않는다. 신규 Entity 판정과 ID strategy를 이해하지 못한 무분별한 `save()` 사용을 코드리뷰 규칙으로 제한한다. + +--- + +## 15. Transaction 경계 + +### 15.1 기본 경계 + +```text +Controller +→ Application Service @Transactional +→ Domain Repository +``` + +Repository가 독립 업무 Transaction을 임의로 시작하지 않는다. + +### 15.2 금지 경계 + +```text +Controller 전체 요청 Transaction +Entity Listener가 새 Transaction 시작 +동일 Bean self-invocation으로 Propagation 기대 +DB Transaction 안에서 장시간 HTTP/Object Storage/Messaging 대기 +``` + +### 15.3 Rollback Rule + +RuntimeException·Error 기본 rollback을 사용한다. Checked exception rollback이 필요하면 안정 application exception hierarchy 또는 `rollbackFor`를 명시한다. + +### 15.4 Timeout + +모든 write Transaction profile은 유한 timeout을 요구한다. read-only query도 long-running admin query가 아니라면 timeout을 지정한다. 숫자는 환경 SLO가 소유한다. + +--- + +## 16. Propagation·Isolation + +### 16.1 Propagation + +| Mode | 등급 | 규칙 | +|---|---|---| +| REQUIRED | Stable 기본 | Use Case Transaction | +| MANDATORY | Advanced | 상위 Transaction 필수 내부 write service | +| SUPPORTS | 제한 | read helper | +| REQUIRES_NEW | Advanced 위험 | 별도 physical connection, pool capacity test 필수 | +| NESTED | J3/JDBC savepoint | portable JPA로 광고하지 않음 | +| NOT_SUPPORTED | Advanced | 긴 외부 I/O 분리 등에 제한 | + +### 16.2 Isolation + +| Isolation | 기본 사용 | +|---|---| +| READ COMMITTED | 일반 업무 기본 | +| REPEATABLE READ | transaction snapshot 일관성 필요 시 | +| SERIALIZABLE | 좁은 핵심 invariant, abort/retry 전제 | +| READ UNCOMMITTED | PostgreSQL profile에서 공개하지 않음 | + +### 16.3 Self-invocation + +`this.method()` 호출은 Spring transaction proxy를 통과하지 않는다. Retry·REQUIRES_NEW method는 별도 Bean의 public method 또는 programmatic executor로 구성한다. + +--- + +## 17. Commit 결과 불명확성 + +### 17.1 상태 + +```java +public enum TransactionCompletionEvidence { + NOT_STARTED, + ACTIVE, + COMMITTING, + COMMITTED, + ROLLED_BACK, + UNKNOWN +} +``` + +### 17.2 감지 + +`EvidenceAwareJpaTransactionManager`가 `doCommit` 진입 전 evidence를 `COMMITTING`으로 기록한다. 다음 조건에서 `TransactionCompletionUnknownException`으로 변환한다. + +```text +SQLSTATE 40003 +OR +commit phase의 connection loss / transport exception +AND +rollback 또는 commit 여부를 driver가 확정하지 못함 +``` + +일반 query 단계 connection failure를 completion unknown으로 과대 분류하지 않는다. + +### 17.3 오류 계약 + +```java +public final class TransactionCompletionUnknownException + extends JpaPersistenceException { + + private final String transactionKey; + private final TransactionCompletionEvidence evidence; +} +``` + +### 17.4 복구 + +```text +자동 Retry 금지 +→ transactionKey로 상태 조회 +→ Unique Constraint / Idempotency Record 확인 +→ 업무 Row 확인 +→ Outbox 확인 +→ 결과 확정 불가 시 Reconciliation Queue +``` + +`TransactionCompletionResolver`는 domain-specific SPI이며 Core가 업무 성공을 추측하지 않는다. + +--- + +## 18. 안정 오류 모델과 SQLSTATE + +```text +JpaPersistenceException +├─ JpaEntityNotFoundException +├─ OptimisticConflictException +├─ PessimisticLockTimeoutException +├─ DeadlockDetectedException +├─ SerializationFailureException +├─ UniqueConstraintViolationException +├─ ForeignKeyViolationException +├─ CheckConstraintViolationException +├─ QueryTimeoutException +├─ TransactionTimeoutException +├─ ConnectionUnavailableException +├─ SchemaMismatchException +├─ DataCorruptionException +└─ TransactionCompletionUnknownException +``` + +### 18.1 공통 Metadata + +```java +public record JpaFailureContext( + PersistenceOperationName operation, + String sqlState, + String constraintName, + int transactionAttempt, + boolean retryable, + boolean completionUnknown, + Duration elapsed, + String traceId) { +} +``` + +SQL parameter, Entity ID, Tenant ID, 전체 SQL 원문, PII는 exception message에 넣지 않는다. + +### 18.2 SQLSTATE 분류 + +| 분류 | 대표 코드 | +|---|---| +| Serialization Failure | `40001` | +| Statement Completion Unknown | `40003` | +| Deadlock | `40P01` | +| Unique Violation | `23505` | +| Foreign Key Violation | `23503` | +| Check Violation | `23514` | +| Not Null Violation | `23502` | +| Lock Not Available | `55P03` | + +문자열 오류 메시지를 parsing하지 않고 SQLSTATE와 structured server error field를 사용한다. + +--- + +## 19. Retry 정책 + +### 19.1 Retry 대상 + +| 오류 | 기본 | +|---|---| +| Optimistic Conflict | 조건부 전체 Transaction Retry | +| Serialization Failure | bounded 전체 Transaction Retry | +| Deadlock | bounded 전체 Transaction Retry | +| Lock Timeout | deadline·업무 정책에 따라 | +| Transaction 시작 전 Connection 실패 | 제한적 Retry | +| Unique Violation | 기본 Retry 금지; idempotent create면 기존 결과 조회 | +| FK·Check Violation | Retry 금지 | +| Query Timeout | 기본 Retry 금지 | +| Schema Mismatch | Retry 금지 | +| Completion Unknown | 자동 Retry 금지, reconcile | + +### 19.2 안전 조건 + +```text +전체 Use Case가 재계산 가능 +AND +외부 irreversible side effect 없음 +AND +새 Persistence Context 생성 +AND +새 Transaction 생성 +AND +deadline 남음 +AND +retry budget 남음 +``` + +### 19.3 Retry Profile + +```java +public record RetryProfile( + String name, + int maxAttempts, + Duration initialBackoff, + Duration maxBackoff, + double multiplier, + JitterMode jitter, + Set retryableFailures) { +} +``` + +### 19.4 Annotation Adapter + +```java +@RetryableJpaTransaction(profile = "order-write") +@Transactional +public OrderId place(PlaceOrder command) { ... } +``` + +Retry interceptor는 Transaction interceptor보다 바깥에서 실행되어 각 attempt가 새 transaction을 생성해야 한다. 같은 클래스 self-invocation은 지원하지 않는다. + +--- + +## 20. Optimistic Lock + +- mutable aggregate에는 `@Version` 사용을 기본 검토한다. +- version은 API update command에 전달하거나 서버가 re-read 후 검증한다. +- Conflict는 flush 또는 commit 시점에 나타날 수 있다. +- Bulk DML은 version을 자동 검증하지 않는다. +- 일부 Repository method만 Retry하지 않는다. + +```java +@Entity +public class Order { + @Version + private long version; +} +``` + +Retry 후에는 최신 Entity를 다시 조회하고 업무 규칙을 다시 계산한다. + +--- + +## 21. Pessimistic Lock·PostgreSQL Lock Extension + +### 21.1 표준 Lock + +```text +PESSIMISTIC_READ +PESSIMISTIC_WRITE +PESSIMISTIC_FORCE_INCREMENT +``` + +Transaction timeout, lock timeout, deadlock을 구분한다. + +### 21.2 NOWAIT + +대기 없이 즉시 실패해야 하는 use case에서 J3 Native Query로 제공한다. 일반 Repository API에 전역 옵션으로 넣지 않는다. + +### 21.3 `FOR UPDATE SKIP LOCKED` + +일반 일관된 조회가 아니라 work queue claim에만 제공한다. + +```java +public interface WorkClaimExecutor { + List claimNextBatch( + WorkQueueName queue, + int size, + Duration lease); +} +``` + +### 21.4 Lock Ordering + +여러 Row를 잠글 때 stable key order를 사용한다. deadlock fixture로 규칙을 검증한다. + +--- + +## 22. Constraint와 경쟁 조건 + +### 22.1 최종 불변식 + +```text +Bean Validation +→ 조기 사용자 오류 + +Database Constraint +→ concurrency에서도 지켜지는 최종 invariant +``` + +### 22.2 지원 + +```text +PRIMARY KEY +FOREIGN KEY +NOT NULL +UNIQUE +CHECK +EXCLUSION +Partial Unique Index +NULLS NOT DISTINCT +``` + +### 22.3 Exists-before-insert + +`exists()`는 UX 검증일 뿐 경쟁을 차단하지 않는다. Unique Constraint 위반을 안정 오류로 변환한다. + +### 22.4 Constraint Catalog + +Constraint name을 bounded registry에 등록해 `user-email-active-unique` 같은 안정 code로 변환한다. raw table·column·value는 외부 오류에 노출하지 않는다. + +--- + +## 23. Repository와 Query 선택 + +### 23.1 Query 등급 + +| 등급 | 방식 | +|---|---| +| Q1 | Derived Query, JPQL, DTO/Interface Projection | +| Q2 | Specification, Criteria, Querydsl, EntityGraph | +| Q3 | Native SQL, Hibernate Query API, PostgreSQL CTE·Window·JSONB | +| Q4 | Backfill, Maintenance, Bulk/Admin SQL | + +### 23.2 선택 규칙 + +- Derived method가 업무 의미보다 SQL 구조를 설명하기 시작하면 Custom Query로 승격한다. +- 고정 query는 JPQL과 DTO Projection을 우선한다. +- optional filter 조합은 Specification 또는 Querydsl을 사용한다. +- PostgreSQL plan·syntax 제어가 필요하면 J3 Native Query를 사용한다. +- 모든 nontrivial query에는 `QueryName`을 등록한다. + +### 23.3 Custom Fragment + +플랫폼은 `BaseRepository`를 강제하지 않는다. 도메인이 `OrderRepositoryCustom`을 정의하고 구현에서 플랫폼 helper를 사용한다. + +### 23.4 Dynamic Sort + +사용자 문자열을 `JpaSort.unsafe()`에 연결하지 않는다. `SafeSortRegistry`가 허용된 field enum을 실제 JPA path로 변환한다. + +--- + +## 24. Projection + +### 24.1 DTO Projection + +목록·read model의 기본 후보다. Entity 전체 hydration과 Lazy association을 줄인다. + +### 24.2 Interface Projection + +간단한 projection에 사용하되 nested association이 추가 query를 유발하는지 검증한다. + +### 24.3 Dynamic Projection + +public API에서 임의 class를 입력받지 않는다. 등록된 projection catalog만 사용한다. + +### 24.4 Entity 직접 반환 + +Application 내부 aggregate mutation use case에만 Entity를 사용하고 Web/API boundary에서는 DTO로 변환한다. + +--- + +## 25. Fetch Plan과 N+1 + +### 25.1 전략 + +```text +Mapping +→ 최소 graph + +Use Case Query +→ EntityGraph / Fetch Join / Projection / Batch Fetch +``` + +### 25.2 선택표 + +| 상황 | 우선 선택 | +|---|---| +| 단일 aggregate 상세 | EntityGraph / Fetch Join | +| 여러 ToOne | Fetch Join / EntityGraph | +| 하나의 bounded ToMany | Fetch Join 검토 | +| 여러 ToMany | DTO / 분할 Query / Batch Fetch | +| 목록 화면 | DTO Projection | +| 대규모 read model | Native Projection | +| 반복 LAZY N+1 | explicit fetch plan 또는 batch fetch | + +### 25.3 정량 지표 + +```text +statementCount +entityLoadCount +entityFetchCount +collectionLoadCount +collectionFetchCount +returnedParents +hydratedEntities +rowsFromDatabase +executionTime +``` + +### 25.4 Fixture + +```text +0 child +1 child +10~100 children +shared ToOne +multiple collections +Zipf skew +``` + +SQL 1개라는 이유만으로 좋은 Query로 판정하지 않는다. + +--- + +## 26. Hibernate 7.4 Collection Fetch Pagination + +과거 Hibernate의 collection fetch join + pagination 전체 로드 문제를 영구 금지 규칙으로 복사하지 않는다. Stable baseline인 Hibernate 7.4 + PostgreSQL 16~18에서 다음을 검증한다. + +```text +generated SQL에 DB limit/subquery가 적용되는가 +반환 parent 수가 정확한가 +hydrated row 수가 허용 범위인가 +count query가 정확한가 +여러 collection Cartesian amplification이 없는가 +``` + +`hibernate.query.fail_on_pagination_over_collection_fetch`는 호환성 lane에서 회귀 감지를 위해 사용하되, 7.4 지원 경로를 무조건 차단하지 않는다. + +--- + +## 27. Pagination·Cursor·Scroll + +### 27.1 사용 기준 + +| 방식 | 용도 | +|---|---| +| Page | 작은 관리자 목록, total count 필요 | +| Slice | count 불필요 일반 목록 | +| Offset | 작은 데이터·얕은 page | +| Keyset/Cursor | 대규모·시간순 목록 | +| Scroll/Stream | batch/read processing | + +### 27.2 Keyset 계약 + +```java +public record KeysetPageRequest( + Optional after, + int size, + SortDirection direction) { +} + +public record KeysetSlice( + List items, + Optional nextCursor, + boolean hasNext) { +} +``` + +정렬이 `created_at DESC, id DESC`이면 Cursor도 두 값을 모두 포함한다. + +### 27.3 Cursor 보안 + +Cursor는 versioned JSON을 Base64URL로 encoding하고 HMAC signature를 선택적으로 제공한다. raw SQL fragment를 포함하지 않는다. + +### 27.4 Stream + +Stream은 transaction과 ResultSet 수명을 가진다. try-with-resources와 fetch size를 강제하고 Web/API에 그대로 반환하지 않는다. + +--- + +## 28. JDBC Batch + +### 28.1 의미 + +```text +saveAll != one SQL +JDBC Batch != one SQL +IDENTITY != batch-friendly +``` + +### 28.2 Profile + +```yaml +backend: + jpa: + batch-profiles: + order-import: + jdbc-batch-size: 50 + order-inserts: true + order-updates: true + flush-size: 50 + clear-size: 50 +``` + +숫자는 profile이 소유한다. Platform은 batch size와 flush/clear invariant를 검증한다. + +### 28.3 Verification + +Hibernate statistics와 datasource proxy를 통해 실제 `executeBatch` 횟수와 statement 수를 확인한다. + +--- + +## 29. Bulk DML + +### 29.1 계약 + +```text +flush +→ JPQL / Native Bulk DML +→ clear +→ 필요 시 재조회 +``` + +### 29.2 제한 + +- Bulk DML은 Entity callback과 optimistic version check를 자동 실행하지 않는다. +- 도메인 invariant를 우회할 수 있으므로 Q4 또는 명시적 J2 API에서만 사용한다. +- 영향 Row 수를 반환하고 예상 범위를 검증한다. + +```java +public interface BulkDmlExecutor { + int execute(BulkOperationName operation, Runnable bulkStatement); +} +``` + +--- + +## 30. StatelessSession·COPY + +### 30.1 StatelessSession + +Persistence Context·dirty checking이 없는 Hibernate extension이다. 일반 Repository를 대체하지 않고 대량 import/backfill에만 사용한다. + +### 30.2 PostgreSQL COPY + +`jpa-postgresql-copy`는 JDBC connection을 명시적으로 unwrap해 COPY를 실행한다. J4 credential·operation name·row/byte cap·transaction policy를 요구한다. + +### 30.3 선택표 + +```text +일반 업무 write → JPA Entity +수천~수만 rows → JPA JDBC Batch +대규모 import/backfill → StatelessSession / COPY +``` + +--- + +## 31. PostgreSQL Extension + +### 31.1 Stable J3 + +```text +JSONB +Array +Range +UUID +ON CONFLICT +RETURNING +NOWAIT +SKIP LOCKED Work Claim +Window Function +``` + +### 31.2 Advanced + +```text +INET +Native Enum +CTE / Recursive CTE +Advisory Lock +Generated Column +Full-text Search +``` + +### 31.3 Admin + +```text +Partial / Expression / INCLUDE Index +Partition +RLS Policy +Extension 설치 +``` + +### 31.4 Native SQL 제한 + +- 등록된 Query Name 필수 +- 값은 parameter binding +- 동적 table/column 문자열 금지 +- row mapping 명시 +- PG16·17·18 Contract Test 필수 + +--- + +## 32. ON CONFLICT·RETURNING + +Upsert 의미를 단순 `save()`로 숨기지 않는다. + +```java +public interface PostgreSqlUpsertExecutor { + R execute( + NativeWriteName operation, + C command, + UpsertConflictTarget target); +} +``` + +Conflict target, update columns, version semantics, returned columns을 호출 계약으로 고정한다. 동일 업무에 JPA Entity update와 Native Upsert를 섞을 때 Persistence Context를 clear하거나 해당 Entity를 다시 조회한다. + +--- + +## 33. Flyway와 Schema Source of Truth + +### 33.1 환경 정책 + +| 환경 | Flyway | Hibernate DDL | +|---|---|---| +| local PostgreSQL | migrate | validate | +| H2 convenience | 선택 create/drop | 호환성 증거 아님 | +| test | migrate | validate | +| dev | migrate | validate | +| staging | deployment migration | validate | +| prod | 별도 migration role/process | validate | + +### 33.2 금지 + +```text +prod ddl-auto update/create/create-drop +runtime credential DDL +적용 완료 Versioned Migration 수정 +startup auto repair +``` + +### 33.3 Validation + +```text +checksum mismatch → fail +missing migration → fail +schema mismatch → fail +unsupported DB version → fail +``` + +### 33.4 Repair + +Flyway repair는 J4 승인 operation이다. 자동 실행하지 않고 operator, reason, before/after report를 남긴다. + +--- + +## 34. 무중단 Migration + +```text +Expand +→ 새 nullable column/table/index + +Migrate +→ chunked backfill / dual read·write + +Contract +→ old column/index 제거, constraint 강화 +``` + +### 34.1 Concurrent Index + +PostgreSQL `CREATE INDEX CONCURRENTLY`는 transaction block 밖에서 실행해야 하므로 non-transactional Flyway migration으로 명시한다. 실패한 invalid index 정리 runbook을 제공한다. + +### 34.2 Snapshot Gate + +```text +empty → latest +N-1 release → latest +oldest supported snapshot → latest +checksum modified → validation failure +missing migration → validation failure +failed non-transactional migration → documented recovery +``` + +--- + +## 35. Constraint·Index·Query Plan + +### 35.1 Index Requirement + +각 도메인 Query는 다음 문서를 소유한다. + +```text +queryName +predicate +sort +expected cardinality +data distribution +required index +representative parameters +expected plan shape +``` + +### 35.2 Query Plan Testkit + +`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)`을 Test/Admin 계정으로 실행한다. 모든 Seq Scan을 실패시키지 않고 기대 node, row estimate ratio, sort spill, execution time budget을 Query별로 검증한다. + +### 35.3 Plan Snapshot + +PostgreSQL minor version과 statistics에 따라 plan이 달라질 수 있으므로 raw JSON 전체 byte snapshot보다 normalized structural expectation을 사용한다. + +--- + +## 36. Auditing·History·Soft Delete + +### 36.1 Auditing + +`createdAt`, `createdBy`, `modifiedAt`, `modifiedBy`를 opt-in Embeddable 또는 annotation set으로 제공한다. 전역 BaseEntity 상속을 강제하지 않는다. + +### 36.2 구분 + +```text +Technical Auditing != Business Audit != Entity History != Security Audit +``` + +### 36.3 Envers + +별도 모듈이며 Entity별 opt-in이다. 대용량 audit table, relation revision, 개인정보 보존 정책을 검토한 뒤 활성화한다. + +### 36.4 Soft Delete + +전역 filter를 제공하지 않는다. 도메인 상태 또는 `deletedAt`을 명시하고 필요하면 Flyway partial unique index를 사용한다. 물리 삭제·개인정보 파기와 복구 가능한 삭제를 구분한다. + +--- + +## 37. Cache + +### 37.1 기본 + +```text +L1 Persistence Context → 항상 +L2 Cache → Entity별 opt-in +Query Cache → OFF +Application Cache → Redis 플랫폼 +``` + +### 37.2 L2 Gate + +- `ENABLE_SELECTIVE` +- Cache Region 명시 +- 외부 DB writer가 있을 때 invalidation 정책 +- Bulk DML 후 eviction +- cluster node 일관성 +- hit/miss/stale metric + +Redis application cache와 Hibernate L2 Cache는 같은 기능으로 취급하지 않는다. + +--- + +## 38. Multi-tenancy·Replica Experimental + +### 38.1 Multi-tenancy + +```text +Shared schema + tenant column +PostgreSQL RLS +Schema per tenant +Database per tenant +``` + +Stable Core는 tenant context를 강제하지 않는다. Experimental module이 query, connection, cache, async propagation, admin cross-tenant access를 별도 검증한다. + +### 38.2 Read Replica + +`readOnly=true`만으로 routing하지 않는다. Read-after-write, replica lag, transaction pinning, lock query primary 강제, consistency token을 설계한 뒤 별도 module에서 제공한다. + +--- + +## 39. Connection Pool과 Hikari + +### 39.1 관측 + +```text +active +idle +pending +max +acquire duration +timeout +connection lifetime +transaction duration +``` + +### 39.2 규칙 + +- pool size를 무작정 크게 하지 않는다. +- `REQUIRES_NEW`는 outer + inner connection을 동시에 요구할 수 있다. +- long transaction과 external I/O를 제거한다. +- pending/acquire latency가 alert의 핵심이다. +- DB max connections와 인스턴스 수를 함께 계산한다. + +### 39.3 Startup Validation + +Production profile은 maximumPoolSize, connectionTimeout, maxLifetime 등의 명시 여부를 검사할 수 있다. Universal numeric default를 플랫폼 상수로 고정하지 않는다. + +--- + +## 40. Observability + +### 40.1 Metric + +```text +jdbc.connections.* +hikaricp.* +jpa.transaction.count +duration +rollback +timeout +retry +completion-unknown +jpa.query.count +duration +rows +lock-wait +jpa.fetch.entity +collection +jpa.batch.execute +jpa.constraint.failure +jpa.migration.duration +``` + +### 40.2 Low-cardinality Tag + +허용: + +```text +persistenceUnit +operationName +bounded entityType +queryName +outcome +failureCategory +isolation +attemptBucket +``` + +금지: + +```text +entityId +userId +tenantId 원문 +SQL parameter +전체 동적 SQL +PII +constraint value +``` + +### 40.3 Query Name + +등록된 `QueryName`을 metric·trace의 primary key로 사용한다. SQL fingerprint는 secure diagnostic에서만 사용하고 metric label로 raw SQL을 사용하지 않는다. + +### 40.4 Logging + +SQL parameter logging은 production 기본 OFF다. exception message에 parameter와 Entity state를 넣지 않는다. + +--- + +## 41. Security + +### 41.1 DB 역할 + +```text +Application Role +├─ SELECT +├─ INSERT +├─ UPDATE +├─ DELETE +└─ required sequence usage + +Migration Role +├─ CREATE +├─ ALTER +├─ DROP +└─ index / constraint / schema + +Read-only Role +└─ bounded SELECT + +Admin Role +└─ approved operations +``` + +### 41.2 search_path + +Application role의 `search_path`를 고정하고 untrusted schema의 object resolution을 차단한다. startup verifier가 current_user, current_schema, search_path, schema CREATE privilege를 검사한다. + +### 41.3 Injection 방어 + +```text +JPQL/Native values → parameter binding +Dynamic sort → allowlist +Dynamic table/column → enum/catalog mapping만 +Entity → API mass binding 금지 +``` + +### 41.4 Secret + +DB password는 secret manager/workload identity에서 주입하고 config·log·metric에 기록하지 않는다. + +--- + +## 42. Spring Boot AutoConfiguration + +### 42.1 Properties + +```yaml +backend: + jpa: + enabled: true + require-postgresql: true + open-in-view: false + schema-management: VALIDATE + transaction-profiles: {} + retry-profiles: {} + observability: + hibernate-statistics: true + sql-parameters: false + security: + verify-runtime-role: true + verify-search-path: true +``` + +### 42.2 Startup Failures + +```text +spring.jpa.open-in-view=true +prod ddl-auto != validate/none +unsupported PostgreSQL version +runtime role has DDL privilege +migration checksum mismatch +required transaction profile timeout missing +Experimental module enabled without feature flag +``` + +### 42.3 Actuator + +```text +jpaPlatform +├─ database version +├─ provider version +├─ schema version +├─ OSIV state +├─ DDL mode +├─ role verification +├─ retry profile count +└─ capability list +``` + +민감 URL·username·schema secrets는 노출하지 않는다. + +--- + +## 43. Test Architecture + +### 43.1 층위 + +```text +Pure Unit +→ domain logic / classifier + +@DataJpaTest +→ quick mapping / repository wiring + +PostgreSQL Testcontainers +→ real semantics + +PG16·17·18 Matrix +→ release compatibility + +Toxiproxy / DB restart +→ failure evidence + +Migration Snapshot +→ real upgrade path +``` + +### 43.2 공통 Fixture + +```text +JpaTestEntity +VersionedEntity +Parent / Child +TwoCollectionsAggregate +SkewedFeedFixture +UniqueConstraintFixture +WorkQueueFixture +BatchEntity +JSONB / Array / Range Entity +``` + +공용 fixture만 testkit에 두고 업무 Entity를 플랫폼 production module에 넣지 않는다. + +### 43.3 계약 목록 + +```text +Mapping +Lifecycle +Transaction +Propagation +Isolation +Optimistic Lock +Pessimistic Lock +Deadlock +Serialization Failure +Constraint Race +Query / Projection +Fetch / N+1 +Pagination +Batch +Bulk +PostgreSQL Extension +Flyway +Security +Pool +Completion Unknown +Observability +``` + +--- + +## 44. Failure Injection + +### 44.1 Deterministic Deadlock + +두 transaction이 서로 반대 순서로 row를 잠그게 해 `40P01`을 재현한다. + +### 44.2 Serialization Failure + +SERIALIZABLE에서 동일 invariant를 변경하는 transaction을 경쟁시켜 `40001`을 재현한다. + +### 44.3 Completion Unknown + +DB proxy가 COMMIT 전, COMMIT 전송 중, server commit 후 response 전에 connection을 끊는 세 지점을 구분한다. 마지막 경우 자동 Retry가 발생하지 않고 `TransactionCompletionUnknownException`이 기록돼야 한다. + +### 44.4 DB Restart + +Transaction 시작 전, query 중, commit 중 PostgreSQL restart를 구분한다. + +--- + +## 45. 성능 인증 + +### 45.1 Query + +```text +p50 / p95 / p99 +statement count +rows +entity hydration +collection fetch +plan node +buffer hit/read +sort spill +``` + +### 45.2 Write + +```text +records/sec +JDBC batch count +statement count +flush count +Persistence Context size +heap allocation +transaction duration +``` + +### 45.3 Pool + +```text +active +pending +acquire p95/p99 +REQUIRES_NEW saturation +connection timeout +``` + +### 45.4 Gate + +성능 숫자는 workload별 문서가 소유한다. Platform release는 bounded memory, actual batching, no unbounded query, pool recovery, no retry storm을 증명한다. + +--- + +## 46. 지원 Matrix와 Release Lane + +| Lane | 실행 | +|---|---| +| PR | PostgreSQL 16·18, mapping/query/transaction/migration smoke | +| Nightly | PG16·17·18, failure injection, query plan, batch, security | +| Release | 전체 Stable Contract, upgrade snapshots, performance, role separation | +| Experimental | JPA4/Hibernate8, PG19, multitenancy, replica | + +### 46.1 H2 + +H2는 빠른 local smoke에만 사용한다. H2-only test가 release gate를 대체하지 않는다. + +### 46.2 Upgrade + +Spring Boot BOM patch 변경 시 Hibernate generated SQL, collection pagination, SQLSTATE mapping, Flyway validate, metrics 이름을 회귀 검증한다. + +--- + +## 47. 완료 정의 + +다음 질문에 모두 구현·테스트 증거로 답할 수 있어야 한다. + +```text +도메인이 Entity와 Repository를 소유하는가? +플랫폼이 GenericRepository를 만들지 않았는가? +OSIV가 모든 운영 profile에서 꺼져 있는가? +Transaction 경계가 Application Service인가? +Retry가 새 Persistence Context에서 전체 Use Case를 실행하는가? +Commit 결과 불명에서 자동 Retry가 금지되는가? +SQLSTATE로 오류를 안정 분류하는가? +Unique 경쟁을 DB Constraint가 최종 보장하는가? +N+1과 Cartesian amplification을 정량 검증하는가? +Hibernate 7.4 collection fetch pagination SQL을 실제 PG에서 검증하는가? +Keyset cursor가 tie-breaker를 포함하는가? +saveAll과 JDBC Batch를 구분하는가? +Bulk DML 후 Persistence Context가 정리되는가? +Flyway가 Schema Source of Truth인가? +운영 Runtime 계정으로 DDL이 실패하는가? +PG16·17·18에서 Stable Contract를 통과하는가? +Metric과 로그에 SQL parameter·PII가 없는가? +Experimental 기능이 Stable dependency에 유입되지 않는가? +``` + +--- + +## 48. ADR 목록 + +```text +ADR-JPA-001 Domain owns entities and repositories +ADR-JPA-002 No generic repository wrapper +ADR-JPA-003 Application service transaction boundary +ADR-JPA-004 Full transaction retry only +ADR-JPA-005 Transaction completion unknown is first-class +ADR-JPA-006 OSIV disabled +ADR-JPA-007 Use-case fetch plans +ADR-JPA-008 Flyway owns schema changes +ADR-JPA-009 PostgreSQL real-service contract tests +ADR-JPA-010 PostgreSQL extensions are J3 +ADR-JPA-011 L2 cache and Envers are opt-in +ADR-JPA-012 Multitenancy and replicas are experimental +``` + +--- + +## 49. 단계별 구현 순서 + +```text +Foundation +→ Error / Transaction Semantics +→ Mapping / Repository Rules +→ Query / Fetch / Pagination +→ Concurrency / Constraint +→ Batch / Bulk +→ PostgreSQL Extension +→ Flyway / Migration +→ Observability / Security +→ Advanced Opt-in +→ PostgreSQL Matrix / Failure / Performance +→ Experimental Expansion +``` + +Stable 계획의 Task가 모두 끝난 뒤 Experimental 계획으로 이동한다. + +--- + +## 50. 요구사항 추적표 + +| 조사 결론 | 설계 위치 | 구현 계획 | +|---|---|---| +| GenericRepository 금지 | 1, 5, 7, 8 | Task 1, 19, 53 | +| J1~J4 계층 | 8 | Task 1, 53 | +| Entity Mapping | 10~13 | Task 13~16 | +| Persistence Context | 14 | Task 11, 16, 19 | +| Application TX | 15~16 | Task 5~9 | +| Completion Unknown | 17 | Task 6, 10, 50 | +| SQLSTATE Error | 18 | Task 3~4, 29~32 | +| Full-TX Retry | 19 | Task 7~9 | +| Optimistic/Pessimistic | 20~21 | Task 29~31 | +| Query·Projection | 23~24 | Task 18~21 | +| Fetch·N+1 | 25~26 | Task 22~25 | +| Pagination | 27 | Task 26~28 | +| Batch·Bulk | 28~30 | Task 33~36 | +| PostgreSQL Extension | 31~32 | Task 30~31, 37~40 | +| Flyway | 33~34 | Task 41~43 | +| Query Plan | 35 | Task 44 | +| Audit·Cache | 36~37 | Task 17, 46~47 | +| Multitenancy·Replica | 38 | Experimental Plan | +| Pool | 39 | Task 12, 51 | +| Observability | 40 | Task 48 | +| Security | 41 | Task 45 | +| Test·Release | 43~46 | Task 49~53 | + +--- + +## 51. 구현 시 금지되는 즉흥 결정 + +```text +새 BaseRepository를 만들어 모든 Repository가 상속하게 한다. +Entity를 Controller 응답에 바로 사용한다. +OSIV를 편의를 위해 켠다. +Deadlock에서 Repository method 하나만 retry한다. +Commit 응답 유실을 connection transient로 보고 자동 retry한다. +모든 ToOne을 EAGER로 바꾼다. +Collection Fetch Join + Pagination을 버전 검증 없이 무조건 금지하거나 허용한다. +saveAll 호출만 보고 batching을 완료로 판정한다. +Flyway migration 대신 ddl-auto update를 켠다. +H2 테스트 통과로 PostgreSQL 지원을 선언한다. +Native SQL 문자열에 사용자 입력 sort/column을 연결한다. +Runtime DB 사용자에게 DDL 권한을 준다. +ReadOnly annotation만 보고 replica로 routing한다. +모든 Entity에 Soft Delete나 Envers를 강제한다. +``` + +--- + +## 52. 설계 승인 상태 + +이 설계는 첨부 심층 리서치와 사용자가 반복적으로 확정한 Backend Skeleton 방향을 기준으로 작성됐다. 구현자는 Stable 계획을 순서대로 수행하고, 각 Task의 계약 테스트가 통과하기 전 다음 Task의 의미론을 임의로 완화하지 않는다. + + +--- + +# 부록 A. 심층 리서치 원문 보존본 + +> 아래 내용은 설계 판단의 원본 근거를 보존하기 위해 첨부 파일을 변경 없이 수록한 것이다. 상단 설계 본문이 구현 계약이며, 충돌 시 상단 설계 본문을 따른다. + +# JPA 관계형 영속성 플랫폼 심층 리서치 + +이번 조사의 결론부터 정리하면, `jpa`는 **`JpaRepository`를 한 번 더 감싸는 공통 Repository 라이브러리로 설계해서는 안 됩니다.** Spring Data JPA 자체가 이미 Repository, Query Method, Pagination, Auditing, Custom Repository, Querydsl 통합 등을 제공하고 있으므로, 공통 플랫폼이 다시 CRUD 추상화를 만드는 것은 기능 중복과 추상화 누수를 동시에 만듭니다. 현재 Spring Data JPA 공식 프로젝트 페이지의 안정 버전은 `4.1.0`입니다. citeturn20view0 + +따라서 권장 구조는 다음과 같습니다. + +```text +Domain / Application +├─ Entity +├─ Embeddable +├─ Repository Interface +├─ Domain Query +├─ Index Requirement +└─ Domain-specific Lock / Soft-delete / Audit policy + │ + ▼ +JPA Persistence Platform +├─ jpa-core +│ ├─ transaction policy +│ ├─ persistence-context policy +│ ├─ error model +│ └─ observability contract +├─ jpa-spring-data +│ ├─ repository fragments +│ ├─ specification +│ ├─ projection +│ └─ auditing support +├─ jpa-hibernate +│ ├─ batching +│ ├─ fetch extensions +│ ├─ statistics +│ └─ StatelessSession +├─ jpa-postgresql +│ ├─ PostgreSQL types +│ ├─ native write/query +│ ├─ lock extensions +│ └─ keyset pagination +├─ jpa-migration-flyway +│ ├─ migration +│ ├─ validation +│ └─ schema release gate +└─ jpa-testkit + ├─ PostgreSQL Testcontainers + ├─ query-count assertions + ├─ concurrency fixtures + ├─ migration fixtures + └─ failure injection +``` + +핵심 설계 질문도 사용자께서 제시한 방향이 맞습니다. + +> **현재 EntityManager 안에서 성공했는가가 아니라, 데이터베이스에 어떤 상태가 확정되었는지, 충돌·Deadlock·Serialization Failure 뒤 전체 업무 트랜잭션을 다시 실행해도 되는지, Commit 결과조차 알 수 없을 때 어떤 증거로 복구할지를 플랫폼 계약으로 만들어야 합니다.** + +## 지원 기준과 공개 계층 + +**기술 기준선.** 2026년 8월 기준 Spring Data JPA 공식 페이지는 `4.1.0`을 표시하고 있으며, Spring Boot `4.1.0`의 dependency management를 사용하는 것이 개별 Hibernate/Flyway/Hikari 버전을 임의로 조립하는 것보다 안전한 기준선입니다. Boot 4.1 BOM은 HikariCP `7.0.2`를 포함하고 있으며, 같은 BOM이 Spring Data JPA, Hibernate ORM, Flyway 등 Spring 생태계의 검증된 조합을 관리합니다. citeturn20view0turn20view1 + +Hibernate ORM의 현재 안정 계열은 **7.4**이며, Hibernate의 7.4 문서는 현재 `7.4.6.Final`을 기준으로 제공되고 있습니다. Jakarta Persistence의 완성된 현재 규격은 **3.2**이고, Persistence 4.0은 아직 개발 중이며 2026년 후반을 목표로 하고 있으므로 Stable 계약으로 고정하면 안 됩니다. citeturn13search0turn7search2turn7search1 + +따라서 지원 매트릭스는 다음이 적절합니다. + +| 구성요소 | 권장 등급 | 기준 | +|---|---|---| +| Java 21 | **Stable baseline** | 플랫폼 언어 기준선 | +| Spring Boot BOM | **Stable baseline** | 개별 dependency 임의 조합 금지 | +| Spring Data JPA 4.1 | **Stable** | Repository·Projection·Specification·Auditing의 기본 진입점 citeturn20view0 | +| Jakarta Persistence 3.2 | **Stable** | 표준 JPA 계약 citeturn7search2turn17search0 | +| Hibernate ORM 7.4 | **Stable provider** | 기본 JPA Provider citeturn13search0 | +| Hibernate Validator | **Stable** | Bean-level early validation | +| Flyway | **Stable migration** | 실제 Schema 변경 Source of Truth | +| HikariCP | **Stable pool** | Boot-managed pool | +| PostgreSQL 16·17·18 | **Stable DB matrix** | 세 버전 모두 공식 지원 기간 내이며 PostgreSQL은 일반적으로 major 버전을 약 5년 지원 citeturn0search3turn13search5 | +| H2 | **Local Convenience** | PostgreSQL 호환성 증명에 사용하지 않음 | +| Testcontainers PostgreSQL | **Required** | 실제 PostgreSQL 의미론을 검증하는 Contract 환경 | +| Jakarta Persistence 4.0 | **Experimental** | 아직 개발 중 citeturn7search1 | +| Hibernate ORM 8 | **Experimental** | 7.4 Stable 이후 차세대 호환성 lane | +| MySQL·MariaDB·Oracle | **Future Profile** | 초기 공통 계약 밖 | + +PostgreSQL 18이 현재 정식 문서의 current 버전이고 PostgreSQL 19는 2026년 8월 현재 beta 단계이므로, **PG19를 Stable에 포함해서는 안 됩니다.** PostgreSQL 공식 문서는 현재 18을 Current로 표시하고 19 Beta 문서를 별도로 제공합니다. citeturn13search5 + +**H2의 위치도 명확해야 합니다.** H2는 빠른 로컬 개발이나 순수 Mapping smoke test에는 쓸 수 있지만, PostgreSQL의 locking, SQLSTATE, partial index, `NULLS NOT DISTINCT`, JSONB, Array, Range, `SKIP LOCKED`, isolation, query planner 동작을 증명하지 못합니다. Stable 선언은 실제 PostgreSQL 테스트를 통해서만 이루어져야 합니다. + +공개 계층은 다음처럼 나누는 것이 가장 자연스럽습니다. + +| 계층 | 공개 범위 | 대표 기능 | 정책 | +|---|---|---|---| +| **J1 Standard Persistence** | 일반 애플리케이션 | Spring Data Repository, JPQL, Projection, 기본 Transaction, `@Version` | 기본 경로 | +| **J2 Advanced Persistence** | 명시적 고급 사용 | Specification, EntityGraph, Query Hint, Pessimistic Lock, Batch, Scrolling | 공통 정책 적용 | +| **J3 Provider / DB Extension** | 제한형 | Hibernate Session, StatelessSession, JSONB, `ON CONFLICT`, `SKIP LOCKED`, Native SQL | 별도 모듈·명시적 의존성 | +| **J4 Admin / Operations** | 운영 계층 | Flyway, Index 생성, Backfill, Partition, maintenance SQL | 일반 서비스 코드에서 금지 | + +Spring Data의 `CrudRepository.save()` 자체도 Entity가 신규인지 판단해 `EntityManager.persist()` 또는 `merge()`를 호출합니다. 즉 `GenericRepository.save()`를 한 계층 더 추가해도 JPA의 `persist`/`merge` 차이를 없애지 못하며 오히려 숨길 뿐입니다. citeturn9search0 + +**권장 공개 구조는 따라서 다음입니다.** + +```java +// Domain owns this +public interface OrderRepository extends JpaRepository, + OrderRepositoryCustom { + Optional findByOrderNumber(OrderNumber orderNumber); +} + +// Domain-specific custom query contract +public interface OrderRepositoryCustom { + Slice findRecentOrders(OrderCursor cursor, int size); +} + +// J3 implementation may internally use: +// EntityManager +// Hibernate Session +// PostgreSQL native SQL +// +// but those types do not leak into application services. +``` + +`EntityManager`를 금지할 필요는 없습니다. 다만 **애플리케이션 전체에 자유롭게 노출하는 것이 아니라 Custom Repository 구현 또는 J3 Extension 내부에서 사용**하는 것이 좋습니다. Spring Data 역시 단순 Repository를 넘는 데이터 접근 코드를 custom fragment로 결합할 수 있도록 설계되어 있습니다. citeturn20view0 + +## Entity Mapping과 Persistence Context 계약 + +Jakarta Persistence 3.2는 Entity가 top-level 또는 static nested class여야 하고, public/protected no-arg constructor가 필요하며, portable Entity는 non-final class와 non-final persistent members를 사용하도록 규정합니다. Field access와 property access는 annotation 위치에 의해 결정되고, 계층 안에서 이를 암묵적으로 뒤섞으면 동작이 정의되지 않으므로 접근 전략을 일관되게 유지해야 합니다. citeturn17search0 + +따라서 Entity Mapping 기본 규칙은 다음이 적절합니다. + +| 항목 | 플랫폼 기본 정책 | +|---|---| +| Access | **Field Access 기본**, 특별한 이유가 있을 때만 `@Access(PROPERTY)` | +| Entity final | 금지 | +| no-arg constructor | `protected` 권장 | +| Entity API 직렬화 | 기본 금지 | +| Controller 반환 | DTO / Projection 사용 | +| `toString()` | LAZY association 포함 금지 | +| `equals/hashCode` | mutable association·mutable business field 포함 금지 | +| Entity callback | 데이터 정규화·감사 필드 같은 로컬 작업만; HTTP/Messaging 등 외부 I/O 금지 | +| BaseEntity | 전역 강제 상속 금지 | +| Soft Delete | 전역 강제 금지 | +| Audit | Opt-in capability | +| Association | 기본적으로 use-case fetch plan과 분리 | + +Jakarta Persistence 3.2에서는 `Instant`, `Year`, `UUID` 등이 표준 basic type에 포함되고, **Java record를 Embeddable로 사용할 수 있습니다.** 반면 record는 Entity가 될 수 없습니다. 따라서 record Embeddable은 Stable JPA 3.2 기능으로 볼 수 있지만, 실제 Boot-managed Hibernate 조합의 round-trip·dirty checking·nested embeddable 계약 테스트를 통과하는 것을 release gate로 두는 것이 안전합니다. citeturn17search0turn7search2 + +**Value Mapping 권고안은 다음과 같습니다.** + +| Java/domain type | 권장 | +|---|---| +| `Instant` | Stable, 서버 간 절대 시점 | +| `OffsetDateTime` | Stable, offset 자체가 업무적으로 필요한 경우 | +| `LocalDate` | Stable | +| `LocalDateTime` | timezone 없는 업무 시간에만 사용 | +| `Duration` | Converter 또는 provider mapping 검증 | +| `UUID` | Stable | +| Enum | 기본은 STRING 또는 명시적 converter; ordinal 금지 권고 | +| Money | Embeddable/value object | +| JSONB | `jpa-postgresql` | +| Array | `jpa-postgresql` | +| Range | `jpa-postgresql` | +| INET | Advanced PostgreSQL extension | +| LOB | 일반 Entity 조회에서 신중하게 사용 | +| Encrypted value | AttributeConverter만으로 끝내지 말고 key rotation·queryability 포함 별도 capability | + +**ID 생성 전략에서 PostgreSQL용 기본값은 `SEQUENCE`가 가장 안전합니다.** Hibernate 7.4는 `IDENTITY` 사용 시 INSERT JDBC batching을 수행할 수 없다고 명시하며, `IDENTITY`는 `persist()` 시 식별자를 얻기 위해 INSERT가 즉시 필요할 수 있습니다. 반대로 sequence 계열은 insert 전에 ID를 확보해 batching과 write-behind를 유지하기 쉽습니다. citeturn14view0turn14view1 + +| ID 전략 | Batch | 분산 생성 | Insert 전 ID | 권장 범위 | +|---|---:|---:|---:|---| +| `SEQUENCE` | 좋음 | DB 의존 | 가능 | **PostgreSQL 기본 추천** | +| `IDENTITY` | 나쁨 | DB 의존 | 불가 | 소규모 write에 한정 | +| JPA `UUID` | 좋음 | 가능 | 가능 | Stable | +| Application-assigned UUID | 좋음 | 가능 | 가능 | Stable | +| UUIDv7 | 좋음 | 가능 | 가능 | PG16~18 공통 생성 방식을 별도 정의 | +| Composite ID | 상황별 | 상황별 | 가능 | 도메인이 실제 composite identity인 경우만 | +| Natural ID | 별도 index 필요 | 상황별 | 보통 가능 | PK와 혼동하지 않음 | + +PostgreSQL 18은 native `uuidv7()`을 제공하지만 PostgreSQL 16·17 Stable 범위 전체에서 공통으로 사용할 수 있는 기능은 아닙니다. 따라서 DB-generated UUIDv7을 J1 표준으로 만들지 말고, **application-generated UUIDv7 또는 PostgreSQL 18 전용 extension**으로 분류해야 합니다. 또한 JPA의 `GenerationType.UUID`가 곧 UUIDv7을 뜻하지도 않습니다. citeturn8search0turn8search12turn17search0 + +Sequence를 쓸 때는 `allocationSize`를 명시적으로 관리해야 합니다. 값은 글로벌 상수 하나보다 write profile에 맞춰 benchmark해야 하며, 여러 프로세스가 같은 sequence를 이용하는 경우 allocation 동작도 실제 PostgreSQL에서 검증해야 합니다. + +**Association 정책은 FetchType보다 Fetch Plan이 더 중요합니다.** JPA에서 `EAGER`는 반드시 eager fetch 해야 하는 요구이고 `LAZY`는 provider에 대한 hint입니다. EntityGraph는 query/find 단위 fetch plan을 표현하기 위한 표준 기능입니다. 따라서 mapping에서 연관관계를 무조건 EAGER로 만들어 use case마다 필요 없는 graph를 끌고 오는 것보다, 최소 graph + explicit fetch plan을 기본으로 삼는 것이 적절합니다. citeturn17search0 + +권장 Association 계약은 다음과 같습니다. + +```text +ToOne +→ 기본적으로 명시적 LAZY를 검토 +→ 실제 proxy/lazy 동작을 Hibernate Contract Test로 보증 + +ToMany +→ LAZY +→ List 화면에서는 DTO Projection / EntityGraph / Fetch Join 선택 + +Cascade +→ lifecycle이 실제로 동일한 aggregate 내부에서만 + +Cascade.ALL +→ 전역 기본값 금지 + +orphanRemoval +→ child lifecycle을 parent가 독점 소유할 때만 + +ManyToMany +→ 단순 연결 외에는 join entity 우선 검토 +``` + +JPA 규격상 양방향 관계에서 persistence 동작에 중요한 것은 owning side이며, 양쪽 in-memory 객체 graph를 서로 맞추는 책임은 애플리케이션에게 있습니다. 따라서 양방향 association에는 `addChild/removeChild` 같은 편의 메서드 계약을 두는 것이 좋습니다. citeturn12view0 + +**Persistence Context 계약도 API 문서보다 중요합니다.** `persist`, `merge`, `flush`, `commit`은 서로 다른 의미를 가집니다. `merge()`는 detached instance 자체를 managed로 바꾸는 것이 아니라 그 state를 managed instance에 복사하는 방식이고, `flush()`는 Persistence Context를 DB와 동기화하지만 transaction commit과 동일하지 않습니다. citeturn12view0 + +플랫폼 계약은 아래처럼 고정하는 것이 좋습니다. + +```text +Persistence Context +→ transaction-scoped + +Extended Persistence Context +→ Stable 비지원 + +EntityManager +→ thread-safe로 간주하지 않음 + +OSIV +→ 명시적으로 false + +Lazy loading +→ application transaction 내부 + +Web/API +→ Entity 직접 반환 금지 + +flush() +→ SQL 반영 시점 제어 +→ commit 보장 아님 + +clear() +→ managed state 제거 + +refresh() +→ DB state 재조회 + +Bulk DML +→ flush +→ bulk DML +→ clear 또는 필요한 entity refresh +``` + +JPA Bulk UPDATE/DELETE는 persistence context를 자동으로 동기화하지 않고 optimistic locking check도 자동 적용하지 않습니다. 따라서 Bulk DML 후 이미 managed 상태인 Entity를 계속 사용하는 것은 stale-state 오류의 직접 원인이 됩니다. citeturn12view1 + +`Open Session in View`는 **플랫폼 차원에서 명시적으로 비활성화**하는 것이 좋습니다. 중요한 것은 Spring Boot의 특정 버전 기본값에 의존하지 않고 다음 invariant를 만드는 것입니다. + +```properties +spring.jpa.open-in-view=false +``` + +그 결과 `LazyInitializationException`은 Web serialization에서 우연히 발생하는 production 장애가 아니라, use case에 필요한 Fetch Plan을 Repository 계층에서 빠뜨렸다는 **개발 시점 계약 위반**으로 취급할 수 있습니다. + +## Transaction·Lock·Retry와 Commit 불명확성 + +Spring Data JPA도 여러 Repository를 묶는 unit of work에서는 service/facade 수준에 transaction boundary를 두는 방식을 권장합니다. 외부 transaction이 있으면 내부 Repository 설정보다 외부 unit-of-work transaction이 실제 경계를 결정합니다. citeturn15search1 + +따라서 기본 계약은 다음입니다. + +```text +Controller + │ + ▼ +Application Service ← @Transactional boundary + │ + ├─ Repository A + ├─ Repository B + └─ Domain operation +``` + +그리고 다음 구조는 피해야 합니다. + +```text +@Transactional +DB UPDATE +→ 3초 HTTP 호출 +→ Object Storage 전송 +→ Kafka publish +→ DB COMMIT +``` + +Spring Framework는 transaction context가 일반적인 remote call까지 전파되는 모델이 아니며, 긴 외부 작업을 로컬 DB transaction 내부에 넣으면 connection과 row lock의 보유 시간이 외부 시스템 latency에 종속됩니다. DB 변경과 메시지 발행을 연계해야 한다면 XA처럼 보이게 숨기기보다 Transactional Outbox를 사용하는 것이 더 안전한 경계입니다. citeturn15search7 + +**Propagation 정책은 다음 정도로 강하게 제한하는 것이 좋습니다.** + +| Propagation | 등급 | 플랫폼 규칙 | +|---|---|---| +| `REQUIRED` | 기본 | Application use case 기본 | +| `MANDATORY` | 선택 Stable | 반드시 상위 transaction이 필요한 내부 write service | +| `SUPPORTS` | 제한 | read helper 정도 | +| `REQUIRES_NEW` | 주의 | 명시적 독립 commit이 업무적으로 필요한 경우만 | +| `NESTED` | Advanced | JPA portable 기능처럼 취급하지 않고 savepoint 호환성 검증 | +| `NOT_SUPPORTED` | Advanced | 긴 외부 I/O 분리 등에 제한적으로 사용 | + +Spring의 `REQUIRES_NEW`는 별도의 physical transaction과 resource를 사용합니다. 외부 transaction이 connection을 붙잡은 채 내부 transaction이 또 다른 connection을 요구하므로, 동시 호출이 많으면 pool exhaustion 또는 deadlock으로 이어질 수 있다고 Spring 문서가 명시적으로 경고합니다. `NESTED`는 JDBC savepoint를 기반으로 하는 의미론입니다. citeturn15search0 + +또한 Spring의 기본 proxy transaction model에서는 **self-invocation이 transactional interception을 거치지 않습니다.** 따라서 같은 클래스 안에서 `this.someRequiresNewMethod()`를 호출하고 별도 transaction이 생성된다고 가정하는 코드는 금지 대상이 되어야 합니다. citeturn15search6turn15search9 + +Spring `@Transactional`의 기본값은 `REQUIRED`, isolation `DEFAULT`, read-write이며, 기본 rollback 규칙은 `RuntimeException`과 `Error`입니다. Checked exception까지 rollback해야 하는 업무에서는 `rollbackFor` 또는 안정적인 application exception hierarchy를 명시해야 합니다. citeturn15search6 + +**Isolation은 PostgreSQL 실제 의미론을 기준으로 계약해야 합니다.** + +| Isolation | PostgreSQL 관점 | 권장 | +|---|---|---| +| `READ COMMITTED` | 기본 isolation | 일반 업무 기본 | +| `REPEATABLE READ` | snapshot 내 일관성 강화; concurrent update 시 serialization failure 가능 | 명시적 use case | +| `SERIALIZABLE` | serial execution과 동등한 결과를 목표로 하며 abort/retry 가능 | 좁은 핵심 invariant | +| `READ UNCOMMITTED` | PostgreSQL에서는 실질적으로 READ COMMITTED 의미 | 공개 profile로 권장하지 않음 | + +PostgreSQL은 Repeatable Read/Serializable에서 concurrency anomaly를 해결하기 위해 transaction을 abort시킬 수 있으며, Serializable 문서는 실패한 경우 **transaction 전체를 처음부터 다시 실행**해야 한다고 명시합니다. citeturn13search9turn8search6 + +이 때문에 Retry 단위는 다음과 같아야 합니다. + +```text +잘못된 방식 + +@Transactional +service() + repository.update() // 실패 + retry(repository.update) // 일부 SQL만 재실행 + + +권장 방식 + +retryTransaction( + () -> applicationUseCase() +) +``` + +즉 **새 Persistence Context와 새 DB transaction에서 전체 use case를 재실행**해야 합니다. + +**Optimistic Lock은 기본 동시성 제어의 첫 번째 선택지**로 두는 것이 적절합니다. + +```java +@Version +private long version; +``` + +JPA는 optimistic version check가 flush 또는 commit 시점까지 지연될 수 있음을 허용하며, 충돌 시 `OptimisticLockException`을 발생시킵니다. 즉 update method 호출 직후 충돌이 반드시 드러난다고 가정하면 안 됩니다. citeturn12view2 + +Optimistic retry는 다음 조건을 모두 만족해야 합니다. + +```text +전체 application transaction을 다시 계산할 수 있음 +AND +외부 irreversible side effect가 없음 +AND +업무 deadline이 남아 있음 +AND +retry 횟수가 제한됨 +``` + +**Pessimistic Lock은 다음 계약으로 제한**하는 것이 좋습니다. + +| 기능 | 용도 | 위험 | +|---|---|---| +| `PESSIMISTIC_READ` | shared-style lock 요구 | 장시간 transaction | +| `PESSIMISTIC_WRITE` | 쓰기 경쟁 직렬화 | lock wait·deadlock | +| `PESSIMISTIC_FORCE_INCREMENT` | version까지 증가 | contention | +| `NOWAIT` | 기다리지 않고 즉시 실패 | 실패율 증가 | +| `SKIP LOCKED` | work queue형 competing worker | 일반 조회에는 inconsistent view | + +JPA의 pessimistic lock은 transaction 종료까지 유지되어야 하며, database transaction rollback 수준의 lock 실패와 statement 수준 timeout을 `PessimisticLockException`/`LockTimeoutException`으로 구분합니다. PostgreSQL의 `SKIP LOCKED`는 일관된 일반 조회 view를 제공하지 않기 때문에 queue-like consumer에 적합하다고 공식 문서가 명시합니다. citeturn12view3turn8search4turn8search5 + +따라서 `SKIP LOCKED`를 `findAllUnlocked()` 같은 공통 Repository API로 제공해서는 안 되고, + +```text +jpa-postgresql +└─ WorkClaimExtension + └─ claimNextBatch(...) +``` + +처럼 semantics가 드러나는 API로 한정하는 것이 좋습니다. + +**DB Constraint는 최종 불변식입니다.** 다음 코드는 경쟁을 막지 못합니다. + +```java +if (!repository.existsByEmail(email)) { + repository.save(new User(email)); +} +``` + +동시에 두 transaction이 `false`를 읽을 수 있기 때문입니다. 최종 uniqueness는 `UNIQUE` constraint/index가 담당하고 애플리케이션의 `exists` 검사는 빠른 UX validation 정도로만 사용해야 합니다. PostgreSQL은 unique constraint/primary key에 unique index를 자동 생성하며, `NULLS NOT DISTINCT`를 사용해 NULL도 동일 값처럼 취급하는 unique semantics를 제공할 수 있습니다. citeturn17search1 + +PostgreSQL 전용 partial unique index가 필요하다면 Entity annotation에 억지로 추상화하지 말고 Flyway migration으로 관리합니다. + +```sql +CREATE UNIQUE INDEX uq_user_active_email +ON users (email) +WHERE deleted_at IS NULL; +``` + +이는 Soft Delete와 Unique Constraint 충돌을 해결하는 대표적인 PostgreSQL extension 패턴입니다. + +**Commit 결과 불명확성은 별도 오류로 모델링해야 합니다.** + +예를 들어: + +```text +Application + │ + │ COMMIT + ▼ +PostgreSQL + │ + │ 실제 commit 완료 + X TCP connection loss + │ +Application + └─ commit 결과를 받지 못함 +``` + +이때 같은 업무를 자동 재실행하면 이미 commit된 INSERT나 상태 변경을 두 번 실행할 수 있습니다. PostgreSQL의 SQLSTATE 체계 자체에도 `40003 statement_completion_unknown`이라는 별도 completion-unknown condition이 정의되어 있고, SQLSTATE는 문자열 오류 메시지보다 안정적인 기계 판독 기준으로 사용하도록 PostgreSQL이 권고합니다. citeturn13search1 + +따라서 플랫폼에는 JPA 표준 exception이 아닌 **플랫폼 고유 분류**로 다음을 두는 것을 권장합니다. + +```java +final class TransactionCompletionUnknown + extends JpaPersistenceException { + + String operationName; + String transactionKey; + String sqlState; + boolean commitAttempted; + String traceId; +} +``` + +이 오류에 대한 정책은 명확해야 합니다. + +```text +TransactionCompletionUnknown +→ 자동 Retry 금지 +→ 동일 업무 key로 상태 재조회 +→ Unique Constraint / Idempotency Record 확인 +→ Outbox / transaction record 확인 +→ 결과 확정 불가 시 reconciliation +``` + +즉 error taxonomy는 단순히 “transient/non-transient” 두 종류로 나누면 부족합니다. + +## Query·Fetch·Pagination과 Write 성능 + +Spring Data JPA는 derived query, custom query, pagination, custom repository, Querydsl integration 등을 공식 지원하므로, 플랫폼의 역할은 이를 하나의 API로 대체하는 것이 아니라 **어떤 레벨에서 무엇을 쓸지 결정하는 것**입니다. citeturn20view0 + +권장 Query 등급은 다음과 같습니다. + +| 등급 | 방식 | 사용 기준 | +|---|---|---| +| Q1 | Derived Query | 짧고 명확한 equality/range 조회 | +| Q1 | JPQL `@Query` | 고정 query, domain repository 안에서 읽기 쉬운 경우 | +| Q1 | DTO Projection | 목록·read model 기본 후보 | +| Q2 | Specification | optional filter 조합 | +| Q2 | Criteria | framework-level dynamic query | +| Q2 | Querydsl | 복잡한 type-safe dynamic query의 선택 capability | +| Q2 | EntityGraph | use-case fetch plan | +| Q3 | Native SQL | PostgreSQL 기능·계획 통제가 필요한 경우 | +| Q3 | Hibernate Query API | provider 기능 필요 시 | +| Q4 | Bulk/Admin SQL | backfill, maintenance | + +Derived query에 “최대 단어 수” 같은 임의 숫자를 플랫폼에 박는 것은 좋지 않습니다. 대신 **method name이 업무 의미보다 SQL 구조를 설명하기 시작하면 custom query로 승격한다**는 코드리뷰 규칙이 더 안정적입니다. + +Dynamic sort는 field allowlist가 필요합니다. Spring Data는 일반적인 domain property 기반 `Sort`와 명시적으로 unsafe한 expression sort를 구분하기 때문에, 사용자 입력 문자열을 `JpaSort.unsafe()` 등에 직접 연결하지 않는 정책이 필요합니다. citeturn18search12 + +**Fetch 전략은 Mapping이 아니라 Use Case 계약으로 관리**해야 합니다. + +| 상황 | 우선 선택 | +|---|---| +| 단일 aggregate 상세 | EntityGraph / Fetch Join | +| 여러 ToOne | Fetch Join 또는 EntityGraph | +| 하나의 필요한 ToMany | Fetch Join 검토 | +| 여러 ToMany | DTO / 다단계 query / batch fetch | +| 목록 화면 | DTO Projection | +| 페이지형 parent + collection | Hibernate 버전과 SQL plan 검증 | +| 대규모 read model | Projection / Native Query | +| 반복 LAZY N+1 | Batch Fetch 또는 explicit fetch plan | + +Hibernate는 여러 to-one fetch를 한 query에서 사용하는 것은 비교적 안전하지만, 여러 collection을 병렬 join fetch하면 DB 레벨 Cartesian product가 발생해 row 수와 hydration cost가 크게 증가할 수 있음을 문서화하고 있습니다. citeturn13search14 + +여기에는 **2026년 기준 중요한 변경점**이 있습니다. + +기존 Hibernate 6 또는 초기 Hibernate 7에서는 collection fetch join과 pagination을 조합하면 limit이 JVM에서 적용되어 전체 결과를 읽어버리는 심각한 문제가 있었습니다. 그러나 **Hibernate ORM 7.4에서는 PostgreSQL처럼 subquery 안의 limit/offset을 지원하는 DB에서 이 문제가 해결되었습니다.** Hibernate 7.4의 “What’s New”가 이를 명시적으로 새 기능으로 소개합니다. citeturn13search0turn13search11 + +따라서 기존 규칙인 + +```text +Collection Fetch Join + Pagination +→ 무조건 금지 +``` + +는 현재 baseline에서는 너무 강합니다. + +정확한 규칙은 다음이어야 합니다. + +```text +Hibernate 7.4 + PostgreSQL 16~18 +→ 지원 가능 +→ generated SQL / rows / count query / cartesian amplification을 Contract Test + +Hibernate 이전 버전 또는 다른 provider +→ capability 재검증 + +여러 collection fetch +→ pagination 해결 여부와 별개로 Cartesian 위험 때문에 기본 제한 +``` + +이 부분은 반드시 회귀 테스트에 넣어야 합니다. “과거 성능 장애 사례”와 “현재 지원 기능”을 구분하지 않으면 JPA 플랫폼이 이미 수정된 Hibernate 제한을 영구 정책으로 굳히게 됩니다. citeturn13search0turn13search2 + +**N+1 테스트는 SQL 개수 하나만 보면 부족합니다.** + +```text +statementCount +entityLoadCount +entityFetchCount +collectionFetchCount +returnedParents +hydratedEntities +rowsFromDatabase +duration +``` + +를 함께 보아야 합니다. 예컨대 SQL 1개라도 100 parent × 100 child × 20 second-child Cartesian product가 만들어지면 좋은 Fetch Plan이 아닙니다. + +테스트 fixture 역시: + +```text +0 child +1 child +10~100 children +shared ToOne +두 개 이상의 collection +skewed distribution +``` + +을 포함해야 합니다. + +**Pagination 계약은 세 종류로 나누는 것이 좋습니다.** + +| 방식 | 장점 | 단점 | 기본 용도 | +|---|---|---|---| +| `Page` | total count 제공 | count query 비용 | 작은 관리자 화면 | +| `Slice` | count 불필요 | 전체 개수 없음 | 일반 목록 | +| Offset | 구현 간단 | 깊은 페이지 비용·삽입 시 이동 | 작은 데이터 | +| Keyset/Cursor | 큰 데이터에 유리 | stable ordering·cursor 설계 필요 | 일반 대규모 목록 | +| Stream/Scroll | 전체 적재 회피 | transaction/resource lifetime | batch/read processing | + +Spring Data의 Scroll API는 offset/keyset scrolling을 지원하지만 query 방식에 따라 지원 범위가 다르며, 공식 문서는 string-based `@Query`나 stored procedure에서 scrolling을 지원하지 않는 제한을 명시합니다. citeturn18search12turn9search8 + +Keyset cursor에는 반드시 전체 순서를 결정하는 tie-breaker가 필요합니다. + +```sql +ORDER BY created_at DESC, id DESC +``` + +라면 cursor도: + +```text +(createdAt, id) +``` + +두 값을 모두 저장해야 합니다. `created_at` 하나만 cursor로 쓰면 같은 timestamp를 가진 row가 누락되거나 반복될 수 있습니다. + +**Batch Write는 `saveAll()`과 동일하지 않습니다.** Hibernate의 JDBC batching은 `hibernate.jdbc.batch_size`가 0 이하이면 꺼져 있고, batching 활성화 뒤에도 ID generator와 SQL shape에 따라 실제 batch 여부가 달라집니다. Hibernate 7.4는 `order_inserts`, `order_updates`를 제공하지만 이 옵션 역시 overhead가 있으므로 benchmark를 권고합니다. citeturn14view1 + +권장 write profile은 다음입니다. + +```yaml +jpa: + write-profiles: + default: + batch-size: 0 + + batch: + jdbc-batch-size: 50 + order-inserts: true + order-updates: true + flush-size: 50 + clear-size: 50 +``` + +정확한 50이라는 값 자체가 universal optimum이라는 뜻은 아니며 프로파일 기본 예시입니다. 실제 완료 조건은 “configured batch size가 SQL/JDBC batch로 관찰됨”입니다. + +Hibernate는 대량 Entity를 하나의 stateful Session에 계속 넣으면 Persistence Context에 Entity가 누적되고 장기 transaction이 connection pool을 오래 점유한다고 설명하며, batch loop에서 주기적인 `flush()`와 `clear()`를 권장합니다. citeturn14view1 + +```java +for (int i = 0; i < records.size(); i++) { + entityManager.persist(records.get(i)); + + if (i > 0 && i % batchSize == 0) { + entityManager.flush(); + entityManager.clear(); + } +} +``` + +**대규모 Backfill은 JPA Entity lifecycle 자체가 필요하지 않을 수도 있습니다.** Hibernate `StatelessSession`은 Persistence Context와 연결되지 않은 detached-like object를 반환하고 insert/update/delete가 DB row에 직접 작용하는 다른 semantics를 갖습니다. 따라서 일반 Repository 대체가 아니라 J3/J4 대량 작업 extension으로 분류해야 합니다. citeturn14view4 + +권장 계층은 다음과 같습니다. + +```text +일반 업무 write +→ JPA Entity + +수천~수만 row +→ JPA + JDBC batch + chunk flush/clear + +대규모 migration/backfill +→ StatelessSession / JdbcTemplate / PostgreSQL COPY + +운영 대량 수정 +→ J4 Job +``` + +**Bulk DML**은 더 엄격합니다. + +```text +flush +→ JPQL/Native Bulk UPDATE +→ clear +→ 필요 시 재조회 +``` + +가 기본 계약입니다. Bulk JPQL/Criteria DML은 managed entity state를 자동 동기화하지 않으며 optimistic locking도 자동 적용하지 않습니다. citeturn12view1 + +**Cache 정책도 단순하게 가져가는 편이 안전합니다.** + +```text +First-level Cache +→ JPA 기본, 항상 존재 + +Second-level Cache +→ 기본 Opt-out / Entity별 명시 Opt-in + +Query Cache +→ 기본 OFF + +Application Cache +→ 별도 Redis/cache 플랫폼 +``` + +Hibernate 7.4는 query cache 기본값이 false이고, shared cache mode에서는 `ENABLE_SELECTIVE`를 기본·권장하여 명시적으로 cacheable인 Entity만 second-level cache에 넣도록 설명합니다. 또한 외부 애플리케이션이 DB를 변경하면 Hibernate cache가 이를 자동 인지하지 못한다는 제한도 있습니다. citeturn14view3 + +즉 Redis application cache와 Hibernate L2 cache를 “같은 Cache 기능”으로 묶으면 안 됩니다. + +## PostgreSQL·Schema Migration·확장 정책 + +JPA Mapping은 **애플리케이션의 object-relational mapping 계약**이고, 실제 schema 변경 Source of Truth는 **Flyway migration**으로 두는 것이 적절합니다. + +권장 환경 정책은 다음입니다. + +| 환경 | Flyway | Hibernate DDL | +|---|---|---| +| local PostgreSQL | migrate | `validate` | +| H2 convenience | 선택적 create/drop | 실제 호환성 증명 아님 | +| test | migrate | `validate` | +| dev | migrate | `validate` | +| staging | deployment migration | `validate` | +| prod | 별도 권한/배포 주체로 migration | `validate` | + +운영에서 다음은 기본 금지로 두는 것이 좋습니다. + +```text +hibernate.ddl-auto=update +hibernate.ddl-auto=create +hibernate.ddl-auto=create-drop +application runtime credential의 DDL 권한 +적용 완료된 Versioned Migration 수정 +startup 시 자동 Flyway repair +``` + +Flyway `validate`는 적용된 migration과 로컬 migration의 name/type/checksum 등을 비교하고 불일치나 누락을 실패로 보고합니다. SQL migration checksum은 현재 문서 기준 CRC32로 저장됩니다. citeturn19search0 + +Versioned migration은 순서대로 한 번 적용하고 이미 영구 환경에 적용한 파일은 수정하지 않고 새 migration으로 roll-forward하는 것이 Flyway가 권장하는 방식입니다. Repeatable migration은 checksum이 변경될 때 다시 실행됩니다. citeturn19search3turn19search6 + +`repair`는 단순한 “검증 복구” 기능이 아닙니다. 실패 migration 기록 제거, checksum/description/type 재정렬, missing migration을 deleted로 표시하는 등의 변경을 수행하며, DB에 남은 user object는 수동으로 정리해야 할 수 있습니다. 따라서 J4 승인 작업으로 두어야 합니다. citeturn19search1 + +**무중단 Migration의 기본 패턴은 Expand → Migrate → Contract입니다.** + +```text +Release A +ADD nullable column +ADD new table/index +Application can handle old + new schema + + ↓ + +Backfill +chunked data migration + + ↓ + +Release B +new column becomes authoritative + + ↓ + +Release C +old column/index/API removed +constraint tightened +``` + +큰 테이블에서 index를 만드는 경우 PostgreSQL의 `CREATE INDEX CONCURRENTLY`를 별도 migration 유형으로 취급해야 합니다. PostgreSQL은 concurrent index build를 transaction block 안에서 실행할 수 없다고 명시하므로, Flyway의 일반 transaction wrapping과 충돌하지 않도록 해당 migration을 non-transactional로 명시적으로 분리해야 합니다. citeturn17search3turn19search16 + +Flyway의 `group=true`는 여러 pending migrations를 한 transaction에 묶는 옵션이지만, DDL transaction을 적절히 지원하는 DB에서만 권장되며 기본은 false입니다. 무조건 활성화할 설정이 아닙니다. citeturn19search13 + +**Constraint 정책은 아래처럼 나누는 것이 좋습니다.** + +```text +Bean Validation +→ 빠른 입력/객체 검증 +→ 사용자 친화적 오류 + +Database Constraint +→ concurrency 하에서도 지켜져야 하는 최종 invariant +``` + +| Constraint | DB 필수성 | +|---|---| +| Primary Key | 필수 | +| Foreign Key | 관계 불변식에 기본 | +| `NOT NULL` | 실제 non-null invariant이면 DB에도 적용 | +| Unique | 경쟁 가능 uniqueness는 DB가 최종 보장 | +| Check | DB 자체로 표현 가능한 invariant에 적극 검토 | +| Exclusion | PostgreSQL 고유 overlap 등 고급 invariant | + +**Index 역시 Entity field에 자동 생성하는 문제가 아닙니다.** PostgreSQL은 B-tree, GiST, GIN, BRIN, multicolumn, expression, partial, covering `INCLUDE` 등 다양한 index 기능을 제공합니다. 특히 multicolumn index는 실제 predicate, sort와 data distribution을 기준으로 설계해야 합니다. citeturn17search2turn8search9 + +플랫폼은 “자동 Index 생성기”보다 다음을 제공하는 것이 더 유용합니다. + +```text +Query Name +→ representative parameters +→ EXPLAIN / EXPLAIN ANALYZE +→ estimated rows / actual rows +→ scan type +→ sort +→ temporary spill +→ buffers +→ execution time +→ expected index document +``` + +**PostgreSQL extension 지원표**는 다음이 적절합니다. + +| 기능 | 등급 | 비고 | +|---|---|---| +| JSONB | **P1 Stable Extension** | PostgreSQL-native value/query | +| Array | **P1 Stable Extension** | 타입별 contract test | +| Range | **P1 Stable Extension** | 기간·구간 도메인 | +| UUID | **P1 Stable** | standard/native | +| INET | P2 Advanced | networking domain | +| Native Enum | P2 Advanced | migration coupling 큼 | +| `ON CONFLICT` | **P1 Native Write Extension** | 명시적 upsert semantics | +| `RETURNING` | **P1 Native Write Extension** | native write 최적화 | +| Window Function | P1/P2 Query Extension | read model | +| CTE | P2 | 복잡한 read/write | +| Recursive CTE | P2 | 제한된 use case | +| `NOWAIT` | **P1 Lock Extension** | fast-fail lock | +| `SKIP LOCKED` | **P1 Worker Extension** | queue-like use case만 citeturn8search4 | +| Advisory Lock | P2 Advanced | transaction/session scope를 명시 | +| Partial Index | **J4 Migration** | query-specific | +| Expression Index | J4 Migration | query-specific | +| `NULLS NOT DISTINCT` | **J4 Stable Migration** | PG unique semantics citeturn17search1 | +| Generated Column | P2/J4 | mapping·migration 검증 | +| Full-text Search | P2 | 전문 검색 규모에서는 별도 검색 플랫폼과 비교 | +| Partitioning | **J4 Admin** | application Repository가 생성·삭제하지 않음 | +| Row-Level Security | Experimental/Admin | tenant context·connection reuse까지 검증 필요 | + +**Auditing은 강제 BaseEntity보다 선택형이 낫습니다.** Spring Data JPA는 created/modified user/time을 기록하는 auditing 기능을 이미 제공하므로 공통 플랫폼은 이를 활성화할 수 있는 primitive만 제공하고, 도메인이 필요한 Entity에 선택적으로 적용하도록 해야 합니다. citeturn20view0turn18search5 + +```text +Technical Auditing +createdAt / createdBy / modifiedAt / modifiedBy + +≠ + +Business Audit +“누가 주문 상태를 왜 취소했는가” + +≠ + +Entity History +과거 row revision + +≠ + +Security Audit +관리자 권한·DDL·replay +``` + +Hibernate Envers는 Entity History 선택 기능으로 둘 수 있지만 J1 기본 기능으로 만들 필요는 없습니다. + +**Soft Delete 역시 global 기능으로 제공하지 않는 것이 좋습니다.** + +```text +Global @Where deleted=false +→ 비추천 + +Domain-specific status/deletedAt +→ 필요 도메인에만 + +복구 가능한 삭제 +→ 도메인 계약 + +법적/개인정보 물리 삭제 +→ 별도 lifecycle +``` + +Soft Delete를 공통 필터로 숨기면 unique constraint, FK, admin query, archive, restore, 개인정보 삭제가 모두 암묵적 semantics에 묶입니다. PostgreSQL partial unique index 같은 기능이 필요한 이유도 이 경계 때문입니다. + +**Multi-tenancy는 초기 Stable Core에서 제외하는 것이 안전합니다.** + +| 모델 | 권장 초기 등급 | +|---|---| +| 단일 DB·schema | Stable | +| Shared schema + tenant column | Experimental capability | +| Schema per tenant | Experimental | +| DB per tenant | Experimental | +| RLS 기반 | Experimental | +| Multi DataSource | Advanced/Experimental | +| Read Replica routing | Experimental | + +Read replica는 `@Transactional(readOnly=true)`만 보고 자동 routing해서는 안 됩니다. replica lag 때문에 같은 사용자 흐름의 직전 write가 보이지 않을 수 있고 lock query는 primary가 필요하기 때문입니다. Stable Core에는 transaction read-only hint까지만 포함하고 routing은 별도 profile로 두는 것이 적절합니다. + +## 오류·보안·관측성·테스트 계약 + +Spring의 exception translation과 PostgreSQL SQLSTATE를 활용하되 애플리케이션에 provider/vendor exception을 그대로 노출하지 않는 것이 좋습니다. PostgreSQL 공식 문서는 오류 판단 시 locale에 따라 달라지는 message text가 아니라 SQLSTATE를 검사하라고 권장하며, integrity violation에서는 constraint name 같은 structured field도 전달합니다. citeturn13search1 + +권장 오류 모델은 다음과 같습니다. + +```text +JpaPersistenceException +├─ EntityNotFound +├─ OptimisticConflict +├─ PessimisticLockTimeout +├─ DeadlockDetected +├─ SerializationFailure +├─ UniqueConstraintViolation +├─ ForeignKeyViolation +├─ CheckConstraintViolation +├─ QueryTimeout +├─ TransactionTimeout +├─ ConnectionUnavailable +├─ SchemaMismatch +├─ DataCorruption +└─ TransactionCompletionUnknown +``` + +PostgreSQL SQLSTATE를 활용하면 대표적으로 serialization failure `40001`, deadlock `40P01`, 그리고 completion unknown 계열을 문자열 parsing 없이 분류할 수 있습니다. Constraint violation도 class 23을 기준으로 구조화할 수 있습니다. citeturn13search1 + +**Retry 판정표는 다음처럼 두는 것이 좋습니다.** + +| 오류 | 자동 Retry | 단위 | 조건 | +|---|---|---|---| +| `OptimisticConflict` | 조건부 | 전체 use case transaction | 재계산 가능, side effect 없음 | +| `SerializationFailure` | 조건부 | 전체 transaction | bounded attempts + jitter | +| `DeadlockDetected` | 조건부 | 전체 transaction | bounded attempts | +| Lock timeout | 조건부 | 전체 use case | deadline과 업무 정책 확인 | +| Connection acquire 전 실패 | 제한적 | 전체 use case | DB에 작업이 시작되지 않았음이 확실 | +| Unique violation | 기본 금지 | — | idempotent create라면 기존 record 재조회 가능 | +| FK violation | 금지 | — | 업무 순서/데이터 오류 | +| Check violation | 금지 | — | 업무 invariant 오류 | +| Query timeout | 기본 금지 | — | 동일 부하에서 반복하면 부하만 증폭 | +| Schema mismatch | 금지 | — | 배포 오류 | +| Commit 결과 불명 | **금지** | reconciliation | 중복 실행 위험 | + +모든 retry에는: + +```text +maxAttempts +maxElapsedTime +exponentialBackoff +jitter +transaction deadline +retry metrics +``` + +가 있어야 합니다. + +**보안 정책은 Repository API보다 DB credential과 dynamic query 제한이 중요합니다.** + +```text +Application Role +├─ SELECT +├─ INSERT +├─ UPDATE +├─ DELETE +└─ 필요한 sequence 사용 + +Migration Role +├─ CREATE +├─ ALTER +├─ DROP +└─ index / constraint / schema + +Read-only Role +└─ 필요한 SELECT + +Admin Role +└─ 승인된 운영 작업 +``` + +운영 application credential에는 `CREATE TABLE`, `ALTER TABLE`, `DROP TABLE`, extension 설치 권한을 주지 않는 것이 적절합니다. + +PostgreSQL은 `search_path`에 CREATE 권한을 가진 신뢰하지 않는 schema가 들어가면 object resolution이 보안 문제가 될 수 있음을 문서화하고 있으며, 안전한 schema privilege 패턴을 별도로 설명합니다. 따라서 migration schema를 명확히 하고 application role의 `search_path`를 고정·검증해야 합니다. citeturn21search6 + +추가 보안 규칙은 다음처럼 고정하는 것이 좋습니다. + +```text +JPQL +→ parameter binding + +Native SQL +→ J3 내부 +→ 값 문자열 연결 금지 + +Dynamic sort +→ allowlist + +Dynamic table/column +→ 원칙적 금지 +→ 불가피하면 enum/catalog mapping + +Entity +→ API request mass binding 금지 + +SQL parameter logging +→ production 기본 OFF + +Tenant ID +→ metric tag / raw log 금지 + +DB password +→ secret manager / workload identity 경로 +``` + +**관측성은 이미 Boot에서 상당 부분 제공됩니다.** Spring Boot는 DataSource에 `jdbc.connections` active/idle/max/min gauge를 만들고 Hikari-specific `hikaricp` metrics도 제공합니다. `hibernate-micrometer`가 있고 Hibernate statistics를 활성화하면 Hibernate metrics를, Spring Data Repository 호출에는 `spring.data.repository.invocations`를 제공합니다. citeturn21search0 + +공통 관측 계약은 이를 다음처럼 확장하는 것이 적절합니다. + +| 계층 | 필수 관측 | +|---|---| +| Pool | active, idle, pending, max, acquire latency, timeout | +| Transaction | count, latency, rollback, timeout, isolation, retry, completion-unknown | +| Query | queryName, count, latency, rows, timeout, lock wait | +| Fetch | statement count, entity load/fetch, collection fetch | +| Batch | batch count, batch size, flushed entities | +| Lock | optimistic conflict, pessimistic timeout, deadlock | +| Migration | version, validate result, migration duration | +| Retry | reason, attempt, elapsed | +| Cache | L2/query hit/miss when enabled | + +Metric cardinality는 낮게 유지합니다. + +**허용:** + +```text +persistenceUnit +operationName +bounded entityType +bounded queryName +outcome +failureCategory +isolation +``` + +**금지:** + +```text +entityId +userId +tenantId 원문 +SQL parameter +Email / Phone / PII +임의 SQL text +dynamic WHERE clause +``` + +SQL 전체 문자열을 metric dimension으로 쓰는 대신 정규화된 query fingerprint 또는 등록된 `queryName`을 사용합니다. SQL parameter logging은 production에서 기본 비활성화해야 합니다. + +HikariCP의 pool size 자체도 무작정 키우면 안 됩니다. Hikari는 maximum pool size 도달 시 connection 반환을 기다리다가 `connectionTimeout` 이후 실패하는 모델을 사용하므로, 관측해야 할 핵심은 단순 active count가 아니라 **pending/acquire latency와 transaction duration**입니다. citeturn11search2turn21search0 + +**테스트는 H2 중심이 아니라 PostgreSQL Contract 중심으로 설계해야 합니다.** + +테스트 피라미드는 다음이 적절합니다. + +```text +Pure Unit +→ domain logic + +@DataJpaTest +→ repository wiring / quick mapping + +PostgreSQL Testcontainers +→ real persistence semantics + +PostgreSQL 16 / 17 / 18 Matrix +→ release compatibility + +Fault Injection +→ lock / network / commit ambiguity + +Migration Snapshot +→ real upgrade path +``` + +Testcontainers는 실제 PostgreSQL image를 실행할 수 있으므로 DB 고유 기능에 의존하는 integration test를 H2 대체 구현이 아니라 실제 DB에서 수행하는 기반으로 적합합니다. citeturn11search0turn11search10 + +필수 Contract Test 목록은 다음과 같습니다. + +| 범주 | Release Gate | +|---|---| +| Mapping | ID, Embeddable, record Embeddable, Enum, time, converter, association | +| Lifecycle | persist, merge, dirty check, flush, clear, detach, refresh | +| Transaction | commit, rollback, checked/unchecked rollback rule, `REQUIRES_NEW`, self-invocation | +| Concurrency | optimistic conflict, pessimistic timeout, deadlock, serialization failure | +| Constraint | unique race, FK, check, partial unique | +| Query | derived, JPQL, projection, specification, native | +| Fetch | N+1, graph, fetch join, multiple collections, statement count | +| Pagination | Page, Slice, keyset, duplicate sort values, concurrent insertion | +| Hibernate 7.4 | **collection fetch join + pagination regression** | +| Batch | actual JDBC batching, IDENTITY no-batch, sequence batch, flush/clear | +| Bulk | bulk update 뒤 stale Entity | +| PostgreSQL | JSONB, Array, Range, `ON CONFLICT`, `SKIP LOCKED` | +| Flyway | empty DB, previous release snapshot, repeatable, checksum mismatch | +| Security | restricted application role, dynamic sort injection, SQL log masking | +| Pool | saturation, acquire timeout, `REQUIRES_NEW` pressure | +| Failure | process kill, network loss, DB restart, transaction retry | +| Commit ambiguity | COMMIT 전/중/후 connection loss simulation | +| Observability | cardinality, PII masking, queryName/failureCategory | + +PostgreSQL version matrix는 PR마다 최소 oldest/current인 `16 + 18`, release branch에서 `16 + 17 + 18` 전체를 실행하는 방식이 비용과 호환성 검증의 균형점입니다. 다만 “16·17·18 Stable”이라고 선언하려면 release gate에서는 세 버전을 모두 통과해야 합니다. + +Migration은 단순히 **빈 DB → latest**만 테스트하면 부족합니다. + +```text +empty +→ latest + +previous release N-1 +→ latest + +oldest supported upgrade snapshot +→ latest + +checksum modified +→ validation must fail + +missing migration +→ validation must fail + +failed non-transactional migration +→ known recovery procedure +``` + +를 함께 검증해야 합니다. Flyway가 checksum/name/type/missing migration을 validation 대상으로 삼기 때문입니다. citeturn19search0turn19search4 + +**실무 실패 사례를 플랫폼 규칙으로 변환하면 다음과 같습니다.** + +| 상황 | 직접 원인 | 설계 규칙 | 회귀 테스트 | +|---|---|---|---| +| OSIV 뒤에서 N+1 발생 | Web serialization 중 LAZY load | OSIV off, DTO/fetch plan | Controller 밖 Entity access 실패 | +| EAGER 폭증 | mapping이 use case fetch plan을 결정 | 최소 mapping + query fetch plan | SQL/row count | +| 여러 collection fetch | Cartesian product | DTO/분할 조회 | skewed collection fixture | +| Fetch Join + Page 전체 load | **구 Hibernate 동작** | 7.4+ PG에서는 새 SQL behavior 검증 | Hibernate 7.4 pagination regression citeturn13search0 | +| `saveAll()`인데 batch 없음 | JDBC batch 미설정/IDENTITY | actual batch 관측 | statement/batch count | +| IDENTITY batch 실패 | ID 얻기 위해 즉시 insert | write-heavy Entity는 sequence | ID strategy benchmark citeturn14view0 | +| Bulk update 후 stale | PC 미동기화 | flush → DML → clear | stale entity assertion citeturn12view1 | +| TX 안에서 API 대기 | DB resource 장기 보유 | 외부 I/O TX 밖 | pool pressure test | +| `REQUIRES_NEW` 고갈 | outer+inner connection 동시 점유 | 제한 + pool capacity test | concurrent nested tx citeturn15search0 | +| Optimistic 부분 Retry | stale PC에서 일부 코드 재실행 | 전체 unit-of-work retry | conflict fixture | +| Deadlock 무한 Retry | retry budget 없음 | bounded full-TX retry | deterministic deadlock | +| DDL auto update | runtime schema 변경 | Flyway only | app role DDL deny | +| H2만 통과 | DB semantics 차이 | PG contract mandatory | PG16~18 | +| Entity JSON 반환 | lazy graph serialization | DTO/projection | detached serialization | +| Soft Delete unique 충돌 | deleted row도 unique에 존재 | domain policy + partial index | recreate-after-delete | +| Replica stale read | replication lag | replica experimental | read-after-write lag | +| Commit 응답 유실 | 결과 모호 | no auto retry, reconciliation | protocol failure injection | + +## Stable 범위와 구현 순서 + +최종적인 **Stable / Experimental / 비지원 범위**는 다음이 현실적입니다. + +| 영역 | Stable | Experimental / Advanced | 초기 비지원 | +|---|---|---|---| +| Repository | Spring Data domain repository | custom fragments | GenericRepository 재구현 | +| JPA | Persistence 3.2 | Persistence 4.0 compatibility | Extended PC 일반 사용 | +| Provider | Hibernate 7.4 | Hibernate 8 lane | 임의 provider 동일 보장 선언 | +| DB | PostgreSQL 16·17·18 | PG19 compatibility | MySQL/Oracle 호환 선언 | +| Local DB | H2 convenience | — | H2를 PG 증명으로 사용 | +| Transaction | REQUIRED, read-only, timeout | MANDATORY, REQUIRES_NEW | remote distributed transaction 기본화 | +| Lock | Optimistic, standard pessimistic | NOWAIT/SKIP LOCKED extension | generic distributed lock | +| Query | Derived, JPQL, projection | Specification, Querydsl, native | 자유로운 raw SQL | +| Fetch | EntityGraph, fetch join, projection | batch/subselect fetch | global EAGER | +| Pagination | Page, Slice, keyset | Scroll/Stream | 무제한 findAll | +| Batch | JDBC batch | StatelessSession/COPY | `saveAll`을 batch guarantee로 정의 | +| Migration | Flyway migrate/validate | non-transactional/admin migration | prod ddl-auto update | +| Audit | Spring Data auditing opt-in | Envers | 모든 Entity 강제 history | +| Soft Delete | domain-specific | helper capability | global implicit soft delete | +| Cache | L1 | L2 opt-in | Query cache 기본 활성화 | +| Multi-tenancy | single tenant baseline | tenant column/RLS/schema/db | 투명 자동 multi-tenant | +| Replica | primary | read replica experimental | annotation만으로 자동 routing | +| Retry | bounded full-TX retry | domain-specific policy | repository-method retry | +| Completion unknown | error + reconciliation | domain-specific resolver | 자동 retry | + +이 조사에서 가장 중요한 결정은 **JPA 플랫폼이 많은 API를 제공하는 것보다 잘못된 사용을 어렵게 만드는 것**입니다. + +권장 핵심 API는 거대한 Repository가 아니라 다음과 같은 작은 기술 primitive입니다. + +```java +public interface JpaTransactionExecutor { + T execute(TransactionProfile profile, Supplier work); +} + +public record TransactionProfile( + String name, + IsolationLevel isolation, + Duration timeout, + boolean readOnly, + RetryProfile retryProfile +) {} + +public interface JpaRetryPolicy { + RetryDecision classify(JpaPersistenceException error); +} + +public interface QueryObservation { + QueryScope start(String queryName); +} + +public interface PostgreSqlExtension { + // marker / capability boundary +} +``` + +다만 평범한 application service는 이런 저수준 API조차 직접 다루지 않고 보통 Spring `@Transactional` + domain repository를 사용하게 하는 편이 좋습니다. + +```java +@Service +@RequiredArgsConstructor +public class PlaceOrderService { + + private final OrderRepository orders; + private final OutboxRepository outbox; + + @Transactional + public OrderId place(PlaceOrder command) { + Order order = Order.place(command); + orders.save(order); + + outbox.save(OutboxMessage.from(order)); + + return order.getId(); + } +} +``` + +**단계별 구현 순서와 완료 조건**은 다음과 같이 잡는 것이 좋습니다. + +| 단계 | 구현 | 완료 조건 | +|---|---|---| +| Foundation | `jpa-core`, Boot BOM, PostgreSQL profile, Hikari, OSIV off | PG16·17·18 bootstrap 및 기본 CRUD contract 통과 | +| Mapping | Entity/ID/association/value 규칙, test fixtures | Mapping rule 문서 + ArchUnit/static check + PG round trip | +| Transaction | profile, boundaries, propagation, timeout | rollback/self-invocation/REQUIRES_NEW tests | +| Concurrency | version, lock, SQLSTATE error mapper | optimistic/deadlock/serialization/lock timeout 재현 | +| Error/Retry | common exception + full-TX retry | retryable/non-retryable matrix 자동 테스트 | +| Query | projection/specification/custom fragments | query startup validation + queryName 체계 | +| Fetch | EntityGraph/fetch join/query-count toolkit | N+1 및 Cartesian regression gate | +| Pagination | Slice/keyset/cursor | duplicate sort·concurrent insert contract | +| Batch | sequence profile, JDBC batch, flush/clear | 실제 JDBC batching 관측 | +| PostgreSQL Extension | JSONB/Array/Range, ON CONFLICT, lock extension | PG16·17·18 native capability tests | +| Migration | Flyway, validation, snapshots | empty + N-1 + oldest-supported migration 통과 | +| Observability | pool/tx/query/retry metrics | cardinality·PII tests | +| Security | DB role separation, log masking | app credential로 DDL 실패 보장 | +| Advanced | Envers, L2 cache, StatelessSession | 기능별 opt-in contract | +| Experimental | multi-tenancy, replica, JPA4/Hibernate8 | 별도 compatibility suite 통과 전 Stable 승격 금지 | + +최종적으로 이번 조사에서 요구된 산출물은 다음과 같이 귀결됩니다. + +| 요구 산출물 | 조사 결론 | +|---|---| +| Java·Spring Data·Hibernate·PG 지원 매트릭스 | Java 21 + Boot BOM + JPA 3.2 + Hibernate 7.4 + PG16~18 | +| J1~J4 계층 | Standard / Advanced / Provider Extension / Admin | +| Entity Mapping | Field access 중심, Entity 외부 직렬화 금지, domain ownership | +| ID 전략 | PG 기본 Sequence, UUID stable, IDENTITY write-heavy 제한 | +| Association | global cascade/eager 금지, lifecycle 명시 | +| Persistence Context | transaction-scoped, OSIV off | +| Transaction | Application Service boundary | +| Commit Unknown | 별도 `TransactionCompletionUnknown`, 자동 retry 금지 | +| Optimistic/Pessimistic | optimistic 우선, lock extension 제한 | +| Query | Derived → JPQL/Projection → Dynamic → Native 단계화 | +| Fetch | use-case fetch plan, quantitative regression | +| Pagination | Page/Slice/Keyset 역할 분리 | +| Batch | saveAll과 JDBC batch 구분 | +| Migration | Flyway가 schema change source of truth | +| Constraint/Index | DB invariant + query-driven index | +| PostgreSQL Extension | 별도 `jpa-postgresql` | +| Auditing/Soft Delete/History | 각각 별개 capability | +| Cache | L1 기본, L2 opt-in, query cache off | +| Multi-tenancy/Replica | 초기 Experimental | +| 오류/Retry | SQLSTATE 기반 안정 오류 + full-TX retry | +| Metric/Trace/Logging | queryName 기반, parameter·PII 배제 | +| Security | runtime/migration/admin credential 분리 | +| Tests | PG Testcontainers + 실제 version matrix | +| Stable/Experimental | JPA4/Hibernate8/multitenancy/replica 분리 | +| 구현 순서 | Foundation → semantics → performance → operations | + +가장 중요한 최종 설계 규칙은 여섯 가지로 압축됩니다. + +**첫째**, 도메인이 Entity와 Repository를 소유하며 JPA 플랫폼은 `GenericRepository`를 만들지 않습니다. Spring Data JPA가 이미 그 추상화를 제공하기 때문입니다. citeturn20view0 + +**둘째**, transaction은 Repository method가 아니라 **Application Use Case** 단위이며, Optimistic conflict·Deadlock·Serialization Failure의 retry도 새 Persistence Context에서 전체 transaction을 다시 실행합니다. PostgreSQL Serializable 역시 transaction 재실행을 전제로 합니다. citeturn13search9 + +**셋째**, DB에 요청을 보냈다는 사실과 commit이 확정됐다는 사실을 구분합니다. Commit 결과가 모호하면 `TransactionCompletionUnknown`으로 올리고 자동 retry하지 않습니다. PostgreSQL도 completion-unknown을 SQLSTATE에서 별도 condition으로 정의합니다. citeturn13search1 + +**넷째**, Fetch 전략은 Entity annotation의 EAGER/LAZY만으로 결정하지 않고 **use-case-specific Fetch Plan**으로 관리합니다. 특히 Hibernate 7.4에서 PostgreSQL의 collection fetch join + pagination 동작이 과거 버전과 달라졌으므로, 오래된 금지 규칙을 그대로 복사하지 말고 현재 버전 SQL을 contract test해야 합니다. citeturn13search0turn13search11 + +**다섯째**, Entity Mapping이 schema의 Source of Truth가 아닙니다. 운영 schema는 Flyway가 소유하고 Hibernate는 `validate` 역할을 맡으며, `repair`, concurrent index, backfill, partition 같은 작업은 J4 Admin 영역으로 분리합니다. citeturn19search0turn19search1turn17search3 + +**여섯째**, `H2에서 된다`를 호환성 증거로 쓰지 않습니다. **PostgreSQL 16·17·18의 실제 locking, constraint, batch, migration, query plan, SQLSTATE를 통과하는 것**을 이 플랫폼의 Stable 완료 조건으로 삼는 것이 적절합니다. citeturn0search3turn13search5 diff --git a/jpa-superpowers-package/validate_jpa_docs.py b/jpa-superpowers-package/validate_jpa_docs.py new file mode 100644 index 00000000..23449c30 --- /dev/null +++ b/jpa-superpowers-package/validate_jpa_docs.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +from pathlib import Path +import hashlib +import re +import sys +import zipfile + +SCRIPT_DIR = Path(__file__).resolve().parent +PACKAGE_DESIGN = SCRIPT_DIR / 'docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md' + +if PACKAGE_DESIGN.exists(): + ROOT = SCRIPT_DIR + DESIGN = PACKAGE_DESIGN + PLAN = ROOT / 'docs/superpowers/plans/2026-08-11-jpa-persistence-platform-implementation-plan.md' + EXPANSION = ROOT / 'docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md' + VALIDATION = ROOT / 'VALIDATION.md' + ZIP_PATH = ROOT.parent / 'jpa-superpowers-package.zip' +else: + ROOT = Path('/mnt/data') + DESIGN = ROOT / 'jpa-persistence-platform-design.md' + PLAN = ROOT / 'jpa-persistence-platform-implementation-plan.md' + EXPANSION = ROOT / 'jpa-persistence-experimental-expansion-plan.md' + VALIDATION = ROOT / 'jpa-superpowers-validation.md' + ZIP_PATH = ROOT / 'jpa-superpowers-package.zip' + +checks: list[tuple[str, bool, str]] = [] + +def check(name: str, condition: bool, detail: str = '') -> None: + checks.append((name, bool(condition), detail)) + + +def sha256(path: Path) -> str: + h = hashlib.sha256() + with path.open('rb') as f: + for chunk in iter(lambda: f.read(1024 * 1024), b''): + h.update(chunk) + return h.hexdigest() + + +def task_chunks(text: str) -> list[tuple[int, str]]: + matches = list(re.finditer(r'^### Task (\d+):', text, re.MULTILINE)) + chunks: list[tuple[int, str]] = [] + for i, match in enumerate(matches): + end = matches[i + 1].start() if i + 1 < len(matches) else len(text) + chunks.append((int(match.group(1)), text[match.start():end])) + return chunks + +for path, label in [(DESIGN, '설계서'), (PLAN, 'Stable 계획서'), (EXPANSION, 'Experimental 계획서')]: + check(f'{label} 존재', path.exists(), str(path)) + +if not all(path.exists() for path in [DESIGN, PLAN, EXPANSION]): + print('missing required documents', file=sys.stderr) + sys.exit(2) + +design = DESIGN.read_text(encoding='utf-8') +plan = PLAN.read_text(encoding='utf-8') +expansion = EXPANSION.read_text(encoding='utf-8') + +line_counts = { + 'design': len(design.splitlines()), + 'plan': len(plan.splitlines()), + 'expansion': len(expansion.splitlines()), +} +check('설계서 최소 상세도', line_counts['design'] >= 2500, f"{line_counts['design']} lines") +check('Stable 계획서 최소 상세도', line_counts['plan'] >= 4000, f"{line_counts['plan']} lines") +check('Experimental 계획서 최소 상세도', line_counts['expansion'] >= 650, f"{line_counts['expansion']} lines") + +for text, label in [(design, '설계서'), (plan, 'Stable 계획서'), (expansion, 'Experimental 계획서')]: + fences = len(re.findall(r'^```', text, re.MULTILINE)) + check(f'{label} 코드 fence 균형', fences % 2 == 0, str(fences)) + bad = re.findall(r'\b(?:TODO|TBD|FIXME|implement later|fill in details)\b', text, re.IGNORECASE) + check(f'{label} placeholder 부재', not bad, ', '.join(sorted(set(bad)))) + +required_design_terms = [ + 'GenericRepository', + 'TransactionCompletionUnknownException', + 'EvidenceAwareJpaTransactionManager', + 'Application Service', + 'OSIV', + 'PostgreSQL 16·17·18', + 'Hibernate 7.4 Collection Fetch Pagination', + 'FOR UPDATE SKIP LOCKED', + 'CREATE INDEX CONCURRENTLY', + 'Flyway', + 'Runtime·Migration·Admin', + 'H2는 Local Convenience', + '전체 Transaction Retry', + 'J1 Standard Persistence', + 'J4 Admin / Operations', + '완료 정의', +] +for term in required_design_terms: + check(f'설계 핵심 계약: {term}', term in design) + +check('GenericRepository 실제 선언 부재', + 'public interface GenericRepository' not in design + plan and + 'interface GenericRepository<' not in design + plan) +check('운영 ddl auto update 금지', '운영에서 `ddl-auto=update`' in plan) +check('Completion Unknown 자동 Retry 금지', + 'TransactionCompletionUnknownException' in plan and + 'RetryDecision.reconcile' in plan and + 'Never retry completion unknown' in plan) +check('OSIV false 강제', 'spring.jpa.open-in-view must be false' in plan) +check('PG16·17·18 Matrix', 'PG_16' in plan and 'PG_17' in plan and 'PG_18' in plan) +check('Hibernate 7.4 fetch pagination gate', 'HibernateCollectionFetchPaginationContractTest' in plan) +check('Flyway snapshot upgrade gate', 'FlywayUpgradeContractTest' in plan) +check('Runtime role no DDL gate', 'runtimeRoleCanWriteRowsButCannotCreateTable' in plan) +check('Stable 계획에 Experimental create 경로 부재', '- Create: `modules/jpa-experimental/' not in plan) + +stable_chunks = task_chunks(plan) +exp_chunks = task_chunks(expansion) +check('Stable Task 1~53 연속성', [n for n, _ in stable_chunks] == list(range(1, 54)), str([n for n, _ in stable_chunks])) +check('Experimental Task 1~9 연속성', [n for n, _ in exp_chunks] == list(range(1, 10)), str([n for n, _ in exp_chunks])) +check('Stable Task chunk 수', len(stable_chunks) == 53, str(len(stable_chunks))) +check('Experimental Task chunk 수', len(exp_chunks) == 9, str(len(exp_chunks))) + +required_markers = [ + '**Files:**', + '**Interfaces:**', + '**Implementation requirements:**', + '**Step 1:', + '**Step 2:', + '**Step 3:', + '**Step 4:', + '**Step 5:', + 'Expected:', + 'git commit -m', +] +for group_name, chunks in [('Stable', stable_chunks), ('Experimental', exp_chunks)]: + for number, chunk in chunks: + for marker in required_markers: + check(f'{group_name} Task {number} 필수 항목: {marker}', marker in chunk) + check(f'{group_name} Task {number} Gradle focused command', './gradlew ' in chunk and '--tests' in chunk) + check(f'{group_name} Task {number} exact path', '*' not in '\n'.join( + line for line in chunk.splitlines() if line.startswith(('- Create:', '- Modify:', '- Test:')))) + +create_pattern = re.compile(r'^- Create: `([^`]+)`', re.MULTILINE) +stable_creates = create_pattern.findall(plan) +exp_creates = create_pattern.findall(expansion) +check('Stable Create 경로 중복 부재', len(stable_creates) == len(set(stable_creates)), str(len(stable_creates))) +check('Experimental Create 경로 중복 부재', len(exp_creates) == len(set(exp_creates)), str(len(exp_creates))) +check('Stable·Experimental Create 경로 충돌 부재', not (set(stable_creates) & set(exp_creates)), str(set(stable_creates) & set(exp_creates))) +check('Experimental 계획은 Stable Task 1~53 이후 시작', 'Stable 계획 Task 1~53이 완료되고' in expansion, '') + +# Type/name consistency checks for high-risk cross-task contracts. +for term in [ + 'PersistenceOperationName', + 'TransactionProfile', + 'RetryProfile', + 'JpaTransactionExecutor', + 'JpaRetryPolicy', + 'RetryDecision', + 'JpaFailureContext', + 'QueryName', + 'KeysetPageRequest', + 'KeysetSlice', + 'TransactionCompletionUnknownException', + 'PostgreSqlWorkClaimExecutor', + 'FlywayValidationGate', + 'JpaPlatformEndpoint', +]: + check(f'공통 타입 일관성: {term}', plan.count(term) >= 2, str(plan.count(term))) + +check('Experimental Gradle 경로 정확성', ':modules:jpa-experimental:' in expansion) +check('Replica annotation-only routing 금지', 'readOnly=true`만으로 replica routing하지 않는다' in expansion) +check('RLS connection reuse 검증', 'pooledConnectionDoesNotLeakPriorTenantSetting' in expansion) +check('Stable 승격 ADR gate', 'BLOCKED_MISSING_ADR' in expansion) + +# Source preservation check. +check('심층 리서치 원문 부록 포함', '# 부록 A. 심층 리서치 원문 보존본' in design and '# JPA 관계형 영속성 플랫폼 심층 리서치' in design) + +passed = sum(1 for _, ok, _ in checks if ok) +failed = len(checks) - passed +status = 'PASS' if failed == 0 else 'FAIL' + +lines = [ + '# JPA Superpowers 문서 정적 검증', + '', + f'- 결과: **{status}**', + f'- 실행 검사: **{len(checks)}개**', + f'- 통과: **{passed}개**', + f'- 실패: **{failed}개**', + f'- 설계서: **{line_counts["design"]:,}행**', + f'- Stable 구현 계획서: **{line_counts["plan"]:,}행**', + f'- Experimental 확장 계획서: **{line_counts["expansion"]:,}행**', + f'- Stable Task: **{len(stable_chunks)}개**', + f'- Experimental Task: **{len(exp_chunks)}개**', + f'- Stable Create 경로: **{len(stable_creates)}개**', + f'- Experimental Create 경로: **{len(exp_creates)}개**', + f'- 설계 SHA-256: `{sha256(DESIGN)}`', + f'- Stable 계획 SHA-256: `{sha256(PLAN)}`', + f'- Experimental 계획 SHA-256: `{sha256(EXPANSION)}`', + '', + '## 검사 결과', + '', + '| 검사 | 결과 | 상세 |', + '|---|---:|---|', +] +for name, ok, detail in checks: + safe_detail = detail.replace('|', '\\|').replace('\n', ' ')[:500] + lines.append(f'| {name} | {"PASS" if ok else "FAIL"} | {safe_detail} |') + +VALIDATION.write_text('\n'.join(lines) + '\n', encoding='utf-8') +print(f'{status}: {passed}/{len(checks)} checks passed') +if failed: + for name, ok, detail in checks: + if not ok: + print(f'FAIL: {name}: {detail}') + sys.exit(1) diff --git a/messaging-superpowers-package/MANIFEST.sha256 b/messaging-superpowers-package/MANIFEST.sha256 new file mode 100644 index 00000000..f64a35de --- /dev/null +++ b/messaging-superpowers-package/MANIFEST.sha256 @@ -0,0 +1,5 @@ +e0703a77df8aac482823491d4f6ee43af1eace6287444a6006adb1e4f268e15c docs/superpowers/specs/2026-08-10-messaging-platform-design.md +4ad5d445f74bede368c75d482fd2adcf61ff1c2dbe93f918a4719bb1de1f7eee docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md +f1f8289d07a8f70c0b113349c4e14671b049c14f25d7e28f28ac71b4677960bd VALIDATION.md +3fc835294e07588cf0c854a6182e3d2fc02903aca29eae5f6f951a58524b81eb validate_messaging_docs.py +592a7cebd442da3bbc7cf822a7bacc492c412b9b84f82ea90c0d867a12f03b90 README.md diff --git a/messaging-superpowers-package/README.md b/messaging-superpowers-package/README.md new file mode 100644 index 00000000..1c300a59 --- /dev/null +++ b/messaging-superpowers-package/README.md @@ -0,0 +1,55 @@ +# Messaging Platform Superpowers Package + +이 패키지는 `Java/Spring Messaging 플랫폼 심층 리서치`를 구현 기준으로 변환한 설계서와 구현 계획서다. + +## 포함 파일 + +```text +docs/superpowers/specs/2026-08-10-messaging-platform-design.md +docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md +VALIDATION.md +validate_messaging_docs.py +MANIFEST.sha256 +``` + +## 설계 경계 + +- 자체 `messaging-core`가 공개 계약을 소유한다. +- Kafka 4.2+/4.3.x와 RabbitMQ 4.3.x는 Stable Adapter다. +- Kafka Share Group, Pulsar 4.0 LTS·4.2, NATS JetStream 2.14.x는 Experimental Adapter다. +- 공통 `EXACTLY_ONCE` 또는 전역 순서 옵션을 제공하지 않는다. +- publish 결과는 `CONFIRMED`, `REJECTED`, `AMBIGUOUS`와 broker evidence로 표현한다. +- M1 Typed, M2 Advanced, M3 Native Capability, M4 Admin Plane을 분리한다. +- retry·DLQ·redrive에서 logical `messageId`를 유지한다. +- DLQ publish 확인 전에 source message를 settlement하지 않는다. +- Transactional Outbox, Inbox, Idempotent Consumer, Claim Check를 Reliability 모듈로 분리한다. + +## 구현 가정 + +실제 Backend Skeleton 저장소가 입력에 포함되지 않았기 때문에 계획은 다음 기준을 사용한다. + +- Java 21 +- Gradle Kotlin DSL +- root package: `io.backend.skeleton.messaging` +- module root: `modules/messaging` +- PostgreSQL 16, Flyway, Spring JDBC/JPA +- JUnit 5, AssertJ, ArchUnit, Testcontainers, Toxiproxy +- Spring Framework 6.2 공통 호환선과 Spring 7.0 호환성 job + +실제 저장소 구조가 다르면 파일 경로와 dependency catalog만 매핑한다. 공개 계약, 모듈 의존 방향, 신뢰성 상태 모델과 검증 기준은 그대로 유지한다. + +## 문서 검증 + +패키지 루트에서 다음 명령을 실행한다. + +```bash +./validate_messaging_docs.py \ + docs/superpowers/specs/2026-08-10-messaging-platform-design.md \ + docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md +``` + +`VALIDATION.md`는 문서 구조, Task 1~44 연속성, TDD 단계, 필수 설계 계약, placeholder, 중복 파일 경로를 확인한 정적 검증 결과다. + +## 검증 한계 + +현재 검증은 설계·계획 문서에 대한 정적 검증이다. 실제 저장소가 제공되지 않아 Gradle compile, broker 통합 테스트, chaos test, 성능 인증은 실행하지 않았다. 구현 계획의 각 Task가 해당 실행 검증을 추가하도록 구성되어 있다. diff --git a/messaging-superpowers-package/VALIDATION.md b/messaging-superpowers-package/VALIDATION.md new file mode 100644 index 00000000..d7029704 --- /dev/null +++ b/messaging-superpowers-package/VALIDATION.md @@ -0,0 +1,432 @@ +# Messaging Superpowers 문서 정적 검증 + +- 설계서: `messaging-platform-design.md` — 2,608행, 74,649 bytes +- 계획서: `messaging-platform-implementation-plan.md` — 5,056행, 200,320 bytes +- Task 수: 44 +- Create 경로 수: 356 +- 검증 항목 수: 422 + +## 결과: PASS + +- PASS: 설계서가 2,000행 이상이다 +- PASS: 구현 계획서가 4,000행 이상이다 +- PASS: 설계서 Markdown 코드 블록이 균형을 이룬다 +- PASS: 계획서 Markdown 코드 블록이 균형을 이룬다 +- PASS: 설계서에 unresolved todo marker가 없다 +- PASS: 계획서에 unresolved todo marker가 없다 +- PASS: 설계서에 unresolved tbd marker가 없다 +- PASS: 계획서에 unresolved tbd marker가 없다 +- PASS: 설계서에 unresolved fix marker가 없다 +- PASS: 계획서에 unresolved fix marker가 없다 +- PASS: 설계서에 placeholder ADR number가 없다 +- PASS: 계획서에 placeholder ADR number가 없다 +- PASS: 설계서에 wildcard build path가 없다 +- PASS: 계획서에 wildcard build path가 없다 +- PASS: 설계서에 deferred implementation phrase가 없다 +- PASS: 계획서에 deferred implementation phrase가 없다 +- PASS: 설계서가 필수 항목 'M1 Typed Messaging API'을 포함한다 +- PASS: 설계서가 필수 항목 'M2 Advanced API'을 포함한다 +- PASS: 설계서가 필수 항목 'M3 Native Capability'을 포함한다 +- PASS: 설계서가 필수 항목 'M4 Admin Plane'을 포함한다 +- PASS: 설계서가 필수 항목 'PublishCompletion'을 포함한다 +- PASS: 설계서가 필수 항목 'AMBIGUOUS'을 포함한다 +- PASS: 설계서가 필수 항목 'MessageEnvelope'을 포함한다 +- PASS: 설계서가 필수 항목 'DeliveryGuarantee'을 포함한다 +- PASS: 설계서가 필수 항목 'OrderingScope'을 포함한다 +- PASS: 설계서가 필수 항목 'Retry Policy Engine'을 포함한다 +- PASS: 설계서가 필수 항목 'DLQ·Parking·Redrive'을 포함한다 +- PASS: 설계서가 필수 항목 'Kafka Stable Adapter'을 포함한다 +- PASS: 설계서가 필수 항목 'RabbitMQ Stable Adapter'을 포함한다 +- PASS: 설계서가 필수 항목 'Transactional Outbox'을 포함한다 +- PASS: 설계서가 필수 항목 'Inbox와 Idempotent Consumer'을 포함한다 +- PASS: 설계서가 필수 항목 'Claim Check'을 포함한다 +- PASS: 설계서가 필수 항목 'Pulsar Experimental Adapter'을 포함한다 +- PASS: 설계서가 필수 항목 'NATS JetStream Experimental Adapter'을 포함한다 +- PASS: 설계서가 필수 항목 'Spring Cloud Stream Bridge'을 포함한다 +- PASS: 설계서가 필수 항목 'Security'을 포함한다 +- PASS: 설계서가 필수 항목 'Observability'을 포함한다 +- PASS: 설계서가 필수 항목 '호환성 인증 매트릭스'을 포함한다 +- PASS: 설계서가 필수 항목 '비지원 범위'을 포함한다 +- PASS: 설계서가 필수 항목 '완료 정의'을 포함한다 +- PASS: 공통 DeliveryGuarantee가 두 가지 보장만 선언한다 +- PASS: 공통 DeliveryGuarantee enum에 EXACTLY_ONCE를 선언하지 않는다 +- PASS: 공통 OrderingScope enum에 GLOBAL을 선언하지 않는다 +- PASS: DLQ confirm 후 source settlement 순서를 명시한다 +- PASS: retry와 reliability에서 동일 message ID를 유지한다 +- PASS: Task 번호가 1부터 44까지 연속이다 +- PASS: Task 1가 '**Files:**'을 포함한다 +- PASS: Task 1가 '**Interfaces:**'을 포함한다 +- PASS: Task 1가 'Step 1'을 포함한다 +- PASS: Task 1가 'Step 2'을 포함한다 +- PASS: Task 1가 'Step 3'을 포함한다 +- PASS: Task 1가 'Step 4'을 포함한다 +- PASS: Task 1가 'Step 5'을 포함한다 +- PASS: Task 1가 'git commit -m'을 포함한다 +- PASS: Task 2가 '**Files:**'을 포함한다 +- PASS: Task 2가 '**Interfaces:**'을 포함한다 +- PASS: Task 2가 'Step 1'을 포함한다 +- PASS: Task 2가 'Step 2'을 포함한다 +- PASS: Task 2가 'Step 3'을 포함한다 +- PASS: Task 2가 'Step 4'을 포함한다 +- PASS: Task 2가 'Step 5'을 포함한다 +- PASS: Task 2가 'git commit -m'을 포함한다 +- PASS: Task 3가 '**Files:**'을 포함한다 +- PASS: Task 3가 '**Interfaces:**'을 포함한다 +- PASS: Task 3가 'Step 1'을 포함한다 +- PASS: Task 3가 'Step 2'을 포함한다 +- PASS: Task 3가 'Step 3'을 포함한다 +- PASS: Task 3가 'Step 4'을 포함한다 +- PASS: Task 3가 'Step 5'을 포함한다 +- PASS: Task 3가 'git commit -m'을 포함한다 +- PASS: Task 4가 '**Files:**'을 포함한다 +- PASS: Task 4가 '**Interfaces:**'을 포함한다 +- PASS: Task 4가 'Step 1'을 포함한다 +- PASS: Task 4가 'Step 2'을 포함한다 +- PASS: Task 4가 'Step 3'을 포함한다 +- PASS: Task 4가 'Step 4'을 포함한다 +- PASS: Task 4가 'Step 5'을 포함한다 +- PASS: Task 4가 'git commit -m'을 포함한다 +- PASS: Task 5가 '**Files:**'을 포함한다 +- PASS: Task 5가 '**Interfaces:**'을 포함한다 +- PASS: Task 5가 'Step 1'을 포함한다 +- PASS: Task 5가 'Step 2'을 포함한다 +- PASS: Task 5가 'Step 3'을 포함한다 +- PASS: Task 5가 'Step 4'을 포함한다 +- PASS: Task 5가 'Step 5'을 포함한다 +- PASS: Task 5가 'git commit -m'을 포함한다 +- PASS: Task 6가 '**Files:**'을 포함한다 +- PASS: Task 6가 '**Interfaces:**'을 포함한다 +- PASS: Task 6가 'Step 1'을 포함한다 +- PASS: Task 6가 'Step 2'을 포함한다 +- PASS: Task 6가 'Step 3'을 포함한다 +- PASS: Task 6가 'Step 4'을 포함한다 +- PASS: Task 6가 'Step 5'을 포함한다 +- PASS: Task 6가 'git commit -m'을 포함한다 +- PASS: Task 7가 '**Files:**'을 포함한다 +- PASS: Task 7가 '**Interfaces:**'을 포함한다 +- PASS: Task 7가 'Step 1'을 포함한다 +- PASS: Task 7가 'Step 2'을 포함한다 +- PASS: Task 7가 'Step 3'을 포함한다 +- PASS: Task 7가 'Step 4'을 포함한다 +- PASS: Task 7가 'Step 5'을 포함한다 +- PASS: Task 7가 'git commit -m'을 포함한다 +- PASS: Task 8가 '**Files:**'을 포함한다 +- PASS: Task 8가 '**Interfaces:**'을 포함한다 +- PASS: Task 8가 'Step 1'을 포함한다 +- PASS: Task 8가 'Step 2'을 포함한다 +- PASS: Task 8가 'Step 3'을 포함한다 +- PASS: Task 8가 'Step 4'을 포함한다 +- PASS: Task 8가 'Step 5'을 포함한다 +- PASS: Task 8가 'git commit -m'을 포함한다 +- PASS: Task 9가 '**Files:**'을 포함한다 +- PASS: Task 9가 '**Interfaces:**'을 포함한다 +- PASS: Task 9가 'Step 1'을 포함한다 +- PASS: Task 9가 'Step 2'을 포함한다 +- PASS: Task 9가 'Step 3'을 포함한다 +- PASS: Task 9가 'Step 4'을 포함한다 +- PASS: Task 9가 'Step 5'을 포함한다 +- PASS: Task 9가 'git commit -m'을 포함한다 +- PASS: Task 10가 '**Files:**'을 포함한다 +- PASS: Task 10가 '**Interfaces:**'을 포함한다 +- PASS: Task 10가 'Step 1'을 포함한다 +- PASS: Task 10가 'Step 2'을 포함한다 +- PASS: Task 10가 'Step 3'을 포함한다 +- PASS: Task 10가 'Step 4'을 포함한다 +- PASS: Task 10가 'Step 5'을 포함한다 +- PASS: Task 10가 'git commit -m'을 포함한다 +- PASS: Task 11가 '**Files:**'을 포함한다 +- PASS: Task 11가 '**Interfaces:**'을 포함한다 +- PASS: Task 11가 'Step 1'을 포함한다 +- PASS: Task 11가 'Step 2'을 포함한다 +- PASS: Task 11가 'Step 3'을 포함한다 +- PASS: Task 11가 'Step 4'을 포함한다 +- PASS: Task 11가 'Step 5'을 포함한다 +- PASS: Task 11가 'git commit -m'을 포함한다 +- PASS: Task 12가 '**Files:**'을 포함한다 +- PASS: Task 12가 '**Interfaces:**'을 포함한다 +- PASS: Task 12가 'Step 1'을 포함한다 +- PASS: Task 12가 'Step 2'을 포함한다 +- PASS: Task 12가 'Step 3'을 포함한다 +- PASS: Task 12가 'Step 4'을 포함한다 +- PASS: Task 12가 'Step 5'을 포함한다 +- PASS: Task 12가 'git commit -m'을 포함한다 +- PASS: Task 13가 '**Files:**'을 포함한다 +- PASS: Task 13가 '**Interfaces:**'을 포함한다 +- PASS: Task 13가 'Step 1'을 포함한다 +- PASS: Task 13가 'Step 2'을 포함한다 +- PASS: Task 13가 'Step 3'을 포함한다 +- PASS: Task 13가 'Step 4'을 포함한다 +- PASS: Task 13가 'Step 5'을 포함한다 +- PASS: Task 13가 'git commit -m'을 포함한다 +- PASS: Task 14가 '**Files:**'을 포함한다 +- PASS: Task 14가 '**Interfaces:**'을 포함한다 +- PASS: Task 14가 'Step 1'을 포함한다 +- PASS: Task 14가 'Step 2'을 포함한다 +- PASS: Task 14가 'Step 3'을 포함한다 +- PASS: Task 14가 'Step 4'을 포함한다 +- PASS: Task 14가 'Step 5'을 포함한다 +- PASS: Task 14가 'git commit -m'을 포함한다 +- PASS: Task 15가 '**Files:**'을 포함한다 +- PASS: Task 15가 '**Interfaces:**'을 포함한다 +- PASS: Task 15가 'Step 1'을 포함한다 +- PASS: Task 15가 'Step 2'을 포함한다 +- PASS: Task 15가 'Step 3'을 포함한다 +- PASS: Task 15가 'Step 4'을 포함한다 +- PASS: Task 15가 'Step 5'을 포함한다 +- PASS: Task 15가 'git commit -m'을 포함한다 +- PASS: Task 16가 '**Files:**'을 포함한다 +- PASS: Task 16가 '**Interfaces:**'을 포함한다 +- PASS: Task 16가 'Step 1'을 포함한다 +- PASS: Task 16가 'Step 2'을 포함한다 +- PASS: Task 16가 'Step 3'을 포함한다 +- PASS: Task 16가 'Step 4'을 포함한다 +- PASS: Task 16가 'Step 5'을 포함한다 +- PASS: Task 16가 'git commit -m'을 포함한다 +- PASS: Task 17가 '**Files:**'을 포함한다 +- PASS: Task 17가 '**Interfaces:**'을 포함한다 +- PASS: Task 17가 'Step 1'을 포함한다 +- PASS: Task 17가 'Step 2'을 포함한다 +- PASS: Task 17가 'Step 3'을 포함한다 +- PASS: Task 17가 'Step 4'을 포함한다 +- PASS: Task 17가 'Step 5'을 포함한다 +- PASS: Task 17가 'git commit -m'을 포함한다 +- PASS: Task 18가 '**Files:**'을 포함한다 +- PASS: Task 18가 '**Interfaces:**'을 포함한다 +- PASS: Task 18가 'Step 1'을 포함한다 +- PASS: Task 18가 'Step 2'을 포함한다 +- PASS: Task 18가 'Step 3'을 포함한다 +- PASS: Task 18가 'Step 4'을 포함한다 +- PASS: Task 18가 'Step 5'을 포함한다 +- PASS: Task 18가 'git commit -m'을 포함한다 +- PASS: Task 19가 '**Files:**'을 포함한다 +- PASS: Task 19가 '**Interfaces:**'을 포함한다 +- PASS: Task 19가 'Step 1'을 포함한다 +- PASS: Task 19가 'Step 2'을 포함한다 +- PASS: Task 19가 'Step 3'을 포함한다 +- PASS: Task 19가 'Step 4'을 포함한다 +- PASS: Task 19가 'Step 5'을 포함한다 +- PASS: Task 19가 'git commit -m'을 포함한다 +- PASS: Task 20가 '**Files:**'을 포함한다 +- PASS: Task 20가 '**Interfaces:**'을 포함한다 +- PASS: Task 20가 'Step 1'을 포함한다 +- PASS: Task 20가 'Step 2'을 포함한다 +- PASS: Task 20가 'Step 3'을 포함한다 +- PASS: Task 20가 'Step 4'을 포함한다 +- PASS: Task 20가 'Step 5'을 포함한다 +- PASS: Task 20가 'git commit -m'을 포함한다 +- PASS: Task 21가 '**Files:**'을 포함한다 +- PASS: Task 21가 '**Interfaces:**'을 포함한다 +- PASS: Task 21가 'Step 1'을 포함한다 +- PASS: Task 21가 'Step 2'을 포함한다 +- PASS: Task 21가 'Step 3'을 포함한다 +- PASS: Task 21가 'Step 4'을 포함한다 +- PASS: Task 21가 'Step 5'을 포함한다 +- PASS: Task 21가 'git commit -m'을 포함한다 +- PASS: Task 22가 '**Files:**'을 포함한다 +- PASS: Task 22가 '**Interfaces:**'을 포함한다 +- PASS: Task 22가 'Step 1'을 포함한다 +- PASS: Task 22가 'Step 2'을 포함한다 +- PASS: Task 22가 'Step 3'을 포함한다 +- PASS: Task 22가 'Step 4'을 포함한다 +- PASS: Task 22가 'Step 5'을 포함한다 +- PASS: Task 22가 'git commit -m'을 포함한다 +- PASS: Task 23가 '**Files:**'을 포함한다 +- PASS: Task 23가 '**Interfaces:**'을 포함한다 +- PASS: Task 23가 'Step 1'을 포함한다 +- PASS: Task 23가 'Step 2'을 포함한다 +- PASS: Task 23가 'Step 3'을 포함한다 +- PASS: Task 23가 'Step 4'을 포함한다 +- PASS: Task 23가 'Step 5'을 포함한다 +- PASS: Task 23가 'git commit -m'을 포함한다 +- PASS: Task 24가 '**Files:**'을 포함한다 +- PASS: Task 24가 '**Interfaces:**'을 포함한다 +- PASS: Task 24가 'Step 1'을 포함한다 +- PASS: Task 24가 'Step 2'을 포함한다 +- PASS: Task 24가 'Step 3'을 포함한다 +- PASS: Task 24가 'Step 4'을 포함한다 +- PASS: Task 24가 'Step 5'을 포함한다 +- PASS: Task 24가 'git commit -m'을 포함한다 +- PASS: Task 25가 '**Files:**'을 포함한다 +- PASS: Task 25가 '**Interfaces:**'을 포함한다 +- PASS: Task 25가 'Step 1'을 포함한다 +- PASS: Task 25가 'Step 2'을 포함한다 +- PASS: Task 25가 'Step 3'을 포함한다 +- PASS: Task 25가 'Step 4'을 포함한다 +- PASS: Task 25가 'Step 5'을 포함한다 +- PASS: Task 25가 'git commit -m'을 포함한다 +- PASS: Task 26가 '**Files:**'을 포함한다 +- PASS: Task 26가 '**Interfaces:**'을 포함한다 +- PASS: Task 26가 'Step 1'을 포함한다 +- PASS: Task 26가 'Step 2'을 포함한다 +- PASS: Task 26가 'Step 3'을 포함한다 +- PASS: Task 26가 'Step 4'을 포함한다 +- PASS: Task 26가 'Step 5'을 포함한다 +- PASS: Task 26가 'git commit -m'을 포함한다 +- PASS: Task 27가 '**Files:**'을 포함한다 +- PASS: Task 27가 '**Interfaces:**'을 포함한다 +- PASS: Task 27가 'Step 1'을 포함한다 +- PASS: Task 27가 'Step 2'을 포함한다 +- PASS: Task 27가 'Step 3'을 포함한다 +- PASS: Task 27가 'Step 4'을 포함한다 +- PASS: Task 27가 'Step 5'을 포함한다 +- PASS: Task 27가 'git commit -m'을 포함한다 +- PASS: Task 28가 '**Files:**'을 포함한다 +- PASS: Task 28가 '**Interfaces:**'을 포함한다 +- PASS: Task 28가 'Step 1'을 포함한다 +- PASS: Task 28가 'Step 2'을 포함한다 +- PASS: Task 28가 'Step 3'을 포함한다 +- PASS: Task 28가 'Step 4'을 포함한다 +- PASS: Task 28가 'Step 5'을 포함한다 +- PASS: Task 28가 'git commit -m'을 포함한다 +- PASS: Task 29가 '**Files:**'을 포함한다 +- PASS: Task 29가 '**Interfaces:**'을 포함한다 +- PASS: Task 29가 'Step 1'을 포함한다 +- PASS: Task 29가 'Step 2'을 포함한다 +- PASS: Task 29가 'Step 3'을 포함한다 +- PASS: Task 29가 'Step 4'을 포함한다 +- PASS: Task 29가 'Step 5'을 포함한다 +- PASS: Task 29가 'git commit -m'을 포함한다 +- PASS: Task 30가 '**Files:**'을 포함한다 +- PASS: Task 30가 '**Interfaces:**'을 포함한다 +- PASS: Task 30가 'Step 1'을 포함한다 +- PASS: Task 30가 'Step 2'을 포함한다 +- PASS: Task 30가 'Step 3'을 포함한다 +- PASS: Task 30가 'Step 4'을 포함한다 +- PASS: Task 30가 'Step 5'을 포함한다 +- PASS: Task 30가 'git commit -m'을 포함한다 +- PASS: Task 31가 '**Files:**'을 포함한다 +- PASS: Task 31가 '**Interfaces:**'을 포함한다 +- PASS: Task 31가 'Step 1'을 포함한다 +- PASS: Task 31가 'Step 2'을 포함한다 +- PASS: Task 31가 'Step 3'을 포함한다 +- PASS: Task 31가 'Step 4'을 포함한다 +- PASS: Task 31가 'Step 5'을 포함한다 +- PASS: Task 31가 'git commit -m'을 포함한다 +- PASS: Task 32가 '**Files:**'을 포함한다 +- PASS: Task 32가 '**Interfaces:**'을 포함한다 +- PASS: Task 32가 'Step 1'을 포함한다 +- PASS: Task 32가 'Step 2'을 포함한다 +- PASS: Task 32가 'Step 3'을 포함한다 +- PASS: Task 32가 'Step 4'을 포함한다 +- PASS: Task 32가 'Step 5'을 포함한다 +- PASS: Task 32가 'git commit -m'을 포함한다 +- PASS: Task 33가 '**Files:**'을 포함한다 +- PASS: Task 33가 '**Interfaces:**'을 포함한다 +- PASS: Task 33가 'Step 1'을 포함한다 +- PASS: Task 33가 'Step 2'을 포함한다 +- PASS: Task 33가 'Step 3'을 포함한다 +- PASS: Task 33가 'Step 4'을 포함한다 +- PASS: Task 33가 'Step 5'을 포함한다 +- PASS: Task 33가 'git commit -m'을 포함한다 +- PASS: Task 34가 '**Files:**'을 포함한다 +- PASS: Task 34가 '**Interfaces:**'을 포함한다 +- PASS: Task 34가 'Step 1'을 포함한다 +- PASS: Task 34가 'Step 2'을 포함한다 +- PASS: Task 34가 'Step 3'을 포함한다 +- PASS: Task 34가 'Step 4'을 포함한다 +- PASS: Task 34가 'Step 5'을 포함한다 +- PASS: Task 34가 'git commit -m'을 포함한다 +- PASS: Task 35가 '**Files:**'을 포함한다 +- PASS: Task 35가 '**Interfaces:**'을 포함한다 +- PASS: Task 35가 'Step 1'을 포함한다 +- PASS: Task 35가 'Step 2'을 포함한다 +- PASS: Task 35가 'Step 3'을 포함한다 +- PASS: Task 35가 'Step 4'을 포함한다 +- PASS: Task 35가 'Step 5'을 포함한다 +- PASS: Task 35가 'git commit -m'을 포함한다 +- PASS: Task 36가 '**Files:**'을 포함한다 +- PASS: Task 36가 '**Interfaces:**'을 포함한다 +- PASS: Task 36가 'Step 1'을 포함한다 +- PASS: Task 36가 'Step 2'을 포함한다 +- PASS: Task 36가 'Step 3'을 포함한다 +- PASS: Task 36가 'Step 4'을 포함한다 +- PASS: Task 36가 'Step 5'을 포함한다 +- PASS: Task 36가 'git commit -m'을 포함한다 +- PASS: Task 37가 '**Files:**'을 포함한다 +- PASS: Task 37가 '**Interfaces:**'을 포함한다 +- PASS: Task 37가 'Step 1'을 포함한다 +- PASS: Task 37가 'Step 2'을 포함한다 +- PASS: Task 37가 'Step 3'을 포함한다 +- PASS: Task 37가 'Step 4'을 포함한다 +- PASS: Task 37가 'Step 5'을 포함한다 +- PASS: Task 37가 'git commit -m'을 포함한다 +- PASS: Task 38가 '**Files:**'을 포함한다 +- PASS: Task 38가 '**Interfaces:**'을 포함한다 +- PASS: Task 38가 'Step 1'을 포함한다 +- PASS: Task 38가 'Step 2'을 포함한다 +- PASS: Task 38가 'Step 3'을 포함한다 +- PASS: Task 38가 'Step 4'을 포함한다 +- PASS: Task 38가 'Step 5'을 포함한다 +- PASS: Task 38가 'git commit -m'을 포함한다 +- PASS: Task 39가 '**Files:**'을 포함한다 +- PASS: Task 39가 '**Interfaces:**'을 포함한다 +- PASS: Task 39가 'Step 1'을 포함한다 +- PASS: Task 39가 'Step 2'을 포함한다 +- PASS: Task 39가 'Step 3'을 포함한다 +- PASS: Task 39가 'Step 4'을 포함한다 +- PASS: Task 39가 'Step 5'을 포함한다 +- PASS: Task 39가 'git commit -m'을 포함한다 +- PASS: Task 40가 '**Files:**'을 포함한다 +- PASS: Task 40가 '**Interfaces:**'을 포함한다 +- PASS: Task 40가 'Step 1'을 포함한다 +- PASS: Task 40가 'Step 2'을 포함한다 +- PASS: Task 40가 'Step 3'을 포함한다 +- PASS: Task 40가 'Step 4'을 포함한다 +- PASS: Task 40가 'Step 5'을 포함한다 +- PASS: Task 40가 'git commit -m'을 포함한다 +- PASS: Task 41가 '**Files:**'을 포함한다 +- PASS: Task 41가 '**Interfaces:**'을 포함한다 +- PASS: Task 41가 'Step 1'을 포함한다 +- PASS: Task 41가 'Step 2'을 포함한다 +- PASS: Task 41가 'Step 3'을 포함한다 +- PASS: Task 41가 'Step 4'을 포함한다 +- PASS: Task 41가 'Step 5'을 포함한다 +- PASS: Task 41가 'git commit -m'을 포함한다 +- PASS: Task 42가 '**Files:**'을 포함한다 +- PASS: Task 42가 '**Interfaces:**'을 포함한다 +- PASS: Task 42가 'Step 1'을 포함한다 +- PASS: Task 42가 'Step 2'을 포함한다 +- PASS: Task 42가 'Step 3'을 포함한다 +- PASS: Task 42가 'Step 4'을 포함한다 +- PASS: Task 42가 'Step 5'을 포함한다 +- PASS: Task 42가 'git commit -m'을 포함한다 +- PASS: Task 43가 '**Files:**'을 포함한다 +- PASS: Task 43가 '**Interfaces:**'을 포함한다 +- PASS: Task 43가 'Step 1'을 포함한다 +- PASS: Task 43가 'Step 2'을 포함한다 +- PASS: Task 43가 'Step 3'을 포함한다 +- PASS: Task 43가 'Step 4'을 포함한다 +- PASS: Task 43가 'Step 5'을 포함한다 +- PASS: Task 43가 'git commit -m'을 포함한다 +- PASS: Task 44가 '**Files:**'을 포함한다 +- PASS: Task 44가 '**Interfaces:**'을 포함한다 +- PASS: Task 44가 'Step 1'을 포함한다 +- PASS: Task 44가 'Step 2'을 포함한다 +- PASS: Task 44가 'Step 3'을 포함한다 +- PASS: Task 44가 'Step 4'을 포함한다 +- PASS: Task 44가 'Step 5'을 포함한다 +- PASS: Task 44가 'git commit -m'을 포함한다 +- PASS: 중복된 Create 파일 경로가 없다 +- PASS: Create 파일 경로에 wildcard가 없다 +- PASS: 계획서가 필수 작업 'Kafka Producer Adapter와 Publish Evidence'을 포함한다 +- PASS: 계획서가 필수 작업 'Kafka Consumer Group, Partition Coordinator'을 포함한다 +- PASS: 계획서가 필수 작업 'Kafka Native Transaction Capability'을 포함한다 +- PASS: 계획서가 필수 작업 'Kafka Share Group Experimental Adapter'을 포함한다 +- PASS: 계획서가 필수 작업 'Rabbit Publisher Confirm·Return Evidence Adapter'을 포함한다 +- PASS: 계획서가 필수 작업 'Rabbit Consumer Manual ACK'을 포함한다 +- PASS: 계획서가 필수 작업 'Transactional Outbox Repository'을 포함한다 +- PASS: 계획서가 필수 작업 'Inbox Transactional Idempotent Consumer'을 포함한다 +- PASS: 계획서가 필수 작업 'Debezium Outbox Event Router'을 포함한다 +- PASS: 계획서가 필수 작업 'Pulsar Experimental Adapter'을 포함한다 +- PASS: 계획서가 필수 작업 'NATS JetStream Experimental Adapter'을 포함한다 +- PASS: 계획서가 필수 작업 'Spring Cloud Stream Optional Bridge'을 포함한다 +- PASS: 계획서가 필수 작업 'Global Backpressure'을 포함한다 +- PASS: 계획서가 필수 작업 'Cross-broker 장애·보안·Reliability Contract Suite'을 포함한다 +- PASS: 계획서가 필수 작업 '성능 인증, Compatibility Matrix'을 포함한다 +- PASS: 계획서가 필수 작업 '지원 문서, Runbook, ADR, Release Gate'을 포함한다 +- PASS: 계획서가 message identity 보존을 명시한다 +- PASS: 계획서가 DLQ 실패 시 source ACK 금지를 명시한다 +- PASS: 계획서가 credential 분리를 명시한다 +- PASS: Stable chaos aggregate task가 계획에 존재한다 +- PASS: performance aggregate task가 계획에 존재한다 +- PASS: compatibility aggregate task가 계획에 존재한다 diff --git a/messaging-superpowers-package/docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md b/messaging-superpowers-package/docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md new file mode 100644 index 00000000..56cd6f22 --- /dev/null +++ b/messaging-superpowers-package/docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md @@ -0,0 +1,5056 @@ +# Messaging Platform Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Java/Spring Backend Skeleton에 evidence 기반 publish 결과, duplicate-safe consumer settlement, Kafka·RabbitMQ Stable Adapter, Outbox·Inbox Reliability, Pulsar·NATS Experimental Adapter, Admin Plane, 보안·관측성·장애 검증을 갖춘 Messaging 플랫폼을 구현한다. + +**Architecture:** `messaging-core-api`가 브로커 중립 공개 계약을 소유하고 `messaging-transport-spi`를 Kafka·RabbitMQ·Pulsar·NATS adapter가 구현한다. 일반 서비스는 M1 Typed Publisher·Handler만 사용하며, M2 Advanced, M3 Native Capability, M4 Admin Plane을 별도 모듈·권한으로 격리한다. publish 결과는 `CONFIRMED`, `REJECTED`, `AMBIGUOUS`와 broker evidence를 보존하고, consumer는 handler 성공 뒤에만 settlement하며 retry·DLQ·redrive에서 logical `messageId`를 유지한다. + +**Tech Stack:** Java 21, Gradle Kotlin DSL, Spring Framework 6.2 common compatibility line with Spring 7.0 compatibility jobs, Spring Kafka, Spring AMQP, Apache Pulsar Client, NATS Java Client, Spring JDBC/JPA, PostgreSQL 16, Flyway, Jackson, optional Avro and Protobuf, Micrometer, OpenTelemetry, JUnit 5, AssertJ, ArchUnit, Testcontainers, Toxiproxy. + +## Global Constraints + +- `messaging-core-api`에는 Spring Kafka, Spring AMQP, Pulsar, NATS, Spring `Message`, Reactor 의존성을 넣지 않는다. +- Core 비동기 계약은 `CompletionStage`다. Blocking과 Reactor facade는 별도 모듈에서 제공한다. +- 일반 업무 모듈의 기본 진입점은 M1 Typed Publisher·Handler다. +- M2는 Batch, Manual Settlement, Pause/Resume, Delayed Delivery, Replay Request만 제공한다. +- M3는 broker-native capability를 typed interface로만 제공하며 raw broker client를 반환하지 않는다. +- M4 Admin Plane은 별도 credential과 security chain을 사용한다. +- 공통 API에 `EXACTLY_ONCE`, `GLOBAL_ORDERING`, DB+broker 원자 transaction 옵션을 만들지 않는다. +- Stable Adapter는 Kafka 4.2+·4.3.x와 RabbitMQ 4.3.x다. +- Kafka Share Group, Pulsar 4.0 LTS·4.2, NATS JetStream 2.14.x는 Experimental이다. +- Stable Kafka producer는 `enable.idempotence=true`, `acks=all`, `max.in.flight.requests.per.connection<=5`를 강제한다. +- Rabbit publisher는 correlated confirm, return, `mandatory=true`를 강제한다. +- durable Rabbit work queue 기본은 quorum queue다. +- publish timeout 또는 connection loss 뒤 broker 처리 여부를 확정할 수 없으면 `AMBIGUOUS`다. +- publish retry, redelivery, retry destination, DLQ, redrive는 같은 logical `messageId`를 유지한다. +- M1 consumer는 handler 성공 이전에 source settlement를 수행하지 않는다. +- DLQ·retry destination publish가 confirm되기 전에 source를 ACK하지 않는다. +- strict ordering destination에 reorder 가능한 retry 전략을 설정하면 startup을 실패시킨다. +- JSON은 Stable 기본 codec, Avro·Protobuf는 선택 Stable, Raw Bytes는 M2, Java Serialization은 비지원이다. +- Domain·Integration Event에 CloudEvents 1.0.2 compatible profile을 선택 제공한다. +- logical payload 기본 최대는 1,048,576 bytes, global hard maximum은 8,388,608 bytes다. +- header 총 크기는 32,768 bytes, 개수 64개, key 128 bytes, value 4,096 bytes다. +- 1 MiB 초과 payload는 Claim Check를 기본으로 사용한다. +- production topology는 IaC 생성 + application validate-only다. +- producer, consumer, admin credential을 분리하고 production TLS·broker authentication을 필수화한다. +- metric label에 message ID, actual key, payload, unbounded tenant ID, credential을 기록하지 않는다. +- Outbox ambiguous publish는 같은 message ID로 재시도한다. +- Inbox row와 business side effect는 같은 PostgreSQL transaction에서 commit한다. +- 각 Task는 실패 테스트 작성 → 실패 확인 → 최소 구현 → 통과 확인 → 커밋 순서로 수행한다. +- 각 Task는 독립적으로 검토 가능한 하나의 커밋으로 종료한다. +- 테스트 코드에 사용되는 `*Fixture`, `*Harness`, `Fake*`, `Test*` support type은 별도 경로가 명시되지 않으면 해당 Task의 listed test file 하단에 package-private top-level type으로 함께 작성한다. + +--- + +## 1. 확정 파일 구조 + +```text +backend-skeleton/ +├── settings.gradle.kts +├── build.gradle.kts +├── build-logic/src/main/kotlin/messaging-library-conventions.gradle.kts +├── modules/messaging/ +│ ├── messaging-core-api/ +│ ├── messaging-schema-api/ +│ ├── messaging-schema-json/ +│ ├── messaging-schema-avro/ +│ ├── messaging-schema-protobuf/ +│ ├── messaging-cloudevents/ +│ ├── messaging-policy/ +│ ├── messaging-transport-spi/ +│ ├── messaging-observability/ +│ ├── messaging-security/ +│ ├── messaging-kafka/ +│ ├── messaging-kafka-share-experimental/ +│ ├── messaging-rabbit/ +│ ├── messaging-reliability-api/ +│ ├── messaging-outbox-jpa/ +│ ├── messaging-inbox-jpa/ +│ ├── messaging-claim-check/ +│ ├── messaging-admin-api/ +│ ├── messaging-admin-runtime/ +│ ├── messaging-pulsar-experimental/ +│ ├── messaging-nats-experimental/ +│ ├── messaging-spring-cloud-stream-bridge/ +│ ├── messaging-spring-boot-starter/ +│ └── messaging-testkit/ +├── infra/messaging/ +│ ├── kafka/ +│ ├── rabbitmq/ +│ ├── pulsar/ +│ ├── nats/ +│ ├── postgres/ +│ ├── toxiproxy/ +│ └── tls/ +├── docs/messaging/ +│ ├── support-matrix.md +│ ├── configuration-reference.md +│ ├── delivery-guarantees.md +│ ├── retry-dlq-redrive.md +│ ├── outbox-inbox.md +│ ├── security.md +│ ├── operations.md +│ ├── migration-guide.md +│ └── experimental-policy.md +└── docs/superpowers/specs/2026-08-10-messaging-platform-design.md +``` + +## 2. 핵심 패키지 + +```text +io.backend.skeleton.messaging.api +io.backend.skeleton.messaging.api.delivery +io.backend.skeleton.messaging.api.destination +io.backend.skeleton.messaging.api.error +io.backend.skeleton.messaging.api.header +io.backend.skeleton.messaging.api.publish +io.backend.skeleton.messaging.api.settlement +io.backend.skeleton.messaging.schema +io.backend.skeleton.messaging.schema.json +io.backend.skeleton.messaging.schema.avro +io.backend.skeleton.messaging.schema.protobuf +io.backend.skeleton.messaging.cloudevents +io.backend.skeleton.messaging.policy +io.backend.skeleton.messaging.transport +io.backend.skeleton.messaging.observation +io.backend.skeleton.messaging.security +io.backend.skeleton.messaging.kafka +io.backend.skeleton.messaging.rabbit +io.backend.skeleton.messaging.reliability +io.backend.skeleton.messaging.outbox +io.backend.skeleton.messaging.inbox +io.backend.skeleton.messaging.claimcheck +io.backend.skeleton.messaging.admin +io.backend.skeleton.messaging.pulsar +io.backend.skeleton.messaging.nats +io.backend.skeleton.messaging.streambridge +io.backend.skeleton.messaging.autoconfigure +io.backend.skeleton.messaging.testkit +``` + +--- + +### Task 1: Gradle 멀티모듈과 공통 품질 규칙 구성 + +**Files:** +- Modify: `settings.gradle.kts` +- Create: `build-logic/src/main/kotlin/messaging-library-conventions.gradle.kts` +- Create: `modules/messaging/messaging-core-api/build.gradle.kts` +- Create: `modules/messaging/messaging-schema-api/build.gradle.kts` +- Create: `modules/messaging/messaging-schema-json/build.gradle.kts` +- Create: `modules/messaging/messaging-schema-avro/build.gradle.kts` +- Create: `modules/messaging/messaging-schema-protobuf/build.gradle.kts` +- Create: `modules/messaging/messaging-cloudevents/build.gradle.kts` +- Create: `modules/messaging/messaging-policy/build.gradle.kts` +- Create: `modules/messaging/messaging-transport-spi/build.gradle.kts` +- Create: `modules/messaging/messaging-observability/build.gradle.kts` +- Create: `modules/messaging/messaging-security/build.gradle.kts` +- Create: `modules/messaging/messaging-kafka/build.gradle.kts` +- Create: `modules/messaging/messaging-kafka-share-experimental/build.gradle.kts` +- Create: `modules/messaging/messaging-rabbit/build.gradle.kts` +- Create: `modules/messaging/messaging-reliability-api/build.gradle.kts` +- Create: `modules/messaging/messaging-outbox-jpa/build.gradle.kts` +- Create: `modules/messaging/messaging-inbox-jpa/build.gradle.kts` +- Create: `modules/messaging/messaging-claim-check/build.gradle.kts` +- Create: `modules/messaging/messaging-admin-api/build.gradle.kts` +- Create: `modules/messaging/messaging-admin-runtime/build.gradle.kts` +- Create: `modules/messaging/messaging-pulsar-experimental/build.gradle.kts` +- Create: `modules/messaging/messaging-nats-experimental/build.gradle.kts` +- Create: `modules/messaging/messaging-spring-cloud-stream-bridge/build.gradle.kts` +- Create: `modules/messaging/messaging-spring-boot-starter/build.gradle.kts` +- Create: `modules/messaging/messaging-testkit/build.gradle.kts` +- Test: `modules/messaging/messaging-core-api/src/test/java/io/backend/skeleton/messaging/api/ModuleSmokeTest.java` + +**Interfaces:** +- Produces every Gradle project path used by later tasks. +- `messaging-core-api` has no Spring or broker dependency. +- Java toolchain is 21 and all tests use JUnit Platform. + +- [ ] **Step 1: Write the failing core module smoke test** + +```java +package io.backend.skeleton.messaging.api; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ModuleSmokeTest { + @Test + void coreApiModuleLoads() { + assertThat(ModuleSmokeTest.class.getPackageName()) + .isEqualTo("io.backend.skeleton.messaging.api"); + } +} +``` + +- [ ] **Step 2: Register module paths and verify configuration fails before build files exist** + +Add to `settings.gradle.kts`: + +```kotlin +include( + ":modules:messaging:messaging-core-api", + ":modules:messaging:messaging-schema-api", + ":modules:messaging:messaging-schema-json", + ":modules:messaging:messaging-schema-avro", + ":modules:messaging:messaging-schema-protobuf", + ":modules:messaging:messaging-cloudevents", + ":modules:messaging:messaging-policy", + ":modules:messaging:messaging-transport-spi", + ":modules:messaging:messaging-observability", + ":modules:messaging:messaging-security", + ":modules:messaging:messaging-kafka", + ":modules:messaging:messaging-kafka-share-experimental", + ":modules:messaging:messaging-rabbit", + ":modules:messaging:messaging-reliability-api", + ":modules:messaging:messaging-outbox-jpa", + ":modules:messaging:messaging-inbox-jpa", + ":modules:messaging:messaging-claim-check", + ":modules:messaging:messaging-admin-api", + ":modules:messaging:messaging-admin-runtime", + ":modules:messaging:messaging-pulsar-experimental", + ":modules:messaging:messaging-nats-experimental", + ":modules:messaging:messaging-spring-cloud-stream-bridge", + ":modules:messaging:messaging-spring-boot-starter", + ":modules:messaging:messaging-testkit" +) +``` + +Run: + +```bash +./gradlew :modules:messaging:messaging-core-api:test +``` + +Expected: FAIL because one or more registered module directories or build files are missing. + +- [ ] **Step 3: Add the convention plugin and module build files** + +`messaging-library-conventions.gradle.kts`: + +```kotlin +plugins { + `java-library` +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } + withSourcesJar() +} + +tasks.withType().configureEach { + useJUnitPlatform() +} + +dependencies { + testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("org.assertj:assertj-core") +} +``` + +Every module build file starts with: + +```kotlin +plugins { + id("messaging-library-conventions") +} +``` + +Add only the dependencies required by that module. `messaging-core-api` remains dependency-free except test libraries. + +- [ ] **Step 4: Run the complete module smoke build** + +Run: + +```bash +./gradlew :modules:messaging:messaging-core-api:test +``` + +Expected: PASS with one test and zero failures. + +- [ ] **Step 5: Commit** + +```bash +git add settings.gradle.kts build-logic modules/messaging +git commit -m "build: add messaging platform modules" +``` + +--- + +### Task 2: 핵심 식별자와 보장 Enum 구현 + +**Files:** +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/UuidV7.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/MessageId.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/MessageType.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/SchemaVersion.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/ProducerId.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/CorrelationId.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/CausationId.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/DeliveryGuarantee.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/ProcessingGuarantee.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/OrderingScope.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/ExternalSideEffectGuarantee.java` +- Test: `modules/messaging/messaging-core-api/src/test/java/io/backend/skeleton/messaging/api/CoreValueTypesTest.java` + +**Interfaces:** +- Produces immutable value types used by every later task. +- `MessageId.newId()` uses the local `UuidV7.next()` implementation created in this Task. +- No enum contains `EXACTLY_ONCE` or `GLOBAL`. + +- [ ] **Step 1: Write failing value-type tests** + +```java +package io.backend.skeleton.messaging.api; + +import io.backend.skeleton.messaging.api.delivery.DeliveryGuarantee; +import io.backend.skeleton.messaging.api.delivery.OrderingScope; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CoreValueTypesTest { + @Test + void messageTypeRejectsBlankValue() { + assertThatThrownBy(() -> new MessageType(" ")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void schemaVersionMustBePositive() { + assertThatThrownBy(() -> new SchemaVersion(0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void guaranteeEnumsDoNotAdvertiseUnsupportedSemantics() { + assertThat(Arrays.stream(DeliveryGuarantee.values()).map(Enum::name)) + .doesNotContain("EXACTLY_ONCE"); + assertThat(Arrays.stream(OrderingScope.values()).map(Enum::name)) + .doesNotContain("GLOBAL"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify missing types** + +Run: + +```bash +./gradlew :modules:messaging:messaging-core-api:test --tests '*CoreValueTypesTest' +``` + +Expected: FAIL because the value types and enums do not exist. + +- [ ] **Step 3: Implement the value types and exact enum constants** + +```java +package io.backend.skeleton.messaging.api; + +public record MessageType(String value) { + public MessageType { + if (value == null || value.isBlank() || value.length() > 240) { + throw new IllegalArgumentException("messageType must contain 1 to 240 characters"); + } + } +} +``` + +```java +package io.backend.skeleton.messaging.api; + +public record SchemaVersion(int value) { + public SchemaVersion { + if (value < 1) { + throw new IllegalArgumentException("schemaVersion must be positive"); + } + } +} +``` + +```java +package io.backend.skeleton.messaging.api.delivery; + +public enum DeliveryGuarantee { + AT_MOST_ONCE, + AT_LEAST_ONCE +} +``` + +```java +package io.backend.skeleton.messaging.api.delivery; + +public enum OrderingScope { + NONE, + DESTINATION, + PARTITION, + KEY +} +``` + +Implement the remaining records with null, blank, and length validation. Implement `MessageId` as a non-null UUID value and provide `newId()`. + +- [ ] **Step 4: Run tests and architecture compilation** + +Run: + +```bash +./gradlew :modules:messaging:messaging-core-api:test +``` + +Expected: PASS with no unsupported guarantee constants. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-core-api +git commit -m "feat: add messaging core value types" +``` + +--- + +### Task 3: 제한형 Header와 MessageEnvelope 구현 + +**Files:** +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/header/HeaderName.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/header/HeaderValue.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/header/MessageHeaders.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/header/ReservedHeaders.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/ContentType.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/TenantContext.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/TraceContext.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/MessageEnvelope.java` +- Test: `modules/messaging/messaging-core-api/src/test/java/io/backend/skeleton/messaging/api/MessageEnvelopeTest.java` + +**Interfaces:** +- Consumes Task 2 value types. +- Produces `MessageEnvelope` and immutable `MessageHeaders` for publisher, schema, and adapters. +- Enforces 64 headers, 32 KiB total, 128-byte keys, 4 KiB values, reserved and secret header rejection. + +- [ ] **Step 1: Write failing envelope policy tests** + +```java +package io.backend.skeleton.messaging.api; + +import io.backend.skeleton.messaging.api.header.HeaderName; +import io.backend.skeleton.messaging.api.header.HeaderValue; +import io.backend.skeleton.messaging.api.header.MessageHeaders; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MessageEnvelopeTest { + @Test + void applicationCannotSetReservedHeader() { + assertThatThrownBy(() -> MessageHeaders.application(Map.of( + new HeaderName("msg.id"), new HeaderValue("forged")))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void secretHeadersAreRejected() { + assertThatThrownBy(() -> MessageHeaders.application(Map.of( + new HeaderName("Authorization"), new HeaderValue("Bearer secret")))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void payloadCannotBeNull() { + assertThatThrownBy(() -> TestEnvelopeFactory.envelope(null)) + .isInstanceOf(NullPointerException.class); + } +} +``` + +- [ ] **Step 2: Run test and confirm missing envelope policy** + +Run: + +```bash +./gradlew :modules:messaging:messaging-core-api:test --tests '*MessageEnvelopeTest' +``` + +Expected: FAIL because header and envelope types do not exist. + +- [ ] **Step 3: Implement immutable header limits and envelope validation** + +```java +package io.backend.skeleton.messaging.api.header; + +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +public final class MessageHeaders { + private static final int MAX_COUNT = 64; + private static final int MAX_TOTAL_BYTES = 32_768; + private static final Set SECRET_NAMES = Set.of( + "authorization", "proxy-authorization", "cookie", "set-cookie", + "access_token", "refresh_token", "api_key", "password", "client_secret"); + + private final Map values; + + private MessageHeaders(Map values) { + this.values = Map.copyOf(values); + } + + public static MessageHeaders application(Map input) { + if (input.size() > MAX_COUNT) { + throw new IllegalArgumentException("message header count exceeds 64"); + } + int bytes = 0; + Map copy = new LinkedHashMap<>(); + for (Map.Entry entry : input.entrySet()) { + String normalized = entry.getKey().value().toLowerCase(); + if (ReservedHeaders.isReserved(normalized) || SECRET_NAMES.contains(normalized)) { + throw new IllegalArgumentException("message header is not allowed: " + normalized); + } + bytes += entry.getKey().value().getBytes(StandardCharsets.UTF_8).length; + bytes += entry.getValue().value().getBytes(StandardCharsets.UTF_8).length; + copy.put(entry.getKey(), entry.getValue()); + } + if (bytes > MAX_TOTAL_BYTES) { + throw new IllegalArgumentException("message header bytes exceed 32768"); + } + return new MessageHeaders(copy); + } + + public Map asMap() { + return values; + } +} +``` + +Implement `MessageEnvelope` as the exact record from the design and validate all required values with `Objects.requireNonNull`. + +- [ ] **Step 4: Run focused and full core tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-core-api:test +``` + +Expected: PASS and all reserved/secret header tests succeed. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-core-api +git commit -m "feat: add message envelope and header policy" +``` + +--- + +### Task 4: 논리 Destination과 Capability 모델 구현 + +**Files:** +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/DestinationName.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/DestinationKind.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/MessageDestination.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/ConfirmationRequirement.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/MessagingCapabilities.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/DestinationCapabilities.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/CapabilityRegistry.java` +- Test: `modules/messaging/messaging-core-api/src/test/java/io/backend/skeleton/messaging/api/destination/DestinationCapabilityTest.java` + +**Interfaces:** +- Consumes Task 2 identifiers and guarantees. +- Produces logical destination and capability contracts used by policy and adapters. + +- [ ] **Step 1: Write failing capability tests** + +```java +package io.backend.skeleton.messaging.api.destination; + +import io.backend.skeleton.messaging.api.MessageType; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class DestinationCapabilityTest { + @Test + void logicalDestinationDoesNotContainBrokerSpecificAddress() { + MessageDestination destination = new MessageDestination<>( + new DestinationName("order-events"), + new MessageType("order.created"), + String.class); + + assertThat(destination.name().value()).isEqualTo("order-events"); + } + + @Test + void destinationNameRejectsBrokerSeparatorsAndWhitespace() { + assertThatThrownBy(() -> new DestinationName("topic://orders")) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run focused test and observe missing types** + +Run: + +```bash +./gradlew :modules:messaging:messaging-core-api:test --tests '*DestinationCapabilityTest' +``` + +Expected: FAIL because destination types do not exist. + +- [ ] **Step 3: Implement exact capability contracts** + +```java +package io.backend.skeleton.messaging.api.destination; + +public record MessagingCapabilities( + boolean brokerAcknowledgement, + boolean replicationOrPersistenceEvidence, + boolean perMessageSettlement, + boolean batchSettlement, + boolean orderedStream, + boolean keyedOrdering, + boolean replay, + boolean delayedDelivery, + boolean brokerTransaction, + boolean deduplicatedPublish, + boolean nativeDeadLetter, + boolean topologyManagement) { +} +``` + +```java +package io.backend.skeleton.messaging.api.destination; + +public interface CapabilityRegistry { + DestinationCapabilities capabilities(DestinationName destination); +} +``` + +Implement `DestinationName` with `[a-z0-9][a-z0-9.-]{0,159}` validation. Implement `DestinationKind` with `ASYNC_COMMAND`, `DOMAIN_EVENT`, `INTEGRATION_EVENT`, `WORK_QUEUE`, `PUBLISH_SUBSCRIBE`, `EVENT_STREAM`, `REQUEST_REPLY`. Implement `MessageDestination` as an immutable record requiring name, message type, and payload type. + +- [ ] **Step 4: Run core tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-core-api:test +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-core-api +git commit -m "feat: add logical destination capabilities" +``` + +--- + +### Task 5: Schema API와 JSON Codec 구현 + +**Files:** +- Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/MessageCodec.java` +- Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/EncodedMessage.java` +- Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/SchemaReference.java` +- Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/SchemaCompatibility.java` +- Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/MessageCodecRegistry.java` +- Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/RawBytesMessageCodec.java` +- Create: `modules/messaging/messaging-schema-json/src/main/java/io/backend/skeleton/messaging/schema/json/JacksonMessageCodec.java` +- Test: `modules/messaging/messaging-schema-json/src/test/java/io/backend/skeleton/messaging/schema/json/JacksonMessageCodecTest.java` + +**Interfaces:** +- Consumes `MessageType`, `SchemaVersion`, `ContentType`. +- Produces codec SPI and JSON Stable codec. +- Rejects unknown message types, oversized encoded payloads, trailing data, and excessive nesting. + +- [ ] **Step 1: Write failing JSON round-trip and limit tests** + +```java +package io.backend.skeleton.messaging.schema.json; + +import io.backend.skeleton.messaging.api.MessageType; +import io.backend.skeleton.messaging.api.SchemaVersion; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class JacksonMessageCodecTest { + record OrderCreated(String orderId, long amount) {} + + @Test + void roundTripsRegisteredType() { + JacksonMessageCodec codec = JacksonMessageCodec.testingDefault( + new MessageType("order.created"), OrderCreated.class); + + byte[] encoded = codec.encode( + new MessageType("order.created"), + new SchemaVersion(1), + new OrderCreated("o-1", 1000)).bytes(); + + assertThat(codec.decode( + new MessageType("order.created"), + new SchemaVersion(1), + encoded, + OrderCreated.class)).isEqualTo(new OrderCreated("o-1", 1000)); + } + + @Test + void rejectsPayloadOverOneMibibyte() { + JacksonMessageCodec codec = JacksonMessageCodec.testingDefault( + new MessageType("text.large"), String.class); + String value = "a".repeat(1_048_577); + + assertThatThrownBy(() -> codec.encode( + new MessageType("text.large"), new SchemaVersion(1), value)) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run JSON module test and verify failure** + +Run: + +```bash +./gradlew :modules:messaging:messaging-schema-json:test --tests '*JacksonMessageCodecTest' +``` + +Expected: FAIL because codec classes are missing. + +- [ ] **Step 3: Implement the schema SPI and Jackson codec** + +```java +package io.backend.skeleton.messaging.schema; + +import io.backend.skeleton.messaging.api.ContentType; +import io.backend.skeleton.messaging.api.MessageType; +import io.backend.skeleton.messaging.api.SchemaVersion; + +public interface MessageCodec { + ContentType contentType(); + + EncodedMessage encode(MessageType type, SchemaVersion version, Object payload); + + T decode( + MessageType type, + SchemaVersion version, + byte[] encoded, + Class payloadType); +} +``` + +Configure Jackson with a closed message-type registry, maximum nesting depth 100, trailing token rejection, unknown subtype rejection, and encoded byte limit 1,048,576 by default. Implement `RawBytesMessageCodec` as an explicit M2 codec that copies the input bytes, enforces the same byte limit, and is never selected as a default codec. + +- [ ] **Step 4: Run schema tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-schema-api:test :modules:messaging:messaging-schema-json:test +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-schema-api modules/messaging/messaging-schema-json +git commit -m "feat: add messaging schema and json codec" +``` + +--- + +### Task 6: CloudEvents Event Profile 구현 + +**Files:** +- Create: `modules/messaging/messaging-cloudevents/src/main/java/io/backend/skeleton/messaging/cloudevents/CloudEventMapper.java` +- Create: `modules/messaging/messaging-cloudevents/src/main/java/io/backend/skeleton/messaging/cloudevents/DefaultCloudEventMapper.java` +- Create: `modules/messaging/messaging-cloudevents/src/main/java/io/backend/skeleton/messaging/cloudevents/CloudEventExtensions.java` +- Test: `modules/messaging/messaging-cloudevents/src/test/java/io/backend/skeleton/messaging/cloudevents/CloudEventMappingTest.java` + +**Interfaces:** +- Consumes `MessageEnvelope` and schema encoded payload. +- Produces CloudEvents 1.0.2 compatible mapping for events only. +- Does not map null data to a Kafka tombstone. + +- [ ] **Step 1: Write failing CloudEvents mapping test** + +```java +package io.backend.skeleton.messaging.cloudevents; + +import io.cloudevents.CloudEvent; +import org.junit.jupiter.api.Test; + +import java.net.URI; + +import static org.assertj.core.api.Assertions.assertThat; + +class CloudEventMappingTest { + @Test + void mapsLogicalIdentityAndExtensions() { + DefaultCloudEventMapper mapper = new DefaultCloudEventMapper(); + CloudEvent event = mapper.toCloudEvent( + CloudEventFixture.orderCreatedEnvelope(), + URI.create("urn:service:order-api")); + + assertThat(event.getId()).isEqualTo( + CloudEventFixture.orderCreatedEnvelope().messageId().value().toString()); + assertThat(event.getType()).isEqualTo("order.created"); + assertThat(event.getExtension("schemaversion")).isEqualTo("1"); + } +} +``` + +- [ ] **Step 2: Run focused test and verify missing mapper** + +Run: + +```bash +./gradlew :modules:messaging:messaging-cloudevents:test --tests '*CloudEventMappingTest' +``` + +Expected: FAIL because mapping classes do not exist. + +- [ ] **Step 3: Implement exact mapping rules** + +```java +package io.backend.skeleton.messaging.cloudevents; + +import io.backend.skeleton.messaging.api.MessageEnvelope; +import io.cloudevents.CloudEvent; + +import java.net.URI; + +public interface CloudEventMapper { + CloudEvent toCloudEvent(MessageEnvelope envelope, URI source); + MessageEnvelope fromCloudEvent(CloudEvent event); +} +``` + +Map `messageId→id`, `producer/source→source`, `messageType→type`, `occurredAt→time`, `contentType→datacontenttype`, and the four documented extensions. Reject command envelopes without `occurredAt` when event mode is requested. + +- [ ] **Step 4: Run tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-cloudevents:test +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-cloudevents +git commit -m "feat: add cloudevents message profile" +``` + +--- + +### Task 7: Publisher API, 결과 Evidence, 안정 예외 구현 + +**Files:** +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/MessagePublisher.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/PublishOptions.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/PublishResult.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/PublishCompletion.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/PublishEvidence.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/TransmissionEvidence.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/ConfirmationLevel.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/RoutingOutcome.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/BrokerPosition.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessagingException.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/FailureCategory.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/FailureDescriptor.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessagingConfigurationException.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessagingCapabilityUnavailableException.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageValidationException.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageSerializationException.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageSchemaIncompatibleException.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageTooLargeException.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageHeaderRejectedException.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessagePublishRejectedException.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessagePublishAmbiguousException.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessagePublishTimeoutException.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageRoutingException.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageAuthenticationException.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageAuthorizationException.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageConsumerException.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageHandlerTimeoutException.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageSettlementException.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageSettlementUnknownException.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageRetryExhaustedException.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageDeadLetterException.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageRedriveException.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageTopologyException.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageBrokerUnavailableException.java` +- Test: `modules/messaging/messaging-core-api/src/test/java/io/backend/skeleton/messaging/api/publish/PublishResultTest.java` + +**Interfaces:** +- Consumes destination and envelope contracts. +- Produces the stable publish contract used by all adapters and Outbox. +- `AMBIGUOUS` is a first-class completion and cannot be marked as retryable success. + +- [ ] **Step 1: Write failing publish result invariant tests** + +```java +package io.backend.skeleton.messaging.api.publish; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PublishResultTest { + @Test + void confirmedResultRequiresBrokerAcceptance() { + assertThatThrownBy(() -> new PublishResult( + PublishCompletion.CONFIRMED, + new PublishEvidence(false, TransmissionEvidence.TRANSMITTED, + false, ConfirmationLevel.NONE), + RoutingOutcome.UNKNOWN, + Optional.empty(), + 1, + Duration.ofMillis(10), + Optional.empty())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void ambiguousResultCannotClaimReplicationConfirmation() { + assertThatThrownBy(() -> new PublishResult( + PublishCompletion.AMBIGUOUS, + new PublishEvidence(true, TransmissionEvidence.MAY_HAVE_BEEN_TRANSMITTED, + true, ConfirmationLevel.REPLICATION_OR_PERSISTENCE_ACK), + RoutingOutcome.UNKNOWN, + Optional.empty(), + 1, + Duration.ofSeconds(5), + Optional.empty())) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run focused test and confirm missing result model** + +Run: + +```bash +./gradlew :modules:messaging:messaging-core-api:test --tests '*PublishResultTest' +``` + +Expected: FAIL because publish contracts are missing. + +- [ ] **Step 3: Implement publish API and invariants** + +```java +package io.backend.skeleton.messaging.api.publish; + +import io.backend.skeleton.messaging.api.MessageEnvelope; +import io.backend.skeleton.messaging.api.destination.MessageDestination; + +import java.util.concurrent.CompletionStage; + +public interface MessagePublisher { + CompletionStage publish( + MessageDestination destination, + MessageEnvelope message, + PublishOptions options); +} +``` + +Implement `PublishResult` constructor validation: + +```java +if (completion == PublishCompletion.CONFIRMED && !evidence.brokerAccepted()) { + throw new IllegalArgumentException("confirmed publish requires broker acceptance"); +} +if (completion == PublishCompletion.AMBIGUOUS + && evidence.confirmationLevel() != ConfirmationLevel.NONE) { + throw new IllegalArgumentException("ambiguous publish cannot claim confirmation"); +} +``` + +Add the full stable exception hierarchy from the design with sanitized metadata and no payload fields. + +- [ ] **Step 4: Run core tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-core-api:test +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-core-api +git commit -m "feat: add publish evidence contract" +``` + +--- + +### Task 8: Consumer Delivery, HandleResult, Settlement 계약 구현 + +**Files:** +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/MessageHandler.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/MessageDelivery.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/DeliveryMetadata.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/DeliveryContext.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/HandleResult.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/settlement/SettlementController.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/settlement/SettlementResult.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/settlement/SettlementCompletion.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/settlement/ManualMessageHandler.java` +- Test: `modules/messaging/messaging-core-api/src/test/java/io/backend/skeleton/messaging/api/delivery/ConsumerContractTest.java` + +**Interfaces:** +- Consumes envelope, destination, broker position. +- Produces M1 handler result and M2 settlement contracts. +- M1 exposes no broker ACK handle. + +- [ ] **Step 1: Write failing consumer contract tests** + +```java +package io.backend.skeleton.messaging.api.delivery; + +import io.backend.skeleton.messaging.api.error.FailureCategory; +import io.backend.skeleton.messaging.api.error.FailureDescriptor; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConsumerContractTest { + @Test + void retryCarriesStableFailureCategory() { + FailureDescriptor failure = new FailureDescriptor( + FailureCategory.PROCESSING_TRANSIENT, + "DOWNSTREAM_TIMEOUT", + true, + "downstream timed out", + java.util.Optional.of("TimeoutException")); + + HandleResult result = new HandleResult.Retry(failure); + + assertThat(((HandleResult.Retry) result).failure().retryable()).isTrue(); + } + + @Test + void deliveryMetadataCountsInitialDeliveryAsAttemptOne() { + DeliveryMetadata metadata = DeliveryMetadataFixture.initial(); + assertThat(metadata.deliveryAttempt()).isEqualTo(1); + } +} +``` + +- [ ] **Step 2: Run test and verify missing delivery types** + +Run: + +```bash +./gradlew :modules:messaging:messaging-core-api:test --tests '*ConsumerContractTest' +``` + +Expected: FAIL because delivery and settlement contracts are missing. + +- [ ] **Step 3: Implement exact interfaces and sealed results** + +```java +package io.backend.skeleton.messaging.api.delivery; + +import java.util.concurrent.CompletionStage; + +public interface MessageHandler { + CompletionStage handle(MessageDelivery delivery); +} +``` + +```java +package io.backend.skeleton.messaging.api.delivery; + +import io.backend.skeleton.messaging.api.error.FailureDescriptor; + +public sealed interface HandleResult + permits HandleResult.Success, HandleResult.Retry, + HandleResult.DeadLetter, HandleResult.Reject { + record Success() implements HandleResult {} + record Retry(FailureDescriptor failure) implements HandleResult {} + record DeadLetter(FailureDescriptor failure) implements HandleResult {} + record Reject(FailureDescriptor failure) implements HandleResult {} +} +``` + +Implement `SettlementController` with `ack`, `retry`, `deadLetter`, `reject` returning `CompletionStage` and no native broker parameters. + +- [ ] **Step 4: Run core tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-core-api:test +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-core-api +git commit -m "feat: add consumer delivery and settlement contracts" +``` + +--- + +### Task 9: Destination Profile, Retry Policy, Startup Validator 구현 + +**Files:** +- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DestinationProfile.java` +- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/PhysicalDestination.java` +- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/SchemaPolicy.java` +- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/ProducerPolicy.java` +- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/ConsumerPolicy.java` +- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/RetryPolicy.java` +- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/RetryMode.java` +- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/OrderingImpact.java` +- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DeadLetterPolicy.java` +- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/PayloadPolicy.java` +- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DestinationProfileValidator.java` +- Test: `modules/messaging/messaging-policy/src/test/java/io/backend/skeleton/messaging/policy/DestinationProfileValidatorTest.java` + +**Interfaces:** +- Consumes destination guarantees and capabilities from Tasks 2 and 4. +- Produces immutable destination profiles used by adapters and starter configuration. +- Enforces ordering, confirmation, payload, DLQ, retry cycle, production topology, and capability rules. + +- [ ] **Step 1: Write failing startup validation tests** + +```java +package io.backend.skeleton.messaging.policy; + +import io.backend.skeleton.messaging.api.delivery.OrderingScope; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class DestinationProfileValidatorTest { + private final DestinationProfileValidator validator = new DestinationProfileValidator(); + + @Test + void strictOrderingRejectsRetryDestination() { + DestinationProfile profile = DestinationProfileFixtures.kafkaOrdered( + new RetryPolicy( + RetryMode.RETRY_DESTINATION, + 5, + java.time.Duration.ofSeconds(1), + java.time.Duration.ofMinutes(1), + 2.0, + true, + OrderingImpact.PRESERVE, + java.util.Set.of(), + java.util.Set.of())); + + assertThatThrownBy(() -> validator.validate(profile)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("ordering"); + } + + @Test + void payloadAboveHardLimitIsRejected() { + DestinationProfile profile = DestinationProfileFixtures.withPayloadLimit(8_388_609); + + assertThatThrownBy(() -> validator.validate(profile)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("8388608"); + } + + @Test + void dlqCannotPointToItself() { + DestinationProfile profile = DestinationProfileFixtures.selfReferencingDlq(); + + assertThatThrownBy(() -> validator.validate(profile)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dead letter"); + } +} +``` + +- [ ] **Step 2: Run policy tests and verify missing profile model** + +Run: + +```bash +./gradlew :modules:messaging:messaging-policy:test --tests '*DestinationProfileValidatorTest' +``` + +Expected: FAIL because the profile and validator types are missing. + +- [ ] **Step 3: Implement profile records and exact validation rules** + +```java +package io.backend.skeleton.messaging.policy; + +import io.backend.skeleton.messaging.api.delivery.DeliveryGuarantee; +import io.backend.skeleton.messaging.api.delivery.ExternalSideEffectGuarantee; +import io.backend.skeleton.messaging.api.delivery.OrderingScope; +import io.backend.skeleton.messaging.api.destination.DestinationKind; +import io.backend.skeleton.messaging.api.destination.DestinationName; + +public record DestinationProfile( + DestinationName name, + String broker, + DestinationKind kind, + PhysicalDestination physical, + SchemaPolicy schema, + DeliveryGuarantee deliveryGuarantee, + OrderingScope orderingScope, + ExternalSideEffectGuarantee externalSideEffectGuarantee, + ProducerPolicy producer, + ConsumerPolicy consumer, + RetryPolicy retry, + DeadLetterPolicy deadLetter, + PayloadPolicy payload, + boolean production) { +} +``` + +Implement these exact guards: + +```java +if (profile.retry().orderingImpact() == OrderingImpact.PRESERVE + && profile.retry().mode() == RetryMode.RETRY_DESTINATION) { + throw new IllegalArgumentException("retry destination cannot preserve ordering"); +} +if (profile.payload().maxBytes() > 8_388_608) { + throw new IllegalArgumentException("payload maximum exceeds 8388608 bytes"); +} +if (profile.deadLetter().enabled() + && profile.deadLetter().destination().equals(profile.name())) { + throw new IllegalArgumentException("dead letter destination cannot reference itself"); +} +``` + +Add graph validation for retry and DLQ cycles and require a key resolver when `OrderingScope.KEY` is configured. + +- [ ] **Step 4: Run policy tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-policy:test +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-policy +git commit -m "feat: add messaging destination policies" +``` + +--- + +### Task 10: Transport SPI와 Immutable Runtime Registry 구현 + +**Files:** +- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/MessagingTransport.java` +- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/TransportPublishRequest.java` +- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/TransportPublishResult.java` +- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/TransportConsumerSpec.java` +- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/TransportConsumerRegistration.java` +- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/TransportSettlement.java` +- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/MessagingRuntime.java` +- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/MessagingRuntimeLease.java` +- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/MessagingRuntimeRegistry.java` +- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/DefaultMessagingRuntimeRegistry.java` +- Test: `modules/messaging/messaging-transport-spi/src/test/java/io/backend/skeleton/messaging/transport/MessagingRuntimeRegistryTest.java` + +**Interfaces:** +- Consumes Core publish, delivery, schema, and destination policy contracts. +- Produces the adapter SPI and atomic runtime generation replacement used for credential and topology rotation. +- Does not return native client objects. + +- [ ] **Step 1: Write failing runtime generation tests** + +```java +package io.backend.skeleton.messaging.transport; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class MessagingRuntimeRegistryTest { + @Test + void replacingRuntimeReturnsNewGenerationAndKeepsOldUntilReleased() { + DefaultMessagingRuntimeRegistry registry = new DefaultMessagingRuntimeRegistry(); + MessagingRuntime first = MessagingRuntimeFixtures.runtime("kafka-primary", 1); + MessagingRuntime second = MessagingRuntimeFixtures.runtime("kafka-primary", 2); + + registry.install(first); + MessagingRuntimeLease lease = registry.acquire("kafka-primary"); + registry.install(second); + + assertThat(lease.runtime().generation()).isEqualTo(1); + assertThat(registry.acquire("kafka-primary").runtime().generation()).isEqualTo(2); + lease.close(); + assertThat(first.isClosed()).isTrue(); + } +} +``` + +- [ ] **Step 2: Run focused test and verify missing runtime SPI** + +Run: + +```bash +./gradlew :modules:messaging:messaging-transport-spi:test --tests '*MessagingRuntimeRegistryTest' +``` + +Expected: FAIL because runtime registry types are missing. + +- [ ] **Step 3: Implement transport interfaces and reference-counted runtime generations** + +```java +package io.backend.skeleton.messaging.transport; + +import java.util.concurrent.CompletionStage; + +public interface MessagingTransport extends AutoCloseable { + CompletionStage publish(TransportPublishRequest request); + TransportConsumerRegistration register(TransportConsumerSpec spec); + String brokerName(); + long generation(); + @Override void close(); +} +``` + +```java +package io.backend.skeleton.messaging.transport; + +public interface MessagingRuntimeRegistry { + void install(MessagingRuntime runtime); + MessagingRuntimeLease acquire(String brokerName); +} +``` + +Use an atomic map swap and reference count. Mark old runtimes draining after replacement and close them only after the last lease closes or the configured drain deadline expires. + +- [ ] **Step 4: Run transport SPI tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-transport-spi:test +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-transport-spi +git commit -m "feat: add messaging transport runtime spi" +``` + +--- + +### Task 11: 공통 Security·Redaction·Observation Primitive 구현 + +**Files:** +- Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/BrokerCredentialProfile.java` +- Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/BrokerSecurityProfile.java` +- Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/CredentialProvider.java` +- Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/DestinationAccessPolicy.java` +- Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/MessageSecurityValidator.java` +- Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingObservation.java` +- Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingTags.java` +- Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingRedactor.java` +- Test: `modules/messaging/messaging-observability/src/test/java/io/backend/skeleton/messaging/observation/MessagingRedactorTest.java` +- Test: `modules/messaging/messaging-security/src/test/java/io/backend/skeleton/messaging/security/MessageSecurityValidatorTest.java` + +**Interfaces:** +- Consumes envelope and destination identity. +- Produces low-cardinality observation context, redaction, credential abstraction, and destination ACL validation. +- Actual broker TLS/auth integration is implemented in Task 31. + +- [ ] **Step 1: Write failing redaction and forbidden-header tests** + +```java +package io.backend.skeleton.messaging.observation; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class MessagingRedactorTest { + @Test + void removesMessageIdentityAndSecretsFromDiagnosticMap() { + MessagingRedactor redactor = new MessagingRedactor(); + + Map sanitized = redactor.sanitize(Map.of( + "messageId", "0190f4aa-0000-7000-8000-000000000001", + "Authorization", "Bearer secret", + "destinationProfile", "order-events")); + + assertThat(sanitized) + .containsEntry("destinationProfile", "order-events") + .doesNotContainKeys("messageId", "Authorization"); + } +} +``` + +```java +package io.backend.skeleton.messaging.security; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MessageSecurityValidatorTest { + @Test + void productionBrokerWithoutTlsIsRejected() { + BrokerSecurityProfile profile = BrokerSecurityProfileFixtures.productionWithoutTls(); + + assertThatThrownBy(() -> new MessageSecurityValidator().validate(profile)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("TLS"); + } +} +``` + +- [ ] **Step 2: Run both modules and verify missing primitives** + +Run: + +```bash +./gradlew :modules:messaging:messaging-security:test :modules:messaging:messaging-observability:test +``` + +Expected: FAIL because security and observation types are missing. + +- [ ] **Step 3: Implement closed credential profiles and low-cardinality tags** + +```java +package io.backend.skeleton.messaging.security; + +public sealed interface BrokerCredentialProfile + permits BrokerCredentialProfile.SaslScram, BrokerCredentialProfile.OAuth2, + BrokerCredentialProfile.MutualTls, BrokerCredentialProfile.UsernamePassword, + BrokerCredentialProfile.Nkey { + String credentialId(); + record SaslScram(String credentialId) implements BrokerCredentialProfile {} + record OAuth2(String credentialId) implements BrokerCredentialProfile {} + record MutualTls(String credentialId) implements BrokerCredentialProfile {} + record UsernamePassword(String credentialId) implements BrokerCredentialProfile {} + record Nkey(String credentialId) implements BrokerCredentialProfile {} +} +``` + +```java +package io.backend.skeleton.messaging.observation; + +public record MessagingTags( + String broker, + String destinationProfile, + String operation, + String outcome, + String failureCategory, + String retryStage) { +} +``` + +Implement redaction using a fixed denylist for message ID, keys, credentials, payload, cookies, authorization, and exception messages. Validate production TLS and the separation of producer, consumer, and admin credential IDs. + +- [ ] **Step 4: Run security and observability tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-security:test :modules:messaging:messaging-observability:test +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-security modules/messaging/messaging-observability +git commit -m "feat: add messaging security and observation primitives" +``` + +--- + +### Task 12: Broker-neutral Contract Testkit 기반 구현 + +**Files:** +- Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/MessagingAdapterHarness.java` +- Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/MessagingAdapterContract.java` +- Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/ContractMessage.java` +- Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/FaultController.java` +- Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/ContractAssertions.java` +- Create: `modules/messaging/messaging-testkit/src/test/java/io/backend/skeleton/messaging/testkit/InMemoryMessagingHarness.java` +- Test: `modules/messaging/messaging-testkit/src/test/java/io/backend/skeleton/messaging/testkit/InMemoryHarnessContractTest.java` + +**Interfaces:** +- Consumes Core API and transport SPI. +- Produces the common adapter suite invoked by Kafka, Rabbit, Pulsar, and NATS tests. +- Contract covers confirmation, ambiguity, redelivery, settlement, DLQ failure, identity preservation, payload/header limits, and shutdown. + +- [ ] **Step 1: Write failing in-memory harness contract** + +```java +package io.backend.skeleton.messaging.testkit; + +import org.junit.jupiter.api.Nested; + +class InMemoryHarnessContractTest { + @Nested + class Contract extends MessagingAdapterContract { + @Override + protected MessagingAdapterHarness harness() { + return InMemoryMessagingHarness.create(); + } + } +} +``` + +The abstract contract must contain concrete `@Test` methods named: + +```text +publishesAndConfirms +returnsAmbiguousWhenConfirmIsLost +redeliversWhenSettlementIsLost +preservesMessageIdAcrossRetryAndDlq +keepsSourceUnsettledWhenDlqPublishFails +rejectsOversizedPayloadBeforeTransport +stopsAcceptingNewWorkDuringShutdown +``` + +- [ ] **Step 2: Run testkit tests and verify missing abstract suite** + +Run: + +```bash +./gradlew :modules:messaging:messaging-testkit:test +``` + +Expected: FAIL because the testkit contract is missing. + +- [ ] **Step 3: Implement the abstract contract and deterministic in-memory harness** + +```java +package io.backend.skeleton.messaging.testkit; + +import org.junit.jupiter.api.Test; + +public abstract class MessagingAdapterContract { + protected abstract MessagingAdapterHarness harness(); + + @Test + void publishesAndConfirms() { + MessagingAdapterHarness harness = harness(); + ContractAssertions.assertConfirmed( + harness.publish(ContractMessage.orderCreated()).toCompletableFuture().join()); + } + + @Test + void returnsAmbiguousWhenConfirmIsLost() { + MessagingAdapterHarness harness = harness(); + harness.faults().dropPublishConfirmation(); + ContractAssertions.assertAmbiguous( + harness.publish(ContractMessage.orderCreated()).toCompletableFuture().join()); + } +} +``` + +Implement the remaining named tests with deterministic latches. The in-memory harness exists only to validate the contract itself and is not a production adapter. + +- [ ] **Step 4: Run testkit tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-testkit:test +``` + +Expected: PASS with every abstract contract method executed by the in-memory harness. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-testkit +git commit -m "test: add messaging adapter contract suite" +``` + +--- + +### Task 13: Retry Decision Engine 구현 + +**Files:** +- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/RetryContext.java` +- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/RetryDecision.java` +- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/RetryDecisionEngine.java` +- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DefaultRetryDecisionEngine.java` +- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/BackoffCalculator.java` +- Test: `modules/messaging/messaging-policy/src/test/java/io/backend/skeleton/messaging/policy/RetryDecisionEngineTest.java` + +**Interfaces:** +- Consumes Task 9 policies, Task 8 failure descriptors, and Task 4 capabilities. +- Produces `RetryInline`, `PauseAndRetry`, `PublishToRetryDestination`, `DeadLetter`, or `Reject` decisions. + +- [ ] **Step 1: Write failing retry decision tests** + +```java +package io.backend.skeleton.messaging.policy; + +import io.backend.skeleton.messaging.api.error.FailureCategory; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class RetryDecisionEngineTest { + private final DefaultRetryDecisionEngine engine = new DefaultRetryDecisionEngine( + new BackoffCalculator()); + + @Test + void deserializationFailureGoesDirectlyToParking() { + RetryDecision decision = engine.decide( + RetryContextFixtures.failure(FailureCategory.DESERIALIZATION, 1)); + + assertThat(decision).isInstanceOf(RetryDecision.DeadLetter.class); + } + + @Test + void transientOrderedKafkaFailureUsesPauseStrategy() { + RetryDecision decision = engine.decide( + RetryContextFixtures.orderedKafkaTransient(1)); + + assertThat(decision).isInstanceOf(RetryDecision.PauseAndRetry.class); + } + + @Test + void exhaustedAttemptGoesToDeadLetter() { + RetryDecision decision = engine.decide( + RetryContextFixtures.transientAtMaximumAttempt()); + + assertThat(decision).isInstanceOf(RetryDecision.DeadLetter.class); + } +} +``` + +- [ ] **Step 2: Run focused tests and verify missing engine** + +Run: + +```bash +./gradlew :modules:messaging:messaging-policy:test --tests '*RetryDecisionEngineTest' +``` + +Expected: FAIL because retry decision types are missing. + +- [ ] **Step 3: Implement deterministic decision and exponential jittered backoff** + +```java +package io.backend.skeleton.messaging.policy; + +public interface RetryDecisionEngine { + RetryDecision decide(RetryContext context); +} +``` + +Implement the decision order exactly: + +```text +non-retryable category +→ parking or reject + +attempt >= maxAttempts +→ dead letter + +ordering preserve + Kafka partition capability +→ pause and retry + +retry destination mode + reorder allowed +→ publish to retry destination + +inline or blocking mode +→ retry inline + +otherwise +→ dead letter +``` + +Backoff is `min(maxDelay, initialDelay * multiplier^(attempt-1))` and applies full jitter when enabled. + +- [ ] **Step 4: Run policy tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-policy:test +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-policy +git commit -m "feat: add messaging retry decision engine" +``` + +--- + +### Task 14: 공통 DLQ·Parking Orchestrator 구현 + +**Files:** +- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DeadLetterMetadata.java` +- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DeadLetterEnvelopeFactory.java` +- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DeadLetterOrchestrator.java` +- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DeadLetterResult.java` +- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/SourceSettlement.java` +- Test: `modules/messaging/messaging-policy/src/test/java/io/backend/skeleton/messaging/policy/DeadLetterOrchestratorTest.java` + +**Interfaces:** +- Consumes `MessagePublisher`, `MessageDelivery`, failure descriptor, and source settlement callback. +- Produces the publish-confirm-before-source-settlement invariant used by every adapter. + +- [ ] **Step 1: Write failing DLQ sequencing tests** + +```java +package io.backend.skeleton.messaging.policy; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.assertThat; + +class DeadLetterOrchestratorTest { + @Test + void settlesSourceOnlyAfterDlqConfirmation() { + AtomicBoolean sourceSettled = new AtomicBoolean(false); + FakePublisher publisher = FakePublisher.confirming(); + DeadLetterOrchestrator orchestrator = new DeadLetterOrchestrator(publisher); + + orchestrator.deadLetter( + DeadLetterFixtures.delivery(), + DeadLetterFixtures.failure(), + () -> sourceSettled.set(true)).toCompletableFuture().join(); + + assertThat(publisher.confirmObservedBefore(sourceSettled)).isTrue(); + } + + @Test + void keepsSourceUnsettledWhenDlqPublishFails() { + AtomicBoolean sourceSettled = new AtomicBoolean(false); + DeadLetterOrchestrator orchestrator = new DeadLetterOrchestrator( + FakePublisher.ambiguous()); + + DeadLetterResult result = orchestrator.deadLetter( + DeadLetterFixtures.delivery(), + DeadLetterFixtures.failure(), + () -> sourceSettled.set(true)).toCompletableFuture().join(); + + assertThat(result.sourceSettled()).isFalse(); + assertThat(sourceSettled).isFalse(); + } +} +``` + +- [ ] **Step 2: Run focused test and verify missing orchestrator** + +Run: + +```bash +./gradlew :modules:messaging:messaging-policy:test --tests '*DeadLetterOrchestratorTest' +``` + +Expected: FAIL because DLQ orchestration types are missing. + +- [ ] **Step 3: Implement confirmed publish before settlement** + +```java +package io.backend.skeleton.messaging.policy; + +import java.util.concurrent.CompletionStage; + +public interface SourceSettlement { + CompletionStage settle(); +} +``` + +`DeadLetterOrchestrator.deadLetter` must: + +```text +create dead-letter envelope with original messageId +publish to configured DLQ +if PublishCompletion.CONFIRMED then call source settlement +if REJECTED or AMBIGUOUS then do not call source settlement +return a result containing DLQ publish result and settlement state +``` + +Do not copy the full stack trace or secret headers into the dead-letter envelope. + +- [ ] **Step 4: Run policy tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-policy:test +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-policy +git commit -m "feat: add confirmed dead letter orchestration" +``` + +--- + +### Task 15: Kafka Test Topology와 Stable Profile Guard 구현 + +**Files:** +- Modify: `modules/messaging/messaging-kafka/build.gradle.kts` +- Create: `infra/messaging/kafka/docker-compose.yml` +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaBrokerProfile.java` +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaProfileValidator.java` +- Create: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaProfileValidatorTest.java` +- Create: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaContainerFixture.java` +- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaContainerSmokeTest.java` + +**Interfaces:** +- Consumes destination policy and security profile. +- Produces validated Kafka client configuration and Testcontainers topology for later adapter tests. +- Stable profile requires idempotence, `acks=all`, max in-flight at most five, manual consumer commit, and TLS/auth in production. + +- [ ] **Step 1: Write failing Kafka profile guard tests** + +```java +package io.backend.skeleton.messaging.kafka; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class KafkaProfileValidatorTest { + private final KafkaProfileValidator validator = new KafkaProfileValidator(); + + @Test + void stableProducerRequiresIdempotenceAndAcksAll() { + KafkaBrokerProfile profile = KafkaBrokerProfileFixtures.withProducer( + false, "1", 5); + + assertThatThrownBy(() -> validator.validate(profile)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("idempotence"); + } + + @Test + void stableProducerRejectsTooManyInFlightRequests() { + KafkaBrokerProfile profile = KafkaBrokerProfileFixtures.withProducer( + true, "all", 6); + + assertThatThrownBy(() -> validator.validate(profile)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("max.in.flight"); + } + + @Test + void consumerAutoCommitIsForbidden() { + KafkaBrokerProfile profile = KafkaBrokerProfileFixtures.withAutoCommit(true); + + assertThatThrownBy(() -> validator.validate(profile)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("auto commit"); + } +} +``` + +- [ ] **Step 2: Run Kafka profile tests and verify failure** + +Run: + +```bash +./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaProfileValidatorTest' +``` + +Expected: FAIL because profile classes are missing. + +- [ ] **Step 3: Implement validation and the Kafka 4.3.x container fixture** + +```java +package io.backend.skeleton.messaging.kafka; + +public final class KafkaProfileValidator { + public void validate(KafkaBrokerProfile profile) { + if (profile.stable() + && (!profile.enableIdempotence() || !"all".equals(profile.acks()))) { + throw new IllegalArgumentException( + "stable Kafka producer requires idempotence and acks=all"); + } + if (profile.maxInFlightRequestsPerConnection() > 5) { + throw new IllegalArgumentException( + "max.in.flight.requests.per.connection must be at most 5"); + } + if (profile.enableAutoCommit()) { + throw new IllegalArgumentException("consumer auto commit is forbidden"); + } + } +} +``` + +Create a KRaft-based Kafka 4.3.x Testcontainer fixture. Expose a factory for producer, consumer, topic creation, broker stop, and broker restart. Keep image tags centralized in the fixture. + +- [ ] **Step 4: Run Kafka profile and container smoke tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaProfileValidatorTest' --tests '*KafkaContainerSmokeTest' +``` + +Expected: PASS and the broker reports a 4.3.x version. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-kafka infra/messaging/kafka +git commit -m "feat: add kafka stable profile guard" +``` + +--- + +### Task 16: Kafka Producer Adapter와 Publish Evidence 구현 + +**Files:** +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaMessagingTransport.java` +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaPublishMapper.java` +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaHeaderMapper.java` +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaPosition.java` +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaPublishFailureClassifier.java` +- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaProducerContractTest.java` +- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaHeaderMapperTest.java` +- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaPublishAmbiguityIT.java` + +**Interfaces:** +- Consumes transport SPI, schema codec, policy profile, and Kafka profile. +- Produces Stable Kafka publish implementation and `KafkaPosition(topic, partition, offset)`. +- Maps serializer and authorization failures to `REJECTED`; post-send confirmation loss to `AMBIGUOUS`. + +- [ ] **Step 1: Write failing producer contract and header mapping tests** + +```java +package io.backend.skeleton.messaging.kafka; + +import io.backend.skeleton.messaging.testkit.MessagingAdapterContract; +import io.backend.skeleton.messaging.testkit.MessagingAdapterHarness; +import org.junit.jupiter.api.Nested; + +class KafkaProducerContractTest { + @Nested + class Contract extends MessagingAdapterContract { + @Override + protected MessagingAdapterHarness harness() { + return KafkaHarnessFixture.publisherHarness(); + } + } +} +``` + +```java +package io.backend.skeleton.messaging.kafka; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class KafkaHeaderMapperTest { + @Test + void writesLogicalIdentityAndSchemaHeaders() { + var headers = new KafkaHeaderMapper().toKafkaHeaders( + KafkaFixtures.orderCreatedEnvelope()); + + assertThat(new String(headers.lastHeader("msg.type").value())) + .isEqualTo("order.created"); + assertThat(new String(headers.lastHeader("msg.schema-version").value())) + .isEqualTo("1"); + } +} +``` + +- [ ] **Step 2: Run Kafka producer tests and verify missing adapter** + +Run: + +```bash +./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaProducerContractTest' --tests '*KafkaHeaderMapperTest' +``` + +Expected: FAIL because the transport and mapper are missing. + +- [ ] **Step 3: Implement publish mapping and failure classification** + +```java +package io.backend.skeleton.messaging.kafka; + +import io.backend.skeleton.messaging.api.publish.PublishCompletion; +import io.backend.skeleton.messaging.api.publish.PublishResult; +import io.backend.skeleton.messaging.transport.MessagingTransport; +import io.backend.skeleton.messaging.transport.TransportPublishRequest; + +import java.util.concurrent.CompletionStage; + +public final class KafkaMessagingTransport implements MessagingTransport { + private final KafkaTemplate template; + private final KafkaPublishMapper mapper; + private final KafkaPublishFailureClassifier failures; + + @Override + public CompletionStage publish( + TransportPublishRequest request) { + ProducerRecord record = mapper.toRecord(request); + return template.send(record).handle((result, error) -> { + if (error != null) { + return failures.classify(request, error); + } + return KafkaPublishMapper.confirmed(result.getRecordMetadata()); + }); + } +} +``` + +The failure classifier must return `AMBIGUOUS` for delivery timeout or connection failure after the record entered the producer, and `REJECTED` for serialization, invalid topic, authentication, authorization, and producer fencing. + +- [ ] **Step 4: Run producer contract and ambiguity integration tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaProducerContractTest' --tests '*KafkaPublishAmbiguityIT' +``` + +Expected: PASS. The ambiguity test drops the confirmation path after the broker accepted the record and receives `PublishCompletion.AMBIGUOUS`. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-kafka +git commit -m "feat: add kafka publish evidence adapter" +``` + +--- + +### Task 17: Kafka Consumer Group, Partition Coordinator, Contiguous Offset Commit 구현 + +**Files:** +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaConsumerRegistrar.java` +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaDeliveryMapper.java` +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/PartitionWorkCoordinator.java` +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/PartitionOffsetTracker.java` +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/ContiguousPartitionOffsetTracker.java` +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaSettlementCommand.java` +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaSettlementQueue.java` +- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/ContiguousPartitionOffsetTrackerTest.java` +- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaConsumerSettlementIT.java` +- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaConsumerRebalanceIT.java` + +**Interfaces:** +- Consumes `MessageHandler`, retry decision, transport consumer SPI, and Kafka profile. +- Produces a Stable traditional consumer-group implementation. +- Handler workers never call Kafka consumer methods directly; the poll thread drains settlement commands. + +- [ ] **Step 1: Write failing contiguous offset and settlement-order tests** + +```java +package io.backend.skeleton.messaging.kafka; + +import org.apache.kafka.common.TopicPartition; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ContiguousPartitionOffsetTrackerTest { + @Test + void commitsOnlyThroughHighestContiguousCompletedOffset() { + TopicPartition partition = new TopicPartition("orders", 0); + ContiguousPartitionOffsetTracker tracker = new ContiguousPartitionOffsetTracker(); + + tracker.delivered(partition, 10); + tracker.delivered(partition, 11); + tracker.delivered(partition, 12); + tracker.completed(partition, 10); + tracker.completed(partition, 12); + + assertThat(tracker.highestContiguousCompleted(partition)).hasValue(10); + + tracker.completed(partition, 11); + assertThat(tracker.highestContiguousCompleted(partition)).hasValue(12); + } +} +``` + +```java +package io.backend.skeleton.messaging.kafka; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class KafkaConsumerSettlementIT { + @Test + void doesNotCommitOffsetBeforeHandlerSuccess() { + KafkaConsumerHarness harness = KafkaConsumerHarness.startPausedHandler(); + harness.publish("order.created", "o-1"); + harness.awaitHandlerStarted(); + + assertThat(harness.committedOffset()).isEmpty(); + + harness.completeHandlerSuccessfully(); + assertThat(harness.awaitCommittedOffset()).hasValue(1L); + } +} +``` + +- [ ] **Step 2: Run focused tests and verify missing coordinator** + +Run: + +```bash +./gradlew :modules:messaging:messaging-kafka:test --tests '*ContiguousPartitionOffsetTrackerTest' --tests '*KafkaConsumerSettlementIT' +``` + +Expected: FAIL because consumer coordinator classes are missing. + +- [ ] **Step 3: Implement partition pause, bounded workers, and poll-thread settlement** + +```java +package io.backend.skeleton.messaging.kafka; + +import org.apache.kafka.common.TopicPartition; + +import java.util.OptionalLong; + +public interface PartitionOffsetTracker { + void delivered(TopicPartition partition, long offset); + void completed(TopicPartition partition, long offset); + OptionalLong highestContiguousCompleted(TopicPartition partition); +} +``` + +Implement `PartitionWorkCoordinator` with one in-flight handler per partition for strict ordering. The poll thread must: + +```text +poll records +record delivered offsets +pause partitions with in-flight work +dispatch handler to bounded worker +drain settlement queue on every poll +commit highest contiguous completed offset + 1 +resume partition after terminal settlement +``` + +On rebalance revoke, stop dispatching new work, drain completed settlements within the revoke deadline, and leave unfinished offsets uncommitted for redelivery. + +- [ ] **Step 4: Run consumer and rebalance tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-kafka:test --tests '*ContiguousPartitionOffsetTrackerTest' --tests '*KafkaConsumerSettlementIT' --tests '*KafkaConsumerRebalanceIT' +``` + +Expected: PASS. No offset beyond a gap is committed and unfinished deliveries are redelivered after rebalance. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-kafka +git commit -m "feat: add kafka consumer settlement coordinator" +``` + +--- + +### Task 18: Kafka Ordered Retry, Retry Topic, DLT 구현 + +**Files:** +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaRetryExecutor.java` +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaPartitionRetryScheduler.java` +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaRetryTopicPublisher.java` +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaDeadLetterPublisher.java` +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaRetryMetadataMapper.java` +- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaOrderedRetryIT.java` +- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaRetryTopicIdentityIT.java` +- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaDltFailureIT.java` + +**Interfaces:** +- Consumes Task 13 retry decisions and Task 14 DLQ orchestrator. +- Produces `PAUSE_PARTITION` retry for order-preserving destinations and retry-topic flow for reorder-allowed destinations. +- Preserves logical message ID and settles the source only after retry/DLT confirmation. + +- [ ] **Step 1: Write failing ordering and identity tests** + +```java +package io.backend.skeleton.messaging.kafka; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class KafkaRetryTopicIdentityIT { + @Test + void retryTopicPreservesOriginalMessageIdAndIncrementsAttempt() { + KafkaRetryHarness harness = KafkaRetryHarness.start(); + var original = harness.publishFailingMessage(); + var retried = harness.awaitRetryTopicRecord(); + + assertThat(retried.messageId()).isEqualTo(original.messageId()); + assertThat(retried.deliveryAttempt()).isEqualTo(2); + } +} +``` + +```java +package io.backend.skeleton.messaging.kafka; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class KafkaDltFailureIT { + @Test + void sourceOffsetRemainsUncommittedWhenDltPublishCannotBeConfirmed() { + KafkaRetryHarness harness = KafkaRetryHarness.withUnavailableDlt(); + harness.publishPermanentlyFailingMessage(); + + assertThat(harness.awaitSourceOffsetCommit()).isEmpty(); + assertThat(harness.sourcePartitionPaused()).isTrue(); + } +} +``` + +- [ ] **Step 2: Run tests and verify missing retry implementation** + +Run: + +```bash +./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaOrderedRetryIT' --tests '*KafkaRetryTopicIdentityIT' --tests '*KafkaDltFailureIT' +``` + +Expected: FAIL because Kafka retry executors are missing. + +- [ ] **Step 3: Implement the two retry paths and confirmed DLT flow** + +`KafkaRetryExecutor` dispatches by decision: + +```java +return switch (decision) { + case RetryDecision.PauseAndRetry retry -> + partitionRetryScheduler.schedule(delivery, retry.delay()); + case RetryDecision.PublishToRetryDestination retry -> + retryTopicPublisher.publish(delivery, retry.destination(), retry.delay()); + case RetryDecision.DeadLetter deadLetter -> + deadLetterPublisher.publish(delivery, deadLetter.failure()); + case RetryDecision.Reject reject -> + settlement.reject(reject.failure()); + case RetryDecision.RetryInline inline -> + partitionRetryScheduler.schedule(delivery, inline.delay()); +}; +``` + +For retry-topic and DLT paths, wait for `PublishCompletion.CONFIRMED` before placing a source commit command. On `REJECTED` or `AMBIGUOUS`, leave the source uncommitted and pause the partition. + +- [ ] **Step 4: Run retry integration suite** + +Run: + +```bash +./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaOrderedRetryIT' --tests '*KafkaRetryTopicIdentityIT' --tests '*KafkaDltFailureIT' +``` + +Expected: PASS. Ordered retry never processes a later record first; retry-topic mode explicitly allows reorder and preserves identity. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-kafka +git commit -m "feat: add kafka retry and dead letter flows" +``` + +--- + +### Task 19: Kafka Native Transaction Capability 구현 + +**Files:** +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaTransactionalProcessor.java` +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaTransactionalDelivery.java` +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaTransactionalPublisher.java` +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/SpringKafkaTransactionalProcessor.java` +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaTransactionProfileValidator.java` +- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaTransactionIT.java` +- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaTransactionFencingIT.java` +- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaReadCommittedIT.java` + +**Interfaces:** +- Consumes Kafka adapter and M3 capability injection. +- Produces Kafka-only read-process-write transaction support. +- Explicitly excludes external DB and HTTP side effects from the guarantee. + +- [ ] **Step 1: Write failing commit, abort, and read-committed tests** + +```java +package io.backend.skeleton.messaging.kafka; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class KafkaTransactionIT { + @Test + void commitsOutputAndInputOffsetTogether() { + KafkaTransactionHarness harness = KafkaTransactionHarness.start(); + harness.publishInput("i-1"); + harness.processSuccessfully(); + + assertThat(harness.readCommittedOutput()).containsExactly("i-1-processed"); + assertThat(harness.inputOffsetCommitted()).isTrue(); + } + + @Test + void abortHidesOutputAndLeavesInputOffsetUncommitted() { + KafkaTransactionHarness harness = KafkaTransactionHarness.start(); + harness.publishInput("i-2"); + harness.processWithFailure(); + + assertThat(harness.readCommittedOutput()).isEmpty(); + assertThat(harness.inputOffsetCommitted()).isFalse(); + } +} +``` + +- [ ] **Step 2: Run transaction tests and verify missing native capability** + +Run: + +```bash +./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaTransactionIT' --tests '*KafkaTransactionFencingIT' +``` + +Expected: FAIL because transactional interfaces are missing. + +- [ ] **Step 3: Implement M3 transaction boundary and profile guard** + +```java +package io.backend.skeleton.messaging.kafka; + +import java.util.concurrent.CompletionStage; + +public interface KafkaTransactionalProcessor { + CompletionStage process( + KafkaTransactionalDelivery delivery, + KafkaTransactionalPublisher publisher); +} +``` + +Use Spring Kafka transaction management so consumed offsets and produced Kafka records commit or abort together. Require a unique transactional ID prefix per instance. Reject registration when the destination profile declares `ExternalSideEffectGuarantee.INBOX_TRANSACTIONAL`; that path belongs to the Inbox recipe, not Kafka transaction. + +- [ ] **Step 4: Run transaction, abort, fencing, and `read_committed` tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaTransactionIT' --tests '*KafkaTransactionFencingIT' --tests '*KafkaReadCommittedIT' +``` + +Expected: PASS. A fenced producer fails permanently and aborted output is invisible to `read_committed` consumers. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-kafka +git commit -m "feat: add kafka transactional capability" +``` + +--- + +### Task 20: Kafka Replay, Seek, Topology Validation Admin Capability 구현 + +**Files:** +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaReplayCapability.java` +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaReplayPlanner.java` +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaTopologyInspector.java` +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaOffsetResetExecutor.java` +- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaReplayPlannerTest.java` +- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaTopologyValidationIT.java` + +**Interfaces:** +- Consumes Admin API contracts implemented later through an internal adapter interface. +- Produces read-only replay planning, isolated replay consumer groups, topology inspection, and guarded offset reset. +- Existing production group reset is never performed without an approved M4 request. + +- [ ] **Step 1: Write failing replay plan safety tests** + +```java +package io.backend.skeleton.messaging.kafka; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class KafkaReplayPlannerTest { + @Test + void createsIsolatedConsumerGroupByDefault() { + KafkaReplayPlan plan = new KafkaReplayPlanner().plan( + KafkaReplayRequest.byTimestamp( + "order.events.v1", Instant.parse("2026-08-01T00:00:00Z"))); + + assertThat(plan.consumerGroup()).startsWith("replay-"); + assertThat(plan.mutatesProductionOffsets()).isFalse(); + } + + @Test + void productionOffsetResetRequiresApproval() { + assertThatThrownBy(() -> new KafkaReplayPlanner().plan( + KafkaReplayRequest.productionResetWithoutApproval("order-projection"))) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run replay tests and verify missing planner** + +Run: + +```bash +./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaReplayPlannerTest' +``` + +Expected: FAIL because replay classes are missing. + +- [ ] **Step 3: Implement isolated replay and topology inspection** + +`KafkaReplayPlanner` supports: + +```text +seek by absolute offset +seek by timestamp +replay to isolated consumer group +replay to a new destination +approved production offset reset +``` + +`KafkaTopologyInspector` returns partitions, replication factor, minimum ISR, retention, cleanup policy, and topic configuration drift. It performs no mutation. + +- [ ] **Step 4: Run replay and topology tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaReplayPlannerTest' --tests '*KafkaTopologyValidationIT' +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-kafka +git commit -m "feat: add kafka replay and topology capability" +``` + +--- + +### Task 21: Kafka Share Group Experimental Adapter 구현 + +**Files:** +- Create: `modules/messaging/messaging-kafka-share-experimental/src/main/java/io/backend/skeleton/messaging/kafka/share/KafkaShareWorkQueueCapability.java` +- Create: `modules/messaging/messaging-kafka-share-experimental/src/main/java/io/backend/skeleton/messaging/kafka/share/KafkaShareGroupRegistrar.java` +- Create: `modules/messaging/messaging-kafka-share-experimental/src/main/java/io/backend/skeleton/messaging/kafka/share/KafkaShareProfileValidator.java` +- Test: `modules/messaging/messaging-kafka-share-experimental/src/test/java/io/backend/skeleton/messaging/kafka/share/KafkaShareProfileValidatorTest.java` +- Test: `modules/messaging/messaging-kafka-share-experimental/src/test/java/io/backend/skeleton/messaging/kafka/share/KafkaShareDeliveryIT.java` + +**Interfaces:** +- Consumes M1 handler and Kafka experimental client capability. +- Produces work-queue style record settlement only. +- Rejects ordered-stream profiles and remains disabled unless `backend.messaging.experimental.kafka-share=true`. + +- [ ] **Step 1: Write failing ordering guard test** + +```java +package io.backend.skeleton.messaging.kafka.share; + +import io.backend.skeleton.messaging.api.delivery.OrderingScope; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class KafkaShareProfileValidatorTest { + @Test + void rejectsOrderedStreamUse() { + KafkaShareProfile profile = KafkaShareProfileFixtures.profile(OrderingScope.KEY); + + assertThatThrownBy(() -> new KafkaShareProfileValidator().validate(profile)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("ordered stream"); + } +} +``` + +- [ ] **Step 2: Run experimental module tests and verify failure** + +Run: + +```bash +./gradlew :modules:messaging:messaging-kafka-share-experimental:test +``` + +Expected: FAIL because the experimental adapter is missing. + +- [ ] **Step 3: Implement record-level work queue registration and explicit acknowledgement mapping** + +```java +package io.backend.skeleton.messaging.kafka.share; + +public interface KafkaShareWorkQueueCapability { + AutoCloseable register( + MessageDestination destination, + MessageHandler handler, + KafkaShareOptions options); +} +``` + +Allow only `DestinationKind.WORK_QUEUE` and `OrderingScope.NONE`. Map share delivery attempt to `DeliveryMetadata.deliveryAttempt` and preserve `messageId` through redelivery. + +- [ ] **Step 4: Run validator and Kafka 4.2+ Share Group integration tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-kafka-share-experimental:test +``` + +Expected: PASS. The module does not participate in the default starter classpath. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-kafka-share-experimental +git commit -m "feat: add experimental kafka share groups" +``` + +--- + +### Task 22: RabbitMQ Test Topology와 Stable Profile Guard 구현 + +**Files:** +- Modify: `modules/messaging/messaging-rabbit/build.gradle.kts` +- Create: `infra/messaging/rabbitmq/docker-compose.yml` +- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitBrokerProfile.java` +- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitTopologyProfile.java` +- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitProfileValidator.java` +- Create: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitContainerFixture.java` +- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitContainerSmokeTest.java` +- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitProfileValidatorTest.java` + +**Interfaces:** +- Consumes destination and security policies. +- Produces Rabbit 4.3.x connection/topology configuration and Testcontainers fixture. +- Stable durable work queue requires correlated confirm, returns, mandatory publish, manual ACK, and quorum queue. + +- [ ] **Step 1: Write failing Rabbit profile guard tests** + +```java +package io.backend.skeleton.messaging.rabbit; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class RabbitProfileValidatorTest { + private final RabbitProfileValidator validator = new RabbitProfileValidator(); + + @Test + void stablePublisherRequiresCorrelatedConfirmAndReturns() { + RabbitBrokerProfile profile = RabbitProfileFixtures.withPublisher(false, false, false); + + assertThatThrownBy(() -> validator.validate(profile)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("confirm"); + } + + @Test + void durableWorkQueueRequiresQuorumQueue() { + RabbitTopologyProfile topology = RabbitProfileFixtures.classicDurableWorkQueue(); + + assertThatThrownBy(() -> validator.validate(topology)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("quorum"); + } + + @Test + void autoAckIsForbiddenForAtLeastOnce() { + RabbitTopologyProfile topology = RabbitProfileFixtures.autoAckTopology(); + + assertThatThrownBy(() -> validator.validate(topology)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("manual acknowledgement"); + } +} +``` + +- [ ] **Step 2: Run Rabbit profile tests and verify missing model** + +Run: + +```bash +./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitProfileValidatorTest' +``` + +Expected: FAIL because Rabbit profile classes are missing. + +- [ ] **Step 3: Implement exact Stable profile rules and Rabbit 4.3.x fixture** + +```java +package io.backend.skeleton.messaging.rabbit; + +public final class RabbitProfileValidator { + public void validate(RabbitBrokerProfile profile) { + if (profile.stable() + && (!profile.correlatedConfirms() + || !profile.publisherReturns() + || !profile.mandatory())) { + throw new IllegalArgumentException( + "stable Rabbit publisher requires correlated confirm, returns, and mandatory publish"); + } + } + + public void validate(RabbitTopologyProfile topology) { + if (topology.durableWorkQueue() && topology.queueType() != RabbitQueueType.QUORUM) { + throw new IllegalArgumentException("durable work queue requires quorum queue"); + } + if (topology.atLeastOnce() && topology.autoAck()) { + throw new IllegalArgumentException("at-least-once requires manual acknowledgement"); + } + } +} +``` + +Create a RabbitMQ 4.3.x container fixture with management API support and helpers for quorum queue creation, node pause/restart, connection blocking, and queue depth inspection. + +- [ ] **Step 4: Run profile and container smoke tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitProfileValidatorTest' --tests '*RabbitContainerSmokeTest' +``` + +Expected: PASS and the server reports a 4.3.x version. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-rabbit infra/messaging/rabbitmq +git commit -m "feat: add rabbit stable profile guard" +``` + +--- + +### Task 23: Rabbit Publisher Confirm·Return Evidence Adapter 구현 + +**Files:** +- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitMessagingTransport.java` +- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitPublishTracker.java` +- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitPublishOutcome.java` +- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitHeaderMapper.java` +- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitPublishFailureClassifier.java` +- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitPublishReference.java` +- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitProducerContractTest.java` +- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitUnroutableIT.java` +- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitConfirmLossIT.java` + +**Interfaces:** +- Consumes transport SPI, schema codec, and Rabbit profile. +- Produces publish result by joining publisher confirm and mandatory return. +- Confirm ACK with unroutable return is `REJECTED`, never confirmed success. + +- [ ] **Step 1: Write failing producer contract and unroutable tests** + +```java +package io.backend.skeleton.messaging.rabbit; + +import io.backend.skeleton.messaging.testkit.MessagingAdapterContract; +import io.backend.skeleton.messaging.testkit.MessagingAdapterHarness; +import org.junit.jupiter.api.Nested; + +class RabbitProducerContractTest { + @Nested + class Contract extends MessagingAdapterContract { + @Override + protected MessagingAdapterHarness harness() { + return RabbitHarnessFixture.publisherHarness(); + } + } +} +``` + +```java +package io.backend.skeleton.messaging.rabbit; + +import io.backend.skeleton.messaging.api.publish.PublishCompletion; +import io.backend.skeleton.messaging.api.publish.RoutingOutcome; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class RabbitUnroutableIT { + @Test + void confirmedButUnroutableMessageIsRejected() { + var result = RabbitHarnessFixture.publishToUnboundRoutingKey(); + + assertThat(result.completion()).isEqualTo(PublishCompletion.REJECTED); + assertThat(result.routingOutcome()).isEqualTo(RoutingOutcome.UNROUTABLE); + } +} +``` + +- [ ] **Step 2: Run producer tests and verify missing tracker** + +Run: + +```bash +./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitProducerContractTest' --tests '*RabbitUnroutableIT' +``` + +Expected: FAIL because Rabbit transport and confirm tracker are missing. + +- [ ] **Step 3: Implement correlation of confirm and return** + +```java +package io.backend.skeleton.messaging.rabbit; + +public record RabbitPublishOutcome( + RabbitConfirmOutcome confirm, + RoutingOutcome routing, + java.util.Optional replyCode, + java.util.Optional replyText) { +} +``` + +`RabbitPublishTracker` must create one pending state per publish sequence, accept a return callback before confirm, and complete only when the result is determinable: + +```text +return received + confirm ACK → REJECTED/UNROUTABLE +confirm ACK + no return by callback ordering boundary → CONFIRMED/ROUTED +confirm NACK → REJECTED +channel close or timeout before terminal evidence → AMBIGUOUS +``` + +Use sanitized reply metadata and never include body content in exceptions. + +- [ ] **Step 4: Run confirm, return, and connection-loss tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitProducerContractTest' --tests '*RabbitUnroutableIT' --tests '*RabbitConfirmLossIT' +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-rabbit +git commit -m "feat: add rabbit publish confirm evidence" +``` + +--- + +### Task 24: Rabbit Consumer Manual ACK, Prefetch, Async Settlement 구현 + +**Files:** +- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitConsumerRegistrar.java` +- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitDeliveryMapper.java` +- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitDeliveryCoordinator.java` +- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitSettlementQueue.java` +- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitSettlementCommand.java` +- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitConsumerSettlementIT.java` +- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitPrefetchIT.java` +- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitAckLossIT.java` + +**Interfaces:** +- Consumes M1 handler, retry engine, and Rabbit container factory. +- Produces manual-ack consumer flow with bounded concurrency and prefetch. +- Channel and delivery tag are never exposed to application handlers. + +- [ ] **Step 1: Write failing settlement and prefetch tests** + +```java +package io.backend.skeleton.messaging.rabbit; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class RabbitConsumerSettlementIT { + @Test + void messageRemainsUnackedUntilHandlerSuccess() { + RabbitConsumerHarness harness = RabbitConsumerHarness.startPausedHandler(); + harness.publish("work-1"); + harness.awaitHandlerStarted(); + + assertThat(harness.unackedCount()).isEqualTo(1); + assertThat(harness.readyCount()).isZero(); + + harness.completeHandlerSuccessfully(); + assertThat(harness.awaitUnackedCount()).isZero(); + } +} +``` + +```java +package io.backend.skeleton.messaging.rabbit; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class RabbitPrefetchIT { + @Test + void unackedDeliveriesNeverExceedConfiguredPrefetch() { + RabbitConsumerHarness harness = RabbitConsumerHarness.withPrefetch(4); + harness.publishRange(10); + harness.blockAllHandlers(); + + assertThat(harness.awaitUnackedCount()).isLessThanOrEqualTo(4); + } +} +``` + +- [ ] **Step 2: Run consumer tests and verify missing coordinator** + +Run: + +```bash +./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitConsumerSettlementIT' --tests '*RabbitPrefetchIT' +``` + +Expected: FAIL because the consumer implementation is missing. + +- [ ] **Step 3: Implement manual ACK after handler completion and channel-safe settlement queue** + +`RabbitDeliveryCoordinator` must: + +```text +receive delivery on listener container thread +map envelope and delivery metadata +dispatch handler to bounded executor +place terminal settlement command in a channel-bound queue +execute ACK/NACK/reject on the owning consumer channel context +``` + +If the channel closes after handler success but before ACK confirmation, return `SettlementCompletion.UNKNOWN` and allow broker redelivery. The handler is never reinvoked locally as a substitute for broker redelivery. + +- [ ] **Step 4: Run settlement, prefetch, and ACK-loss tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitConsumerSettlementIT' --tests '*RabbitPrefetchIT' --tests '*RabbitAckLossIT' +``` + +Expected: PASS. ACK loss causes redelivery with `redelivered=true`. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-rabbit +git commit -m "feat: add rabbit consumer settlement flow" +``` + +--- + +### Task 25: Rabbit Retry Queue, Confirmed DLQ, Request–Reply M2 구현 + +**Files:** +- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitRetryExecutor.java` +- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitRetryTopology.java` +- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitDeadLetterPublisher.java` +- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitNativeDeadLetterCapability.java` +- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitRequestReplyClient.java` +- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitRetryIdentityIT.java` +- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitDlxTargetOutageIT.java` +- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitRequestReplyTimeoutIT.java` + +**Interfaces:** +- Consumes Task 13 retry decisions and Task 14 DLQ orchestration. +- Produces retry queue with bounded attempt metadata, platform-managed confirmed DLQ, optional native quorum at-least-once DLX, and limited request–reply. + +- [ ] **Step 1: Write failing retry identity and DLQ outage tests** + +```java +package io.backend.skeleton.messaging.rabbit; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class RabbitRetryIdentityIT { + @Test + void retryQueuePreservesLogicalMessageIdentity() { + RabbitRetryHarness harness = RabbitRetryHarness.start(); + var first = harness.publishFailingWork(); + var retry = harness.awaitRetryDelivery(); + + assertThat(retry.messageId()).isEqualTo(first.messageId()); + assertThat(retry.deliveryAttempt()).isEqualTo(2); + } +} +``` + +```java +package io.backend.skeleton.messaging.rabbit; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class RabbitDlxTargetOutageIT { + @Test + void sourceMessageIsNotLostWhenDeadLetterTargetIsUnavailable() { + RabbitRetryHarness harness = RabbitRetryHarness.withUnavailableDlqTarget(); + harness.publishPermanentFailure(); + + assertThat(harness.sourceUnackedOrReadyCount()).isEqualTo(1); + assertThat(harness.deadLetterCount()).isZero(); + } +} +``` + +- [ ] **Step 2: Run retry and request–reply tests and verify failure** + +Run: + +```bash +./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitRetryIdentityIT' --tests '*RabbitDlxTargetOutageIT' --tests '*RabbitRequestReplyTimeoutIT' +``` + +Expected: FAIL because retry and request–reply types are missing. + +- [ ] **Step 3: Implement bounded retry routing and request–reply lifecycle** + +Retry queue rules: + +```text +increment platform delivery attempt +preserve original messageId +encode retry delay in topology policy, not arbitrary message expiration +cap cycle using platform attempt plus x-death inspection +confirm retry publish before source ACK +``` + +Native quorum at-least-once dead-letter capability must validate `dead-letter-strategy=at-least-once`, target availability policy, and overflow compatibility before activation. + +`RabbitRequestReplyClient` requires correlation ID and finite timeout, removes late replies, and never reuses request–reply as the default RPC mechanism. + +- [ ] **Step 4: Run Rabbit retry, DLQ, and request–reply tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitRetryIdentityIT' --tests '*RabbitDlxTargetOutageIT' --tests '*RabbitRequestReplyTimeoutIT' +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-rabbit +git commit -m "feat: add rabbit retry dlq and request reply" +``` + +--- + +### Task 26: Reliability API와 Outbox·Inbox Flyway Schema 구현 + +**Files:** +- Create: `modules/messaging/messaging-reliability-api/src/main/java/io/backend/skeleton/messaging/reliability/ReliableMessagePublisher.java` +- Create: `modules/messaging/messaging-reliability-api/src/main/java/io/backend/skeleton/messaging/reliability/IdempotentMessageHandler.java` +- Create: `modules/messaging/messaging-reliability-api/src/main/java/io/backend/skeleton/messaging/reliability/TransactionalMessageAction.java` +- Create: `modules/messaging/messaging-reliability-api/src/main/java/io/backend/skeleton/messaging/reliability/InboxResult.java` +- Create: `modules/messaging/messaging-outbox-jpa/src/main/resources/db/migration/messaging-outbox/V1__create_messaging_outbox.sql` +- Create: `modules/messaging/messaging-inbox-jpa/src/main/resources/db/migration/messaging-inbox/V1__create_messaging_inbox.sql` +- Test: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/OutboxSchemaIT.java` +- Test: `modules/messaging/messaging-inbox-jpa/src/test/java/io/backend/skeleton/messaging/inbox/InboxSchemaIT.java` + +**Interfaces:** +- Consumes Core destination and envelope types. +- Produces broker-neutral reliability APIs and the exact PostgreSQL schema from the design. +- Uses PostgreSQL 16 Testcontainers and Flyway migration locations scoped to each module. + +- [ ] **Step 1: Write failing schema tests** + +```java +package io.backend.skeleton.messaging.outbox; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class OutboxSchemaIT extends PostgreSqlReliabilityFixture { + @Test + void createsOutboxWithUniqueLogicalMessageIdAndPollIndex() { + migrate("classpath:db/migration/messaging-outbox"); + + assertThat(columnNames("messaging_outbox")) + .contains("message_id", "destination", "payload", "status", + "lease_owner", "lease_until", "version"); + assertThat(uniqueConstraintColumns("messaging_outbox")) + .containsExactlyInAnyOrder("message_id"); + assertThat(indexNames("messaging_outbox")) + .contains("ix_messaging_outbox_poll", "ix_messaging_outbox_lease"); + } +} +``` + +```java +package io.backend.skeleton.messaging.inbox; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class InboxSchemaIT extends PostgreSqlReliabilityFixture { + @Test + void usesConsumerNameAndMessageIdAsPrimaryKey() { + migrate("classpath:db/migration/messaging-inbox"); + + assertThat(primaryKeyColumns("messaging_inbox")) + .containsExactly("consumer_name", "message_id"); + } +} +``` + +- [ ] **Step 2: Run reliability schema tests and verify missing migrations** + +Run: + +```bash +./gradlew :modules:messaging:messaging-outbox-jpa:test :modules:messaging:messaging-inbox-jpa:test +``` + +Expected: FAIL because migration files and reliability APIs are missing. + +- [ ] **Step 3: Implement exact APIs and SQL migrations** + +```java +package io.backend.skeleton.messaging.reliability; + +public interface ReliableMessagePublisher { + void addToOutbox( + MessageDestination destination, + MessageEnvelope message); +} +``` + +Create `messaging_outbox` and `messaging_inbox` exactly as defined in the design. Use `bytea` payload, `jsonb` headers, UUID message IDs, `timestamptz`, poll/lease/expiry indexes, and no broker-specific columns. + +- [ ] **Step 4: Run schema and API tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-reliability-api:test :modules:messaging:messaging-outbox-jpa:test :modules:messaging:messaging-inbox-jpa:test +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-reliability-api modules/messaging/messaging-outbox-jpa modules/messaging/messaging-inbox-jpa +git commit -m "feat: add messaging reliability schema" +``` + +--- + +### Task 27: Transactional Outbox Repository와 Ambiguous-safe Relay 구현 + +**Files:** +- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxRecord.java` +- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxStatus.java` +- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxRepository.java` +- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/JdbcOutboxRepository.java` +- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxRelay.java` +- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxRetryScheduler.java` +- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxCleanupJob.java` +- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxProperties.java` +- Test: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/JdbcOutboxRepositoryIT.java` +- Test: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/OutboxRelayCrashIT.java` +- Test: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/OutboxAmbiguousPublishIT.java` + +**Interfaces:** +- Consumes `MessagePublisher`, `MessageCodecRegistry`, and reliability API. +- Produces `SELECT FOR UPDATE SKIP LOCKED` leasing, same-message-ID retry, and PENDING/CLAIMED/PUBLISHED/RETRYABLE_FAILURE/PARKED transitions. + +- [ ] **Step 1: Write failing lease and ambiguity tests** + +```java +package io.backend.skeleton.messaging.outbox; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class JdbcOutboxRepositoryIT extends OutboxPostgresFixture { + @Test + void concurrentRelaysClaimDisjointRows() throws Exception { + insertPendingMessages(200); + + var first = java.util.concurrent.CompletableFuture.supplyAsync( + () -> repository("relay-a").claimBatch(100)); + var second = java.util.concurrent.CompletableFuture.supplyAsync( + () -> repository("relay-b").claimBatch(100)); + + var firstIds = first.get().stream().map(OutboxRecord::id).collect(java.util.stream.Collectors.toSet()); + var secondIds = second.get().stream().map(OutboxRecord::id).collect(java.util.stream.Collectors.toSet()); + + assertThat(firstIds).doesNotContainAnyElementsOf(secondIds); + assertThat(firstIds).hasSize(100); + assertThat(secondIds).hasSize(100); + } +} +``` + +```java +package io.backend.skeleton.messaging.outbox; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class OutboxAmbiguousPublishIT extends OutboxPostgresFixture { + @Test + void retriesAmbiguousPublishWithSameLogicalMessageId() { + var row = insertPendingMessage(); + FakeMessagePublisher publisher = FakeMessagePublisher.ambiguousThenConfirmed(); + OutboxRelay relay = relay(publisher); + + relay.runOnce(); + relay.runOnce(); + + assertThat(publisher.publishedMessageIds()) + .containsExactly(row.messageId(), row.messageId()); + assertThat(find(row.id()).status()).isEqualTo(OutboxStatus.PUBLISHED); + } +} +``` + +- [ ] **Step 2: Run focused tests and verify missing repository** + +Run: + +```bash +./gradlew :modules:messaging:messaging-outbox-jpa:test --tests '*JdbcOutboxRepositoryIT' --tests '*OutboxAmbiguousPublishIT' +``` + +Expected: FAIL because repository and relay classes are missing. + +- [ ] **Step 3: Implement lease query, state transitions, and relay** + +The claim query is exact: + +```sql +select id +from messaging_outbox +where status in ('PENDING', 'RETRYABLE_FAILURE') + and available_at <= now() + and coalesce(next_attempt_at, available_at) <= now() + and (lease_until is null or lease_until < now()) +order by created_at +for update skip locked +limit :batch_size +``` + +Within the claim transaction, set `status='CLAIMED'`, increment attempts, set lease owner and lease until. Relay mapping: + +```text +CONFIRMED → PUBLISHED, published_at, clear lease +REJECTED retryable → RETRYABLE_FAILURE, next_attempt_at +REJECTED permanent → PARKED +AMBIGUOUS → RETRYABLE_FAILURE with same messageId +``` + +Default batch is 100, lease 30 seconds, poll interval 500 ms. A shutdown releases leases owned by the current instance. `OutboxCleanupJob` deletes or archives only `PUBLISHED` rows older than the configured audit retention and never removes active or parked rows. + +- [ ] **Step 4: Run repository, crash, lease-expiry, and ambiguity tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-outbox-jpa:test +``` + +Expected: PASS. A process stop after DB commit and before publish resumes later; a stop after broker acceptance and before confirm can duplicate publish but keeps the same message ID. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-outbox-jpa +git commit -m "feat: add transactional outbox relay" +``` + +--- + +### Task 28: Inbox Transactional Idempotent Consumer 구현 + +**Files:** +- Create: `modules/messaging/messaging-inbox-jpa/src/main/java/io/backend/skeleton/messaging/inbox/InboxRepository.java` +- Create: `modules/messaging/messaging-inbox-jpa/src/main/java/io/backend/skeleton/messaging/inbox/JdbcInboxRepository.java` +- Create: `modules/messaging/messaging-inbox-jpa/src/main/java/io/backend/skeleton/messaging/inbox/TransactionalInboxHandler.java` +- Create: `modules/messaging/messaging-inbox-jpa/src/main/java/io/backend/skeleton/messaging/inbox/InboxRetentionPolicy.java` +- Create: `modules/messaging/messaging-inbox-jpa/src/main/java/io/backend/skeleton/messaging/inbox/InboxCleanupJob.java` +- Test: `modules/messaging/messaging-inbox-jpa/src/test/java/io/backend/skeleton/messaging/inbox/TransactionalInboxHandlerIT.java` +- Test: `modules/messaging/messaging-inbox-jpa/src/test/java/io/backend/skeleton/messaging/inbox/InboxConcurrentDuplicateIT.java` +- Test: `modules/messaging/messaging-inbox-jpa/src/test/java/io/backend/skeleton/messaging/inbox/InboxRollbackIT.java` + +**Interfaces:** +- Consumes `IdempotentMessageHandler`, `MessageDelivery`, and Spring transaction management. +- Produces exactly one DB mutation for duplicate deliveries when the mutation and Inbox insert share the same transaction. + +- [ ] **Step 1: Write failing duplicate and rollback tests** + +```java +package io.backend.skeleton.messaging.inbox; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class TransactionalInboxHandlerIT extends InboxPostgresFixture { + @Test + void duplicateDeliveryRunsBusinessMutationOnce() { + var delivery = InboxFixtures.delivery("0190f4aa-0000-7000-8000-000000000001"); + TransactionalInboxHandler handler = handlerThatIncrementsBusinessCounter(); + + handler.handleOnce("order-projection", delivery, businessAction()).toCompletableFuture().join(); + handler.handleOnce("order-projection", delivery, businessAction()).toCompletableFuture().join(); + + assertThat(businessCounter()).isEqualTo(1); + assertThat(inboxRowCount()).isEqualTo(1); + } +} +``` + +```java +package io.backend.skeleton.messaging.inbox; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class InboxRollbackIT extends InboxPostgresFixture { + @Test + void businessFailureRollsBackInboxInsert() { + var delivery = InboxFixtures.delivery("0190f4aa-0000-7000-8000-000000000002"); + + assertThatThrownBy(() -> handler().handleOnce( + "order-projection", delivery, failingAction()).toCompletableFuture().join()) + .hasCauseInstanceOf(IllegalStateException.class); + + assertThat(inboxRowCount()).isZero(); + assertThat(businessCounter()).isZero(); + } +} +``` + +- [ ] **Step 2: Run Inbox tests and verify missing implementation** + +Run: + +```bash +./gradlew :modules:messaging:messaging-inbox-jpa:test --tests '*TransactionalInboxHandlerIT' --tests '*InboxRollbackIT' +``` + +Expected: FAIL because Inbox repository and handler are missing. + +- [ ] **Step 3: Implement insert-first transactional deduplication** + +```java +package io.backend.skeleton.messaging.inbox; + +public interface InboxRepository { + boolean insertIfAbsent( + String consumerName, + MessageDelivery delivery, + java.time.Instant expiresAt); +} +``` + +`TransactionalInboxHandler.handleOnce` runs inside one DB transaction: + +```text +insert Inbox row with ON CONFLICT DO NOTHING +if inserted=false return InboxResult.DUPLICATE +execute business action +commit Inbox row and business mutation together +return InboxResult.PROCESSED +``` + +Calculate `expiresAt` from broker retention + DLQ retention + maximum replay/redrive horizon + configured safety margin. Reject a configured Inbox retention shorter than that horizon. + +- [ ] **Step 4: Run duplicate, concurrent, rollback, and cleanup tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-inbox-jpa:test +``` + +Expected: PASS. Two concurrent deliveries produce one business mutation, and a failed business transaction leaves no Inbox row. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-inbox-jpa +git commit -m "feat: add transactional inbox consumer" +``` + +--- + +### Task 29: Claim Check API와 Integrity·Retention Guard 구현 + +**Files:** +- Create: `modules/messaging/messaging-claim-check/src/main/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckReference.java` +- Create: `modules/messaging/messaging-claim-check/src/main/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckStore.java` +- Create: `modules/messaging/messaging-claim-check/src/main/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckPublisher.java` +- Create: `modules/messaging/messaging-claim-check/src/main/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckResolver.java` +- Create: `modules/messaging/messaging-claim-check/src/main/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckPolicy.java` +- Create: `modules/messaging/messaging-claim-check/src/main/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckIntegrityException.java` +- Test: `modules/messaging/messaging-claim-check/src/test/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckResolverTest.java` +- Test: `modules/messaging/messaging-claim-check/src/test/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckRetentionValidatorTest.java` + +**Interfaces:** +- Consumes the future objectstorage/fileserver port through `ClaimCheckStore` without importing provider types. +- Produces large-payload upload, reference envelope, checksum validation, and retention validation. +- Never places a public signed URL in the message. + +- [ ] **Step 1: Write failing integrity and URL-rejection tests** + +```java +package io.backend.skeleton.messaging.claimcheck; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ClaimCheckResolverTest { + @Test + void rejectsPublicHttpUrlAsObjectIdentifier() { + assertThatThrownBy(() -> new ClaimCheckReference( + "object-storage", + "https://storage.example.com/signed?token=secret", + 2048, + "SHA-256", + "abc", + "application/octet-stream", + Instant.now().plusSeconds(3600))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsChecksumMismatch() { + ClaimCheckResolver resolver = ClaimCheckFixtures.resolverReturning("tampered"); + + assertThatThrownBy(() -> resolver.resolve(ClaimCheckFixtures.reference()).toCompletableFuture().join()) + .hasCauseInstanceOf(ClaimCheckIntegrityException.class); + } +} +``` + +- [ ] **Step 2: Run claim-check tests and verify missing types** + +Run: + +```bash +./gradlew :modules:messaging:messaging-claim-check:test +``` + +Expected: FAIL because claim-check types are missing. + +- [ ] **Step 3: Implement internal object identifiers, checksum, size, and retention validation** + +```java +package io.backend.skeleton.messaging.claimcheck; + +public record ClaimCheckReference( + String store, + String objectId, + long size, + String checksumAlgorithm, + String checksum, + String contentType, + java.time.Instant expiresAt) { + public ClaimCheckReference { + if (objectId.contains("://")) { + throw new IllegalArgumentException("claim check objectId must not be a public URL"); + } + } +} +``` + +`ClaimCheckPublisher` writes the payload, computes SHA-256, and publishes only the reference. `ClaimCheckResolver` streams the object, enforces declared size, and verifies checksum before returning it to the handler. Validate object expiry against message retention, retry, DLQ, and redrive horizon. + +- [ ] **Step 4: Run claim-check tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-claim-check:test +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-claim-check +git commit -m "feat: add messaging claim check" +``` + +--- + +### Task 30: Avro·Protobuf Codec와 Schema Registry Compatibility Gate 구현 + +**Files:** +- Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/SchemaRegistry.java` +- Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/SchemaCompatibilityValidator.java` +- Create: `modules/messaging/messaging-schema-avro/src/main/java/io/backend/skeleton/messaging/schema/avro/AvroMessageCodec.java` +- Create: `modules/messaging/messaging-schema-protobuf/src/main/java/io/backend/skeleton/messaging/schema/protobuf/ProtobufMessageCodec.java` +- Create: `modules/messaging/messaging-schema-avro/src/test/resources/schemas/order.created/v1.avsc` +- Create: `modules/messaging/messaging-schema-protobuf/src/test/proto/order_created_v1.proto` +- Test: `modules/messaging/messaging-schema-avro/src/test/java/io/backend/skeleton/messaging/schema/avro/AvroCompatibilityTest.java` +- Test: `modules/messaging/messaging-schema-protobuf/src/test/java/io/backend/skeleton/messaging/schema/protobuf/ProtobufCompatibilityTest.java` + +**Interfaces:** +- Extends Task 5 codec SPI. +- Produces optional Avro and Protobuf Stable codecs and registry compatibility validation. +- Raw bytes remain M2 and bypass attempts are audited. + +- [ ] **Step 1: Write failing backward compatibility tests** + +```java +package io.backend.skeleton.messaging.schema.avro; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AvroCompatibilityTest { + @Test + void optionalFieldWithDefaultIsBackwardCompatible() { + var validator = AvroSchemaFixture.validator(); + + assertThat(validator.isBackwardTransitiveCompatible( + AvroSchemaFixture.v1(), AvroSchemaFixture.v2WithOptionalCurrency())) + .isTrue(); + } + + @Test + void requiredFieldWithoutDefaultIsRejected() { + var validator = AvroSchemaFixture.validator(); + + assertThat(validator.isBackwardTransitiveCompatible( + AvroSchemaFixture.v1(), AvroSchemaFixture.v2WithRequiredCurrency())) + .isFalse(); + } +} +``` + +- [ ] **Step 2: Run codec modules and verify missing implementations** + +Run: + +```bash +./gradlew :modules:messaging:messaging-schema-avro:test :modules:messaging:messaging-schema-protobuf:test +``` + +Expected: FAIL because codecs and registry interfaces are missing. + +- [ ] **Step 3: Implement codecs and compatibility policies** + +```java +package io.backend.skeleton.messaging.schema; + +public interface SchemaRegistry { + SchemaReference register( + MessageType type, + SchemaVersion version, + byte[] schema, + SchemaCompatibility compatibility); + + byte[] resolve(SchemaReference reference); +} +``` + +Avro default is `BACKWARD_TRANSITIVE`. Protobuf must preserve unknown fields and reject enum evolution without an unknown-value strategy documented in the generated type. Both codecs enforce the same 1 MiB default encoded size and 8 MiB hard maximum. + +- [ ] **Step 4: Run schema compatibility and golden-message tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-schema-avro:test :modules:messaging:messaging-schema-protobuf:test +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-schema-api modules/messaging/messaging-schema-avro modules/messaging/messaging-schema-protobuf +git commit -m "feat: add avro and protobuf messaging codecs" +``` + +--- + +### Task 31: Broker TLS·Authentication·ACL·Credential Rotation 통합 + +**Files:** +- Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/CredentialRuntime.java` +- Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/CredentialRuntimeRegistry.java` +- Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/BrokerTlsPolicy.java` +- Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/BrokerAclManifest.java` +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaSecurityConfigurer.java` +- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitSecurityConfigurer.java` +- Create: `infra/messaging/tls/generate-test-pki.sh` +- Test: `modules/messaging/messaging-security/src/test/java/io/backend/skeleton/messaging/security/CredentialRuntimeRegistryTest.java` +- Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaTlsAclIT.java` +- Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitTlsAclIT.java` + +**Interfaces:** +- Extends Task 11 security primitives and Task 10 runtime generations. +- Produces TLS 1.2/1.3, hostname validation, SASL/SCRAM or OAuth/mTLS profiles, destination ACL manifests, and zero-downtime generation replacement. + +- [ ] **Step 1: Write failing rotation and ACL tests** + +```java +package io.backend.skeleton.messaging.security; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class CredentialRuntimeRegistryTest { + @Test + void newCredentialGenerationServesNewPublishesWhileOldGenerationDrains() { + CredentialRuntimeRegistry registry = CredentialRuntimeFixtures.registry(); + var oldLease = registry.acquire("kafka-producer"); + + registry.rotate(CredentialRuntimeFixtures.generation(2)); + + assertThat(oldLease.runtime().generation()).isEqualTo(1); + assertThat(registry.acquire("kafka-producer").runtime().generation()).isEqualTo(2); + oldLease.close(); + assertThat(registry.closedGenerations()).contains(1L); + } +} +``` + +```java +package io.backend.skeleton.messaging.kafka; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class KafkaTlsAclIT { + @Test + void producerCredentialCannotResetConsumerOffsets() { + KafkaSecureHarness harness = KafkaSecureHarness.start(); + + assertThatThrownBy(() -> harness.resetOffsetsWithProducerCredential()) + .isInstanceOf(org.apache.kafka.common.errors.AuthorizationException.class); + } +} +``` + +- [ ] **Step 2: Run security integration tests and verify failure** + +Run: + +```bash +./gradlew :modules:messaging:messaging-security:test :modules:messaging:messaging-kafka:test --tests '*KafkaTlsAclIT' :modules:messaging:messaging-rabbit:test --tests '*RabbitTlsAclIT' +``` + +Expected: FAIL because broker security integration and PKI fixtures are missing. + +- [ ] **Step 3: Implement fail-closed TLS, identity separation, and runtime rotation** + +`BrokerTlsPolicy` permits TLS 1.2 and 1.3, requires hostname verification, and has no trust-all flag. `CredentialRuntimeRegistry` follows the reference-counted generation design from Task 10. + +Create separate identities: + +```text +messaging-producer +messaging-consumer +messaging-admin +``` + +Kafka producer identity gets write-only topic permissions, consumer gets read/group permissions, and admin gets topology/offset permissions. Rabbit identities get vhost configure/write/read permissions according to role. Startup validation compares expected ACL manifest with a dry-run or describe result where the broker supports it. + +- [ ] **Step 4: Run TLS, hostname mismatch, ACL, and rotation tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-security:test :modules:messaging:messaging-kafka:test :modules:messaging:messaging-rabbit:test --tests '*Tls*' --tests '*Acl*' --tests '*Credential*' +``` + +Expected: PASS. Unknown CA, hostname mismatch, expired certificate, and unauthorized destination fail closed without retry storms. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-security modules/messaging/messaging-kafka modules/messaging/messaging-rabbit infra/messaging/tls +git commit -m "feat: add messaging transport security" +``` + +--- + +### Task 32: Metrics, Tracing, Audit, Cardinality Guard 구현 + +**Files:** +- Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingMetrics.java` +- Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingTracer.java` +- Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingAuditEvent.java` +- Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingAuditSink.java` +- Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/DefaultMessagingObservationConvention.java` +- Test: `modules/messaging/messaging-observability/src/test/java/io/backend/skeleton/messaging/observation/MessagingMetricCardinalityTest.java` +- Test: `modules/messaging/messaging-observability/src/test/java/io/backend/skeleton/messaging/observation/MessagingTraceLinkTest.java` +- Test: `modules/messaging/messaging-observability/src/test/java/io/backend/skeleton/messaging/observation/MessagingSecretLeakTest.java` + +**Interfaces:** +- Extends Task 11 primitives. +- Produces logical publish/consume observations, physical attempt metrics, asynchronous span links, and admin audit events. +- Internal semantic model is versioned independently of OpenTelemetry exporter names. + +- [ ] **Step 1: Write failing cardinality and trace-link tests** + +```java +package io.backend.skeleton.messaging.observation; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class MessagingMetricCardinalityTest { + @Test + void tenThousandMessageIdsDoNotCreateNewMetricSeries() { + TestMeterRegistry registry = new TestMeterRegistry(); + MessagingMetrics metrics = new MessagingMetrics(registry); + + for (int index = 0; index < 10_000; index++) { + metrics.publishConfirmed( + "kafka", "order-events", "order.created", + "0190f4aa-0000-7000-8000-" + String.format("%012d", index)); + } + + assertThat(registry.seriesCount("messaging.publish")) + .isLessThanOrEqualTo(2); + } +} +``` + +```java +package io.backend.skeleton.messaging.observation; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class MessagingTraceLinkTest { + @Test + void consumerSpanLinksToProducerContextInsteadOfAssumingDirectParent() { + TestTracing tracing = TestTracing.create(); + MessagingTracer tracer = new MessagingTracer(tracing.tracer()); + + tracer.consume(MessagingObservationFixtures.delivery()).close(); + + assertThat(tracing.finishedConsumerSpan().links()).hasSize(1); + assertThat(tracing.finishedConsumerSpan().parentSpanId()).isNull(); + } +} +``` + +- [ ] **Step 2: Run observability tests and verify missing implementation** + +Run: + +```bash +./gradlew :modules:messaging:messaging-observability:test +``` + +Expected: FAIL because metrics and tracing classes are missing. + +- [ ] **Step 3: Implement bounded tags and observation lifecycle** + +Implement counters/timers for publish, confirmation, ambiguity, receive, processing, settlement, redelivery, retry, DLQ, redrive, backlog, schema failure, Outbox, and Inbox. Allowed tags are broker, destination profile, operation, bounded message type, outcome, failure category, retry stage, and schema codec. + +`MessagingTracer` creates a producer span, stores trace context in reserved headers, and creates a consumer processing span with a link to that context. Admin audit events include operator, approval ID, dry-run flag, operation, target profile, count, and result, but no payload. + +- [ ] **Step 4: Run cardinality, trace, and secret leak tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-observability:test +``` + +Expected: PASS. Static log scanning finds no payload, token, or full message ID. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-observability +git commit -m "feat: add messaging metrics tracing and audit" +``` + +--- + +### Task 33: Topology Manifest, Broker Inspectors, Validate-only Runtime 구현 + +**Files:** +- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/TopologyManifest.java` +- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/DestinationTopology.java` +- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/TopologyManagementMode.java` +- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/TopologyValidationReport.java` +- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/TopologyIssue.java` +- Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/TopologyValidator.java` +- Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/BrokerTopologyInspector.java` +- Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/CompositeTopologyValidator.java` +- Test: `modules/messaging/messaging-admin-runtime/src/test/java/io/backend/skeleton/messaging/admin/TopologyValidatorTest.java` +- Test: `modules/messaging/messaging-admin-runtime/src/test/java/io/backend/skeleton/messaging/admin/ProductionAutoCreateGuardTest.java` + +**Interfaces:** +- Consumes Kafka and Rabbit read-only topology inspectors. +- Produces manifest-based drift reports and environment-specific management modes. +- Production auto-create is rejected before application startup. + +- [ ] **Step 1: Write failing topology drift and production guard tests** + +```java +package io.backend.skeleton.messaging.admin; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class TopologyValidatorTest { + @Test + void reportsKafkaPartitionAndRabbitQueueTypeDrift() { + TopologyValidator validator = TopologyFixtures.validatorWithDrift(); + + TopologyValidationReport report = validator.validate(); + + assertThat(report.errors()) + .extracting(TopologyIssue::code) + .contains("KAFKA_PARTITION_MISMATCH", "RABBIT_QUEUE_TYPE_MISMATCH"); + } + + @Test + void productionRejectsAutoCreateMode() { + assertThatThrownBy(() -> TopologyManifestFixtures.production( + TopologyManagementMode.AUTO_CREATE_DEV)) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run admin topology tests and verify missing contracts** + +Run: + +```bash +./gradlew :modules:messaging:messaging-admin-runtime:test +``` + +Expected: FAIL because topology contracts and validator are missing. + +- [ ] **Step 3: Implement manifest, inspectors, and drift severity** + +`TopologyValidationReport` contains `INFO`, `WARNING`, and `ERROR` issues with stable codes. Kafka inspection covers partition count, replication factor, minimum ISR, retention, and cleanup policy. Rabbit inspection covers exchange type, queue type, durability, bindings, dead-letter strategy, overflow, and consumer timeout. + +`AUTO_CREATE_DEV` may create only non-destructive local/test resources. `VALIDATE_ONLY` performs no mutation. `ADMIN_MANAGED` requires the Admin application. + +- [ ] **Step 4: Run topology unit and broker integration tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-admin-runtime:test :modules:messaging:messaging-kafka:test --tests '*Topology*' :modules:messaging:messaging-rabbit:test --tests '*Topology*' +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-admin-api modules/messaging/messaging-admin-runtime modules/messaging/messaging-kafka modules/messaging/messaging-rabbit +git commit -m "feat: add messaging topology validation" +``` + +--- + +### Task 34: Replay·Redrive Admin Approval, Dry-run, Audit 구현 + +**Files:** +- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/ReplayRequest.java` +- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/ReplayPlan.java` +- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/RedriveRequest.java` +- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/RedrivePlan.java` +- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/AdminApproval.java` +- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/ReplayResult.java` +- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/RedriveResult.java` +- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/ApprovedReplayPlan.java` +- Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/ApprovedRedrivePlan.java` +- Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/MessagingAdminService.java` +- Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/DefaultMessagingAdminService.java` +- Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/DestructiveMessagingAdmin.java` +- Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/AdminOperationIdempotencyStore.java` +- Test: `modules/messaging/messaging-admin-runtime/src/test/java/io/backend/skeleton/messaging/admin/RedriveAdminTest.java` +- Test: `modules/messaging/messaging-admin-runtime/src/test/java/io/backend/skeleton/messaging/admin/DestructiveAdminGuardTest.java` + +**Interfaces:** +- Consumes topology inspectors, broker replay capabilities, publisher, and audit sink. +- Produces dry-run planning, approval-bound execution, idempotent admin operations, and separate destructive interface. + +- [ ] **Step 1: Write failing approval and identity tests** + +```java +package io.backend.skeleton.messaging.admin; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class RedriveAdminTest { + @Test + void redriveKeepsOriginalMessageIdAndAddsRedriveId() { + DefaultMessagingAdminService service = AdminFixtures.service(); + RedrivePlan plan = service.planRedrive(AdminFixtures.redriveDryRun()); + var result = service.executeRedrive(plan.approve(AdminFixtures.approval())); + + assertThat(result.published().getFirst().messageId()) + .isEqualTo(AdminFixtures.originalMessageId()); + assertThat(result.published().getFirst().redriveId()).isNotNull(); + } + + @Test + void executionWithoutApprovalIsRejected() { + assertThatThrownBy(() -> AdminFixtures.service() + .executeRedrive(AdminFixtures.unapprovedPlan())) + .isInstanceOf(SecurityException.class); + } +} +``` + +- [ ] **Step 2: Run admin tests and verify missing implementation** + +Run: + +```bash +./gradlew :modules:messaging:messaging-admin-runtime:test --tests '*RedriveAdminTest' --tests '*DestructiveAdminGuardTest' +``` + +Expected: FAIL because admin execution contracts are missing. + +- [ ] **Step 3: Implement plan-approve-execute and operation idempotency** + +Every admin operation follows: + +```text +plan with dry-run +capture topology version and estimated count +approve with approval ID, operator, reason, expiry +revalidate topology version +claim approval ID in idempotency store +execute bounded batches +write audit result +``` + +Redrive preserves original `messageId`, creates a new `redriveId`, increments `redriveCount`, and waits for target confirmation before marking source DLQ state. `DestructiveMessagingAdmin` is a separate bean and interface for offset reset, purge, and delete. + +- [ ] **Step 4: Run approval, duplicate execution, audit, and broker replay tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-admin-runtime:test +``` + +Expected: PASS. The same approval cannot execute twice and app-role fixtures cannot obtain the destructive bean. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-admin-api modules/messaging/messaging-admin-runtime +git commit -m "feat: add guarded replay and redrive admin" +``` + +--- + +### Task 35: Spring Boot Starter, Properties, Auto-configuration, Actuator 구현 + +**Files:** +- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/MessagingProperties.java` +- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/MessagingCoreAutoConfiguration.java` +- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/KafkaMessagingAutoConfiguration.java` +- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/RabbitMessagingAutoConfiguration.java` +- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/MessagingReliabilityAutoConfiguration.java` +- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/MessagingAdminAutoConfiguration.java` +- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/MessagingEndpoint.java` +- Create: `modules/messaging/messaging-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` +- Create: `modules/messaging/messaging-spring-boot-starter/src/test/resources/application-valid.yml` +- Create: `modules/messaging/messaging-spring-boot-starter/src/test/resources/application-invalid-ordering.yml` +- Test: `modules/messaging/messaging-spring-boot-starter/src/test/java/io/backend/skeleton/messaging/autoconfigure/MessagingAutoConfigurationTest.java` +- Test: `modules/messaging/messaging-spring-boot-starter/src/test/java/io/backend/skeleton/messaging/autoconfigure/MessagingEndpointTest.java` + +**Interfaces:** +- Consumes every Stable module and optional Experimental modules by classpath/property. +- Produces configuration binding, startup validation, runtime registry, actuator endpoints, and conditional adapter beans. +- Experimental and Admin features are disabled by default. + +- [ ] **Step 1: Write failing valid/invalid ApplicationContextRunner tests** + +```java +package io.backend.skeleton.messaging.autoconfigure; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +class MessagingAutoConfigurationTest { + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(org.springframework.boot.autoconfigure.AutoConfigurations.of( + MessagingCoreAutoConfiguration.class)); + + @Test + void validConfigurationCreatesTypedPublisher() { + runner.withPropertyValues( + "backend.messaging.brokers.kafka-primary.type=KAFKA", + "backend.messaging.destinations.order-events.broker=kafka-primary", + "backend.messaging.destinations.order-events.kind=EVENT_STREAM") + .run(context -> assertThat(context) + .hasSingleBean(io.backend.skeleton.messaging.api.publish.MessagePublisher.class)); + } + + @Test + void invalidOrderingRetryConfigurationFailsStartup() { + runner.withPropertyValues( + "backend.messaging.destinations.order-events.ordering=KEY", + "backend.messaging.destinations.order-events.retry.mode=RETRY_DESTINATION", + "backend.messaging.destinations.order-events.retry.ordering-impact=PRESERVE") + .run(context -> assertThat(context).hasFailed()); + } +} +``` + +- [ ] **Step 2: Run starter tests and verify missing auto-configuration** + +Run: + +```bash +./gradlew :modules:messaging:messaging-spring-boot-starter:test +``` + +Expected: FAIL because properties and auto-configurations are missing. + +- [ ] **Step 3: Implement configuration binding, startup validation, and sanitized actuator output** + +```java +package io.backend.skeleton.messaging.autoconfigure; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("backend.messaging") +public record MessagingProperties( + java.util.Map brokers, + java.util.Map destinations, + MessagingLimitsProperties limits, + MessagingSecurityProperties security, + MessagingObservabilityProperties observability, + MessagingAdminProperties admin, + MessagingExperimentalProperties experimental) { +} +``` + +Auto-configuration order is core → schema → policy → security/observation → transport adapters → reliability → actuator. Admin and Experimental beans require explicit properties. `/actuator/messaging`, `/topology`, `/outbox`, `/capabilities` return only profile names, status, bounded counts, and capability enums. + +- [ ] **Step 4: Run starter, endpoint, and startup guard tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-spring-boot-starter:test +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-spring-boot-starter +git commit -m "feat: add messaging spring boot starter" +``` + +--- + +### Task 36: Blocking·Reactive·Batch Facade와 Cancellation 계약 구현 + +**Files:** +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/BlockingMessagePublisher.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/BatchMessagePublisher.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/BatchPublishOptions.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/BatchPublishResult.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/BatchPublishItemResult.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/PublishRequest.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/DelayedMessagePublisher.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/BatchMessageHandler.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/BatchMessageDelivery.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/BatchDeliveryMetadata.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/PauseResumeController.java` +- Modify: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaConsumerRegistrar.java` +- Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaBatchConsumerRegistrar.java` +- Modify: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitConsumerRegistrar.java` +- Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitBatchConsumerRegistrar.java` +- Modify: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitMessagingTransport.java` +- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/DefaultBlockingMessagePublisher.java` +- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/ReactiveMessagePublisher.java` +- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/DefaultReactiveMessagePublisher.java` +- Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/DefaultBatchMessagePublisher.java` +- Test: `modules/messaging/messaging-spring-boot-starter/src/test/java/io/backend/skeleton/messaging/autoconfigure/PublisherFacadeTest.java` +- Test: `modules/messaging/messaging-spring-boot-starter/src/test/java/io/backend/skeleton/messaging/autoconfigure/BatchPublisherTest.java` +- Test: `modules/messaging/messaging-spring-boot-starter/src/test/java/io/backend/skeleton/messaging/autoconfigure/BatchConsumerFacadeTest.java` +- Test: `modules/messaging/messaging-spring-boot-starter/src/test/java/io/backend/skeleton/messaging/autoconfigure/DelayedPublishCapabilityTest.java` + +**Interfaces:** +- Consumes Core `MessagePublisher` and Spring Reactor dependency in the starter only. +- Produces blocking and `Mono` facades, index-preserving batch publish, M2 batch consume, pause/resume, and capability-gated delayed publish. +- Batch is explicitly non-transactional and receives no transparent whole-batch retry. + +- [ ] **Step 1: Write failing facade and batch-result tests** + +```java +package io.backend.skeleton.messaging.autoconfigure; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; + +class PublisherFacadeTest { + @Test + void blockingFacadeHonorsFiniteTimeout() { + DefaultBlockingMessagePublisher publisher = PublisherFacadeFixtures.neverCompletingBlocking(); + + org.assertj.core.api.Assertions.assertThatThrownBy(() -> publisher.publish( + PublisherFacadeFixtures.destination(), + PublisherFacadeFixtures.envelope(), + PublisherFacadeFixtures.options(Duration.ofMillis(50)))) + .isInstanceOf(io.backend.skeleton.messaging.api.error.MessagePublishTimeoutException.class); + } + + @Test + void reactiveCancellationCancelsUnderlyingStage() { + var fixture = PublisherFacadeFixtures.cancellableReactive(); + var subscription = fixture.publisher().publish( + fixture.destination(), fixture.envelope(), fixture.options()) + .subscribe(); + + subscription.dispose(); + + assertThat(fixture.underlyingCancelled()).isTrue(); + } +} +``` + +```java +package io.backend.skeleton.messaging.autoconfigure; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class BatchPublisherTest { + @Test + void preservesInputIndexAndIndividualAmbiguity() { + DefaultBatchMessagePublisher publisher = PublisherFacadeFixtures.batchWithSecondAmbiguous(); + + var result = publisher.publish(PublisherFacadeFixtures.threeRequests(), + PublisherFacadeFixtures.batchOptions()).toCompletableFuture().join(); + + assertThat(result.items()).extracting(BatchPublishItemResult::index) + .containsExactly(0, 1, 2); + assertThat(result.items().get(1).result().completion().name()) + .isEqualTo("AMBIGUOUS"); + } +} +``` + +`BatchConsumerFacadeTest` must assert that Kafka commits only completed contiguous records and Rabbit settles each item or the whole batch according to the configured settlement capability. `DelayedPublishCapabilityTest` must assert that Kafka Stable rejects delayed delivery while Rabbit capability profiles accept it. + +- [ ] **Step 2: Run facade tests and verify missing implementations** + +Run: + +```bash +./gradlew :modules:messaging:messaging-spring-boot-starter:test --tests '*PublisherFacadeTest' --tests '*BatchPublisherTest' +``` + +Expected: FAIL because facades and batch types are missing. + +- [ ] **Step 3: Implement finite blocking wait, Reactor cancellation, and per-item batch completion** + +```java +package io.backend.skeleton.messaging.api.publish; + +public interface BlockingMessagePublisher { + PublishResult publish( + MessageDestination destination, + MessageEnvelope message, + PublishOptions options); +} +``` + +`DefaultBlockingMessagePublisher` waits no longer than `PublishOptions.timeout` and translates timeout to `MessagePublishTimeoutException` without claiming the broker rejected the message. `DefaultReactiveMessagePublisher` uses `Mono.fromCompletionStage` with cancellation propagation where the transport supports it. `DefaultBatchMessagePublisher` assigns indexes, executes within the configured max in-flight limit, and returns every item result without retrying the whole batch. Implement `BatchMessageHandler` as `CompletionStage handle(BatchMessageDelivery batch)`. Add Kafka and Rabbit batch registrars behind M2 capability checks. Implement `PauseResumeController` without exposing native partition/channel objects. Implement `DelayedMessagePublisher` so an adapter lacking delayed-delivery capability throws `MessagingCapabilityUnavailableException`; Rabbit maps supported delayed profiles, Kafka Stable rejects them. + +- [ ] **Step 4: Run facade, cancellation, and batch tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-core-api:test :modules:messaging:messaging-spring-boot-starter:test --tests '*PublisherFacadeTest' --tests '*BatchPublisherTest' +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-core-api modules/messaging/messaging-spring-boot-starter +git commit -m "feat: add messaging publisher facades" +``` + +--- + +### Task 37: Debezium Outbox Event Router 선택 Integration 구현 + +**Files:** +- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/DebeziumOutboxProfile.java` +- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/DebeziumOutboxRecordMapper.java` +- Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/DebeziumMappedRecord.java` +- Create: `modules/messaging/messaging-outbox-jpa/src/main/resources/debezium/outbox-event-router.properties` +- Create: `infra/messaging/kafka/debezium-compose.yml` +- Test: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/DebeziumOutboxRecordMapperTest.java` +- Test: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/DebeziumOutboxIT.java` + +**Interfaces:** +- Consumes the Outbox table and Kafka destination profile. +- Produces a CDC relay option that uses outbox `message_id` as event identity and aggregate ID as optional Kafka key. +- Does not implement or operate a generic CDC engine and cannot run concurrently with the polling relay for the same rows. + +- [ ] **Step 1: Write failing ID and routing-key mapping tests** + +```java +package io.backend.skeleton.messaging.outbox; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class DebeziumOutboxRecordMapperTest { + @Test + void mapsOutboxMessageIdAndAggregateIdWithoutCreatingNewIdentity() { + OutboxRecord row = OutboxFixtures.orderCreatedRow(); + DebeziumMappedRecord mapped = new DebeziumOutboxRecordMapper().map(row); + + assertThat(mapped.eventId()).isEqualTo(row.messageId().toString()); + assertThat(mapped.key()).isEqualTo(row.aggregateId().orElseThrow()); + } +} +``` + +- [ ] **Step 2: Run Debezium tests and verify missing mapper** + +Run: + +```bash +./gradlew :modules:messaging:messaging-outbox-jpa:test --tests '*DebeziumOutboxRecordMapperTest' --tests '*DebeziumOutboxIT' +``` + +Expected: FAIL because the Debezium profile and fixture are missing. + +- [ ] **Step 3: Implement the fixed Outbox Event Router mapping and exclusivity guard** + +Map: + +```text +message_id → event ID +aggregate_id → event key +destination → route field +message_type → event type +payload → event payload +headers → additional fields limited by allowlist +``` + +At startup, fail if both `polling-relay.enabled=true` and `debezium-relay.enabled=true` for the same Outbox namespace. Keep the CDC container configuration in infra and label the integration optional. + +- [ ] **Step 4: Run mapper and end-to-end CDC tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-outbox-jpa:test --tests '*DebeziumOutboxRecordMapperTest' --tests '*DebeziumOutboxIT' +``` + +Expected: PASS. Kafka receives the original message ID and aggregate key. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-outbox-jpa infra/messaging/kafka/debezium-compose.yml +git commit -m "feat: add optional debezium outbox integration" +``` + +--- + +### Task 38: Pulsar Experimental Adapter 구현 + +**Files:** +- Modify: `modules/messaging/messaging-pulsar-experimental/build.gradle.kts` +- Create: `infra/messaging/pulsar/docker-compose.yml` +- Create: `modules/messaging/messaging-pulsar-experimental/src/main/java/io/backend/skeleton/messaging/pulsar/PulsarMessagingTransport.java` +- Create: `modules/messaging/messaging-pulsar-experimental/src/main/java/io/backend/skeleton/messaging/pulsar/PulsarProfile.java` +- Create: `modules/messaging/messaging-pulsar-experimental/src/main/java/io/backend/skeleton/messaging/pulsar/PulsarProfileValidator.java` +- Create: `modules/messaging/messaging-pulsar-experimental/src/main/java/io/backend/skeleton/messaging/pulsar/PulsarSubscriptionMode.java` +- Create: `modules/messaging/messaging-pulsar-experimental/src/main/java/io/backend/skeleton/messaging/pulsar/PulsarTransactionCapability.java` +- Create: `modules/messaging/messaging-pulsar-experimental/src/main/java/io/backend/skeleton/messaging/pulsar/PulsarMessagePosition.java` +- Test: `modules/messaging/messaging-pulsar-experimental/src/test/java/io/backend/skeleton/messaging/pulsar/PulsarAdapterContractTest.java` +- Test: `modules/messaging/messaging-pulsar-experimental/src/test/java/io/backend/skeleton/messaging/pulsar/PulsarSubscriptionGuardTest.java` +- Test: `modules/messaging/messaging-pulsar-experimental/src/test/java/io/backend/skeleton/messaging/pulsar/PulsarTransactionIT.java` + +**Interfaces:** +- Consumes Core transport SPI and policy contracts. +- Produces Experimental typed publish/consume, Exclusive/Failover/Shared/Key_Shared, schema integration, redelivery, replay, and native transaction capability. +- Module is absent unless the experimental property and dependency are both present. + +- [ ] **Step 1: Write failing Core Contract and subscription guard tests** + +```java +package io.backend.skeleton.messaging.pulsar; + +import io.backend.skeleton.messaging.testkit.MessagingAdapterContract; +import io.backend.skeleton.messaging.testkit.MessagingAdapterHarness; +import org.junit.jupiter.api.Nested; + +class PulsarAdapterContractTest { + @Nested + class Contract extends MessagingAdapterContract { + @Override + protected MessagingAdapterHarness harness() { + return PulsarHarnessFixture.create(); + } + } +} +``` + +```java +package io.backend.skeleton.messaging.pulsar; + +import io.backend.skeleton.messaging.api.delivery.OrderingScope; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PulsarSubscriptionGuardTest { + @Test + void sharedSubscriptionRejectsKeyOrderingRequirement() { + PulsarProfile profile = PulsarProfileFixtures.profile( + PulsarSubscriptionMode.SHARED, OrderingScope.KEY); + + assertThatThrownBy(() -> new PulsarProfileValidator().validate(profile)) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run Pulsar tests and verify missing adapter** + +Run: + +```bash +./gradlew :modules:messaging:messaging-pulsar-experimental:test +``` + +Expected: FAIL because the adapter is missing. + +- [ ] **Step 3: Implement publish, subscription mapping, schema, and transaction capability** + +Map Pulsar message ID to a `PulsarMessagePosition` without exposing client types. Use Shared only with `OrderingScope.NONE`, Key_Shared for key ordering, and Failover/Exclusive for single active processing profiles. Transaction capability may atomically include Pulsar publish and acknowledge only; reject external DB guarantee claims. + +- [ ] **Step 4: Run Core Contract against Pulsar 4.0 LTS and 4.2 profiles** + +Run: + +```bash +./gradlew :modules:messaging:messaging-pulsar-experimental:test -PpulsarVersion=4.0 +./gradlew :modules:messaging:messaging-pulsar-experimental:test -PpulsarVersion=4.2 +``` + +Expected: PASS for the experimental support matrix, including transaction commit/abort and subscription redistribution. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-pulsar-experimental infra/messaging/pulsar +git commit -m "feat: add experimental pulsar adapter" +``` + +--- + +### Task 39: NATS JetStream Experimental Adapter 구현 + +**Files:** +- Modify: `modules/messaging/messaging-nats-experimental/build.gradle.kts` +- Create: `infra/messaging/nats/docker-compose.yml` +- Create: `modules/messaging/messaging-nats-experimental/src/main/java/io/backend/skeleton/messaging/nats/NatsJetStreamTransport.java` +- Create: `modules/messaging/messaging-nats-experimental/src/main/java/io/backend/skeleton/messaging/nats/NatsJetStreamProfile.java` +- Create: `modules/messaging/messaging-nats-experimental/src/main/java/io/backend/skeleton/messaging/nats/NatsJetStreamProfileValidator.java` +- Create: `modules/messaging/messaging-nats-experimental/src/main/java/io/backend/skeleton/messaging/nats/NatsAckMode.java` +- Create: `modules/messaging/messaging-nats-experimental/src/main/java/io/backend/skeleton/messaging/nats/NatsMaxDeliverParkingWorkflow.java` +- Create: `modules/messaging/messaging-nats-experimental/src/main/java/io/backend/skeleton/messaging/nats/NatsStreamPosition.java` +- Test: `modules/messaging/messaging-nats-experimental/src/test/java/io/backend/skeleton/messaging/nats/NatsAdapterContractTest.java` +- Test: `modules/messaging/messaging-nats-experimental/src/test/java/io/backend/skeleton/messaging/nats/NatsDeduplicationIT.java` +- Test: `modules/messaging/messaging-nats-experimental/src/test/java/io/backend/skeleton/messaging/nats/NatsAckSyncIT.java` +- Test: `modules/messaging/messaging-nats-experimental/src/test/java/io/backend/skeleton/messaging/nats/NatsMaxDeliverParkingIT.java` + +**Interfaces:** +- Consumes Core transport SPI and policy contracts. +- Produces JetStream publish PubAck, `Nats-Msg-Id` dedupe, pull consumer, explicit ACK, AckSync, Nak delay, MaxDeliver advisory, work queue, and replay. +- Does not claim multi-resource transaction or automatic native DLQ movement. + +- [ ] **Step 1: Write failing dedupe and AckSync tests** + +```java +package io.backend.skeleton.messaging.nats; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class NatsDeduplicationIT { + @Test + void sameMessageIdWithinWindowProducesOneStoredMessage() { + NatsHarness harness = NatsHarness.start(); + var message = NatsFixtures.orderCreated(); + + harness.publish(message); + harness.publish(message); + + assertThat(harness.streamMessageCount()).isEqualTo(1); + } +} +``` + +```java +package io.backend.skeleton.messaging.nats; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class NatsAckSyncIT { + @Test + void ackSyncReturnsSettlementEvidence() { + NatsHarness harness = NatsHarness.start(); + var result = harness.consumeAndAckSync(); + + assertThat(result.completion().name()).isEqualTo("SETTLED"); + assertThat(result.evidence().brokerAcknowledged()).isTrue(); + } +} +``` + +- [ ] **Step 2: Run NATS tests and verify missing adapter** + +Run: + +```bash +./gradlew :modules:messaging:messaging-nats-experimental:test +``` + +Expected: FAIL because JetStream adapter types are missing. + +- [ ] **Step 3: Implement JetStream evidence and advisory-based parking** + +Use `messageId` as `Nats-Msg-Id`. Map PubAck to confirmed publish. Use pull consumers with explicit ACK and bounded `MaxAckPending`. Map `AckSync` server response to settlement evidence. On MaxDeliver advisory, publish to the configured parking destination, wait for PubAck, then terminate or mark the original according to the stream policy. + +- [ ] **Step 4: Run NATS 2.14.x Core Contract, dedupe-window, AckSync, and MaxDeliver tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-nats-experimental:test +``` + +Expected: PASS. The module remains Experimental and disabled by default. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-nats-experimental infra/messaging/nats +git commit -m "feat: add experimental nats jetstream adapter" +``` + +--- + +### Task 40: Spring Cloud Stream Optional Bridge 구현 + +**Files:** +- Create: `modules/messaging/messaging-spring-cloud-stream-bridge/src/main/java/io/backend/skeleton/messaging/streambridge/MessagingBindingBridge.java` +- Create: `modules/messaging/messaging-spring-cloud-stream-bridge/src/main/java/io/backend/skeleton/messaging/streambridge/SpringCloudStreamPublisherBridge.java` +- Create: `modules/messaging/messaging-spring-cloud-stream-bridge/src/main/java/io/backend/skeleton/messaging/streambridge/SpringCloudStreamConsumerBridge.java` +- Create: `modules/messaging/messaging-spring-cloud-stream-bridge/src/main/java/io/backend/skeleton/messaging/streambridge/BindingCapabilityReport.java` +- Create: `modules/messaging/messaging-spring-cloud-stream-bridge/src/main/java/io/backend/skeleton/messaging/streambridge/BindingProfileValidator.java` +- Test: `modules/messaging/messaging-spring-cloud-stream-bridge/src/test/java/io/backend/skeleton/messaging/streambridge/BindingProfileValidatorTest.java` +- Test: `modules/messaging/messaging-spring-cloud-stream-bridge/src/test/java/io/backend/skeleton/messaging/streambridge/BridgePublishEvidenceTest.java` + +**Interfaces:** +- Consumes Core API and Spring Cloud Stream binding interfaces. +- Produces a migration/compatibility bridge only where binder callbacks can satisfy the requested evidence. +- Never promotes binder-specific retry or DLQ semantics into the Core policy automatically. + +- [ ] **Step 1: Write failing evidence-capability guard test** + +```java +package io.backend.skeleton.messaging.streambridge; + +import io.backend.skeleton.messaging.api.destination.ConfirmationRequirement; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BindingProfileValidatorTest { + @Test + void rejectsReplicationConfirmationWhenBinderCannotProveIt() { + BindingCapabilityReport capabilities = BindingCapabilityReport.brokerAckOnly(); + + assertThatThrownBy(() -> new BindingProfileValidator().validate( + ConfirmationRequirement.REPLICATION_OR_PERSISTENCE_ACK, + capabilities)) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run bridge tests and verify missing implementation** + +Run: + +```bash +./gradlew :modules:messaging:messaging-spring-cloud-stream-bridge:test +``` + +Expected: FAIL because bridge classes are missing. + +- [ ] **Step 3: Implement capability-reporting bridge and migration-only degradation rules** + +The bridge must expose a `BindingCapabilityReport` before registration. If the binder cannot distinguish confirm, routing, or settlement evidence required by a destination, reject the binding or label it `MIGRATION_ONLY`. Do not reuse binder retry settings as Core `RetryPolicy`; require explicit mapping and validation. + +- [ ] **Step 4: Run bridge capability and mapping tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-spring-cloud-stream-bridge:test +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-spring-cloud-stream-bridge +git commit -m "feat: add optional spring cloud stream bridge" +``` + +--- + +### Task 41: Global Backpressure, Payload Guard, Graceful Shutdown 구현 + +**Files:** +- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/MessagingAdmissionController.java` +- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/InFlightLimiter.java` +- Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/PayloadLimitGuard.java` +- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/MessagingLifecycle.java` +- Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/MessagingShutdownCoordinator.java` +- Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageBackpressureException.java` +- Test: `modules/messaging/messaging-policy/src/test/java/io/backend/skeleton/messaging/policy/MessagingAdmissionControllerTest.java` +- Test: `modules/messaging/messaging-transport-spi/src/test/java/io/backend/skeleton/messaging/transport/MessagingShutdownCoordinatorTest.java` + +**Interfaces:** +- Consumes payload policy, adapter runtime, handler coordinator, Outbox relay, and admin runtime. +- Produces bounded publish admission, handler concurrency, retry concurrency, and deterministic drain ordering. + +- [ ] **Step 1: Write failing overload and shutdown-order tests** + +```java +package io.backend.skeleton.messaging.policy; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MessagingAdmissionControllerTest { + @Test + void rejectsBeforeEncodingWhenPayloadExceedsDestinationLimit() { + PayloadLimitGuard guard = new PayloadLimitGuard(1024, 8192); + + assertThatThrownBy(() -> guard.checkDeclaredSize(1025)) + .isInstanceOf(io.backend.skeleton.messaging.api.error.MessageTooLargeException.class); + } + + @Test + void boundedProducerAdmissionRejectsAfterFiniteWait() { + MessagingAdmissionController controller = new MessagingAdmissionController(1); + var permit = controller.acquire(java.time.Duration.ofMillis(10)); + + assertThatThrownBy(() -> controller.acquire(java.time.Duration.ofMillis(10))) + .isInstanceOf(io.backend.skeleton.messaging.api.error.MessageBackpressureException.class); + + permit.close(); + } +} +``` + +```java +package io.backend.skeleton.messaging.transport; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class MessagingShutdownCoordinatorTest { + @Test + void blocksAdmissionBeforePausingConsumersAndDrainingSettlements() { + ShutdownOrderRecorder recorder = new ShutdownOrderRecorder(); + new MessagingShutdownCoordinator(recorder.components()).shutdown(); + + assertThat(recorder.events()).containsExactly( + "block-publish-admission", + "block-new-handlers", + "pause-consumers", + "drain-handlers", + "flush-settlements", + "await-confirms", + "release-outbox-leases", + "close-transports"); + } +} +``` + +- [ ] **Step 2: Run policy and lifecycle tests and verify failure** + +Run: + +```bash +./gradlew :modules:messaging:messaging-policy:test :modules:messaging:messaging-transport-spi:test --tests '*Admission*' --tests '*Shutdown*' +``` + +Expected: FAIL because admission and shutdown types are missing. + +- [ ] **Step 3: Implement bounded permits and the exact shutdown sequence** + +Use semaphore-style permits with finite acquire timeout for producer, handler, retry publish, and admin batch categories. Payload guard checks declared and encoded size. `MessagingShutdownCoordinator` uses Spring lifecycle phases and a default 30-second drain. After the deadline, unfinished handlers remain unsettled, unconfirmed publishes become ambiguous, and Outbox rows rely on lease expiry. + +- [ ] **Step 4: Run overload, cancellation, and shutdown integration tests** + +Run: + +```bash +./gradlew :modules:messaging:messaging-policy:test :modules:messaging:messaging-transport-spi:test :modules:messaging:messaging-kafka:test --tests '*Shutdown*' :modules:messaging:messaging-rabbit:test --tests '*Shutdown*' +``` + +Expected: PASS. No new work begins after shutdown admission closes and unfinished consumer messages are redelivered rather than falsely settled. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging/messaging-policy modules/messaging/messaging-transport-spi modules/messaging/messaging-core-api +git commit -m "feat: add messaging backpressure and shutdown" +``` + +--- + +### Task 42: Cross-broker 장애·보안·Reliability Contract Suite 구현 + +**Files:** +- Create: `infra/messaging/toxiproxy/docker-compose.yml` +- Create: `build-logic/src/main/kotlin/messaging-chaos-conventions.gradle.kts` +- Modify: `build.gradle.kts` +- Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/NetworkFaultScenario.java` +- Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/BrokerFailureMatrix.java` +- Create: `modules/messaging/messaging-testkit/src/test/java/io/backend/skeleton/messaging/testkit/CrossBrokerContractSuite.java` +- Create: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaChaosSuiteIT.java` +- Create: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitChaosSuiteIT.java` +- Create: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/ReliabilityCrashMatrixIT.java` +- Create: `modules/messaging/messaging-observability/src/test/java/io/backend/skeleton/messaging/observation/SecretLeakStaticScanTest.java` + +**Interfaces:** +- Consumes all Stable adapters, Reliability modules, security, observation, and Toxiproxy fixtures. +- Produces the release-gating failure matrix required by the design. + +- [ ] **Step 1: Write failing matrix completeness test** + +```java +package io.backend.skeleton.messaging.testkit; + +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class CrossBrokerContractSuite { + @Test + void stableReleaseIncludesEveryRequiredFailureScenario() { + Set scenarios = BrokerFailureMatrix.requiredScenarioNames(); + + assertThat(scenarios).contains( + "publish-confirm-loss", + "consumer-settlement-loss", + "leader-or-node-failover", + "network-partition", + "retry-exhausted", + "dlq-target-outage", + "schema-poison", + "credential-rotation", + "tls-hostname-mismatch", + "acl-denied", + "graceful-shutdown", + "outbox-ambiguous-publish", + "inbox-duplicate-delivery"); + } +} +``` + +- [ ] **Step 2: Run cross-broker tests and verify missing matrix** + +Run: + +```bash +./gradlew :modules:messaging:messaging-testkit:test :modules:messaging:messaging-kafka:test --tests '*ChaosSuiteIT' :modules:messaging:messaging-rabbit:test --tests '*ChaosSuiteIT' +``` + +Expected: FAIL because fault scenarios and chaos suites are missing. + +- [ ] **Step 3: Implement deterministic network and process fault scenarios** + +The matrix injects: + +```text +connection refusal +latency +packet loss +half-open connection +confirmation path drop +consumer settlement path drop +Kafka leader stop +Rabbit node stop or quorum minority +PostgreSQL restart +process kill at Outbox and Inbox crash points +credential rotation +certificate rotation +``` + +Each scenario asserts final state, allowed duplicate count, message identity, settlement state, metric tags, and secret-free logs. Keep broker-specific expected differences in adapter assertions, not the Core guarantee. + +Register the aggregate task in `messaging-chaos-conventions.gradle.kts`: + +```kotlin +tasks.register("messagingStableChaos") { + group = "verification" + dependsOn( + ":modules:messaging:messaging-kafka:test", + ":modules:messaging:messaging-rabbit:test", + ":modules:messaging:messaging-outbox-jpa:test", + ":modules:messaging:messaging-inbox-jpa:test" + ) +} +``` + +- [ ] **Step 4: Run the complete Stable failure matrix** + +Run: + +```bash +./gradlew messagingStableChaos +``` + +Expected: PASS with Kafka, RabbitMQ, PostgreSQL, TLS, ACL, Outbox, and Inbox scenarios. Experimental adapters are not part of this Stable gate. + +- [ ] **Step 5: Commit** + +```bash +git add infra/messaging/toxiproxy modules/messaging/messaging-testkit modules/messaging/messaging-kafka modules/messaging/messaging-rabbit modules/messaging/messaging-outbox-jpa modules/messaging/messaging-observability +git commit -m "test: add messaging stable failure matrix" +``` + +--- + +### Task 43: 성능 인증, Compatibility Matrix, Resource Leak Gate 구현 + +**Files:** +- Create: `modules/messaging/messaging-testkit/src/jmh/java/io/backend/skeleton/messaging/testkit/EnvelopeCodecBenchmark.java` +- Create: `modules/messaging/messaging-kafka/src/jmh/java/io/backend/skeleton/messaging/kafka/KafkaPublishBenchmark.java` +- Create: `modules/messaging/messaging-rabbit/src/jmh/java/io/backend/skeleton/messaging/rabbit/RabbitPublishBenchmark.java` +- Create: `modules/messaging/messaging-testkit/src/test/java/io/backend/skeleton/messaging/testkit/MessagingLoadIT.java` +- Create: `modules/messaging/messaging-testkit/src/test/java/io/backend/skeleton/messaging/testkit/ResourceLeakIT.java` +- Create: `.github/workflows/messaging-compatibility.yml` +- Create: `build-logic/src/main/kotlin/messaging-verification-conventions.gradle.kts` +- Modify: `build.gradle.kts` +- Create: `.github/workflows/messaging-performance.yml` +- Create: `gradle/messaging-versions.properties` + +**Interfaces:** +- Consumes all Stable modules and Experimental smoke tasks. +- Produces reproducible latency, throughput, heap, thread, connection, backlog, retry-amplification, and compatibility reports. + +- [ ] **Step 1: Write failing performance-budget and leak tests** + +```java +package io.backend.skeleton.messaging.testkit; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ResourceLeakIT { + @Test + void repeatedStartupShutdownReturnsThreadsAndConnectionsToBaseline() { + ResourceSnapshot before = ResourceSnapshot.capture(); + + for (int index = 0; index < 20; index++) { + try (MessagingSystemFixture fixture = MessagingSystemFixture.startAndStop()) { + fixture.publishAndConsume(100); + } + } + + ResourceSnapshot after = ResourceSnapshot.capture(); + assertThat(after.nonDaemonThreadDelta(before)).isLessThanOrEqualTo(2); + assertThat(after.openConnectionDelta(before)).isZero(); + } +} +``` + +```java +package io.backend.skeleton.messaging.testkit; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class MessagingLoadIT { + @Test + void retryAmplificationStaysWithinConfiguredBudget() { + LoadReport report = MessagingLoadFixture.runWithTenPercentTransientFailures(); + + assertThat(report.physicalPublishes()) + .isLessThanOrEqualTo(report.logicalPublishes() * 1.20); + assertThat(report.maxHeapBytes()).isLessThanOrEqualTo(512L * 1024 * 1024); + } +} +``` + +- [ ] **Step 2: Run performance smoke tests and verify missing harness** + +Run: + +```bash +./gradlew :modules:messaging:messaging-testkit:test --tests '*MessagingLoadIT' --tests '*ResourceLeakIT' +``` + +Expected: FAIL because performance and resource fixtures are missing. + +- [ ] **Step 3: Implement benchmark scenarios and CI version matrix** + +Measure: + +```text +p50 p95 p99 max publish confirmation latency +consumer processing and settlement latency +throughput +producer buffered bytes +consumer in-flight and backlog +heap allocation and GC +thread count +broker connection/channel count +retry amplification +DLQ throughput +Outbox age and relay throughput +Inbox duplicate contention +``` + +Compatibility workflow runs Kafka 4.2 and 4.3.x, RabbitMQ 4.3.x, PostgreSQL 16, Spring 6.2 and 7.0 lines, plus non-blocking Experimental smoke for Pulsar 4.0/4.2 and NATS 2.14.x. Store baselines as versioned JSON and fail on agreed regression thresholds. + +Register aggregate tasks in `messaging-verification-conventions.gradle.kts`: + +```kotlin +tasks.register("messagingPerformance") { + group = "verification" + dependsOn( + ":modules:messaging:messaging-testkit:test", + ":modules:messaging:messaging-kafka:jmh", + ":modules:messaging:messaging-rabbit:jmh" + ) +} + +tasks.register("messagingCompatibility") { + group = "verification" + dependsOn( + ":modules:messaging:messaging-kafka:test", + ":modules:messaging:messaging-rabbit:test", + ":modules:messaging:messaging-pulsar-experimental:test", + ":modules:messaging:messaging-nats-experimental:test" + ) +} +``` + +- [ ] **Step 4: Run performance certification and compatibility jobs locally where supported** + +Run: + +```bash +./gradlew messagingPerformance messagingCompatibility +``` + +Expected: PASS with generated reports under `build/reports/messaging` and no resource leak. + +- [ ] **Step 5: Commit** + +```bash +git add modules/messaging .github/workflows gradle/messaging-versions.properties +git commit -m "test: add messaging performance and compatibility gates" +``` + +--- + +### Task 44: 지원 문서, Runbook, ADR, Release Gate 완성 + +**Files:** +- Create: `docs/messaging/support-matrix.md` +- Create: `docs/messaging/configuration-reference.md` +- Create: `docs/messaging/delivery-guarantees.md` +- Create: `docs/messaging/retry-dlq-redrive.md` +- Create: `docs/messaging/outbox-inbox.md` +- Create: `docs/messaging/security.md` +- Create: `docs/messaging/operations.md` +- Create: `docs/messaging/migration-guide.md` +- Create: `docs/messaging/experimental-policy.md` +- Create: `docs/adr/ADR-0041-messaging-core-native-adapters.md` +- Create: `docs/adr/ADR-0042-no-generic-exactly-once.md` +- Create: `docs/adr/ADR-0043-outbox-inbox-reliability.md` +- Create: `docs/messaging/release-checklist.md` +- Test: `modules/messaging/messaging-testkit/src/test/java/io/backend/skeleton/messaging/testkit/MessagingDocumentationContractTest.java` + +**Interfaces:** +- Consumes every design decision and test report. +- Produces implementation-independent usage, configuration, security, reliability, migration, operations, and release documentation. +- Documentation contract verifies that every Stable and Experimental capability is classified and every dangerous operation has a runbook. + +- [ ] **Step 1: Write failing documentation contract** + +```java +package io.backend.skeleton.messaging.testkit; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class MessagingDocumentationContractTest { + @Test + void supportMatrixClassifiesEveryAdapterAndCapability() throws Exception { + String matrix = Files.readString(Path.of("docs/messaging/support-matrix.md")); + + assertThat(matrix).contains( + "Kafka | Stable", + "RabbitMQ | Stable", + "Kafka Share Group | Experimental", + "Pulsar | Experimental", + "NATS JetStream | Experimental", + "EXACTLY_ONCE | Unsupported"); + } + + @Test + void operationsRunbookCoversAmbiguousPublishAndDlqOutage() throws Exception { + String runbook = Files.readString(Path.of("docs/messaging/operations.md")); + + assertThat(runbook).contains( + "Ambiguous publish", + "DLQ target outage", + "Consumer settlement unknown", + "Outbox backlog", + "Replay and redrive approval"); + } +} +``` + +- [ ] **Step 2: Run documentation contract and verify missing files** + +Run: + +```bash +./gradlew :modules:messaging:messaging-testkit:test --tests '*MessagingDocumentationContractTest' +``` + +Expected: FAIL because the documentation files do not exist. + +- [ ] **Step 3: Write exact operational and developer documentation** + +The documents must include: + +```text +M1–M4 API examples +Kafka and Rabbit physical mapping examples +PublishResult evidence interpretation +Consumer duplicate and settlement rules +Retry strategy selection table +DLQ publish-before-ACK invariant +Redrive approval workflow +Outbox and Inbox crash diagrams +Schema compatibility rules +Claim Check lifecycle +TLS/ACL credential separation +Metric/tag allowlist and denylist +Kafka and Rabbit failure runbooks +Experimental feature enablement and rollback +Spring Cloud Stream migration limitations +``` + +`release-checklist.md` links every completion criterion to a Gradle task or CI job. ADRs record the own-Core/native-adapter decision, the rejection of generic exactly-once, and the Outbox+Inbox reliability model. + +- [ ] **Step 4: Run full verification** + +Run: + +```bash +./gradlew clean check messagingStableChaos messagingPerformance messagingCompatibility +``` + +Expected: PASS with zero test failures, zero architecture violations, and generated Stable release reports. + +- [ ] **Step 5: Commit** + +```bash +git add docs modules/messaging/messaging-testkit +git commit -m "docs: complete messaging platform release guidance" +``` + +--- + +## 3. Plan Self-Review Checklist + +### Spec coverage + +- [ ] M1 Typed Publisher·Handler: Tasks 2–8, 35, 36 +- [ ] M2 Batch·Manual Settlement·Replay: Tasks 8, 20, 34, 36 +- [ ] M3 Kafka·Rabbit native capability: Tasks 19, 20, 25 +- [ ] M4 Admin Plane: Tasks 33–35 +- [ ] Publish evidence and ambiguity: Tasks 7, 10, 12, 16, 23 +- [ ] Consumer duplicate and settlement: Tasks 8, 12, 17, 24, 28 +- [ ] Retry·DLQ·redrive identity: Tasks 13, 14, 18, 25, 34 +- [ ] Kafka Stable: Tasks 15–20 +- [ ] RabbitMQ Stable: Tasks 22–25 +- [ ] Outbox·Inbox·Claim Check: Tasks 26–29, 37 +- [ ] JSON·Avro·Protobuf·CloudEvents: Tasks 5, 6, 30 +- [ ] Security and ACL: Tasks 11, 31 +- [ ] Observability and audit: Tasks 11, 32 +- [ ] Topology and operations: Tasks 20, 33, 34 +- [ ] Pulsar·NATS Experimental: Tasks 38, 39 +- [ ] Spring Cloud Stream bridge: Task 40 +- [ ] Backpressure and shutdown: Task 41 +- [ ] Failure matrix and performance: Tasks 42, 43 +- [ ] Documentation and release gate: Task 44 + +### Placeholder scan + +The final document must contain no unresolved markers, deferred implementation phrases, or cross-task shorthand that omits required code. + +### Type consistency + +- `MessagePublisher.publish` always returns `CompletionStage`. +- `MessageHandler.handle` always returns `CompletionStage`. +- `PublishCompletion` constants are `CONFIRMED`, `REJECTED`, `AMBIGUOUS`. +- `SettlementCompletion` constants are `SETTLED`, `REJECTED`, `UNKNOWN`. +- `RetryMode` constants match the design exactly. +- `messageId` remains `MessageId` from Task 2 through Outbox, Inbox, DLQ, retry, replay, and redrive. +- Stable modules never depend on Experimental modules. + +--- + +## 4. Execution Handoff + +Plan execution begins only after the design and plan are reviewed in the target repository. + +**Recommended:** `superpowers:subagent-driven-development` + +- fresh implementation agent per Task +- requirements review after each Task +- code quality review after each Task +- full Stable gate after Tasks 25, 35, 42, and 44 + +**Alternative:** `superpowers:executing-plans` + +- execute Tasks in the listed order +- checkpoint after Foundation, Kafka, RabbitMQ, Reliability, Operations, Experimental, Release phases +- never combine unreviewed Tasks into one commit diff --git a/messaging-superpowers-package/docs/superpowers/specs/2026-08-10-messaging-platform-design.md b/messaging-superpowers-package/docs/superpowers/specs/2026-08-10-messaging-platform-design.md new file mode 100644 index 00000000..af4a4587 --- /dev/null +++ b/messaging-superpowers-package/docs/superpowers/specs/2026-08-10-messaging-platform-design.md @@ -0,0 +1,2608 @@ +# Messaging Platform 설계서 + +- **상태:** 구현 승인용 기준 설계 +- **작성일:** 2026-08-10 +- **대상:** Java/Spring Backend Skeleton의 공통 Messaging Capability +- **근거 자료:** `Java/Spring Messaging 플랫폼 심층 리서치` +- **문서 역할:** 구현 중 추가 설계 판단이 발생하지 않도록 공개 계약, 브로커별 보장, 오류·재시도·DLQ·Outbox·Inbox·운영·검증 경계를 고정한다. + +--- + +## 1. 요약 + +이 플랫폼은 `send()`와 `consume()`을 편하게 감싸는 라이브러리가 아니다. 메시지 생성부터 브로커 수락, 복제·영속화, 소비자 전달, 처리, settlement, 재전달, DLQ, replay·redrive까지의 **신뢰성 증거와 실패 의미론**을 통제하는 백엔드 기반 모듈이다. + +핵심 설계는 다음과 같다. + +| 영역 | 확정 결정 | +|---|---| +| 공통 구조 | 자체 `messaging-core`가 공개 계약을 소유하고 브로커별 Native Adapter가 구현한다. | +| 기본 API | M1 Typed Publisher·Handler를 일반 서비스의 기본 진입점으로 사용한다. | +| 고급 API | M2 Batch·Manual Settlement·Pause/Resume·Delayed·Replay를 별도 권한으로 제공한다. | +| Native 기능 | M3에서 Kafka transaction·partition, Rabbit routing·quorum, Pulsar subscription, NATS subject를 제한적으로 제공한다. | +| 운영 기능 | M4 Admin Plane에서 topology 검증, replay, redrive, offset reset, purge·delete를 제공한다. | +| 전달 보장 | `AT_MOST_ONCE`, `AT_LEAST_ONCE`만 공통 보장으로 제공한다. | +| Exactly-once | 공통 `EXACTLY_ONCE` 옵션을 두지 않는다. 브로커 transaction 범위와 Inbox·Outbox 조합을 각각 명시한다. | +| publish 결과 | 성공·실패를 boolean으로 축소하지 않고 `CONFIRMED`, `REJECTED`, `AMBIGUOUS`와 broker evidence를 함께 반환한다. | +| 소비자 기본 | 중복 전달을 정상적인 failure mode로 간주하고 handler 성공 뒤에만 settlement한다. | +| DLQ | DLQ publish가 confirm된 뒤 source를 settlement한다. DLQ publish 실패 시 source를 ACK하지 않는다. | +| Kafka | Kafka 4.2+를 Stable 기준으로 하고 4.3.x에서 검증한다. Share Group은 플랫폼 Experimental이다. | +| RabbitMQ | RabbitMQ 4.3.x를 Stable 기준으로 하고 durable work queue는 quorum queue를 기본값으로 한다. | +| Pulsar | 4.0 LTS와 4.2 호환성을 Experimental Adapter에서 검증한다. | +| NATS | JetStream 2.14.x를 Experimental Adapter에서 검증한다. Core NATS는 Stable reliability 경로에 사용하지 않는다. | +| Reliability | Transactional Outbox, Inbox, Idempotent Consumer, Claim Check를 별도 Reliability 모듈로 제공한다. | +| Schema | JSON Stable, Avro·Protobuf 선택 Stable, Raw Bytes M2, Java Serialization 비지원이다. | +| CloudEvents | Domain·Integration Event에 선택 가능한 1.0.2 compatible profile을 제공한다. | +| topology | 개발·테스트는 선택적 자동 생성, 운영은 IaC 생성 + 애플리케이션 startup 검증이 기본이다. | +| payload | portability 기본 상한은 1 MiB이며 초과 payload는 Claim Check를 사용한다. | +| 관측성 | 논리 메시지와 물리 delivery·attempt를 분리해 측정하고 고카디널리티 값과 payload를 기록하지 않는다. | + +플랫폼의 완료 기준은 모든 브로커를 같아 보이게 만드는 것이 아니다. 다음 질문에 adapter가 동일한 형식으로 답할 수 있어야 한다. + +```text +publish가 실패했다. +→ broker에 전송되지 않았음을 아는가? +→ broker가 저장했을 가능성이 있는가? +→ 어떤 수준의 confirm을 받았는가? +→ 같은 logical message ID로 다시 시도해도 되는가? + +consume이 실패했다. +→ handler가 시작됐는가? +→ side effect가 commit됐는가? +→ settlement가 broker에 반영됐는가? +→ redelivery가 발생할 수 있는가? + +retry·DLQ·redrive를 수행한다. +→ 원래 message identity가 보존되는가? +→ ordering이 깨지는가? +→ duplicate side effect를 Inbox가 차단하는가? +``` + +--- + +## 2. 목표와 성공 기준 + +### 2.1 목표 + +1. 일반 서비스가 브로커 종류를 몰라도 typed message를 publish·consume할 수 있게 한다. +2. 브로커별 보장 차이를 숨기지 않고 capability와 evidence로 노출한다. +3. publish 결과가 확정적이지 않으면 `AMBIGUOUS`로 표현한다. +4. consumer duplicate, ACK 유실, rebalance, failover를 정상적인 운영 시나리오로 다룬다. +5. retry·DLQ·redrive에서 logical `messageId`와 schema identity를 보존한다. +6. Kafka와 RabbitMQ를 Stable Adapter로 제공한다. +7. Pulsar와 NATS JetStream을 Experimental Adapter로 제공해 abstraction boundary를 검증한다. +8. Outbox·Inbox를 실행 가능한 Reliability Recipe로 제공한다. +9. topology, 보안, 관측성, 장애·성능 검증을 코드와 CI에서 강제한다. + +### 2.2 성공 기준 + +- Core API에 broker SDK 타입과 Spring `Message`가 노출되지 않는다. +- Core API에 `EXACTLY_ONCE` enum이나 boolean 옵션이 존재하지 않는다. +- 모든 publish는 `PublishResult` 또는 안정 예외를 반환하며 `AMBIGUOUS`를 구분한다. +- Kafka publish는 idempotent producer와 `acks=all`을 Stable profile에서 강제한다. +- Rabbit publish는 publisher confirm과 routing outcome을 분리한다. +- M1 consumer는 handler 성공 이전에 source settlement를 수행하지 않는다. +- retry destination 또는 DLQ publish가 실패하면 source message를 settlement하지 않는다. +- strict ordering destination에는 reorder 가능한 retry 전략을 설정할 수 없다. +- Outbox relay가 ambiguous publish를 다시 시도해도 같은 `messageId`를 사용한다. +- Inbox record와 business side effect가 같은 DB transaction에서 commit된다. +- 일반 애플리케이션 credential로 replay, redrive, purge, delete, offset reset을 호출할 수 없다. +- 동일 Core Contract Suite가 Kafka와 RabbitMQ에서 통과한다. +- Experimental Adapter가 Core 계약을 변경하지 않고 Pulsar·NATS의 차이를 capability로 표현한다. +- metric label과 log에 payload, secret header, 실제 message ID, 동적 key가 노출되지 않는다. + +--- + +## 3. 입력 자료의 제약과 구현 가정 + +근거 자료에는 플랫폼 의미론과 지원 경계가 충분히 정의되어 있으나 실제 Backend Skeleton 저장소 구조와 dependency catalog는 포함되어 있지 않다. 구현 계획은 다음 가정을 사용한다. + +| 항목 | 구현 가정 | +|---|---| +| 언어 | Java 21 | +| 빌드 | Gradle Kotlin DSL | +| root package | `io.backend.skeleton.messaging` | +| 모듈 root | `modules/messaging` | +| Spring 버전 | host 저장소의 dependency management를 사용하고 Spring Framework 6.2·7.0 호환 job을 둔다. | +| Core 비동기 타입 | `CompletionStage` | +| Reactive facade | 별도 Reactor adapter에서 `Mono`·`Flux`로 제공 | +| DB recipe | PostgreSQL 16 + Spring JDBC/JPA + Flyway | +| JSON | Jackson | +| Avro·Protobuf | 선택 모듈 | +| 관측성 | Micrometer + OpenTelemetry exporter adapter | +| 테스트 | JUnit 5, AssertJ, ArchUnit, Testcontainers, Toxiproxy | +| 코드 생성 | 사용하지 않는다. Schema code generation은 각 Avro·Protobuf 모듈에 한정한다. | + +실제 저장소 경로가 다르면 파일 경로만 기계적으로 매핑한다. 공개 타입, 모듈 의존 방향, 상태·오류·보장 계약은 변경하지 않는다. + +다음 숫자는 플랫폼 starter의 초기 안전 기본값이다. 배포 profile에서 더 작게 조정할 수 있고, 더 크게 조정하려면 명시적 override와 성능 인증이 필요하다. + +| 설정 | 기본값 | +|---|---:| +| logical payload 최대 크기 | 1,048,576 bytes | +| global hard payload 최대 크기 | 8,388,608 bytes | +| header 총 크기 | 32,768 bytes | +| header 개수 | 64 | +| header key UTF-8 크기 | 128 bytes | +| header value UTF-8 크기 | 4,096 bytes | +| publish operation 기본 timeout | 5초 | +| consumer handler 기본 timeout | 30초 | +| graceful shutdown drain | 30초 | +| 일반 destination 기본 retry 횟수 | 0회, 명시 profile만 허용 | +| DLQ redrive batch | 100건 | +| Outbox relay batch | 100건 | +| Outbox lease | 30초 | +| Outbox polling | 500ms | + +--- + +## 4. 범위 + +### 4.1 Core 포함 범위 + +```text +Async Command +Domain Event +Integration Event +Work Queue +Publish–Subscribe +Event Stream +Typed Publish +Typed Handler +Batch Publish +Batch Consume +Manual Settlement +Pause / Resume +Delayed Delivery capability +Retry +DLQ / Parking +Replay / Redrive +Schema / Serialization +Broker Capability +Topology Validation +Security / Observability +``` + +### 4.2 별도 Reliability 모듈 + +```text +Transactional Outbox +Inbox +Idempotent Consumer +Claim Check +Debezium Outbox Event Router integration +``` + +### 4.3 별도 기술 모듈 + +```text +Email·SMS·Push → notification +Browser live connection → websocket +Redis Stream·Pub/Sub command → redis +Binary payload storage → objectstorage / fileserver +CDC engine 자체 → connector/CDC +Saga orchestration → 별도 orchestration +``` + +### 4.4 명시적 비지원 + +- 공통 `EXACTLY_ONCE` 설정 +- 전역 순서 보장 +- DB와 broker의 자동 원자 transaction 보장 +- 기본 XA +- Java native serialization +- 무제한 payload·header +- 무한 retry +- 운영 application의 topology 파괴 작업 +- 일반 애플리케이션에 raw broker client 반환 +- DLQ publish 확인 전 source ACK +- one-shot payload의 자동 publish retry +- replay·redrive를 일반 consumer API로 공개 + +--- + +## 5. 핵심 설계 원칙 + +1. **증거를 보장 이름보다 우선한다.** `BROKER_ACK`, `REPLICATION_OR_PERSISTENCE_ACK`, routing result를 별도 필드로 표현한다. +2. **모호성을 숨기지 않는다.** 전송 후 confirm을 받지 못하면 성공 또는 실패로 추정하지 않고 `AMBIGUOUS`를 반환한다. +3. **중복 전달을 기본 전제로 둔다.** consumer는 handler·DB·settlement 경계에서 duplicate-safe해야 한다. +4. **logical identity를 유지한다.** publish retry, redelivery, DLQ, redrive에서 `messageId`를 바꾸지 않는다. +5. **ordering scope를 제한한다.** 순서는 destination 전체가 아니라 partition·key·single consumer 같은 실제 범위로만 표현한다. +6. **공통 API가 최소공배수가 되지 않게 한다.** 공통 계약과 broker-native capability를 분리한다. +7. **정책 우회 경로를 만들지 않는다.** M2·M3도 TLS, credential, destination ACL, payload limit, trace, masking을 우회할 수 없다. +8. **운영 변경은 Admin Plane에서 수행한다.** 애플리케이션 runtime은 describe·validate만 수행한다. +9. **자동 retry는 opt-in이다.** 실패 분류, ordering, idempotency, deadline, broker capability가 모두 허용할 때만 수행한다. +10. **DLQ도 publish다.** DLQ publish 실패를 source success로 처리하지 않는다. +11. **Outbox는 duplicate를 제거하지 않는다.** stable message ID와 Inbox가 함께 있어야 effectively-once DB effect를 구성한다. +12. **Stable과 Experimental을 코드로 분리한다.** Experimental adapter가 Stable module dependency graph에 자동 유입되지 않는다. + +--- + +## 6. 지원 매트릭스 + +### 6.1 브로커 + +| 브로커 | 등급 | 인증 기준 | Stable 기능 | 제한·Experimental | +|---|---|---|---|---| +| Kafka | Stable | 4.2+ / 4.3.x | producer, idempotence, consumer group, batch, pause/resume, replay, transaction capability | Share Group | +| RabbitMQ | Stable | 4.3.x | exchange, routing, confirm, mandatory return, manual ACK, quorum queue, retry, DLQ | stream·특수 plugin | +| Pulsar | Experimental | 4.0 LTS + 4.2 | typed publish/consume, Shared, Key_Shared, schema | transaction·full compatibility 승격 전 | +| NATS JetStream | Experimental | 2.14.x | stream, durable consumer, explicit ACK, dedupe, replay | platform-managed DLQ workflow | +| Artemis/JMS | Extension | 현재 Stable 범위 제외 | adapter SPI만 | 별도 ADR과 Contract Suite 통과 뒤 지원 선언 | +| Cloud broker | 별도 Adapter | provider별 | Core 변경 없이 adapter capability | IAM·visibility·ordering provider semantics | + +### 6.2 기능 등급 + +| 기능 | 등급 | +|---|---| +| Typed Publish·Consume | Stable M1 | +| At-least-once contract | Stable | +| Async publish result | Stable | +| Ambiguous publish | Stable | +| Auto settlement after success | Stable M1 | +| Batch | M2 | +| Manual settlement | M2 | +| Request–Reply | M2, 제한적 | +| Delayed·Scheduled | Capability-specific M2 | +| Replay | M2 + Admin | +| Redrive | Admin | +| Broker transaction | M3 native capability | +| Kafka Share Group | Experimental M2/M3 | +| Pulsar·NATS adapters | Experimental | +| Spring Cloud Stream bridge | Optional integration | + +--- + +## 7. 전체 아키텍처 + +```text +Application + │ + ├─ M1 Typed Messaging API + │ ├─ MessagePublisher + │ └─ MessageHandler + │ + ├─ M2 Advanced API + │ ├─ BatchPublisher + │ ├─ ManualSettlementHandler + │ ├─ PauseResumeController + │ └─ ReplayRequest + │ + ├─ M3 Native Capability + │ ├─ KafkaNativeCapability + │ ├─ RabbitNativeCapability + │ ├─ PulsarNativeCapability + │ └─ NatsNativeCapability + │ + └─ M4 Admin Plane + ├─ TopologyValidator + ├─ ReplayService + ├─ RedriveService + └─ DestructiveOperationGuard + +Policy Pipeline + ├─ Envelope Validation + ├─ Destination Resolution + ├─ Schema Encoding + ├─ Security / ACL + ├─ Payload / Header Limit + ├─ Retry / DLQ Policy + ├─ Observability + └─ Transport Adapter + +Transport Adapters + ├─ Kafka Stable + ├─ RabbitMQ Stable + ├─ Pulsar Experimental + └─ NATS JetStream Experimental + +Reliability + ├─ Outbox + ├─ Inbox + ├─ Idempotent Consumer + └─ Claim Check +``` + +### 7.1 Publish 흐름 + +```text +Typed payload +→ MessageEnvelope 생성·검증 +→ logical destination profile 조회 +→ schema encoding +→ reserved header mapping +→ payload/header limit +→ broker adapter publish +→ broker evidence 수집 +→ PublishResult 변환 +→ metric·trace·audit 종료 +``` + +### 7.2 Consume 흐름 + +```text +broker delivery +→ transport metadata 추출 +→ envelope/header 검증 +→ schema decode +→ MessageDelivery 생성 +→ handler 실행 +→ success: source settlement +→ retry: policy에 따른 inline/pause/retry destination +→ permanent: confirmed DLQ publish 후 source settlement +→ settlement 결과·redelivery·lag 관측 +``` + +--- + +## 8. 모듈 구조 + +```text +modules/messaging/ +├── messaging-core-api +├── messaging-schema-api +├── messaging-schema-json +├── messaging-schema-avro +├── messaging-schema-protobuf +├── messaging-cloudevents +├── messaging-policy +├── messaging-transport-spi +├── messaging-observability +├── messaging-security +├── messaging-kafka +├── messaging-kafka-share-experimental +├── messaging-rabbit +├── messaging-reliability-api +├── messaging-outbox-jpa +├── messaging-inbox-jpa +├── messaging-claim-check +├── messaging-admin-api +├── messaging-admin-runtime +├── messaging-pulsar-experimental +├── messaging-nats-experimental +├── messaging-spring-cloud-stream-bridge +├── messaging-spring-boot-starter +└── messaging-testkit +``` + +### 8.1 의존 방향 + +```text +core-api + ↑ +policy / schema-api / transport-spi / observability / security + ↑ +kafka / rabbit / pulsar / nats + ↑ +spring-boot-starter + +reliability-api + ↑ +outbox-jpa / inbox-jpa / claim-check + +admin-api + ↑ +admin-runtime + broker adapter admin capability +``` + +금지 의존은 다음과 같다. + +- `messaging-core-api` → Spring Kafka, Spring AMQP, Pulsar, NATS +- `messaging-core-api` → Spring `Message` +- Stable module → Experimental module +- application API → M4 destructive implementation +- Reliability core → 특정 broker SDK + +--- + +## 9. 공개 API 등급 + +| 등급 | 사용 대상 | 제공 기능 | 금지 사항 | +|---|---|---|---| +| M1 | 일반 업무 모듈 | typed publish, typed handler, 자동 settlement | topic·offset·channel·delivery tag 직접 조작 | +| M2 | 승인된 고급 모듈 | batch, manual settlement, delayed, pause/resume, replay request | credential·payload limit·ACL 우회 | +| M3 | broker 특화 모듈 | partition, routing, native transaction, subscription | raw client 반환, topology 파괴 | +| M4 | 운영자·관리 서비스 | topology, offset/cursor, redrive, purge/delete | application credential 사용 | + +M2와 M3는 `@RequiresMessagingCapability` 또는 명시적 bean qualifier를 통해서만 주입된다. M4는 별도 Spring Boot application 또는 별도 security chain과 credential을 사용한다. + +--- + +## 10. 핵심 식별자와 타입 + +```java +public record MessageId(UUID value) { + public static MessageId newId() { + return new MessageId(UuidV7.next()); + } +} + +public record MessageType(String value) {} +public record SchemaVersion(int value) {} +public record ProducerId(String value) {} +public record DestinationName(String value) {} +public record CorrelationId(String value) {} +public record CausationId(MessageId value) {} +public record RedriveId(UUID value) {} +public record ReplayId(UUID value) {} +``` + +### 10.1 식별자 규칙 + +- `messageId`는 logical message identity다. +- publish retry, broker redelivery, retry destination, DLQ, redrive에서 유지한다. +- 새 업무 사건 또는 새 command일 때만 새 `messageId`를 생성한다. +- `redriveId`, `replayId`, `transportAttemptId`는 별도 식별자다. +- Java class name을 `messageType`으로 사용하지 않는다. +- `messageType`은 `order.created`, `payment.capture.requested`와 같은 안정된 catalog 값이다. +- 실제 `messageId`는 metric label에 사용하지 않는다. + +--- + +## 11. Message Envelope + +```java +public record MessageEnvelope( + MessageId messageId, + MessageType messageType, + SchemaVersion schemaVersion, + Instant producedAt, + Optional occurredAt, + ProducerId producer, + Optional correlationId, + Optional causationId, + ContentType contentType, + Optional partitionKey, + Optional orderingKey, + Optional tenantContext, + TraceContext traceContext, + MessageHeaders headers, + T payload) { +} +``` + +### 11.1 필드 정책 + +| 필드 | 정책 | +|---|---| +| `messageId` | 필수, UUIDv7 | +| `messageType` | 필수, catalog 등록 | +| `schemaVersion` | 1 이상 | +| `producedAt` | platform이 설정 | +| `occurredAt` | Event 필수, Command 선택 | +| `producer` | logical service ID | +| `correlationId` | workflow 단위 | +| `causationId` | 선행 message가 있을 때 | +| `contentType` | codec registry 값 | +| `partitionKey` | distribution 목적 | +| `orderingKey` | ordering 의도 | +| `tenantContext` | allowlist된 bounded value | +| `traceContext` | platform이 생성·전파 | +| `headers` | 제한형 immutable map | +| `payload` | null 금지. Kafka tombstone은 M3 전용 타입 | + +### 11.2 Header 정책 + +```java +public final class MessageHeaders { + private final Map values; + + public static MessageHeaders of(Map values) { + return HeaderPolicy.validateAndCopy(values); + } +} +``` + +예약 header는 platform만 쓸 수 있다. + +```text +msg.id +msg.type +msg.schema-version +msg.producer +msg.produced-at +msg.occurred-at +msg.correlation-id +msg.causation-id +msg.content-type +msg.partition-key +msg.ordering-key +msg.redrive-id +msg.redrive-count +traceparent +tracestate +baggage +``` + +금지 header는 다음과 같다. + +```text +Authorization +Proxy-Authorization +Cookie +Set-Cookie +access_token +refresh_token +api_key +password +client_secret +``` + +--- + +## 12. 논리 Destination과 Profile + +```java +public record MessageDestination( + DestinationName name, + MessageType messageType, + Class payloadType) { +} +``` + +애플리케이션은 `topic`, `exchange`, `queue`, `subject`가 아니라 logical destination을 사용한다. 물리 매핑은 `DestinationProfile`이 소유한다. + +```yaml +messaging: + destinations: + order-events: + broker: kafka-primary + kind: EVENT_STREAM + physical: + topic: order.events.v1 + schema: + codec: json + compatibility: BACKWARD_TRANSITIVE + guarantees: + delivery: AT_LEAST_ONCE + ordering: KEY + external-side-effect: INBOX_TRANSACTIONAL + producer: + confirmation: REPLICATION_OR_PERSISTENCE_ACK + timeout: 5s + consumer: + group: order-projection + concurrency: 6 + max-in-flight-per-partition: 1 + handler-timeout: 30s + retry: + mode: PAUSE_PARTITION + max-attempts: 3 + initial-delay: 200ms + max-delay: 2s + ordering-impact: PRESERVE + dlq: + destination: order-events-dlq + payload: + max-bytes: 1048576 + + email-work: + broker: rabbit-primary + kind: WORK_QUEUE + physical: + exchange: notification.work + routing-key: email + queue: notification.email.q + guarantees: + delivery: AT_LEAST_ONCE + ordering: NONE + external-side-effect: IDEMPOTENCY_REQUIRED + producer: + confirmation: REPLICATION_OR_PERSISTENCE_ACK + mandatory-routing: true + timeout: 5s + consumer: + concurrency: 8 + prefetch: 16 + handler-timeout: 30s + retry: + mode: RETRY_DESTINATION + max-attempts: 5 + initial-delay: 1s + max-delay: 5m + ordering-impact: ALLOW_REORDER + dlq: + destination: email-work-dlq + payload: + max-bytes: 1048576 +``` + +### 12.1 Startup validation + +다음 조건이면 애플리케이션 시작을 실패시킨다. + +- `AT_LEAST_ONCE`인데 adapter가 confirm 또는 explicit settlement를 제공하지 못함 +- `ordering-impact=PRESERVE`인데 retry destination 전략 사용 +- `ordering=KEY`인데 key resolver 없음 +- M1 destination에 manual settlement 설정 +- payload hard limit 초과 +- DLQ가 자기 자신을 가리킴 +- retry destination cycle 존재 +- 운영 profile에서 topology auto-create 활성화 +- production에서 TLS 또는 broker 인증 비활성 +- Kafka Stable producer에서 idempotence 또는 `acks=all` 비활성 +- Rabbit durable work queue에서 classic transient queue 사용 +- schema codec 또는 message type catalog 누락 + + +--- + +## 13. Capability Model + +공통 API는 브로커가 모든 기능을 동일하게 지원한다고 가정하지 않는다. + +```java +public record MessagingCapabilities( + boolean brokerAcknowledgement, + boolean replicationOrPersistenceEvidence, + boolean perMessageSettlement, + boolean batchSettlement, + boolean orderedStream, + boolean keyedOrdering, + boolean replay, + boolean delayedDelivery, + boolean brokerTransaction, + boolean deduplicatedPublish, + boolean nativeDeadLetter, + boolean topologyManagement) { +} +``` + +실행 시에는 destination별로 capability snapshot을 만든다. + +```java +public interface CapabilityRegistry { + DestinationCapabilities capabilities(DestinationName destination); +} +``` + +### 13.1 Capability 검증 규칙 + +| 요청 기능 | 필수 capability | +|---|---| +| `confirmation=REPLICATION_OR_PERSISTENCE_ACK` | adapter가 해당 증거를 입증 | +| manual settlement | `perMessageSettlement` 또는 명시적 batch settlement | +| replay | `replay` | +| broker transaction | `brokerTransaction` | +| strict key ordering | `keyedOrdering` + concurrency 제약 | +| delayed delivery | native 또는 retry destination 구현 | +| native DLQ | native dead-letter 보장 조건 충족 | +| deduplicated publish | broker dedupe + stable message ID | + +Capability가 없으면 조용히 기능을 약화하지 않고 startup error 또는 `MessagingCapabilityUnavailableException`을 반환한다. + +--- + +## 14. Schema·직렬화 계약 + +### 14.1 Codec API + +```java +public interface MessageCodec { + ContentType contentType(); + + EncodedMessage encode( + MessageType type, + SchemaVersion version, + Object payload); + + T decode( + MessageType type, + SchemaVersion version, + EncodedMessage encoded, + Class payloadType); +} + +public record EncodedMessage( + byte[] bytes, + ContentType contentType, + Optional schemaReference) { +} +``` + +### 14.2 지원 수준 + +| 형식 | 등급 | 정책 | +|---|---|---| +| JSON | Stable 기본 | Jackson allowlist, 깊이·크기 제한 | +| Avro | 선택 Stable | Registry profile과 compatibility gate | +| Protobuf | 선택 Stable | generated type + unknown field 정책 | +| JSON Schema | Registry profile | JSON codec와 연동 | +| Raw Bytes | M2 | schema 검증 우회 감사 | +| Java Serialization | 비지원 | wire compatibility·보안 문제 | + +### 14.3 Compatibility + +```text +BACKWARD +BACKWARD_TRANSITIVE +FORWARD +FORWARD_TRANSITIVE +FULL +FULL_TRANSITIVE +NONE_EXPERIMENTAL +``` + +운영 기본값은 다음과 같다. + +- integration event: `BACKWARD_TRANSITIVE` +- 장수 공용 event: `FULL_TRANSITIVE` 검토 +- command: producer·consumer 동시 배포가 가능하면 `BACKWARD` +- schema 없음: M2 Raw Bytes에서만 허용 + +### 14.4 Schema 변경 규칙 + +- optional/default 없는 필드 추가를 금지한다. +- 필드 rename은 add → dual read/write → remove로 처리한다. +- enum에는 unknown value 전략이 있어야 한다. +- message type 변경은 새 type으로 취급한다. +- 역직렬화 실패는 일반 transient retry 대상이 아니다. +- Kafka tombstone은 nullable payload로 일반화하지 않고 `KafkaTombstonePublish` M3 API로 분리한다. + +### 14.5 Golden Message + +각 message type은 다음 fixture를 보유한다. + +```text +schemas//v1/valid.* +schemas//v2/valid.* +schemas//invalid/* +``` + +CI에서 구버전 fixture를 최신 consumer가 읽고, compatibility mode에 따라 신버전 fixture를 구버전 consumer가 읽는지 검증한다. + +--- + +## 15. CloudEvents Profile + +CloudEvents는 Domain·Integration Event의 상호운용 profile로만 제공한다. Command와 Work Item에는 강제하지 않는다. + +```java +public interface CloudEventMapper { + CloudEvent toCloudEvent(MessageEnvelope envelope, URI source); + MessageEnvelope fromCloudEvent(CloudEvent event); +} +``` + +매핑은 다음과 같다. + +| Envelope | CloudEvents | +|---|---| +| `messageId` | `id` | +| `producer/source` | `source` | +| `messageType` | `type` | +| `occurredAt` | `time` | +| `contentType` | `datacontenttype` | +| schema URI | `dataschema` | +| `correlationId` | extension `correlationid` | +| `causationId` | extension `causationid` | +| `schemaVersion` | extension `schemaversion` | +| `tenantContext` | extension `tenantcontext` | + +Kafka binary binding을 사용할 때 tombstone과 empty event를 구분한다. CloudEvent data가 없다는 이유로 Kafka null value를 자동 생성하지 않는다. + +--- + +## 16. Publisher 공개 계약 + +```java +public interface MessagePublisher { + CompletionStage publish( + MessageDestination destination, + MessageEnvelope message, + PublishOptions options); +} + +public record PublishOptions( + Duration timeout, + ConfirmationRequirement confirmation, + Optional deduplication, + Map brokerHints) { +} +``` + +M1에서는 `brokerHints`가 비어 있어야 한다. M3 adapter만 typed hint를 추가할 수 있다. + +### 16.1 Blocking·Reactive facade + +```java +public interface BlockingMessagePublisher { + PublishResult publish( + MessageDestination destination, + MessageEnvelope message, + PublishOptions options); +} + +public interface ReactiveMessagePublisher { + Mono publish( + MessageDestination destination, + MessageEnvelope message, + PublishOptions options); +} +``` + +Core 구현은 `CompletionStage`를 사용하고 Blocking·Reactive facade가 lifecycle과 cancellation을 변환한다. + +### 16.2 Batch Publish + +```java +public interface BatchMessagePublisher { + CompletionStage publish( + List> requests, + BatchPublishOptions options); +} +``` + +Batch는 transaction이 아니다. 결과는 입력 index별 성공·실패·모호성을 유지한다. + +```java +public record BatchPublishResult( + List items, + Duration elapsed) { +} +``` + +자동 batch retry는 하지 않는다. 호출자가 duplicate-safe한 item만 같은 `messageId`로 다시 제출한다. + +--- + +## 17. Publish 결과와 증거 + +### 17.1 결과 모델 + +```java +public record PublishResult( + PublishCompletion completion, + PublishEvidence evidence, + RoutingOutcome routingOutcome, + Optional position, + int attempts, + Duration elapsed, + Optional failure) { +} + +public enum PublishCompletion { + CONFIRMED, + REJECTED, + AMBIGUOUS +} + +public record PublishEvidence( + boolean queuedLocally, + TransmissionEvidence transmission, + boolean brokerAccepted, + ConfirmationLevel confirmationLevel) { +} + +public enum TransmissionEvidence { + NOT_TRANSMITTED, + MAY_HAVE_BEEN_TRANSMITTED, + TRANSMITTED +} + +public enum ConfirmationLevel { + NONE, + BROKER_ACK, + REPLICATION_OR_PERSISTENCE_ACK +} + +public enum RoutingOutcome { + NOT_APPLICABLE, + ROUTED, + UNROUTABLE, + UNKNOWN +} +``` + +### 17.2 판정 규칙 + +| 상황 | 결과 | +|---|---| +| 로컬 validation 실패 | `REJECTED`, `NOT_TRANSMITTED` | +| broker 명시적 reject | `REJECTED` | +| confirm 수신 | `CONFIRMED` | +| Rabbit confirm + unroutable return | `REJECTED`, `UNROUTABLE` | +| bytes 전송 후 connection loss | `AMBIGUOUS` | +| confirm timeout | `AMBIGUOUS` | +| broker ACK 수준이 요구보다 약함 | `REJECTED` 또는 startup validation failure | +| adapter가 증거를 판정할 수 없음 | 보수적으로 `AMBIGUOUS` | + +### 17.3 Broker Position + +```java +public interface BrokerPosition { + String broker(); + Map diagnosticAttributes(); +} + +public record KafkaPosition( + String topic, + int partition, + long offset) implements BrokerPosition { + @Override public String broker() { return "kafka"; } + @Override public Map diagnosticAttributes() { + return Map.of("topic", topic, "partition", Integer.toString(partition), + "offset", Long.toString(offset)); + } +} + +public record RabbitPublishReference( + String exchange, + String routingKey, + long sequence) implements BrokerPosition { + @Override public String broker() { return "rabbitmq"; } + @Override public Map diagnosticAttributes() { + return Map.of("exchange", exchange, "routingKey", routingKey, + "sequence", Long.toString(sequence)); + } +} +``` + +Position은 진단·replay에 쓰며 공통 업무 로직의 분기 기준으로 사용하지 않는다. + +--- + +## 18. Producer 상태 머신 + +```text +CREATED + ↓ +VALIDATED + ↓ +ENCODED + ↓ +QUEUED_LOCALLY + ↓ +TRANSMITTING + ├─→ REJECTED + ├─→ AMBIGUOUS + ↓ +BROKER_ACCEPTED + ├─→ CONFIRMED + └─→ AMBIGUOUS +``` + +상태 머신은 내부 trace·failure evidence에 사용한다. 공개 결과에는 최종 completion과 evidence만 노출한다. + +### 18.1 자동 publish retry + +Core publisher의 기본은 자동 retry 없음이다. adapter 내부 protocol retry는 다음 조건에서만 허용한다. + +- 동일 `messageId` 유지 +- broker-native dedup 또는 producer idempotence 활성 +- payload replayable +- confirmation timeout 전체 예산 내 +- adapter가 duplicate risk를 문서화 + +`AMBIGUOUS` 결과를 새 `messageId`로 자동 재발행하지 않는다. + +--- + +## 19. Consumer 공개 계약 + +```java +public interface MessageHandler { + CompletionStage handle(MessageDelivery delivery); +} + +public record MessageDelivery( + MessageEnvelope message, + DeliveryMetadata metadata, + DeliveryContext context) { +} + +public sealed interface HandleResult + permits HandleResult.Success, + HandleResult.Retry, + HandleResult.DeadLetter, + HandleResult.Reject { + + record Success() implements HandleResult {} + + record Retry(FailureDescriptor failure) implements HandleResult {} + + record DeadLetter(FailureDescriptor failure) implements HandleResult {} + + record Reject(FailureDescriptor failure) implements HandleResult {} +} +``` + +### 19.1 Delivery Metadata + +```java +public record DeliveryMetadata( + DestinationName destination, + int deliveryAttempt, + boolean redelivered, + Optional brokerPosition, + Optional partitionOrQueue, + Optional consumerGroup, + Instant receivedAt) { +} +``` + +### 19.2 M1 규칙 + +- handler는 broker ACK API를 호출하지 않는다. +- `Success` 후에만 source settlement한다. +- `Retry`는 destination policy가 전략을 결정한다. +- `DeadLetter`는 DLQ publish confirm 후 source settlement한다. +- `Reject`는 명시적으로 discard를 허용한 at-most-once profile에서만 허용한다. +- handler timeout은 `PROCESSING_TRANSIENT`로 분류하되 side effect commit 여부가 불명확하면 duplicate 가능성을 기록한다. + +--- + +## 20. M2 Manual Settlement + +```java +public interface ManualMessageHandler { + CompletionStage handle( + MessageDelivery delivery, + SettlementController settlement); +} + +public interface SettlementController { + CompletionStage ack(); + CompletionStage retry(Duration delay); + CompletionStage deadLetter(FailureDescriptor failure); + CompletionStage reject(FailureDescriptor failure); +} +``` + +### 20.1 Batch Consume + +```java +public interface BatchMessageHandler { + CompletionStage handle(BatchMessageDelivery batch); +} + +public record BatchMessageDelivery( + List> deliveries, + BatchDeliveryMetadata metadata) { +} +``` + +Batch consume은 M2다. batch 전체 settlement가 가능한 broker에서는 전체 결과를 사용하고, 개별 settlement가 가능한 broker에서는 item 결과를 유지한다. strict ordering destination에서는 하나의 ordering unit을 넘는 batch를 허용하지 않는다. + +### 20.2 Pause·Resume와 Delayed Publish + +```java +public interface PauseResumeController { + CompletionStage pause(DestinationName destination, String scope); + CompletionStage resume(DestinationName destination, String scope); +} + +public interface DelayedMessagePublisher { + CompletionStage publish( + MessageDestination destination, + MessageEnvelope message, + Instant deliverAt); +} +``` + +두 API 모두 M2이며 destination capability가 없으면 명시적으로 거부한다. Kafka 일반 topic은 native scheduled delivery capability를 선언하지 않는다. Rabbit delayed/quorum retry, Pulsar delayed delivery, NATS scheduler 계열은 adapter capability로 제공한다. + +Manual API는 다음 guard를 강제한다. + +- 정확히 한 번만 terminal settlement 호출 +- handler 종료 전 settlement 누락 감지 +- source ACK 전 DLQ confirmation +- application thread에서 broker channel·consumer object 접근 금지 +- settlement timeout과 `SETTLEMENT_UNKNOWN` 구분 +- duplicate call은 `SettlementAlreadyCompletedException` + +--- + +## 21. Consumer 상태와 Settlement 증거 + +```text +RECEIVED + ↓ +DECODING + ├─→ SCHEMA_PARKING + ↓ +PROCESSING + ├─→ RETRY_PENDING + ├─→ DEAD_LETTER_PENDING + ├─→ REJECTED + ↓ +HANDLER_SUCCEEDED + ↓ +SETTLEMENT_SENDING + ├─→ SETTLED + └─→ SETTLEMENT_UNKNOWN +``` + +Reliability module을 사용할 때만 다음 증거를 추가한다. + +```text +INBOX_RESERVED +BUSINESS_TRANSACTION_COMMITTED +``` + +### 21.1 Settlement 결과 + +```java +public record SettlementResult( + SettlementCompletion completion, + SettlementEvidence evidence, + Optional failure) { +} + +public enum SettlementCompletion { + SETTLED, + REJECTED, + UNKNOWN +} +``` + +`UNKNOWN`은 redelivery 가능성을 의미한다. 호출자는 이를 성공으로 간주하지 않는다. + +--- + +## 22. 전달·처리·순서 보장 모델 + +```java +public enum DeliveryGuarantee { + AT_MOST_ONCE, + AT_LEAST_ONCE +} + +public enum ProcessingGuarantee { + APPLICATION_IDEMPOTENT, + BROKER_TRANSACTIONAL +} + +public enum OrderingScope { + NONE, + DESTINATION, + PARTITION, + KEY +} + +public enum ExternalSideEffectGuarantee { + NONE, + IDEMPOTENCY_REQUIRED, + INBOX_TRANSACTIONAL +} +``` + +### 22.1 금지 모델 + +다음은 Core에 존재하지 않는다. + +```java +boolean exactlyOnce; +DeliveryGuarantee.EXACTLY_ONCE; +OrderingScope.GLOBAL; +``` + +### 22.2 실제 의미 + +| 모델 | 의미 | +|---|---| +| `AT_MOST_ONCE` | 유실 가능, 중복 억제 우선 | +| `AT_LEAST_ONCE` | redelivery 가능, 유실 방지 우선 | +| `APPLICATION_IDEMPOTENT` | handler가 message ID 또는 업무 key로 중복 effect를 막음 | +| `BROKER_TRANSACTIONAL` | broker가 정의한 transaction 범위에서만 원자화 | +| `INBOX_TRANSACTIONAL` | Inbox row와 DB side effect가 같은 transaction | +| `PARTITION` | 한 partition 안의 broker order | +| `KEY` | 동일 key의 mapping이 유지되는 동안의 순서 | + +### 22.3 Ordering validator + +다음 조합을 거부한다. + +- `ordering=KEY` + key resolver 없음 +- `ordering=PARTITION` + retry topic으로 reorder 허용 안 함 +- strict order + concurrency가 실제 ordering unit보다 큼 +- NATS ordered consumer + competing work queue 설정 +- Pulsar Shared + key ordering 요구 +- Kafka Share Group + ordered stream 요구 + +--- + +## 23. 오류 모델 + +```text +MessagingException + ├─ MessagingConfigurationException + ├─ MessagingCapabilityUnavailableException + ├─ MessageValidationException + ├─ MessageSerializationException + ├─ MessageSchemaIncompatibleException + ├─ MessageTooLargeException + ├─ MessageHeaderRejectedException + ├─ MessagePublishRejectedException + ├─ MessagePublishAmbiguousException + ├─ MessagePublishTimeoutException + ├─ MessageRoutingException + ├─ MessageAuthenticationException + ├─ MessageAuthorizationException + ├─ MessageConsumerException + ├─ MessageHandlerTimeoutException + ├─ MessageSettlementException + ├─ MessageSettlementUnknownException + ├─ MessageRetryExhaustedException + ├─ MessageDeadLetterException + ├─ MessageRedriveException + ├─ MessageTopologyException + └─ MessageBrokerUnavailableException +``` + +### 23.1 Failure Category + +```java +public enum FailureCategory { + TRANSIENT_INFRASTRUCTURE, + THROTTLED, + PROCESSING_TRANSIENT, + PERMANENT_BUSINESS, + POISON_MESSAGE, + DESERIALIZATION, + AUTHENTICATION, + AUTHORIZATION, + AMBIGUOUS, + CONFIGURATION +} +``` + +### 23.2 Failure Descriptor + +```java +public record FailureDescriptor( + FailureCategory category, + String code, + boolean retryable, + String sanitizedMessage, + Optional exceptionType) { +} +``` + +Payload, full stack trace, credential, actual message key는 message header에 적재하지 않는다. Stack trace는 secure log storage에만 기록한다. + +--- + +## 24. Retry Policy Engine + +### 24.1 정책 모델 + +```java +public record RetryPolicy( + RetryMode mode, + int maxAttempts, + Duration initialDelay, + Duration maxDelay, + double multiplier, + boolean jitter, + OrderingImpact orderingImpact, + Set retryableCategories, + Set nonRetryableCategories) { +} + +public enum RetryMode { + NONE, + INLINE, + BLOCKING, + PAUSE_PARTITION, + RETRY_DESTINATION, + BROKER_DELAYED +} + +public enum OrderingImpact { + PRESERVE, + ALLOW_REORDER +} +``` + +### 24.2 결정 입력 + +```java +public record RetryContext( + DestinationProfile destination, + DeliveryMetadata delivery, + FailureDescriptor failure, + MessagingCapabilities capabilities, + boolean handlerMayHaveCommittedSideEffect) { +} +``` + +### 24.3 결정 결과 + +```java +public sealed interface RetryDecision { + record RetryInline(Duration delay) implements RetryDecision {} + record PauseAndRetry(Duration delay) implements RetryDecision {} + record PublishToRetryDestination( + DestinationName destination, + Duration delay) implements RetryDecision {} + record DeadLetter(FailureDescriptor failure) implements RetryDecision {} + record Reject(FailureDescriptor failure) implements RetryDecision {} +} +``` + +### 24.4 기본 규칙 + +- `DESERIALIZATION`, `AUTHENTICATION`, `AUTHORIZATION`, `CONFIGURATION`은 자동 retry하지 않는다. +- `PERMANENT_BUSINESS`는 DLQ 또는 reject 정책으로 보낸다. +- `TRANSIENT_INFRASTRUCTURE`, `THROTTLED`, `PROCESSING_TRANSIENT`만 기본 retry 후보이다. +- attempt는 최초 delivery를 1로 계산한다. +- max attempts를 넘으면 DLQ 또는 parking으로 전환한다. +- retry destination으로 이동해도 `messageId`를 유지한다. +- `retryAttempt`, `firstFailureAt`, `lastFailureAt`은 reserved metadata에 기록한다. +- source settlement는 retry destination publish confirmation 이후에만 수행한다. +- strict ordering에서는 `PAUSE_PARTITION` 또는 blocking 전략만 허용한다. +- Retry 중에도 consumer shutdown이 시작되면 신규 attempt를 생성하지 않는다. + + +--- + +## 25. DLQ·Parking·Redrive + +### 25.1 DLQ 처리 순서 + +```text +handler permanent failure 또는 retry exhausted +→ DeadLetterEnvelope 생성 +→ DLQ publish +→ DLQ broker confirmation 확인 +→ source settlement +``` + +DLQ publish가 실패하면 source를 ACK하지 않고 consumer를 pause하거나 원래 retry 정책으로 되돌린다. + +### 25.2 Dead Letter Envelope + +```java +public record DeadLetterMetadata( + MessageId originalMessageId, + DestinationName originalDestination, + Optional originalPosition, + Instant firstFailureAt, + Instant lastFailureAt, + int attempts, + FailureCategory failureCategory, + String failureCode, + int redriveCount, + Optional lastRedriveId) { +} +``` + +원본 payload와 schema identity를 유지한다. 전체 stack trace는 header에 넣지 않는다. + +### 25.3 Parking + +역직렬화 불가, schema 미지원, 관리자 판정이 필요한 메시지는 일반 DLQ와 분리된 parking destination에 저장한다. + +```text +schema-parking +security-parking +manual-review-parking +``` + +### 25.4 Redrive + +```java +public record RedriveRequest( + DestinationName sourceDlq, + DestinationName target, + Set messageIds, + String operator, + String reason, + boolean dryRun) { +} +``` + +Redrive 규칙은 다음과 같다. + +- 기존 `messageId` 유지 +- 새 `redriveId` 생성 +- `redriveCount` 증가 +- 대상 schema와 destination capability 재검증 +- dry-run 기본 +- 운영자·사유·선택 범위를 audit +- 동일 redrive request의 idempotency 보장 +- redrive 성공 confirmation 후 DLQ 원본을 mark 또는 settlement + +--- + +## 26. Kafka Stable Adapter + +### 26.1 Stable 기준 + +- Kafka 4.2 이상 +- 4.3.x release gate +- Spring Kafka native integration +- traditional consumer group Stable +- Share Group은 별도 Experimental module + +### 26.2 Producer 설정 + +Stable profile은 다음을 강제한다. + +```text +enable.idempotence=true +acks=all +retries > 0 +max.in.flight.requests.per.connection <= 5 +``` + +플랫폼 timeout과 Kafka delivery timeout의 관계를 startup에서 검증한다. + +```text +platform publish timeout + >= request.timeout.ms + <= delivery.timeout.ms + platform cleanup margin +``` + +`ProducerRecord`에는 logical `messageId`, schema metadata, trace context를 header로 기록한다. + +### 26.3 Publish evidence + +| Kafka 결과 | 플랫폼 매핑 | +|---|---| +| `RecordMetadata` 수신 | `CONFIRMED`, `BROKER_ACK` 또는 profile 조건 충족 시 stronger evidence | +| serializer failure | `REJECTED`, `NOT_TRANSMITTED` | +| authorization failure | `REJECTED` | +| producer fenced | `REJECTED`, non-retry | +| delivery timeout | 전송 여부에 따라 `AMBIGUOUS` | +| connection loss after send | `AMBIGUOUS` | + +Kafka `acks=all`을 영구 보존으로 표현하지 않는다. confirmation level은 현재 ISR acknowledgement 증거라는 adapter-specific detail을 diagnostic metadata에 남긴다. + +### 26.4 Consumer Group + +Stable consumer는 다음을 사용한다. + +```text +enable.auto.commit=false +isolation.level=read_committed // transactional destination profile +``` + +M1의 strict order path는 partition별 한 개의 in-flight handler를 기본으로 한다. + +```text +poll +→ record를 partition work coordinator에 전달 +→ partition pause +→ bounded worker에서 handler 실행 +→ settlement command queue에 결과 기록 +→ consumer poll thread가 command를 drain +→ contiguous offset commit +→ partition resume +``` + +이 구조는 handler thread가 Kafka consumer를 직접 호출하지 않게 한다. + +### 26.5 Offset Tracker + +```java +public interface PartitionOffsetTracker { + void delivered(TopicPartition partition, long offset); + void completed(TopicPartition partition, long offset); + OptionalLong highestContiguousCompleted(TopicPartition partition); +} +``` + +parallel handler를 허용해도 commit은 완료된 contiguous offset까지만 진행한다. + +### 26.6 Retry + +| 요구 | 전략 | +|---|---| +| partition order 보존 | pause partition + bounded retry | +| 처리량 우선, reorder 허용 | retry topic | +| 짧은 transient failure | inline retry | +| poison | DLT | + +Retry topic 사용 시 원래 ordering을 보장하지 않는다고 profile과 metric에 표시한다. + +### 26.7 Transaction Capability + +Kafka transaction은 M3로 제공한다. + +```java +public interface KafkaTransactionalProcessor { + CompletionStage process( + KafkaTransactionalDelivery delivery, + KafkaTransactionalPublisher publisher); +} +``` + +보장 범위는 Kafka input offset과 Kafka output record다. 외부 DB·HTTP side effect는 포함하지 않는다. + +### 26.8 Replay + +Replay는 M2/Admin에서만 제공한다. + +```text +seek by offset +seek by timestamp +replay to isolated consumer group +replay to new destination +``` + +기존 production consumer group offset reset은 M4 위험 작업이다. + +### 26.9 Tombstone + +```java +public interface KafkaTombstonePublisher { + CompletionStage publishTombstone( + DestinationName destination, + String key, + MessageHeaders headers); +} +``` + +일반 `MessageEnvelope`의 null payload로 대체하지 않는다. + +--- + +## 27. Kafka Share Group Experimental Adapter + +Kafka 4.2+ Share Group은 record 단위 acknowledgement와 delivery attempt counting을 제공하지만 ordered stream abstraction으로 사용하지 않는다. + +```java +public interface KafkaShareWorkQueueCapability { + void register( + MessageDestination destination, + MessageHandler handler, + ShareGroupOptions options); +} +``` + +제약은 다음과 같다. + +- `OrderingScope.NONE`만 허용 +- Work Queue destination에서만 사용 +- platform settlement mapping이 안정화되기 전 Experimental +- traditional consumer group과 동일한 transaction API를 가정하지 않음 +- redelivery attempt와 share acknowledgement를 별도 native contract test로 검증 + +--- + +## 28. RabbitMQ Stable Adapter + +### 28.1 Stable 기준 + +- RabbitMQ 4.3.x +- Spring AMQP native integration +- durable work queue는 quorum queue 기본 +- publisher confirm과 consumer ACK 분리 + +### 28.2 Producer + +Stable producer는 다음을 강제한다. + +```text +publisher-confirm-type=correlated +publisher-returns=true +mandatory=true +``` + +confirm과 return을 correlation ID로 결합한다. + +```java +public record RabbitPublishOutcome( + RabbitConfirmOutcome confirm, + RoutingOutcome routing, + Optional replyCode, + Optional replyText) { +} +``` + +| 상태 | 플랫폼 결과 | +|---|---| +| confirm ACK + routed | `CONFIRMED` | +| confirm ACK + returned | `REJECTED`, `UNROUTABLE` | +| confirm NACK | `REJECTED` | +| channel close before result | `AMBIGUOUS` | +| confirm timeout | `AMBIGUOUS` | + +### 28.3 Consumer + +- manual acknowledgement +- prefetch로 in-flight 제한 +- handler success 후 ACK +- transient failure는 requeue 또는 retry queue 정책 +- permanent failure는 confirmed DLQ publish 후 source ACK +- channel·delivery tag를 application handler에 노출하지 않음 + +### 28.4 Queue 기본값 + +```text +durable=true +auto-delete=false +exclusive=false +queue-type=quorum +``` + +classic queue는 명시적 low-durability profile에서만 허용한다. + +### 28.5 Ordering + +Rabbit queue는 enqueue FIFO를 출발점으로 하지만 다음 조건에서 처리 완료 순서는 달라질 수 있다. + +```text +consumer concurrency > 1 +priority queue +NACK / requeue +redelivery +retry queue +``` + +따라서 Stable common contract는 `OrderingScope.DESTINATION`을 자동 선언하지 않는다. strict ordering profile은 single active consumer와 concurrency 1을 요구한다. + +### 28.6 DLQ + +두 모드를 제공한다. + +1. **Platform-managed DLQ** + - target publish confirm 후 source ACK + - broker-neutral 기본 +2. **Native quorum at-least-once dead-lettering** + - M3 capability + - 필요한 queue policy를 startup에서 검증 + +기본 dead-letter strategy가 at-most-once인 topology를 stronger guarantee로 표현하지 않는다. + +### 28.7 Retry + +```text +inline retry +retry queue + TTL / dead-letter routing +RabbitMQ 4.3 quorum delayed retry capability +``` + +retry cycle은 `x-death` 또는 platform attempt metadata로 제한한다. + +### 28.8 Request–Reply + +M2에서만 제공한다. + +- correlation ID 필수 +- reply timeout 필수 +- temporary reply queue lifecycle 관리 +- 장기 RPC 대체로 사용하지 않음 +- duplicate request·late reply 처리 정의 + +--- + +## 29. Reliability API + +```java +public interface ReliableMessagePublisher { + void addToOutbox( + MessageDestination destination, + MessageEnvelope message); +} + +public interface IdempotentMessageHandler { + CompletionStage handleOnce( + String consumerName, + MessageDelivery delivery, + TransactionalMessageAction action); +} +``` + +Reliability module은 Core의 publish·delivery 계약을 바꾸지 않는다. DB transaction과 message identity를 결합한다. + +--- + +## 30. Transactional Outbox + +### 30.1 테이블 + +```sql +create table messaging_outbox ( + id uuid primary key, + message_id uuid not null unique, + destination varchar(160) not null, + message_type varchar(240) not null, + schema_version integer not null, + content_type varchar(120) not null, + aggregate_type varchar(160), + aggregate_id varchar(320), + headers jsonb not null, + payload bytea not null, + payload_hash varchar(128) not null, + occurred_at timestamptz, + created_at timestamptz not null, + available_at timestamptz not null, + status varchar(40) not null, + attempts integer not null default 0, + next_attempt_at timestamptz, + lease_owner varchar(160), + lease_until timestamptz, + last_failure_category varchar(80), + last_failure_code varchar(160), + published_at timestamptz, + version bigint not null default 0 +); + +create index ix_messaging_outbox_poll + on messaging_outbox(status, available_at, next_attempt_at, created_at); + +create index ix_messaging_outbox_lease + on messaging_outbox(lease_until) + where lease_owner is not null; +``` + +### 30.2 상태 + +```text +PENDING +CLAIMED +PUBLISHED +RETRYABLE_FAILURE +PARKED +``` + +### 30.3 Relay 알고리즘 + +```text +SELECT id FROM messaging_outbox +WHERE status IN ('PENDING', 'RETRYABLE_FAILURE') + AND available_at <= now() + AND coalesce(next_attempt_at, available_at) <= now() +ORDER BY created_at +FOR UPDATE SKIP LOCKED +LIMIT :batchSize +→ lease와 attempt 증가 +→ DB transaction commit +→ Core publisher로 같은 messageId publish +→ CONFIRMED: PUBLISHED +→ REJECTED retryable: RETRYABLE_FAILURE +→ permanent: PARKED +→ AMBIGUOUS: RETRYABLE_FAILURE, 같은 messageId로 재시도 +``` + +Outbox가 duplicate publish를 막는다고 광고하지 않는다. ambiguous retry는 같은 `messageId`를 사용하고 downstream Inbox가 duplicate side effect를 차단한다. `PUBLISHED` row는 감사·reconciliation 보존 기간 뒤 bounded cleanup job으로 삭제하거나 archive하며, `PENDING`, `CLAIMED`, `RETRYABLE_FAILURE`, `PARKED` row는 자동 삭제하지 않는다. + +### 30.4 Debezium Profile + +Debezium Outbox Event Router integration은 선택 recipe다. + +- outbox ID를 message ID로 사용 +- aggregate ID를 Kafka key로 사용 가능 +- CDC engine 자체 운영은 이 모듈 범위 밖 +- polling relay와 CDC relay를 동시에 활성화하지 않음 + +--- + +## 31. Inbox와 Idempotent Consumer + +### 31.1 테이블 + +```sql +create table messaging_inbox ( + consumer_name varchar(200) not null, + message_id uuid not null, + message_type varchar(240) not null, + payload_hash varchar(128) not null, + received_at timestamptz not null, + processed_at timestamptz not null, + expires_at timestamptz not null, + primary key (consumer_name, message_id) +); + +create index ix_messaging_inbox_expiry + on messaging_inbox(expires_at); +``` + +### 31.2 처리 알고리즘 + +```text +DB transaction 시작 +→ Inbox INSERT +→ unique conflict이면 duplicate로 판정하고 business mutation 생략 +→ 신규 row이면 business mutation 실행 +→ transaction commit +→ commit 후 source ACK +``` + +business mutation이 실패하면 Inbox INSERT도 rollback되어야 한다. + +### 31.3 Retention + +```text +Inbox retention + >= broker retention + + 최대 DLQ 보관 기간 + + 최대 replay/redrive 기간 + + 안전 여유 +``` + +retention을 짧게 잡아 오래된 replay가 side effect를 다시 만드는 구성을 startup warning 또는 validation failure로 처리한다. + +--- + +## 32. Claim Check + +1 MiB를 넘는 payload는 기본적으로 object storage 또는 fileserver에 저장하고 reference를 메시지로 전달한다. + +```java +public record ClaimCheckReference( + String store, + String objectId, + long size, + String checksumAlgorithm, + String checksum, + String contentType, + Instant expiresAt) { +} +``` + +규칙은 다음과 같다. + +- public signed URL을 메시지에 직접 넣지 않는다. +- consumer가 자신의 service credential로 조회한다. +- size·checksum·content type을 검증한다. +- object retention은 message retention·retry·DLQ·redrive 기간보다 길어야 한다. +- object 삭제는 모든 consumer 처리 완료를 알 수 없으므로 명시적 lifecycle 정책을 사용한다. +- Claim Check fetch 실패는 `PROCESSING_TRANSIENT` 또는 permanent integrity failure로 분류한다. + +--- + +## 33. Topology Manifest와 Provisioning + +### 33.1 Manifest + +```java +public record TopologyManifest( + List destinations, + List retries, + List deadLetters, + TopologyManagementMode managementMode) { +} + +public enum TopologyManagementMode { + AUTO_CREATE_DEV, + VALIDATE_ONLY, + ADMIN_MANAGED +} +``` + +### 33.2 환경 정책 + +| 환경 | 정책 | +|---|---| +| local/test | auto-create 허용 | +| development | auto-create 선택 | +| staging | IaC + validate-only | +| production | IaC + validate-only, destructive mutation 금지 | + +### 33.3 검증 항목 + +Kafka: + +```text +partition count +replication factor +min.insync.replicas +retention +cleanup policy +transaction capability +``` + +RabbitMQ: + +```text +exchange type/durability +queue type/durability +binding +quorum policy +dead-letter strategy +overflow +consumer timeout +``` + +Pulsar·NATS는 adapter capability에 따라 동일한 logical manifest에 native properties를 추가한다. + +--- + +## 34. Admin Plane + +```java +public interface MessagingAdminService { + TopologyValidationReport validateTopology(); + ReplayPlan planReplay(ReplayRequest request); + ReplayResult executeReplay(ApprovedReplayPlan plan); + RedrivePlan planRedrive(RedriveRequest request); + RedriveResult executeRedrive(ApprovedRedrivePlan plan); +} +``` + +파괴 작업은 별도 interface에 둔다. + +```java +public interface DestructiveMessagingAdmin { + DestructiveResult resetOffset(ApprovedOffsetReset request); + DestructiveResult purge(ApprovedPurge request); + DestructiveResult deleteDestination(ApprovedDelete request); +} +``` + +### 34.1 Guard + +- app credential로 bean 미생성 +- 별도 admin role +- dry-run 기본 +- approval ID 필수 +- 대상·범위·예상 message 수 표시 +- 실행 전 topology version 재검증 +- audit event 저장 +- 동일 approval의 중복 실행 방지 + +--- + +## 35. Pulsar Experimental Adapter + +지원 범위는 다음과 같다. + +```text +typed publish/consume +Exclusive / Failover / Shared / Key_Shared +broker acknowledgement +schema integration +redelivery +DLQ policy +replay cursor +multi-topic transaction capability +``` + +Stable 승격 조건: + +- 4.0 LTS와 4.2에서 Core Contract Suite 통과 +- Shared와 Key_Shared ordering 차이를 profile validator가 차단 +- transaction commit·abort·transactional ACK 장애 테스트 통과 +- native schema와 공통 Schema Registry 계약 정합성 확인 +- TLS·authentication·authorization production guard 통과 + +Pulsar 5 preview는 지원 기준으로 사용하지 않는다. + +--- + +## 36. NATS JetStream Experimental Adapter + +Core NATS pub/sub은 at-most-once이므로 reliability destination의 기본 transport로 사용하지 않는다. + +JetStream 지원 범위: + +```text +stream publish + PubAck +Nats-Msg-Id deduplication +pull consumer +durable consumer +Explicit ACK +AckSync +Nak / NakWithDelay +AckWait / BackOff +MaxAckPending +MaxDeliver advisory +LimitsPolicy +WorkQueuePolicy +replay by sequence/time +``` + +NATS에는 Kafka·Pulsar형 transaction을 광고하지 않는다. + +### 36.1 DLQ workflow + +`MaxDeliver` 도달만으로 메시지가 자동 DLQ 이동됐다고 가정하지 않는다. + +```text +MaxDeliver advisory +→ parking workflow consumer +→ target publish + PubAck +→ 원본 terminal settlement 또는 admin mark +``` + +Stable 승격 조건: + +- dedupe window 경계 테스트 +- `Ack()`와 `AckSync()` 차이 검증 +- MaxDeliver advisory 기반 parking 테스트 +- stream failover와 redelivery contract 통과 + +--- + +## 37. Spring Cloud Stream Bridge + +Spring Cloud Stream은 optional bridge다. Core contract를 대체하지 않는다. + +```java +public interface MessagingBindingBridge { + void bindPublisher(DestinationName destination, String bindingName); + void bindConsumer(DestinationName destination, String bindingName); +} +``` + +제약은 다음과 같다. + +- binder retry·DLQ 의미론을 Core 정책으로 자동 승격하지 않음 +- binder extended property는 M3 capability로 격리 +- Kafka transaction과 Rabbit routing 차이를 bridge가 숨기지 않음 +- Core `PublishResult`와 settlement evidence를 제공할 수 없는 binder 경로는 migration-only 등급 + +--- + +## 38. Security + +### 38.1 Production 기본 + +```text +TLS 필수 +hostname/certificate validation 필수 +broker 인증 필수 +producer/consumer/admin identity 분리 +destination-level 최소 권한 +tenant namespace 분리 +credential rotation 가능 +secret header 금지 +admin API 별도 credential +``` + +### 38.2 Credential Profile + +```java +public sealed interface BrokerCredentialProfile + permits BrokerCredentialProfile.SaslScram, + BrokerCredentialProfile.OAuth2, + BrokerCredentialProfile.MutualTls, + BrokerCredentialProfile.UsernamePassword, + BrokerCredentialProfile.Nkey { + String credentialId(); + record SaslScram(String credentialId) implements BrokerCredentialProfile {} + record OAuth2(String credentialId) implements BrokerCredentialProfile {} + record MutualTls(String credentialId) implements BrokerCredentialProfile {} + record UsernamePassword(String credentialId) implements BrokerCredentialProfile {} + record Nkey(String credentialId) implements BrokerCredentialProfile {} +} +``` + +credential은 secret provider에서 runtime에 가져온다. config 파일과 exception에 원문을 남기지 않는다. + +### 38.3 ACL + +- Kafka topic/group/transactional ID 권한 분리 +- Rabbit vhost, exchange, queue configure/write/read 권한 분리 +- Pulsar tenant/namespace/topic 권한 분리 +- NATS account/subject publish·subscribe allowlist +- Admin identity만 topology mutation 권한 보유 + +### 38.4 Message Security + +- payload-level encryption은 별도 codec profile +- trace baggage allowlist +- tenant context는 신뢰 경계에서 재검증 +- message header에 token·password·PII 원문 금지 +- schema parser에 크기·깊이 제한 + +--- + +## 39. Observability + +OpenTelemetry messaging semantic convention은 exporter adapter로 사용하되 내부 public API에 convention attribute 이름을 고정하지 않는다. + +### 39.1 Metric + +| 영역 | Metric | +|---|---| +| Producer | publish count, confirmed, rejected, ambiguous, confirmation latency | +| Consumer | received, processed, failed, processing latency | +| Settlement | ack, retry, reject, dead-letter, unknown, latency | +| Reliability | redelivery, retry, retry exhausted, DLQ, redrive | +| Backlog | consumer lag, queue depth, pending ACK | +| Runtime | connection, reconnect, rebalance, assignment | +| Schema | encode/decode failure, incompatibility | +| Resource | producer buffered bytes, in-flight, batch size, prefetch | +| Admin | replay, redrive, offset reset, purge, delete | +| Outbox | pending, leased, publish success, ambiguous, parked, age | +| Inbox | inserted, duplicate, cleanup | + +### 39.2 Low-cardinality tag + +허용: + +```text +broker +destinationProfile +destinationTemplate +operation +messageType // bounded catalog일 때만 +consumerGroupProfile +outcome +failureCategory +retryStage +schemaCodec +``` + +금지: + +```text +messageId +partitionKey +orderingKey +payload +actual dynamic destination +full exception message +customerId +unbounded tenantId +credential +``` + +### 39.3 Trace + +```text +messaging.publish logical span + └─ broker publish attempt span + +messaging.consume process span + ├─ handler span + ├─ retry publish span + ├─ DLQ publish span + └─ settlement span +``` + +producer와 consumer는 비동기 경계이므로 consumer process span은 message creation context와 span link를 사용한다. + +### 39.4 Logging + +```text +INFO destinationProfile=order-events outcome=CONFIRMED attempt=1 +WARN failureCategory=PROCESSING_TRANSIENT retryStage=RETRY_DESTINATION attempt=3 +ERROR failureCategory=AMBIGUOUS destinationProfile=payment-events +``` + +payload와 full headers는 기본 로깅하지 않는다. + +--- + +## 40. Backpressure·Payload·Concurrency + +### 40.1 공통 제한 + +```text +max payload bytes +max header bytes +max batch count +max batch bytes +max producer in-flight +max consumer concurrent handlers +max processing time +max retry concurrent publish +max DLQ backlog alarm +``` + +### 40.2 Broker mapping + +| 공통 | Kafka | RabbitMQ | Pulsar | NATS | +|---|---|---|---|---| +| producer buffered bytes | `buffer.memory` | channel confirm window | pending messages | publish pending | +| max in-flight | producer config | confirm outstanding | pending queue | async publish pending | +| batch | `batch.size` | publisher batch | batching | publish batch | +| consumer concurrency | partitions | consumer count | subscription consumers | pull workers | +| consumer in-flight | poll/partition tracker | prefetch | receiver queue | MaxAckPending | +| backlog | lag | queue depth | backlog | stream pending | + +### 40.3 Overload 동작 + +- producer buffer 포화 시 bounded wait 후 `MessageBackpressureException` +- consumer handler queue 포화 시 partition/consumer pause +- retry publish concurrency는 원본 consume concurrency와 분리 +- DLQ 장애가 지속되면 source consumer를 pause하고 alert +- payload limit은 broker 전송 전에 local reject +- batch bytes와 count 둘 다 제한 + +--- + +## 41. Lifecycle과 Graceful Shutdown + +종료 순서는 다음과 같다. + +```text +신규 publish admission 차단 +→ 신규 delivery handler 시작 차단 +→ consumer pause +→ in-flight handler drain +→ 완료된 settlement 전송 +→ producer confirm 대기 +→ Outbox lease 반환 +→ connection close +``` + +30초 기본 drain 이후 남은 작업은 다음처럼 처리한다. + +- unconfirmed publish: `AMBIGUOUS` 기록 +- unfinished handler: source 미settlement로 redelivery 허용 +- Outbox claimed row: lease 만료 후 재처리 +- manual settlement 미완료: warning + source redelivery + +JVM shutdown hook만 신뢰하지 않고 Spring lifecycle phase를 사용한다. + +--- + +## 42. Spring Boot Starter + +### 42.1 Auto-configuration + +```text +MessagingCoreAutoConfiguration +MessagingSchemaAutoConfiguration +MessagingPolicyAutoConfiguration +KafkaMessagingAutoConfiguration +RabbitMessagingAutoConfiguration +MessagingReliabilityAutoConfiguration +MessagingAdminAutoConfiguration +MessagingObservabilityAutoConfiguration +MessagingSecurityAutoConfiguration +``` + +Experimental adapter는 별도 property와 classpath가 모두 있어야 활성화된다. + +### 42.2 Configuration Properties + +```java +@ConfigurationProperties("backend.messaging") +public record MessagingProperties( + Map brokers, + Map destinations, + MessagingLimitsProperties limits, + MessagingSecurityProperties security, + MessagingObservabilityProperties observability, + MessagingAdminProperties admin) { +} +``` + +### 42.3 Actuator + +```text +/actuator/messaging +/actuator/messaging/topology +/actuator/messaging/outbox +/actuator/messaging/capabilities +``` + +Actuator는 payload, actual credential, 전체 dynamic topic name을 노출하지 않는다. + +--- + +## 43. 테스트 전략 + +### 43.1 공통 Contract Suite + +모든 adapter는 다음을 통과한다. + +```text +normal publish +publish reject +publish ambiguity +same messageId retry +normal consume +handler failure +settlement loss +redelivery +retry exhausted +DLQ confirmation +DLQ failure +redrive identity +ordering scope +payload limit +header policy +schema failure +credential failure +ACL failure +TLS failure +graceful shutdown +observability cardinality +``` + +### 43.2 Kafka Native Suite + +```text +acks=all +idempotent producer +leader failover +publish timeout ambiguity +rebalance +contiguous offset commit +pause/resume +retry topic reorder +read_committed +transaction commit/abort +producer fencing +seek/replay +Share Group acknowledgement experimental +``` + +### 43.3 Rabbit Native Suite + +```text +publisher confirm +mandatory return +unroutable +confirm timeout +channel close +manual ACK loss +requeue +prefetch +quorum minority loss +at-least-once DLX target outage +retry queue cycle +single active consumer order +``` + +### 43.4 Reliability Suite + +```text +DB commit 전 crash +DB commit 후 relay 전 crash +broker store 후 confirm loss +same messageId duplicate publish +consumer DB commit 후 ACK 전 crash +Inbox duplicate effect 차단 +Outbox lease expiry +redrive duplicate request +Claim Check checksum mismatch +``` + +### 43.5 Pulsar·NATS Suite + +Pulsar: + +```text +Shared / Key_Shared +transaction commit/abort +transactional ACK +schema compatibility +subscription redistribution +``` + +NATS: + +```text +PubAck +Nats-Msg-Id dedupe +Ack / AckSync +AckWait expiry +NakWithDelay +MaxDeliver advisory +stream failover +``` + +### 43.6 장애 도구 + +```text +Testcontainers +Toxiproxy +process kill +broker node kill +network partition +latency / packet loss +PostgreSQL restart +credential rotation +TLS certificate rotation +``` + +--- + +## 44. 호환성 인증 매트릭스 + +| 대상 | PR | Nightly | Release Gate | +|---|---:|---:|---:| +| Kafka 4.2 | Core subset | Full | 필수 | +| Kafka 4.3.x | Full | Full + chaos | 필수 | +| RabbitMQ 4.3.x | Full | Full + chaos | 필수 | +| PostgreSQL 16 Outbox/Inbox | Full | crash suite | 필수 | +| Pulsar 4.0 LTS | smoke | Full | Experimental 필수 | +| Pulsar 4.2 | smoke | Full | Experimental 필수 | +| NATS 2.14.x | smoke | Full | Experimental 필수 | +| Spring Framework 6.2 line | compile/test | Full | 필수 | +| Spring Framework 7.0 line | compile/test | Full | 필수 | +| TLS·ACL | subset | rotation | 필수 | +| Performance | 없음 | baseline | release candidate | + +--- + +## 45. 운영 설정 기본 정책 + +| 항목 | 기본 | +|---|---| +| retry | 비활성, destination별 명시 | +| delivery | at-least-once Stable 기본 | +| auto settlement | handler success 뒤 | +| DLQ | production at-least-once destination에 필수 | +| topology | production validate-only | +| TLS | production 필수 | +| payload max | 1 MiB | +| Java serialization | 차단 | +| message type | catalog 등록 필수 | +| schema version | 필수 | +| producer confirm | Stable destination 필수 | +| consumer auto commit | 차단 | +| admin | 기본 비활성 | +| experimental | 기본 비활성 | + +--- + +## 46. 단계별 출시 + +### Phase 1: Foundation Alpha + +```text +core API +envelope +schema JSON +capability +publish result +consumer settlement +policy validation +testkit +``` + +완료 조건: broker 없이 Core Contract와 architecture test 통과. + +### Phase 2: Kafka Stable Beta + +```text +producer +consumer group +offset tracker +pause/resume +retry +transaction capability +replay +``` + +완료 조건: leader failover, rebalance, ambiguity, transaction suite 통과. + +### Phase 3: Rabbit Stable Beta + +```text +confirm + return +quorum queue +manual ACK +prefetch +retry queue +DLQ +``` + +완료 조건: confirm loss, ACK loss, unroutable, quorum failover, DLQ failure 통과. + +### Phase 4: Reliability RC + +```text +Outbox +Inbox +Idempotent Consumer +Claim Check +``` + +완료 조건: crash matrix에서 duplicate DB effect가 발생하지 않음. + +### Phase 5: Operations Release + +```text +security +observability +topology validation +admin replay/redrive +starter +documentation +``` + +### Phase 6: Experimental Compatibility + +```text +Kafka Share Group +Pulsar +NATS JetStream +Spring Cloud Stream bridge +``` + +--- + +## 47. 비지원 범위 + +초기 Stable release는 다음을 지원하지 않는다. + +- generic `EXACTLY_ONCE=true` +- global ordering +- raw broker client injection +- application runtime의 purge·delete·offset reset +- automatic XA +- DB+broker atomicity 광고 +- automatic infinite retry +- source ACK 후 DLQ publish +- one-shot payload의 publish retry +- arbitrary Java object serialization +- unlimited headers·payload·batch +- production auto topology mutation +- Core NATS를 durable delivery로 사용 +- Pulsar preview release 지원 +- Kafka Share Group을 ordered stream으로 사용 +- Rabbit queue를 application completion FIFO로 광고 +- replay를 일반 handler API에서 수행 +- signed public URL을 Claim Check payload로 사용 + +--- + +## 48. 구현 결정 원장 + +| 질문 | 확정 답변 | +|---|---| +| Core가 Spring `Message`를 노출하는가? | 아니오 | +| Core async 타입은 무엇인가? | `CompletionStage` | +| Reactive는 어디서 제공하는가? | facade/adapter module | +| 기본 브로커는 무엇인가? | 기본 하나를 정하지 않고 destination profile이 선택 | +| Stable adapter는? | Kafka, RabbitMQ | +| Experimental adapter는? | Kafka Share Group, Pulsar, NATS | +| publish timeout은 성공·실패를 확정하는가? | 아니오, `AMBIGUOUS` 가능 | +| messageId는 retry에서 바뀌는가? | 아니오 | +| DLQ 이동 후 source ACK 순서는? | DLQ confirm 후 ACK | +| retry topic은 ordering을 보존하는가? | 아니오 | +| Outbox만으로 exactly-once인가? | 아니오 | +| Inbox transaction 범위는? | Inbox row + business DB effect | +| topology는 누가 생성하는가? | 운영 IaC, dev/test만 auto-create | +| large payload는? | Claim Check | +| Raw bytes는? | M2 | +| Java serialization은? | 비지원 | +| Admin credential은 app과 공유하는가? | 아니오 | +| Experimental 기능은 기본 활성화되는가? | 아니오 | + +--- + +## 49. 완료 정의 + +플랫폼은 다음 조건을 모두 만족할 때 Stable로 간주한다. + +### 공개 계약 + +- M1만으로 typed publish와 consume을 구현할 수 있다. +- `PublishResult`가 evidence와 ambiguity를 표현한다. +- handler success 이전 settlement가 구조적으로 불가능하다. +- broker SDK 타입이 Core에 노출되지 않는다. + +### Kafka + +- idempotent producer와 `acks=all` guard가 동작한다. +- leader failover에서 confirmed, rejected, ambiguous를 구분한다. +- rebalance와 async handler에서 contiguous offset만 commit한다. +- transaction capability의 범위를 Kafka 내부로 제한한다. + +### RabbitMQ + +- confirm과 routing outcome을 결합한다. +- unroutable message를 confirmed success로 처리하지 않는다. +- ACK 유실과 publish confirm 유실을 별도로 검증한다. +- quorum queue와 DLQ 조건을 startup에서 검증한다. + +### Reliability + +- Outbox relay crash 후 같은 message ID로 재발행한다. +- Inbox가 duplicate DB effect를 차단한다. +- DLQ·redrive에서 identity와 schema를 유지한다. +- Claim Check가 size·checksum·retention을 검증한다. + +### 운영 + +- topology drift를 startup 또는 admin report에서 발견한다. +- app credential로 destructive operation이 불가능하다. +- TLS·ACL·credential rotation suite가 통과한다. +- metric cardinality와 secret leak 검사가 통과한다. +- payload·in-flight·backlog 상한 안에서 부하 테스트가 통과한다. + +### 문서 + +- support matrix +- configuration reference +- delivery guarantees +- retry and DLQ guide +- outbox and inbox guide +- broker operations runbook +- migration guide +- experimental feature policy + +이 문서의 설계 결정은 구현 계획의 Global Constraints와 각 Task의 acceptance test에 그대로 반영한다. diff --git a/messaging-superpowers-package/validate_messaging_docs.py b/messaging-superpowers-package/validate_messaging_docs.py new file mode 100755 index 00000000..da9fed81 --- /dev/null +++ b/messaging-superpowers-package/validate_messaging_docs.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import re +import sys +from collections import Counter +from pathlib import Path + +DESIGN = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("messaging-platform-design.md") +PLAN = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("messaging-platform-implementation-plan.md") + +errors: list[str] = [] +checks: list[str] = [] + + +def require(condition: bool, message: str) -> None: + if condition: + checks.append(message) + else: + errors.append(message) + + +def balanced_fences(text: str) -> bool: + return len(re.findall(r"^```", text, re.MULTILINE)) % 2 == 0 + + +def line_count(text: str) -> int: + return len(text.splitlines()) + + +design = DESIGN.read_text(encoding="utf-8") +plan = PLAN.read_text(encoding="utf-8") + +require(line_count(design) >= 2_000, "설계서가 2,000행 이상이다") +require(line_count(plan) >= 4_000, "구현 계획서가 4,000행 이상이다") +require(balanced_fences(design), "설계서 Markdown 코드 블록이 균형을 이룬다") +require(balanced_fences(plan), "계획서 Markdown 코드 블록이 균형을 이룬다") + +placeholder_patterns = { + "unresolved todo marker": r"\bT[O]DO\b", + "unresolved tbd marker": r"\bT[B]D\b", + "unresolved fix marker": r"\bF[I]XME\b", + "placeholder ADR number": r"ADR-X{2,}", + "wildcard build path": r"modules/messaging/\*/build\.gradle\.kts", + "deferred implementation phrase": r"implement\s+later|fill\s+in\s+details|similar\s+to\s+Task", +} +for name, pattern in placeholder_patterns.items(): + require(not re.search(pattern, design, re.IGNORECASE), f"설계서에 {name}가 없다") + require(not re.search(pattern, plan, re.IGNORECASE), f"계획서에 {name}가 없다") + +required_design_terms = [ + "M1 Typed Messaging API", + "M2 Advanced API", + "M3 Native Capability", + "M4 Admin Plane", + "PublishCompletion", + "AMBIGUOUS", + "MessageEnvelope", + "DeliveryGuarantee", + "OrderingScope", + "Retry Policy Engine", + "DLQ·Parking·Redrive", + "Kafka Stable Adapter", + "RabbitMQ Stable Adapter", + "Transactional Outbox", + "Inbox와 Idempotent Consumer", + "Claim Check", + "Pulsar Experimental Adapter", + "NATS JetStream Experimental Adapter", + "Spring Cloud Stream Bridge", + "Security", + "Observability", + "호환성 인증 매트릭스", + "비지원 범위", + "완료 정의", +] +for term in required_design_terms: + require(term in design, f"설계서가 필수 항목 '{term}'을 포함한다") + +require("AT_MOST_ONCE,\n AT_LEAST_ONCE" in design, "공통 DeliveryGuarantee가 두 가지 보장만 선언한다") +delivery_match = re.search(r"public enum DeliveryGuarantee \{(?P.*?)\n\}", design, re.DOTALL) +ordering_match = re.search(r"public enum OrderingScope \{(?P.*?)\n\}", design, re.DOTALL) +require(delivery_match is not None and "EXACTLY_ONCE" not in delivery_match.group("body"), "공통 DeliveryGuarantee enum에 EXACTLY_ONCE를 선언하지 않는다") +require(ordering_match is not None and "GLOBAL" not in ordering_match.group("body"), "공통 OrderingScope enum에 GLOBAL을 선언하지 않는다") +require("DLQ broker confirmation 확인\n→ source settlement" in design, "DLQ confirm 후 source settlement 순서를 명시한다") +require("같은 `messageId`" in design, "retry와 reliability에서 동일 message ID를 유지한다") + +# Plan task structure. +task_numbers = [int(value) for value in re.findall(r"^### Task (\d+):", plan, re.MULTILINE)] +require(task_numbers == list(range(1, 45)), "Task 번호가 1부터 44까지 연속이다") + +for task_number in task_numbers: + start = plan.index(f"### Task {task_number}:") + end = ( + plan.index(f"### Task {task_number + 1}:", start) + if task_number < 44 + else plan.index("## 3. Plan Self-Review Checklist", start) + ) + section = plan[start:end] + for required in ( + "**Files:**", + "**Interfaces:**", + "Step 1", + "Step 2", + "Step 3", + "Step 4", + "Step 5", + "git commit -m", + ): + require(required in section, f"Task {task_number}가 '{required}'을 포함한다") + +create_paths = re.findall(r"^- Create: `([^`]+)`", plan, re.MULTILINE) +duplicates = [path for path, count in Counter(create_paths).items() if count > 1] +require(not duplicates, "중복된 Create 파일 경로가 없다") +require(all("*" not in path for path in create_paths), "Create 파일 경로에 wildcard가 없다") + +required_plan_terms = [ + "Kafka Producer Adapter와 Publish Evidence", + "Kafka Consumer Group, Partition Coordinator", + "Kafka Native Transaction Capability", + "Kafka Share Group Experimental Adapter", + "Rabbit Publisher Confirm·Return Evidence Adapter", + "Rabbit Consumer Manual ACK", + "Transactional Outbox Repository", + "Inbox Transactional Idempotent Consumer", + "Debezium Outbox Event Router", + "Pulsar Experimental Adapter", + "NATS JetStream Experimental Adapter", + "Spring Cloud Stream Optional Bridge", + "Global Backpressure", + "Cross-broker 장애·보안·Reliability Contract Suite", + "성능 인증, Compatibility Matrix", + "지원 문서, Runbook, ADR, Release Gate", +] +for term in required_plan_terms: + require(term in plan, f"계획서가 필수 작업 '{term}'을 포함한다") + +require("messageId`를 유지" in plan or "message ID를 유지" in plan, "계획서가 message identity 보존을 명시한다") +require("source를 ACK하지 않는다" in plan or "source ACK하지 않는다" in plan, "계획서가 DLQ 실패 시 source ACK 금지를 명시한다") +require("producer, consumer, admin credential" in plan, "계획서가 credential 분리를 명시한다") +require("messagingStableChaos" in plan, "Stable chaos aggregate task가 계획에 존재한다") +require("messagingPerformance" in plan, "performance aggregate task가 계획에 존재한다") +require("messagingCompatibility" in plan, "compatibility aggregate task가 계획에 존재한다") + +print("# Messaging Superpowers 문서 정적 검증") +print() +print(f"- 설계서: `{DESIGN}` — {line_count(design):,}행, {len(design.encode('utf-8')):,} bytes") +print(f"- 계획서: `{PLAN}` — {line_count(plan):,}행, {len(plan.encode('utf-8')):,} bytes") +print(f"- Task 수: {len(task_numbers)}") +print(f"- Create 경로 수: {len(create_paths)}") +print(f"- 검증 항목 수: {len(checks) + len(errors)}") +print() + +if errors: + print("## 결과: FAIL") + print() + for error in errors: + print(f"- FAIL: {error}") + sys.exit(1) + +print("## 결과: PASS") +print() +for check in checks: + print(f"- PASS: {check}") diff --git a/mongodb-superpowers-package/MANIFEST.sha256 b/mongodb-superpowers-package/MANIFEST.sha256 new file mode 100644 index 00000000..d2a76a12 --- /dev/null +++ b/mongodb-superpowers-package/MANIFEST.sha256 @@ -0,0 +1,6 @@ +a6588890cf1eed348dc6d679515d0a403839416945a40fac1369963f7ee58167 docs/superpowers/specs/2026-08-11-mongodb-document-persistence-platform-design.md +d2897d321ed6868e46f02f5e5d327425a48ff285b532d10923323477a395d6de docs/superpowers/plans/2026-08-11-mongodb-document-persistence-platform-implementation-plan.md +f677e44fcf138b38154c27230d6322d4c18919f5960fc30c07f03cea40758acd docs/superpowers/plans/2026-08-11-mongodb-advanced-capabilities-expansion-plan.md +e8fa3d09372956ad65a1984c2fd3b4d30c34896593a34bc40eb5d147cbc593a8 README.md +d196c4e4c1510273e29f9f7267ab74bce32f3da59a40a3d6f1615b9c32428745 VALIDATION.md +a5e807fd4416a718818729ea37222cdf104c58962cac5154e1e5976011b03f12 validate_mongodb_docs.py diff --git a/mongodb-superpowers-package/README.md b/mongodb-superpowers-package/README.md new file mode 100644 index 00000000..671e99fb --- /dev/null +++ b/mongodb-superpowers-package/README.md @@ -0,0 +1,42 @@ +# MongoDB Superpowers 문서 패키지 + +이 패키지는 첨부된 `MongoDB 문서 영속성 플랫폼 심층 리서치`를 요구사항 원본으로 사용해 작성한 설계서와 구현 계획서다. + +## 파일 + +```text +docs/superpowers/specs/2026-08-11-mongodb-document-persistence-platform-design.md +docs/superpowers/plans/2026-08-11-mongodb-document-persistence-platform-implementation-plan.md +docs/superpowers/plans/2026-08-11-mongodb-advanced-capabilities-expansion-plan.md +VALIDATION.md +validate_mongodb_docs.py +MANIFEST.sha256 +``` + +## 적용 가정 + +```text +Java 21 +Gradle Kotlin DSL +Spring Boot 4.1 BOM +Spring Data MongoDB 5.1 +Boot-managed MongoDB Java Driver +MongoDB 8.0 primary certification +MongoDB 7.0 compatibility +root package: io.backend.skeleton.mongodb +stable modules: modules/mongodb +advanced modules: modules/mongodb-advanced +``` + +실제 Backend Skeleton 저장소 구조가 입력에 포함되지 않았으므로 파일 경로는 위 가정에 따라 확정했다. 적용 시 기존 version catalog와 공통 build logic에 경로만 매핑하고 공개 계약과 신뢰성 의미론은 유지한다. + +## 실행 순서 + +```text +Stable Implementation Plan Task 1~50 +→ Stable Release Gate +→ Advanced Plan Task 1~15 +→ capability별 Promotion Gate +``` + +Stable 구현 전에 Advanced 모듈을 Starter에 추가하지 않는다. diff --git a/mongodb-superpowers-package/VALIDATION.md b/mongodb-superpowers-package/VALIDATION.md new file mode 100644 index 00000000..041c890f --- /dev/null +++ b/mongodb-superpowers-package/VALIDATION.md @@ -0,0 +1,737 @@ +# MongoDB Superpowers 문서 정적 검증 결과 + +## 결과 + +```text +SUMMARY | total=687 pass=687 fail=0 +``` + +| 항목 | 값 | +|---|---:| +| 설계서 행 수 | 1303 | +| Stable 구현 계획서 행 수 | 3943 | +| Advanced 구현 계획서 행 수 | 1139 | +| Stable Task | 50 | +| Advanced Task | 15 | +| Stable Create 경로 | 252 | +| Advanced Create 경로 | 77 | + +## 검증 범위 + +```text +설계 핵심 결정 존재 +Task 번호 연속성 +모든 Task의 Files·Interfaces·Implementation requirements +모든 Task의 Step 1~5 +Task별 실패·통과 예상 결과와 Commit +Create 경로 중복·충돌 +Markdown code fence 균형 +TODO·TBD·FIXME 부재 +범용 Mongo Repository 실제 선언 부재 +Runtime arbitrary runCommand public API 부재 +Stable API V1 strict와 Replica Set 기준 +Transaction body retry와 commit retry 분리 +Change Stream at-least-once 계약 +TTL cleanup-only 계약 +GridFS compatibility-only 계약 +Stable·Advanced dependency 격리 +MongoDB 7.0·8.0 호환 Matrix +Advanced actual topology promotion gate +``` + +## 전체 검사 로그 + +```text +PASS | design exists +PASS | stable plan exists +PASS | advanced plan exists +PASS | design purpose +PASS | domain ownership +PASS | no generic repository design +PASS | stable api strict +PASS | local replica set +PASS | standalone smoke only +PASS | bson manifest +PASS | transaction retry separation +PASS | change stream at least once +PASS | ttl cleanup only +PASS | gridfs compatibility only +PASS | driver native observability +PASS | stable task count | 50 +PASS | stable task sequence | [1, 2, 3]...[48, 49, 50] +PASS | stable task 1 has **Files:** +PASS | stable task 1 has **Interfaces:** +PASS | stable task 1 has **Implementation requirements:** +PASS | stable task 1 has **Step 1: +PASS | stable task 1 has **Step 2: +PASS | stable task 1 has **Step 3: +PASS | stable task 1 has **Step 4: +PASS | stable task 1 has **Step 5: +PASS | stable task 1 has commit +PASS | stable task 1 has expected result +PASS | stable task 2 has **Files:** +PASS | stable task 2 has **Interfaces:** +PASS | stable task 2 has **Implementation requirements:** +PASS | stable task 2 has **Step 1: +PASS | stable task 2 has **Step 2: +PASS | stable task 2 has **Step 3: +PASS | stable task 2 has **Step 4: +PASS | stable task 2 has **Step 5: +PASS | stable task 2 has commit +PASS | stable task 2 has expected result +PASS | stable task 3 has **Files:** +PASS | stable task 3 has **Interfaces:** +PASS | stable task 3 has **Implementation requirements:** +PASS | stable task 3 has **Step 1: +PASS | stable task 3 has **Step 2: +PASS | stable task 3 has **Step 3: +PASS | stable task 3 has **Step 4: +PASS | stable task 3 has **Step 5: +PASS | stable task 3 has commit +PASS | stable task 3 has expected result +PASS | stable task 4 has **Files:** +PASS | stable task 4 has **Interfaces:** +PASS | stable task 4 has **Implementation requirements:** +PASS | stable task 4 has **Step 1: +PASS | stable task 4 has **Step 2: +PASS | stable task 4 has **Step 3: +PASS | stable task 4 has **Step 4: +PASS | stable task 4 has **Step 5: +PASS | stable task 4 has commit +PASS | stable task 4 has expected result +PASS | stable task 5 has **Files:** +PASS | stable task 5 has **Interfaces:** +PASS | stable task 5 has **Implementation requirements:** +PASS | stable task 5 has **Step 1: +PASS | stable task 5 has **Step 2: +PASS | stable task 5 has **Step 3: +PASS | stable task 5 has **Step 4: +PASS | stable task 5 has **Step 5: +PASS | stable task 5 has commit +PASS | stable task 5 has expected result +PASS | stable task 6 has **Files:** +PASS | stable task 6 has **Interfaces:** +PASS | stable task 6 has **Implementation requirements:** +PASS | stable task 6 has **Step 1: +PASS | stable task 6 has **Step 2: +PASS | stable task 6 has **Step 3: +PASS | stable task 6 has **Step 4: +PASS | stable task 6 has **Step 5: +PASS | stable task 6 has commit +PASS | stable task 6 has expected result +PASS | stable task 7 has **Files:** +PASS | stable task 7 has **Interfaces:** +PASS | stable task 7 has **Implementation requirements:** +PASS | stable task 7 has **Step 1: +PASS | stable task 7 has **Step 2: +PASS | stable task 7 has **Step 3: +PASS | stable task 7 has **Step 4: +PASS | stable task 7 has **Step 5: +PASS | stable task 7 has commit +PASS | stable task 7 has expected result +PASS | stable task 8 has **Files:** +PASS | stable task 8 has **Interfaces:** +PASS | stable task 8 has **Implementation requirements:** +PASS | stable task 8 has **Step 1: +PASS | stable task 8 has **Step 2: +PASS | stable task 8 has **Step 3: +PASS | stable task 8 has **Step 4: +PASS | stable task 8 has **Step 5: +PASS | stable task 8 has commit +PASS | stable task 8 has expected result +PASS | stable task 9 has **Files:** +PASS | stable task 9 has **Interfaces:** +PASS | stable task 9 has **Implementation requirements:** +PASS | stable task 9 has **Step 1: +PASS | stable task 9 has **Step 2: +PASS | stable task 9 has **Step 3: +PASS | stable task 9 has **Step 4: +PASS | stable task 9 has **Step 5: +PASS | stable task 9 has commit +PASS | stable task 9 has expected result +PASS | stable task 10 has **Files:** +PASS | stable task 10 has **Interfaces:** +PASS | stable task 10 has **Implementation requirements:** +PASS | stable task 10 has **Step 1: +PASS | stable task 10 has **Step 2: +PASS | stable task 10 has **Step 3: +PASS | stable task 10 has **Step 4: +PASS | stable task 10 has **Step 5: +PASS | stable task 10 has commit +PASS | stable task 10 has expected result +PASS | stable task 11 has **Files:** +PASS | stable task 11 has **Interfaces:** +PASS | stable task 11 has **Implementation requirements:** +PASS | stable task 11 has **Step 1: +PASS | stable task 11 has **Step 2: +PASS | stable task 11 has **Step 3: +PASS | stable task 11 has **Step 4: +PASS | stable task 11 has **Step 5: +PASS | stable task 11 has commit +PASS | stable task 11 has expected result +PASS | stable task 12 has **Files:** +PASS | stable task 12 has **Interfaces:** +PASS | stable task 12 has **Implementation requirements:** +PASS | stable task 12 has **Step 1: +PASS | stable task 12 has **Step 2: +PASS | stable task 12 has **Step 3: +PASS | stable task 12 has **Step 4: +PASS | stable task 12 has **Step 5: +PASS | stable task 12 has commit +PASS | stable task 12 has expected result +PASS | stable task 13 has **Files:** +PASS | stable task 13 has **Interfaces:** +PASS | stable task 13 has **Implementation requirements:** +PASS | stable task 13 has **Step 1: +PASS | stable task 13 has **Step 2: +PASS | stable task 13 has **Step 3: +PASS | stable task 13 has **Step 4: +PASS | stable task 13 has **Step 5: +PASS | stable task 13 has commit +PASS | stable task 13 has expected result +PASS | stable task 14 has **Files:** +PASS | stable task 14 has **Interfaces:** +PASS | stable task 14 has **Implementation requirements:** +PASS | stable task 14 has **Step 1: +PASS | stable task 14 has **Step 2: +PASS | stable task 14 has **Step 3: +PASS | stable task 14 has **Step 4: +PASS | stable task 14 has **Step 5: +PASS | stable task 14 has commit +PASS | stable task 14 has expected result +PASS | stable task 15 has **Files:** +PASS | stable task 15 has **Interfaces:** +PASS | stable task 15 has **Implementation requirements:** +PASS | stable task 15 has **Step 1: +PASS | stable task 15 has **Step 2: +PASS | stable task 15 has **Step 3: +PASS | stable task 15 has **Step 4: +PASS | stable task 15 has **Step 5: +PASS | stable task 15 has commit +PASS | stable task 15 has expected result +PASS | stable task 16 has **Files:** +PASS | stable task 16 has **Interfaces:** +PASS | stable task 16 has **Implementation requirements:** +PASS | stable task 16 has **Step 1: +PASS | stable task 16 has **Step 2: +PASS | stable task 16 has **Step 3: +PASS | stable task 16 has **Step 4: +PASS | stable task 16 has **Step 5: +PASS | stable task 16 has commit +PASS | stable task 16 has expected result +PASS | stable task 17 has **Files:** +PASS | stable task 17 has **Interfaces:** +PASS | stable task 17 has **Implementation requirements:** +PASS | stable task 17 has **Step 1: +PASS | stable task 17 has **Step 2: +PASS | stable task 17 has **Step 3: +PASS | stable task 17 has **Step 4: +PASS | stable task 17 has **Step 5: +PASS | stable task 17 has commit +PASS | stable task 17 has expected result +PASS | stable task 18 has **Files:** +PASS | stable task 18 has **Interfaces:** +PASS | stable task 18 has **Implementation requirements:** +PASS | stable task 18 has **Step 1: +PASS | stable task 18 has **Step 2: +PASS | stable task 18 has **Step 3: +PASS | stable task 18 has **Step 4: +PASS | stable task 18 has **Step 5: +PASS | stable task 18 has commit +PASS | stable task 18 has expected result +PASS | stable task 19 has **Files:** +PASS | stable task 19 has **Interfaces:** +PASS | stable task 19 has **Implementation requirements:** +PASS | stable task 19 has **Step 1: +PASS | stable task 19 has **Step 2: +PASS | stable task 19 has **Step 3: +PASS | stable task 19 has **Step 4: +PASS | stable task 19 has **Step 5: +PASS | stable task 19 has commit +PASS | stable task 19 has expected result +PASS | stable task 20 has **Files:** +PASS | stable task 20 has **Interfaces:** +PASS | stable task 20 has **Implementation requirements:** +PASS | stable task 20 has **Step 1: +PASS | stable task 20 has **Step 2: +PASS | stable task 20 has **Step 3: +PASS | stable task 20 has **Step 4: +PASS | stable task 20 has **Step 5: +PASS | stable task 20 has commit +PASS | stable task 20 has expected result +PASS | stable task 21 has **Files:** +PASS | stable task 21 has **Interfaces:** +PASS | stable task 21 has **Implementation requirements:** +PASS | stable task 21 has **Step 1: +PASS | stable task 21 has **Step 2: +PASS | stable task 21 has **Step 3: +PASS | stable task 21 has **Step 4: +PASS | stable task 21 has **Step 5: +PASS | stable task 21 has commit +PASS | stable task 21 has expected result +PASS | stable task 22 has **Files:** +PASS | stable task 22 has **Interfaces:** +PASS | stable task 22 has **Implementation requirements:** +PASS | stable task 22 has **Step 1: +PASS | stable task 22 has **Step 2: +PASS | stable task 22 has **Step 3: +PASS | stable task 22 has **Step 4: +PASS | stable task 22 has **Step 5: +PASS | stable task 22 has commit +PASS | stable task 22 has expected result +PASS | stable task 23 has **Files:** +PASS | stable task 23 has **Interfaces:** +PASS | stable task 23 has **Implementation requirements:** +PASS | stable task 23 has **Step 1: +PASS | stable task 23 has **Step 2: +PASS | stable task 23 has **Step 3: +PASS | stable task 23 has **Step 4: +PASS | stable task 23 has **Step 5: +PASS | stable task 23 has commit +PASS | stable task 23 has expected result +PASS | stable task 24 has **Files:** +PASS | stable task 24 has **Interfaces:** +PASS | stable task 24 has **Implementation requirements:** +PASS | stable task 24 has **Step 1: +PASS | stable task 24 has **Step 2: +PASS | stable task 24 has **Step 3: +PASS | stable task 24 has **Step 4: +PASS | stable task 24 has **Step 5: +PASS | stable task 24 has commit +PASS | stable task 24 has expected result +PASS | stable task 25 has **Files:** +PASS | stable task 25 has **Interfaces:** +PASS | stable task 25 has **Implementation requirements:** +PASS | stable task 25 has **Step 1: +PASS | stable task 25 has **Step 2: +PASS | stable task 25 has **Step 3: +PASS | stable task 25 has **Step 4: +PASS | stable task 25 has **Step 5: +PASS | stable task 25 has commit +PASS | stable task 25 has expected result +PASS | stable task 26 has **Files:** +PASS | stable task 26 has **Interfaces:** +PASS | stable task 26 has **Implementation requirements:** +PASS | stable task 26 has **Step 1: +PASS | stable task 26 has **Step 2: +PASS | stable task 26 has **Step 3: +PASS | stable task 26 has **Step 4: +PASS | stable task 26 has **Step 5: +PASS | stable task 26 has commit +PASS | stable task 26 has expected result +PASS | stable task 27 has **Files:** +PASS | stable task 27 has **Interfaces:** +PASS | stable task 27 has **Implementation requirements:** +PASS | stable task 27 has **Step 1: +PASS | stable task 27 has **Step 2: +PASS | stable task 27 has **Step 3: +PASS | stable task 27 has **Step 4: +PASS | stable task 27 has **Step 5: +PASS | stable task 27 has commit +PASS | stable task 27 has expected result +PASS | stable task 28 has **Files:** +PASS | stable task 28 has **Interfaces:** +PASS | stable task 28 has **Implementation requirements:** +PASS | stable task 28 has **Step 1: +PASS | stable task 28 has **Step 2: +PASS | stable task 28 has **Step 3: +PASS | stable task 28 has **Step 4: +PASS | stable task 28 has **Step 5: +PASS | stable task 28 has commit +PASS | stable task 28 has expected result +PASS | stable task 29 has **Files:** +PASS | stable task 29 has **Interfaces:** +PASS | stable task 29 has **Implementation requirements:** +PASS | stable task 29 has **Step 1: +PASS | stable task 29 has **Step 2: +PASS | stable task 29 has **Step 3: +PASS | stable task 29 has **Step 4: +PASS | stable task 29 has **Step 5: +PASS | stable task 29 has commit +PASS | stable task 29 has expected result +PASS | stable task 30 has **Files:** +PASS | stable task 30 has **Interfaces:** +PASS | stable task 30 has **Implementation requirements:** +PASS | stable task 30 has **Step 1: +PASS | stable task 30 has **Step 2: +PASS | stable task 30 has **Step 3: +PASS | stable task 30 has **Step 4: +PASS | stable task 30 has **Step 5: +PASS | stable task 30 has commit +PASS | stable task 30 has expected result +PASS | stable task 31 has **Files:** +PASS | stable task 31 has **Interfaces:** +PASS | stable task 31 has **Implementation requirements:** +PASS | stable task 31 has **Step 1: +PASS | stable task 31 has **Step 2: +PASS | stable task 31 has **Step 3: +PASS | stable task 31 has **Step 4: +PASS | stable task 31 has **Step 5: +PASS | stable task 31 has commit +PASS | stable task 31 has expected result +PASS | stable task 32 has **Files:** +PASS | stable task 32 has **Interfaces:** +PASS | stable task 32 has **Implementation requirements:** +PASS | stable task 32 has **Step 1: +PASS | stable task 32 has **Step 2: +PASS | stable task 32 has **Step 3: +PASS | stable task 32 has **Step 4: +PASS | stable task 32 has **Step 5: +PASS | stable task 32 has commit +PASS | stable task 32 has expected result +PASS | stable task 33 has **Files:** +PASS | stable task 33 has **Interfaces:** +PASS | stable task 33 has **Implementation requirements:** +PASS | stable task 33 has **Step 1: +PASS | stable task 33 has **Step 2: +PASS | stable task 33 has **Step 3: +PASS | stable task 33 has **Step 4: +PASS | stable task 33 has **Step 5: +PASS | stable task 33 has commit +PASS | stable task 33 has expected result +PASS | stable task 34 has **Files:** +PASS | stable task 34 has **Interfaces:** +PASS | stable task 34 has **Implementation requirements:** +PASS | stable task 34 has **Step 1: +PASS | stable task 34 has **Step 2: +PASS | stable task 34 has **Step 3: +PASS | stable task 34 has **Step 4: +PASS | stable task 34 has **Step 5: +PASS | stable task 34 has commit +PASS | stable task 34 has expected result +PASS | stable task 35 has **Files:** +PASS | stable task 35 has **Interfaces:** +PASS | stable task 35 has **Implementation requirements:** +PASS | stable task 35 has **Step 1: +PASS | stable task 35 has **Step 2: +PASS | stable task 35 has **Step 3: +PASS | stable task 35 has **Step 4: +PASS | stable task 35 has **Step 5: +PASS | stable task 35 has commit +PASS | stable task 35 has expected result +PASS | stable task 36 has **Files:** +PASS | stable task 36 has **Interfaces:** +PASS | stable task 36 has **Implementation requirements:** +PASS | stable task 36 has **Step 1: +PASS | stable task 36 has **Step 2: +PASS | stable task 36 has **Step 3: +PASS | stable task 36 has **Step 4: +PASS | stable task 36 has **Step 5: +PASS | stable task 36 has commit +PASS | stable task 36 has expected result +PASS | stable task 37 has **Files:** +PASS | stable task 37 has **Interfaces:** +PASS | stable task 37 has **Implementation requirements:** +PASS | stable task 37 has **Step 1: +PASS | stable task 37 has **Step 2: +PASS | stable task 37 has **Step 3: +PASS | stable task 37 has **Step 4: +PASS | stable task 37 has **Step 5: +PASS | stable task 37 has commit +PASS | stable task 37 has expected result +PASS | stable task 38 has **Files:** +PASS | stable task 38 has **Interfaces:** +PASS | stable task 38 has **Implementation requirements:** +PASS | stable task 38 has **Step 1: +PASS | stable task 38 has **Step 2: +PASS | stable task 38 has **Step 3: +PASS | stable task 38 has **Step 4: +PASS | stable task 38 has **Step 5: +PASS | stable task 38 has commit +PASS | stable task 38 has expected result +PASS | stable task 39 has **Files:** +PASS | stable task 39 has **Interfaces:** +PASS | stable task 39 has **Implementation requirements:** +PASS | stable task 39 has **Step 1: +PASS | stable task 39 has **Step 2: +PASS | stable task 39 has **Step 3: +PASS | stable task 39 has **Step 4: +PASS | stable task 39 has **Step 5: +PASS | stable task 39 has commit +PASS | stable task 39 has expected result +PASS | stable task 40 has **Files:** +PASS | stable task 40 has **Interfaces:** +PASS | stable task 40 has **Implementation requirements:** +PASS | stable task 40 has **Step 1: +PASS | stable task 40 has **Step 2: +PASS | stable task 40 has **Step 3: +PASS | stable task 40 has **Step 4: +PASS | stable task 40 has **Step 5: +PASS | stable task 40 has commit +PASS | stable task 40 has expected result +PASS | stable task 41 has **Files:** +PASS | stable task 41 has **Interfaces:** +PASS | stable task 41 has **Implementation requirements:** +PASS | stable task 41 has **Step 1: +PASS | stable task 41 has **Step 2: +PASS | stable task 41 has **Step 3: +PASS | stable task 41 has **Step 4: +PASS | stable task 41 has **Step 5: +PASS | stable task 41 has commit +PASS | stable task 41 has expected result +PASS | stable task 42 has **Files:** +PASS | stable task 42 has **Interfaces:** +PASS | stable task 42 has **Implementation requirements:** +PASS | stable task 42 has **Step 1: +PASS | stable task 42 has **Step 2: +PASS | stable task 42 has **Step 3: +PASS | stable task 42 has **Step 4: +PASS | stable task 42 has **Step 5: +PASS | stable task 42 has commit +PASS | stable task 42 has expected result +PASS | stable task 43 has **Files:** +PASS | stable task 43 has **Interfaces:** +PASS | stable task 43 has **Implementation requirements:** +PASS | stable task 43 has **Step 1: +PASS | stable task 43 has **Step 2: +PASS | stable task 43 has **Step 3: +PASS | stable task 43 has **Step 4: +PASS | stable task 43 has **Step 5: +PASS | stable task 43 has commit +PASS | stable task 43 has expected result +PASS | stable task 44 has **Files:** +PASS | stable task 44 has **Interfaces:** +PASS | stable task 44 has **Implementation requirements:** +PASS | stable task 44 has **Step 1: +PASS | stable task 44 has **Step 2: +PASS | stable task 44 has **Step 3: +PASS | stable task 44 has **Step 4: +PASS | stable task 44 has **Step 5: +PASS | stable task 44 has commit +PASS | stable task 44 has expected result +PASS | stable task 45 has **Files:** +PASS | stable task 45 has **Interfaces:** +PASS | stable task 45 has **Implementation requirements:** +PASS | stable task 45 has **Step 1: +PASS | stable task 45 has **Step 2: +PASS | stable task 45 has **Step 3: +PASS | stable task 45 has **Step 4: +PASS | stable task 45 has **Step 5: +PASS | stable task 45 has commit +PASS | stable task 45 has expected result +PASS | stable task 46 has **Files:** +PASS | stable task 46 has **Interfaces:** +PASS | stable task 46 has **Implementation requirements:** +PASS | stable task 46 has **Step 1: +PASS | stable task 46 has **Step 2: +PASS | stable task 46 has **Step 3: +PASS | stable task 46 has **Step 4: +PASS | stable task 46 has **Step 5: +PASS | stable task 46 has commit +PASS | stable task 46 has expected result +PASS | stable task 47 has **Files:** +PASS | stable task 47 has **Interfaces:** +PASS | stable task 47 has **Implementation requirements:** +PASS | stable task 47 has **Step 1: +PASS | stable task 47 has **Step 2: +PASS | stable task 47 has **Step 3: +PASS | stable task 47 has **Step 4: +PASS | stable task 47 has **Step 5: +PASS | stable task 47 has commit +PASS | stable task 47 has expected result +PASS | stable task 48 has **Files:** +PASS | stable task 48 has **Interfaces:** +PASS | stable task 48 has **Implementation requirements:** +PASS | stable task 48 has **Step 1: +PASS | stable task 48 has **Step 2: +PASS | stable task 48 has **Step 3: +PASS | stable task 48 has **Step 4: +PASS | stable task 48 has **Step 5: +PASS | stable task 48 has commit +PASS | stable task 48 has expected result +PASS | stable task 49 has **Files:** +PASS | stable task 49 has **Interfaces:** +PASS | stable task 49 has **Implementation requirements:** +PASS | stable task 49 has **Step 1: +PASS | stable task 49 has **Step 2: +PASS | stable task 49 has **Step 3: +PASS | stable task 49 has **Step 4: +PASS | stable task 49 has **Step 5: +PASS | stable task 49 has commit +PASS | stable task 49 has expected result +PASS | stable task 50 has **Files:** +PASS | stable task 50 has **Interfaces:** +PASS | stable task 50 has **Implementation requirements:** +PASS | stable task 50 has **Step 1: +PASS | stable task 50 has **Step 2: +PASS | stable task 50 has **Step 3: +PASS | stable task 50 has **Step 4: +PASS | stable task 50 has **Step 5: +PASS | stable task 50 has commit +PASS | stable task 50 has expected result +PASS | advanced task count | 15 +PASS | advanced task sequence | [1, 2, 3]...[13, 14, 15] +PASS | advanced task 1 has **Files:** +PASS | advanced task 1 has **Interfaces:** +PASS | advanced task 1 has **Implementation requirements:** +PASS | advanced task 1 has **Step 1: +PASS | advanced task 1 has **Step 2: +PASS | advanced task 1 has **Step 3: +PASS | advanced task 1 has **Step 4: +PASS | advanced task 1 has **Step 5: +PASS | advanced task 1 has commit +PASS | advanced task 1 has expected result +PASS | advanced task 2 has **Files:** +PASS | advanced task 2 has **Interfaces:** +PASS | advanced task 2 has **Implementation requirements:** +PASS | advanced task 2 has **Step 1: +PASS | advanced task 2 has **Step 2: +PASS | advanced task 2 has **Step 3: +PASS | advanced task 2 has **Step 4: +PASS | advanced task 2 has **Step 5: +PASS | advanced task 2 has commit +PASS | advanced task 2 has expected result +PASS | advanced task 3 has **Files:** +PASS | advanced task 3 has **Interfaces:** +PASS | advanced task 3 has **Implementation requirements:** +PASS | advanced task 3 has **Step 1: +PASS | advanced task 3 has **Step 2: +PASS | advanced task 3 has **Step 3: +PASS | advanced task 3 has **Step 4: +PASS | advanced task 3 has **Step 5: +PASS | advanced task 3 has commit +PASS | advanced task 3 has expected result +PASS | advanced task 4 has **Files:** +PASS | advanced task 4 has **Interfaces:** +PASS | advanced task 4 has **Implementation requirements:** +PASS | advanced task 4 has **Step 1: +PASS | advanced task 4 has **Step 2: +PASS | advanced task 4 has **Step 3: +PASS | advanced task 4 has **Step 4: +PASS | advanced task 4 has **Step 5: +PASS | advanced task 4 has commit +PASS | advanced task 4 has expected result +PASS | advanced task 5 has **Files:** +PASS | advanced task 5 has **Interfaces:** +PASS | advanced task 5 has **Implementation requirements:** +PASS | advanced task 5 has **Step 1: +PASS | advanced task 5 has **Step 2: +PASS | advanced task 5 has **Step 3: +PASS | advanced task 5 has **Step 4: +PASS | advanced task 5 has **Step 5: +PASS | advanced task 5 has commit +PASS | advanced task 5 has expected result +PASS | advanced task 6 has **Files:** +PASS | advanced task 6 has **Interfaces:** +PASS | advanced task 6 has **Implementation requirements:** +PASS | advanced task 6 has **Step 1: +PASS | advanced task 6 has **Step 2: +PASS | advanced task 6 has **Step 3: +PASS | advanced task 6 has **Step 4: +PASS | advanced task 6 has **Step 5: +PASS | advanced task 6 has commit +PASS | advanced task 6 has expected result +PASS | advanced task 7 has **Files:** +PASS | advanced task 7 has **Interfaces:** +PASS | advanced task 7 has **Implementation requirements:** +PASS | advanced task 7 has **Step 1: +PASS | advanced task 7 has **Step 2: +PASS | advanced task 7 has **Step 3: +PASS | advanced task 7 has **Step 4: +PASS | advanced task 7 has **Step 5: +PASS | advanced task 7 has commit +PASS | advanced task 7 has expected result +PASS | advanced task 8 has **Files:** +PASS | advanced task 8 has **Interfaces:** +PASS | advanced task 8 has **Implementation requirements:** +PASS | advanced task 8 has **Step 1: +PASS | advanced task 8 has **Step 2: +PASS | advanced task 8 has **Step 3: +PASS | advanced task 8 has **Step 4: +PASS | advanced task 8 has **Step 5: +PASS | advanced task 8 has commit +PASS | advanced task 8 has expected result +PASS | advanced task 9 has **Files:** +PASS | advanced task 9 has **Interfaces:** +PASS | advanced task 9 has **Implementation requirements:** +PASS | advanced task 9 has **Step 1: +PASS | advanced task 9 has **Step 2: +PASS | advanced task 9 has **Step 3: +PASS | advanced task 9 has **Step 4: +PASS | advanced task 9 has **Step 5: +PASS | advanced task 9 has commit +PASS | advanced task 9 has expected result +PASS | advanced task 10 has **Files:** +PASS | advanced task 10 has **Interfaces:** +PASS | advanced task 10 has **Implementation requirements:** +PASS | advanced task 10 has **Step 1: +PASS | advanced task 10 has **Step 2: +PASS | advanced task 10 has **Step 3: +PASS | advanced task 10 has **Step 4: +PASS | advanced task 10 has **Step 5: +PASS | advanced task 10 has commit +PASS | advanced task 10 has expected result +PASS | advanced task 11 has **Files:** +PASS | advanced task 11 has **Interfaces:** +PASS | advanced task 11 has **Implementation requirements:** +PASS | advanced task 11 has **Step 1: +PASS | advanced task 11 has **Step 2: +PASS | advanced task 11 has **Step 3: +PASS | advanced task 11 has **Step 4: +PASS | advanced task 11 has **Step 5: +PASS | advanced task 11 has commit +PASS | advanced task 11 has expected result +PASS | advanced task 12 has **Files:** +PASS | advanced task 12 has **Interfaces:** +PASS | advanced task 12 has **Implementation requirements:** +PASS | advanced task 12 has **Step 1: +PASS | advanced task 12 has **Step 2: +PASS | advanced task 12 has **Step 3: +PASS | advanced task 12 has **Step 4: +PASS | advanced task 12 has **Step 5: +PASS | advanced task 12 has commit +PASS | advanced task 12 has expected result +PASS | advanced task 13 has **Files:** +PASS | advanced task 13 has **Interfaces:** +PASS | advanced task 13 has **Implementation requirements:** +PASS | advanced task 13 has **Step 1: +PASS | advanced task 13 has **Step 2: +PASS | advanced task 13 has **Step 3: +PASS | advanced task 13 has **Step 4: +PASS | advanced task 13 has **Step 5: +PASS | advanced task 13 has commit +PASS | advanced task 13 has expected result +PASS | advanced task 14 has **Files:** +PASS | advanced task 14 has **Interfaces:** +PASS | advanced task 14 has **Implementation requirements:** +PASS | advanced task 14 has **Step 1: +PASS | advanced task 14 has **Step 2: +PASS | advanced task 14 has **Step 3: +PASS | advanced task 14 has **Step 4: +PASS | advanced task 14 has **Step 5: +PASS | advanced task 14 has commit +PASS | advanced task 14 has expected result +PASS | advanced task 15 has **Files:** +PASS | advanced task 15 has **Interfaces:** +PASS | advanced task 15 has **Implementation requirements:** +PASS | advanced task 15 has **Step 1: +PASS | advanced task 15 has **Step 2: +PASS | advanced task 15 has **Step 3: +PASS | advanced task 15 has **Step 4: +PASS | advanced task 15 has **Step 5: +PASS | advanced task 15 has commit +PASS | advanced task 15 has expected result +PASS | 2026-08-11-mongodb-document-persistence-platform-design.md code fences balanced | 132 +PASS | 2026-08-11-mongodb-document-persistence-platform-design.md no TODO markers +PASS | 2026-08-11-mongodb-document-persistence-platform-design.md no wildcard create paths +PASS | 2026-08-11-mongodb-document-persistence-platform-implementation-plan.md code fences balanced | 504 +PASS | 2026-08-11-mongodb-document-persistence-platform-implementation-plan.md no TODO markers +PASS | 2026-08-11-mongodb-document-persistence-platform-implementation-plan.md no wildcard create paths +PASS | 2026-08-11-mongodb-advanced-capabilities-expansion-plan.md code fences balanced | 150 +PASS | 2026-08-11-mongodb-advanced-capabilities-expansion-plan.md no TODO markers +PASS | 2026-08-11-mongodb-advanced-capabilities-expansion-plan.md no wildcard create paths +PASS | stable no duplicate create paths +PASS | advanced no duplicate create paths +PASS | stable and advanced create paths do not collide +PASS | no real generic repository declaration +PASS | no public arbitrary run command +PASS | stable starter excludes advanced +PASS | unknown commit body retry forbidden +PASS | mongo seven and eight matrix +PASS | advanced actual topology gate +SUMMARY | total=687 pass=687 fail=0 +``` + +## 검증 한계 + +현재 검증은 설계서와 구현 계획서의 정적 구조·일관성 검사다. 실제 Backend Skeleton 저장소가 제공되지 않았으므로 Gradle configuration, compile, Spring Context, MongoDB Replica Set, failover, sharded cluster, Atlas/KMS, performance와 Git commit은 실행하지 않았다. diff --git a/mongodb-superpowers-package/docs/superpowers/plans/2026-08-11-mongodb-advanced-capabilities-expansion-plan.md b/mongodb-superpowers-package/docs/superpowers/plans/2026-08-11-mongodb-advanced-capabilities-expansion-plan.md new file mode 100644 index 00000000..3990a179 --- /dev/null +++ b/mongodb-superpowers-package/docs/superpowers/plans/2026-08-11-mongodb-advanced-capabilities-expansion-plan.md @@ -0,0 +1,1139 @@ +# MongoDB Advanced Capabilities 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 MongoDB 문서 영속성 플랫폼의 공개 계약을 변경하지 않고 Sharding, Time Series, CSFLE, Queryable Encryption, Search, Vector Search, Multi-tenancy, Change Stream Messaging Bridge와 GridFS migration compatibility를 선택 모듈로 구현한다. + +**Architecture:** 모든 기능은 `modules/mongodb-advanced`에 격리되고 명시적 feature flag, 별도 privilege, 별도 topology 또는 provider gate를 요구한다. Stable Starter는 이 모듈을 자동 의존하지 않는다. Application-plane descriptor/guardrail과 Admin-plane topology/index/key 변경을 분리한다. + +**Tech Stack:** Stable MongoDB platform, Java 21, Spring Boot 4.1 BOM, Spring Data MongoDB 5.1, MongoDB 8.0 primary lane, actual Sharded Cluster, Atlas Local, actual target Atlas/KMS environments, existing Messaging and Object Storage platforms. + +## Global Constraints + +- Stable Task 1~50과 Stable Release Gate가 먼저 통과해야 한다. +- Advanced module root는 `modules/mongodb-advanced`이다. +- 모든 기능은 `backend.mongodb.advanced..enabled=true`를 요구한다. +- Advanced 모듈은 Stable Starter의 transitive dependency가 아니다. +- Sharding, Search, Encryption, Migration과 Tenant administration은 별도 credential을 사용한다. +- Time Series는 일반 Collection capability를 상속하지 않는다. +- CSFLE와 Queryable Encryption을 같은 Collection에 동시에 적용하지 않는다. +- MongoDB 8.0 Stable에서 QE prefix/suffix/substring query를 지원하지 않는다. +- Search/Vector index creation과 READY 상태를 분리한다. +- Shared Collection tenancy는 tenant context 누락 시 fail-closed다. +- Database-per-tenant는 client/migration concurrency를 제한한다. +- Change Stream 원본을 Messaging 외부 계약으로 직접 발행하지 않는다. +- GridFS는 legacy read/migration compatibility 전용이다. +- 승격에는 실제 topology/provider, security, failure, migration, performance와 runbook evidence가 필요하다. + +--- +### Task 1: Advanced 모듈·Feature Flag·Dependency 격리 구성 + +**Files:** +- Create: `build-logic/src/main/kotlin/mongodb-advanced-library-conventions.gradle.kts` +- Create: `modules/mongodb-advanced/mongodb-sharding/build.gradle.kts` +- Create: `modules/mongodb-advanced/mongodb-timeseries/build.gradle.kts` +- Create: `modules/mongodb-advanced/mongodb-csfle/build.gradle.kts` +- Create: `modules/mongodb-advanced/mongodb-queryable-encryption/build.gradle.kts` +- Create: `modules/mongodb-advanced/mongodb-search/build.gradle.kts` +- Create: `modules/mongodb-advanced/mongodb-vector-search/build.gradle.kts` +- Create: `modules/mongodb-advanced/mongodb-tenancy-shared/build.gradle.kts` +- Create: `modules/mongodb-advanced/mongodb-tenancy-database/build.gradle.kts` +- Create: `modules/mongodb-advanced/mongodb-change-stream-messaging-bridge/build.gradle.kts` +- Create: `modules/mongodb-advanced/mongodb-gridfs-compat/build.gradle.kts` +- Create: `modules/mongodb-advanced/mongodb-testkit-sharded/build.gradle.kts` +- Create: `modules/mongodb-advanced/mongodb-testkit-atlas/build.gradle.kts` +- Modify: `settings.gradle.kts` +- Test: `build-logic/src/test/java/MongoDbAdvancedModuleBoundaryTest.java` + +**Interfaces:** +- Consumes: Completed Stable Tasks 1~50 and Stable release evidence. +- Produces: 12 opt-in modules under `modules/mongodb-advanced` with explicit feature flags and no Stable Starter dependency. + +**Implementation requirements:** +- Require Stable Release Gate evidence before advanced test suites run. +- Every capability requires `backend.mongodb.advanced..enabled=true`. +- No advanced module may be a transitive dependency of mongodb-spring-boot-starter. +- Admin privileges and actual target-environment tests are capability-specific. +- Promotion to Stable requires a separate ADR and evidence package. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoDbAdvancedModuleBoundaryTest { + @org.junit.jupiter.api.Test + void stableStarterHasNoAdvancedDependency() { + org.assertj.core.api.Assertions.assertThat( + MongoAdvancedBuildModel.dependenciesOf("mongodb-spring-boot-starter")) + .noneMatch(it -> it.startsWith("mongodb-advanced")); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew build-logic:test --tests 'MongoDbAdvancedModuleBoundaryTest' +``` + +Expected: FAIL because the advanced capability or its gate is not implemented yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class MongoAdvancedBuildModel { + public static boolean enabled(String capability, java.util.Map flags) { + return Boolean.TRUE.equals(flags.get(capability)); + } +} +``` + +Implement every invariant listed under **Implementation requirements**. The snippet fixes public names and the central safety contract. + +- [ ] **Step 4: Run the focused test and module suite** + +Run: + +```bash +./gradlew build-logic:test --tests 'MongoDbAdvancedModuleBoundaryTest' +./gradlew :modules:mongodb-advanced:mongodb-sharding:test +``` + +Expected: PASS with the focused assertion and module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'build-logic/src/main/kotlin/mongodb-advanced-library-conventions.gradle.kts' 'modules/mongodb-advanced/mongodb-sharding/build.gradle.kts' 'modules/mongodb-advanced/mongodb-timeseries/build.gradle.kts' 'modules/mongodb-advanced/mongodb-csfle/build.gradle.kts' 'modules/mongodb-advanced/mongodb-queryable-encryption/build.gradle.kts' 'modules/mongodb-advanced/mongodb-search/build.gradle.kts' 'modules/mongodb-advanced/mongodb-vector-search/build.gradle.kts' 'modules/mongodb-advanced/mongodb-tenancy-shared/build.gradle.kts' 'modules/mongodb-advanced/mongodb-tenancy-database/build.gradle.kts' 'modules/mongodb-advanced/mongodb-change-stream-messaging-bridge/build.gradle.kts' 'modules/mongodb-advanced/mongodb-gridfs-compat/build.gradle.kts' 'modules/mongodb-advanced/mongodb-testkit-sharded/build.gradle.kts' 'modules/mongodb-advanced/mongodb-testkit-atlas/build.gradle.kts' 'settings.gradle.kts' 'build-logic/src/test/java/MongoDbAdvancedModuleBoundaryTest.java' +git commit -m "build: add isolated mongodb advanced modules" +``` + +### Task 2: Shard Key Descriptor와 Targeted Query Validator 구현 + +**Files:** +- Create: `modules/mongodb-advanced/mongodb-sharding/src/main/java/io/backend/skeleton/mongodb/advanced/sharding/ShardKeyDescriptor.java` +- Create: `modules/mongodb-advanced/mongodb-sharding/src/main/java/io/backend/skeleton/mongodb/advanced/sharding/ShardKeyPart.java` +- Create: `modules/mongodb-advanced/mongodb-sharding/src/main/java/io/backend/skeleton/mongodb/advanced/sharding/ShardStrategy.java` +- Create: `modules/mongodb-advanced/mongodb-sharding/src/main/java/io/backend/skeleton/mongodb/advanced/sharding/ShardAwareQueryValidator.java` +- Create: `modules/mongodb-advanced/mongodb-sharding/src/main/java/io/backend/skeleton/mongodb/advanced/sharding/MongoRoutingClassification.java` +- Test: `modules/mongodb-advanced/mongodb-sharding/src/test/java/io/backend/skeleton/mongodb/advanced/sharding/ShardAwareQueryValidatorTest.java` + +**Interfaces:** +- Consumes: Stable collection/query/index manifests and operation contexts. +- Produces: Application-plane classification of targeted, prefix-targeted and scatter-gather queries. + +**Implementation requirements:** +- Descriptor must preserve compound shard-key order and hashed/range strategy. +- Single-document writes must include routing evidence required by the server version and collection profile. +- Unique indexes must be checked for shard-key compatibility. +- Scatter-gather operations require an explicit reviewed profile and telemetry. +- Do not execute shardCollection, refine or reshard through this module. + +- [ ] **Step 1: Write the failing test** + +```java +class ShardAwareQueryValidatorTest { + @org.junit.jupiter.api.Test + void classifiesMissingShardKeyAsScatterGather() { + ShardKeyDescriptor key = ShardKeyDescriptor.range("tenantId", "orderId"); + MongoRoutingClassification result = + new ShardAwareQueryValidator().classify(key, java.util.Set.of("status")); + org.assertj.core.api.Assertions.assertThat(result) + .isEqualTo(MongoRoutingClassification.SCATTER_GATHER); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb-advanced:mongodb-sharding:test --tests 'io.backend.skeleton.mongodb.advanced.sharding.ShardAwareQueryValidatorTest' +``` + +Expected: FAIL because the advanced capability or its gate is not implemented yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum MongoRoutingClassification { + TARGETED, PREFIX_TARGETED, SCATTER_GATHER, REJECTED +} +``` + +Implement every invariant listed under **Implementation requirements**. The snippet fixes public names and the central safety contract. + +- [ ] **Step 4: Run the focused test and module suite** + +Run: + +```bash +./gradlew :modules:mongodb-advanced:mongodb-sharding:test --tests 'io.backend.skeleton.mongodb.advanced.sharding.ShardAwareQueryValidatorTest' +./gradlew :modules:mongodb-advanced:mongodb-sharding:test +``` + +Expected: PASS with the focused assertion and module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb-advanced/mongodb-sharding/src/main/java/io/backend/skeleton/mongodb/advanced/sharding/ShardKeyDescriptor.java' 'modules/mongodb-advanced/mongodb-sharding/src/main/java/io/backend/skeleton/mongodb/advanced/sharding/ShardKeyPart.java' 'modules/mongodb-advanced/mongodb-sharding/src/main/java/io/backend/skeleton/mongodb/advanced/sharding/ShardStrategy.java' 'modules/mongodb-advanced/mongodb-sharding/src/main/java/io/backend/skeleton/mongodb/advanced/sharding/ShardAwareQueryValidator.java' 'modules/mongodb-advanced/mongodb-sharding/src/main/java/io/backend/skeleton/mongodb/advanced/sharding/MongoRoutingClassification.java' 'modules/mongodb-advanced/mongodb-sharding/src/test/java/io/backend/skeleton/mongodb/advanced/sharding/ShardAwareQueryValidatorTest.java' +git commit -m "feat: add mongodb shard aware query validation" +``` + +### Task 3: analyzeShardKey Readiness Report와 D4 Sharding Admin 구현 + +**Files:** +- Create: `modules/mongodb-advanced/mongodb-sharding/src/main/java/io/backend/skeleton/mongodb/advanced/sharding/admin/ShardKeyReadinessReport.java` +- Create: `modules/mongodb-advanced/mongodb-sharding/src/main/java/io/backend/skeleton/mongodb/advanced/sharding/admin/ShardKeyAnalyzer.java` +- Create: `modules/mongodb-advanced/mongodb-sharding/src/main/java/io/backend/skeleton/mongodb/advanced/sharding/admin/MongoShardingAdminGateway.java` +- Create: `modules/mongodb-advanced/mongodb-sharding/src/main/java/io/backend/skeleton/mongodb/advanced/sharding/admin/ReshardApproval.java` +- Test: `modules/mongodb-advanced/mongodb-sharding/src/test/java/io/backend/skeleton/mongodb/advanced/sharding/admin/ShardKeyAnalyzerTest.java` + +**Interfaces:** +- Consumes: Shard descriptors, D4 admin security and actual server sampling. +- Produces: Cardinality, frequency, monotonicity, read/write distribution and reshard readiness evidence. + +**Implementation requirements:** +- Run analyzeShardKey only through a shard-admin credential. +- Report cardinality, frequency, monotonicity, skew and sampled routing distribution. +- Require supporting index and query-manifest coverage before shardCollection approval. +- Refine and reshard require dry-run, operator reason, rollback/forward strategy and audit. +- Balancer, zones and shard add/remove remain explicit Admin operations. + +- [ ] **Step 1: Write the failing test** + +```java +class ShardKeyAnalyzerTest { + @org.junit.jupiter.api.Test + void rejectsLowCardinalityCandidate() { + ShardKeyReadinessReport report = ShardKeyReadinessReport.lowCardinality("status"); + org.assertj.core.api.Assertions.assertThat(report.approved()).isFalse(); + org.assertj.core.api.Assertions.assertThat(report.reasons()) + .contains("LOW_CARDINALITY"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb-advanced:mongodb-sharding:test --tests 'io.backend.skeleton.mongodb.advanced.sharding.admin.ShardKeyAnalyzerTest' +``` + +Expected: FAIL because the advanced capability or its gate is not implemented yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record ShardKeyReadinessReport( + boolean approved, + java.util.Set reasons, + double monotonicity, + double readTargetingRatio, + double writeTargetingRatio) { +} +``` + +Implement every invariant listed under **Implementation requirements**. The snippet fixes public names and the central safety contract. + +- [ ] **Step 4: Run the focused test and module suite** + +Run: + +```bash +./gradlew :modules:mongodb-advanced:mongodb-sharding:test --tests 'io.backend.skeleton.mongodb.advanced.sharding.admin.ShardKeyAnalyzerTest' +./gradlew :modules:mongodb-advanced:mongodb-sharding:test +``` + +Expected: PASS with the focused assertion and module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb-advanced/mongodb-sharding/src/main/java/io/backend/skeleton/mongodb/advanced/sharding/admin/ShardKeyReadinessReport.java' 'modules/mongodb-advanced/mongodb-sharding/src/main/java/io/backend/skeleton/mongodb/advanced/sharding/admin/ShardKeyAnalyzer.java' 'modules/mongodb-advanced/mongodb-sharding/src/main/java/io/backend/skeleton/mongodb/advanced/sharding/admin/MongoShardingAdminGateway.java' 'modules/mongodb-advanced/mongodb-sharding/src/main/java/io/backend/skeleton/mongodb/advanced/sharding/admin/ReshardApproval.java' 'modules/mongodb-advanced/mongodb-sharding/src/test/java/io/backend/skeleton/mongodb/advanced/sharding/admin/ShardKeyAnalyzerTest.java' +git commit -m "feat: add mongodb shard key readiness and admin gate" +``` + +### Task 4: 실제 Sharded Cluster Testkit과 Chunk Migration 계약 구현 + +**Files:** +- Create: `modules/mongodb-advanced/mongodb-testkit-sharded/src/main/java/io/backend/skeleton/mongodb/advanced/testkit/sharded/MongoShardedCluster.java` +- Create: `modules/mongodb-advanced/mongodb-testkit-sharded/src/main/java/io/backend/skeleton/mongodb/advanced/testkit/sharded/MongoChunkMigrationController.java` +- Create: `modules/mongodb-advanced/mongodb-testkit-sharded/src/main/java/io/backend/skeleton/mongodb/advanced/testkit/sharded/MongoShardingContractSuite.java` +- Create: `modules/mongodb-advanced/mongodb-testkit-sharded/src/test/resources/mongodb/init-sharded-cluster.js` +- Test: `modules/mongodb-advanced/mongodb-testkit-sharded/src/test/java/io/backend/skeleton/mongodb/advanced/testkit/sharded/MongoShardingContractSuiteTest.java` + +**Interfaces:** +- Consumes: Sharding module, Testcontainers/Docker and Stable platform contracts. +- Produces: Actual mongos/config-server/shard topology tests for targeting, migration, failover and cross-shard transactions. + +**Implementation requirements:** +- Run at least two shards and a replicated config server for release evidence. +- Verify targeted versus scatter-gather execution through explain and telemetry. +- Move chunks while reads, writes and change streams are active. +- Test shard-key-missing single-document updates and unique-index restrictions. +- Test cross-shard transaction failure and recovery without hiding latency cost. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoShardingContractSuiteTest { + @org.junit.jupiter.api.Test + void targetedQueryTouchesOneShard() { + MongoShardingReport report = MongoShardingContractSuite.runTargetedQuery(); + org.assertj.core.api.Assertions.assertThat(report.shardsExamined()).isEqualTo(1); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb-advanced:mongodb-testkit-sharded:test --tests 'io.backend.skeleton.mongodb.advanced.testkit.sharded.MongoShardingContractSuiteTest' +``` + +Expected: FAIL because the advanced capability or its gate is not implemented yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public interface MongoShardingContractSuite { + static MongoShardingReport runTargetedQuery() { + return new MongoShardingReport(1, MongoRoutingClassification.TARGETED); + } +} +``` + +Implement every invariant listed under **Implementation requirements**. The snippet fixes public names and the central safety contract. + +- [ ] **Step 4: Run the focused test and module suite** + +Run: + +```bash +./gradlew :modules:mongodb-advanced:mongodb-testkit-sharded:test --tests 'io.backend.skeleton.mongodb.advanced.testkit.sharded.MongoShardingContractSuiteTest' +./gradlew :modules:mongodb-advanced:mongodb-testkit-sharded:test +``` + +Expected: PASS with the focused assertion and module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb-advanced/mongodb-testkit-sharded/src/main/java/io/backend/skeleton/mongodb/advanced/testkit/sharded/MongoShardedCluster.java' 'modules/mongodb-advanced/mongodb-testkit-sharded/src/main/java/io/backend/skeleton/mongodb/advanced/testkit/sharded/MongoChunkMigrationController.java' 'modules/mongodb-advanced/mongodb-testkit-sharded/src/main/java/io/backend/skeleton/mongodb/advanced/testkit/sharded/MongoShardingContractSuite.java' 'modules/mongodb-advanced/mongodb-testkit-sharded/src/test/resources/mongodb/init-sharded-cluster.js' 'modules/mongodb-advanced/mongodb-testkit-sharded/src/test/java/io/backend/skeleton/mongodb/advanced/testkit/sharded/MongoShardingContractSuiteTest.java' +git commit -m "test: add mongodb sharded cluster contract suite" +``` + +### Task 5: Time Series 전용 Collection·Write·Query 계약 구현 + +**Files:** +- Create: `modules/mongodb-advanced/mongodb-timeseries/src/main/java/io/backend/skeleton/mongodb/advanced/timeseries/MongoTimeSeriesDescriptor.java` +- Create: `modules/mongodb-advanced/mongodb-timeseries/src/main/java/io/backend/skeleton/mongodb/advanced/timeseries/MongoTimeSeriesGranularity.java` +- Create: `modules/mongodb-advanced/mongodb-timeseries/src/main/java/io/backend/skeleton/mongodb/advanced/timeseries/MongoTimeSeriesOperations.java` +- Create: `modules/mongodb-advanced/mongodb-timeseries/src/main/java/io/backend/skeleton/mongodb/advanced/timeseries/MongoTimeSeriesCapabilityValidator.java` +- Test: `modules/mongodb-advanced/mongodb-timeseries/src/test/java/io/backend/skeleton/mongodb/advanced/timeseries/MongoTimeSeriesCapabilityValidatorTest.java` + +**Interfaces:** +- Consumes: Stable manifests, D4 collection setup and MongoDB 8.0 capability reports. +- Produces: Separate timeField/metaField/granularity/bucket/TTL API that does not inherit general collection capabilities. + +**Implementation requirements:** +- Reject schema validator, change stream, CSFLE, search and transaction-write combinations. +- Require explicit timeField, optional metaField and bounded granularity/bucket settings. +- Validate server-version-specific sharding restrictions. +- Expose TTL as time-series retention, not an exact scheduler. +- Test document-size and update limitations separately from normal collections. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoTimeSeriesCapabilityValidatorTest { + @org.junit.jupiter.api.Test + void rejectsChangeStreamOnTimeSeries() { + MongoTimeSeriesDescriptor descriptor = MongoTimeSeriesDescriptor.standard("observedAt", "sensor"); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + new MongoTimeSeriesCapabilityValidator().requireChangeStream(descriptor)) + .isInstanceOf(UnsupportedOperationException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb-advanced:mongodb-timeseries:test --tests 'io.backend.skeleton.mongodb.advanced.timeseries.MongoTimeSeriesCapabilityValidatorTest' +``` + +Expected: FAIL because the advanced capability or its gate is not implemented yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record MongoTimeSeriesDescriptor( + String timeField, + String metaField, + MongoTimeSeriesGranularity granularity, + java.time.Duration retention) { +} +``` + +Implement every invariant listed under **Implementation requirements**. The snippet fixes public names and the central safety contract. + +- [ ] **Step 4: Run the focused test and module suite** + +Run: + +```bash +./gradlew :modules:mongodb-advanced:mongodb-timeseries:test --tests 'io.backend.skeleton.mongodb.advanced.timeseries.MongoTimeSeriesCapabilityValidatorTest' +./gradlew :modules:mongodb-advanced:mongodb-timeseries:test +``` + +Expected: PASS with the focused assertion and module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb-advanced/mongodb-timeseries/src/main/java/io/backend/skeleton/mongodb/advanced/timeseries/MongoTimeSeriesDescriptor.java' 'modules/mongodb-advanced/mongodb-timeseries/src/main/java/io/backend/skeleton/mongodb/advanced/timeseries/MongoTimeSeriesGranularity.java' 'modules/mongodb-advanced/mongodb-timeseries/src/main/java/io/backend/skeleton/mongodb/advanced/timeseries/MongoTimeSeriesOperations.java' 'modules/mongodb-advanced/mongodb-timeseries/src/main/java/io/backend/skeleton/mongodb/advanced/timeseries/MongoTimeSeriesCapabilityValidator.java' 'modules/mongodb-advanced/mongodb-timeseries/src/test/java/io/backend/skeleton/mongodb/advanced/timeseries/MongoTimeSeriesCapabilityValidatorTest.java' +git commit -m "feat: add mongodb time series capability" +``` + +### Task 6: CSFLE Automatic·Explicit Encryption 구현 + +**Files:** +- Create: `modules/mongodb-advanced/mongodb-csfle/src/main/java/io/backend/skeleton/mongodb/advanced/encryption/csfle/MongoCsfleProfile.java` +- Create: `modules/mongodb-advanced/mongodb-csfle/src/main/java/io/backend/skeleton/mongodb/advanced/encryption/csfle/MongoCsfleFieldPolicy.java` +- Create: `modules/mongodb-advanced/mongodb-csfle/src/main/java/io/backend/skeleton/mongodb/advanced/encryption/csfle/MongoDataKeyResolver.java` +- Create: `modules/mongodb-advanced/mongodb-csfle/src/main/java/io/backend/skeleton/mongodb/advanced/encryption/csfle/MongoCsfleClientFactory.java` +- Test: `modules/mongodb-advanced/mongodb-csfle/src/test/java/io/backend/skeleton/mongodb/advanced/encryption/csfle/MongoCsfleFieldPolicyTest.java` + +**Interfaces:** +- Consumes: Security profiles, D4 admin client, BSON manifest and actual KMS/key-vault fixtures. +- Produces: Randomized/deterministic field encryption with key resolver and no plaintext telemetry. + +**Implementation requirements:** +- Randomized encryption is the default for non-queryable PII. +- Deterministic encryption requires a documented equality-query requirement and leakage review. +- Key-vault access uses a dedicated principal. +- Plaintext fields, KMS material and data keys never enter logs, traces or failure metadata. +- CSFLE cannot be enabled on a time-series collection or mixed with QE on the same collection. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoCsfleFieldPolicyTest { + @org.junit.jupiter.api.Test + void nonQueryablePiiDefaultsToRandomizedEncryption() { + MongoCsfleFieldPolicy policy = MongoCsfleFieldPolicy.forPii("ssn", false); + org.assertj.core.api.Assertions.assertThat(policy.mode()) + .isEqualTo(MongoCsfleMode.RANDOMIZED); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb-advanced:mongodb-csfle:test --tests 'io.backend.skeleton.mongodb.advanced.encryption.csfle.MongoCsfleFieldPolicyTest' +``` + +Expected: FAIL because the advanced capability or its gate is not implemented yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record MongoCsfleFieldPolicy( + String fieldPath, + MongoCsfleMode mode, + String keyAlias) { +} +``` + +Implement every invariant listed under **Implementation requirements**. The snippet fixes public names and the central safety contract. + +- [ ] **Step 4: Run the focused test and module suite** + +Run: + +```bash +./gradlew :modules:mongodb-advanced:mongodb-csfle:test --tests 'io.backend.skeleton.mongodb.advanced.encryption.csfle.MongoCsfleFieldPolicyTest' +./gradlew :modules:mongodb-advanced:mongodb-csfle:test +``` + +Expected: PASS with the focused assertion and module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb-advanced/mongodb-csfle/src/main/java/io/backend/skeleton/mongodb/advanced/encryption/csfle/MongoCsfleProfile.java' 'modules/mongodb-advanced/mongodb-csfle/src/main/java/io/backend/skeleton/mongodb/advanced/encryption/csfle/MongoCsfleFieldPolicy.java' 'modules/mongodb-advanced/mongodb-csfle/src/main/java/io/backend/skeleton/mongodb/advanced/encryption/csfle/MongoDataKeyResolver.java' 'modules/mongodb-advanced/mongodb-csfle/src/main/java/io/backend/skeleton/mongodb/advanced/encryption/csfle/MongoCsfleClientFactory.java' 'modules/mongodb-advanced/mongodb-csfle/src/test/java/io/backend/skeleton/mongodb/advanced/encryption/csfle/MongoCsfleFieldPolicyTest.java' +git commit -m "feat: add mongodb csfle capability" +``` + +### Task 7: Queryable Encryption Equality·Range 계약 구현 + +**Files:** +- Create: `modules/mongodb-advanced/mongodb-queryable-encryption/src/main/java/io/backend/skeleton/mongodb/advanced/encryption/qe/MongoQueryableEncryptionProfile.java` +- Create: `modules/mongodb-advanced/mongodb-queryable-encryption/src/main/java/io/backend/skeleton/mongodb/advanced/encryption/qe/MongoEncryptedFieldDescriptor.java` +- Create: `modules/mongodb-advanced/mongodb-queryable-encryption/src/main/java/io/backend/skeleton/mongodb/advanced/encryption/qe/MongoQueryableEncryptionCollectionManager.java` +- Create: `modules/mongodb-advanced/mongodb-queryable-encryption/src/main/java/io/backend/skeleton/mongodb/advanced/encryption/qe/MongoEncryptionMetadataOwnership.java` +- Test: `modules/mongodb-advanced/mongodb-queryable-encryption/src/test/java/io/backend/skeleton/mongodb/advanced/encryption/qe/MongoQueryableEncryptionProfileTest.java` + +**Interfaces:** +- Consumes: Security, D4 admin, schema/index ownership and KMS test environment. +- Produces: QE equality and range profiles with protected internal metadata ownership. + +**Implementation requirements:** +- MongoDB 8.0 Stable supports equality and range only. +- Prefix, suffix and substring query profiles are rejected. +- Encrypted collection setup is a D4 operation completed before application writes. +- Mark `__safeContent__` and internal metadata collections as ENCRYPTION_MANAGED. +- Key rotation and compaction/cleanup require separate runbooks and evidence. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoQueryableEncryptionProfileTest { + @org.junit.jupiter.api.Test + void mongoEightRejectsSubstringQueryableEncryption() { + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + MongoQueryableEncryptionProfile.substring("name")) + .isInstanceOf(UnsupportedOperationException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb-advanced:mongodb-queryable-encryption:test --tests 'io.backend.skeleton.mongodb.advanced.encryption.qe.MongoQueryableEncryptionProfileTest' +``` + +Expected: FAIL because the advanced capability or its gate is not implemented yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum MongoQueryableEncryptionQueryType { EQUALITY, RANGE } +``` + +Implement every invariant listed under **Implementation requirements**. The snippet fixes public names and the central safety contract. + +- [ ] **Step 4: Run the focused test and module suite** + +Run: + +```bash +./gradlew :modules:mongodb-advanced:mongodb-queryable-encryption:test --tests 'io.backend.skeleton.mongodb.advanced.encryption.qe.MongoQueryableEncryptionProfileTest' +./gradlew :modules:mongodb-advanced:mongodb-queryable-encryption:test +``` + +Expected: PASS with the focused assertion and module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb-advanced/mongodb-queryable-encryption/src/main/java/io/backend/skeleton/mongodb/advanced/encryption/qe/MongoQueryableEncryptionProfile.java' 'modules/mongodb-advanced/mongodb-queryable-encryption/src/main/java/io/backend/skeleton/mongodb/advanced/encryption/qe/MongoEncryptedFieldDescriptor.java' 'modules/mongodb-advanced/mongodb-queryable-encryption/src/main/java/io/backend/skeleton/mongodb/advanced/encryption/qe/MongoQueryableEncryptionCollectionManager.java' 'modules/mongodb-advanced/mongodb-queryable-encryption/src/main/java/io/backend/skeleton/mongodb/advanced/encryption/qe/MongoEncryptionMetadataOwnership.java' 'modules/mongodb-advanced/mongodb-queryable-encryption/src/test/java/io/backend/skeleton/mongodb/advanced/encryption/qe/MongoQueryableEncryptionProfileTest.java' +git commit -m "feat: add mongodb queryable encryption equality and range" +``` + +### Task 8: MongoDB Search Query·Index Readiness 계약 구현 + +**Files:** +- Create: `modules/mongodb-advanced/mongodb-search/src/main/java/io/backend/skeleton/mongodb/advanced/search/MongoSearchIndexDescriptor.java` +- Create: `modules/mongodb-advanced/mongodb-search/src/main/java/io/backend/skeleton/mongodb/advanced/search/MongoSearchIndexState.java` +- Create: `modules/mongodb-advanced/mongodb-search/src/main/java/io/backend/skeleton/mongodb/advanced/search/MongoSearchQuery.java` +- Create: `modules/mongodb-advanced/mongodb-search/src/main/java/io/backend/skeleton/mongodb/advanced/search/MongoSearchOperations.java` +- Create: `modules/mongodb-advanced/mongodb-search/src/main/java/io/backend/skeleton/mongodb/advanced/search/MongoSearchReadinessGate.java` +- Test: `modules/mongodb-advanced/mongodb-search/src/test/java/io/backend/skeleton/mongodb/advanced/search/MongoSearchReadinessGateTest.java` + +**Interfaces:** +- Consumes: Aggregation guardrails, D4 admin and Atlas environment capability. +- Produces: Search indexes with CREATED/BUILDING/READY/FAILED states and bounded typed queries. + +**Implementation requirements:** +- Index creation success is not equivalent to READY. +- Application search queries run only against an index proven READY. +- Search query paths and operators are allowlisted. +- Actual target deployment validates analyzers, relevance and latency. +- Legacy `$text` remains compatibility-only and is not silently redirected to Search. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoSearchReadinessGateTest { + @org.junit.jupiter.api.Test + void buildingIndexCannotServeTraffic() { + MongoSearchReadinessGate gate = new MongoSearchReadinessGate(); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + gate.requireReady(MongoSearchIndexState.BUILDING)) + .isInstanceOf(MongoOperationRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb-advanced:mongodb-search:test --tests 'io.backend.skeleton.mongodb.advanced.search.MongoSearchReadinessGateTest' +``` + +Expected: FAIL because the advanced capability or its gate is not implemented yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum MongoSearchIndexState { CREATED, BUILDING, READY, FAILED, DELETING } +``` + +Implement every invariant listed under **Implementation requirements**. The snippet fixes public names and the central safety contract. + +- [ ] **Step 4: Run the focused test and module suite** + +Run: + +```bash +./gradlew :modules:mongodb-advanced:mongodb-search:test --tests 'io.backend.skeleton.mongodb.advanced.search.MongoSearchReadinessGateTest' +./gradlew :modules:mongodb-advanced:mongodb-search:test +``` + +Expected: PASS with the focused assertion and module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb-advanced/mongodb-search/src/main/java/io/backend/skeleton/mongodb/advanced/search/MongoSearchIndexDescriptor.java' 'modules/mongodb-advanced/mongodb-search/src/main/java/io/backend/skeleton/mongodb/advanced/search/MongoSearchIndexState.java' 'modules/mongodb-advanced/mongodb-search/src/main/java/io/backend/skeleton/mongodb/advanced/search/MongoSearchQuery.java' 'modules/mongodb-advanced/mongodb-search/src/main/java/io/backend/skeleton/mongodb/advanced/search/MongoSearchOperations.java' 'modules/mongodb-advanced/mongodb-search/src/main/java/io/backend/skeleton/mongodb/advanced/search/MongoSearchReadinessGate.java' 'modules/mongodb-advanced/mongodb-search/src/test/java/io/backend/skeleton/mongodb/advanced/search/MongoSearchReadinessGateTest.java' +git commit -m "feat: add mongodb search readiness contract" +``` + +### Task 9: Vector Search와 Embedding·Score 계약 구현 + +**Files:** +- Create: `modules/mongodb-advanced/mongodb-vector-search/src/main/java/io/backend/skeleton/mongodb/advanced/vector/MongoVectorIndexDescriptor.java` +- Create: `modules/mongodb-advanced/mongodb-vector-search/src/main/java/io/backend/skeleton/mongodb/advanced/vector/MongoEmbedding.java` +- Create: `modules/mongodb-advanced/mongodb-vector-search/src/main/java/io/backend/skeleton/mongodb/advanced/vector/MongoVectorQuery.java` +- Create: `modules/mongodb-advanced/mongodb-vector-search/src/main/java/io/backend/skeleton/mongodb/advanced/vector/MongoVectorSearchOperations.java` +- Create: `modules/mongodb-advanced/mongodb-vector-search/src/main/java/io/backend/skeleton/mongodb/advanced/vector/MongoVectorSearchBenchmarkGate.java` +- Test: `modules/mongodb-advanced/mongodb-vector-search/src/test/java/io/backend/skeleton/mongodb/advanced/vector/MongoEmbeddingTest.java` + +**Interfaces:** +- Consumes: Search readiness, operation budgets and Atlas test environment. +- Produces: Typed embedding dimension, similarity metric, candidate/result limits and benchmark evidence. + +**Implementation requirements:** +- Embedding dimension must exactly match the registered index. +- Similarity metric and score interpretation are part of the index contract. +- Bound numCandidates, result count, filter fields and timeout. +- Do not return raw provider score without a named score contract. +- Actual deployment benchmarks accuracy, latency, memory and index readiness. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoEmbeddingTest { + @org.junit.jupiter.api.Test + void rejectsDimensionMismatch() { + MongoVectorIndexDescriptor index = MongoVectorIndexDescriptor.cosine("embedding", 3); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + MongoEmbedding.forIndex(index, new float[]{1f, 2f})) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb-advanced:mongodb-vector-search:test --tests 'io.backend.skeleton.mongodb.advanced.vector.MongoEmbeddingTest' +``` + +Expected: FAIL because the advanced capability or its gate is not implemented yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record MongoEmbedding(float[] values) { + public MongoEmbedding { values = values.clone(); } + @Override public float[] values() { return values.clone(); } +} +``` + +Implement every invariant listed under **Implementation requirements**. The snippet fixes public names and the central safety contract. + +- [ ] **Step 4: Run the focused test and module suite** + +Run: + +```bash +./gradlew :modules:mongodb-advanced:mongodb-vector-search:test --tests 'io.backend.skeleton.mongodb.advanced.vector.MongoEmbeddingTest' +./gradlew :modules:mongodb-advanced:mongodb-vector-search:test +``` + +Expected: PASS with the focused assertion and module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb-advanced/mongodb-vector-search/src/main/java/io/backend/skeleton/mongodb/advanced/vector/MongoVectorIndexDescriptor.java' 'modules/mongodb-advanced/mongodb-vector-search/src/main/java/io/backend/skeleton/mongodb/advanced/vector/MongoEmbedding.java' 'modules/mongodb-advanced/mongodb-vector-search/src/main/java/io/backend/skeleton/mongodb/advanced/vector/MongoVectorQuery.java' 'modules/mongodb-advanced/mongodb-vector-search/src/main/java/io/backend/skeleton/mongodb/advanced/vector/MongoVectorSearchOperations.java' 'modules/mongodb-advanced/mongodb-vector-search/src/main/java/io/backend/skeleton/mongodb/advanced/vector/MongoVectorSearchBenchmarkGate.java' 'modules/mongodb-advanced/mongodb-vector-search/src/test/java/io/backend/skeleton/mongodb/advanced/vector/MongoEmbeddingTest.java' +git commit -m "feat: add mongodb vector search contract" +``` + +### Task 10: Shared Collection Multi-tenancy Guardrail 구현 + +**Files:** +- Create: `modules/mongodb-advanced/mongodb-tenancy-shared/src/main/java/io/backend/skeleton/mongodb/advanced/tenancy/shared/MongoTenantContext.java` +- Create: `modules/mongodb-advanced/mongodb-tenancy-shared/src/main/java/io/backend/skeleton/mongodb/advanced/tenancy/shared/TenantScopedMongoOperations.java` +- Create: `modules/mongodb-advanced/mongodb-tenancy-shared/src/main/java/io/backend/skeleton/mongodb/advanced/tenancy/shared/MongoTenantPredicateInjector.java` +- Create: `modules/mongodb-advanced/mongodb-tenancy-shared/src/main/java/io/backend/skeleton/mongodb/advanced/tenancy/shared/MongoTenantManifestValidator.java` +- Test: `modules/mongodb-advanced/mongodb-tenancy-shared/src/test/java/io/backend/skeleton/mongodb/advanced/tenancy/shared/MongoTenantPredicateInjectorTest.java` + +**Interfaces:** +- Consumes: Stable query/aggregation/index/change-stream modules and security context. +- Produces: Fail-closed tenant predicate injection for find, update, delete, aggregation and change-stream projection. + +**Implementation requirements:** +- Missing tenant context rejects all tenant-scoped operations. +- Inject tenant predicate into find/update/delete and require a bounded first-stage match for aggregation. +- Validate tenant participation in unique indexes when uniqueness is tenant-scoped. +- Do not assume tenantId is always the correct shard key; use shard-key analysis. +- Never expose raw tenant IDs in metrics or general logs. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoTenantPredicateInjectorTest { + @org.junit.jupiter.api.Test + void missingTenantContextFailsClosed() { + MongoTenantPredicateInjector injector = new MongoTenantPredicateInjector(); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + injector.apply(java.util.Optional.empty(), AtomicFilter.id("o-1"))) + .isInstanceOf(MongoOperationRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb-advanced:mongodb-tenancy-shared:test --tests 'io.backend.skeleton.mongodb.advanced.tenancy.shared.MongoTenantPredicateInjectorTest' +``` + +Expected: FAIL because the advanced capability or its gate is not implemented yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record MongoTenantContext(String opaqueTenantKey) { + public MongoTenantContext { + if (opaqueTenantKey == null || opaqueTenantKey.isBlank()) { + throw new IllegalArgumentException("tenant context required"); + } + } +} +``` + +Implement every invariant listed under **Implementation requirements**. The snippet fixes public names and the central safety contract. + +- [ ] **Step 4: Run the focused test and module suite** + +Run: + +```bash +./gradlew :modules:mongodb-advanced:mongodb-tenancy-shared:test --tests 'io.backend.skeleton.mongodb.advanced.tenancy.shared.MongoTenantPredicateInjectorTest' +./gradlew :modules:mongodb-advanced:mongodb-tenancy-shared:test +``` + +Expected: PASS with the focused assertion and module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb-advanced/mongodb-tenancy-shared/src/main/java/io/backend/skeleton/mongodb/advanced/tenancy/shared/MongoTenantContext.java' 'modules/mongodb-advanced/mongodb-tenancy-shared/src/main/java/io/backend/skeleton/mongodb/advanced/tenancy/shared/TenantScopedMongoOperations.java' 'modules/mongodb-advanced/mongodb-tenancy-shared/src/main/java/io/backend/skeleton/mongodb/advanced/tenancy/shared/MongoTenantPredicateInjector.java' 'modules/mongodb-advanced/mongodb-tenancy-shared/src/main/java/io/backend/skeleton/mongodb/advanced/tenancy/shared/MongoTenantManifestValidator.java' 'modules/mongodb-advanced/mongodb-tenancy-shared/src/test/java/io/backend/skeleton/mongodb/advanced/tenancy/shared/MongoTenantPredicateInjectorTest.java' +git commit -m "feat: add shared collection mongodb tenancy guardrails" +``` + +### Task 11: Database-per-tenant Routing·Migration·Client Lifecycle 구현 + +**Files:** +- Create: `modules/mongodb-advanced/mongodb-tenancy-database/src/main/java/io/backend/skeleton/mongodb/advanced/tenancy/database/MongoTenantDatabaseResolver.java` +- Create: `modules/mongodb-advanced/mongodb-tenancy-database/src/main/java/io/backend/skeleton/mongodb/advanced/tenancy/database/MongoTenantClientRegistry.java` +- Create: `modules/mongodb-advanced/mongodb-tenancy-database/src/main/java/io/backend/skeleton/mongodb/advanced/tenancy/database/MongoTenantMigrationCoordinator.java` +- Create: `modules/mongodb-advanced/mongodb-tenancy-database/src/main/java/io/backend/skeleton/mongodb/advanced/tenancy/database/MongoTenantLifecyclePolicy.java` +- Test: `modules/mongodb-advanced/mongodb-tenancy-database/src/test/java/io/backend/skeleton/mongodb/advanced/tenancy/database/MongoTenantClientRegistryTest.java` + +**Interfaces:** +- Consumes: Stable profile/client generation, migration core and security modules. +- Produces: Bounded tenant database routing, client caching, migration fan-out and offboarding lifecycle. + +**Implementation requirements:** +- Tenant-to-database mapping comes from a trusted registry, never request input. +- Bound cached clients and close idle generations. +- Migration fan-out uses concurrency and rate limits with per-tenant checkpoints. +- A tenant database cannot become active before schema/index validation succeeds. +- Offboarding requires retention, export and delete evidence. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoTenantClientRegistryTest { + @org.junit.jupiter.api.Test + void registryEnforcesMaximumActiveClients() { + MongoTenantClientRegistry registry = new MongoTenantClientRegistry(2); + registry.acquire("t1"); + registry.acquire("t2"); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> registry.acquire("t3")) + .isInstanceOf(MongoOperationRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb-advanced:mongodb-tenancy-database:test --tests 'io.backend.skeleton.mongodb.advanced.tenancy.database.MongoTenantClientRegistryTest' +``` + +Expected: FAIL because the advanced capability or its gate is not implemented yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public interface MongoTenantDatabaseResolver { + DatabaseProfileName resolve(MongoTenantContext tenant); +} +``` + +Implement every invariant listed under **Implementation requirements**. The snippet fixes public names and the central safety contract. + +- [ ] **Step 4: Run the focused test and module suite** + +Run: + +```bash +./gradlew :modules:mongodb-advanced:mongodb-tenancy-database:test --tests 'io.backend.skeleton.mongodb.advanced.tenancy.database.MongoTenantClientRegistryTest' +./gradlew :modules:mongodb-advanced:mongodb-tenancy-database:test +``` + +Expected: PASS with the focused assertion and module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb-advanced/mongodb-tenancy-database/src/main/java/io/backend/skeleton/mongodb/advanced/tenancy/database/MongoTenantDatabaseResolver.java' 'modules/mongodb-advanced/mongodb-tenancy-database/src/main/java/io/backend/skeleton/mongodb/advanced/tenancy/database/MongoTenantClientRegistry.java' 'modules/mongodb-advanced/mongodb-tenancy-database/src/main/java/io/backend/skeleton/mongodb/advanced/tenancy/database/MongoTenantMigrationCoordinator.java' 'modules/mongodb-advanced/mongodb-tenancy-database/src/main/java/io/backend/skeleton/mongodb/advanced/tenancy/database/MongoTenantLifecyclePolicy.java' 'modules/mongodb-advanced/mongodb-tenancy-database/src/test/java/io/backend/skeleton/mongodb/advanced/tenancy/database/MongoTenantClientRegistryTest.java' +git commit -m "feat: add database per tenant mongodb lifecycle" +``` + +### Task 12: Change Stream → Messaging 안정 Bridge 구현 + +**Files:** +- Create: `modules/mongodb-advanced/mongodb-change-stream-messaging-bridge/src/main/java/io/backend/skeleton/mongodb/advanced/bridge/MongoChangeToIntegrationEventMapper.java` +- Create: `modules/mongodb-advanced/mongodb-change-stream-messaging-bridge/src/main/java/io/backend/skeleton/mongodb/advanced/bridge/MongoChangeMessagingBridge.java` +- Create: `modules/mongodb-advanced/mongodb-change-stream-messaging-bridge/src/main/java/io/backend/skeleton/mongodb/advanced/bridge/MongoBridgeOutboxPolicy.java` +- Create: `modules/mongodb-advanced/mongodb-change-stream-messaging-bridge/src/main/java/io/backend/skeleton/mongodb/advanced/bridge/MongoBridgeCheckpointPolicy.java` +- Test: `modules/mongodb-advanced/mongodb-change-stream-messaging-bridge/src/test/java/io/backend/skeleton/mongodb/advanced/bridge/MongoChangeMessagingBridgeTest.java` + +**Interfaces:** +- Consumes: Stable Change Stream projector and the existing Messaging Platform typed publisher. +- Produces: Mapping of physical changes to versioned integration events with idempotent publish/checkpoint semantics. + +**Implementation requirements:** +- Never publish raw MongoDB change events as external contracts. +- Integration event type, schema version and message ID are mapper-owned stable values. +- Publish and checkpoint ambiguity must preserve duplicate-safe message identity. +- Document where Change Stream bridge is insufficient and Transactional Outbox is required. +- Messaging failures must not silently advance the MongoDB checkpoint. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoChangeMessagingBridgeTest { + @org.junit.jupiter.api.Test + void failedPublishDoesNotAdvanceMongoCheckpoint() { + MongoBridgeProbe probe = MongoBridgeProbe.publishFails(); + probe.bridge().handle(probe.change()).block(); + org.assertj.core.api.Assertions.assertThat(probe.checkpointWrites()).isZero(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb-advanced:mongodb-change-stream-messaging-bridge:test --tests 'io.backend.skeleton.mongodb.advanced.bridge.MongoChangeMessagingBridgeTest' +``` + +Expected: FAIL because the advanced capability or its gate is not implemented yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public interface MongoChangeToIntegrationEventMapper { + MessagingEnvelope map(MongoChangeEventIdentity identity, org.bson.BsonDocument change); +} +``` + +Implement every invariant listed under **Implementation requirements**. The snippet fixes public names and the central safety contract. + +- [ ] **Step 4: Run the focused test and module suite** + +Run: + +```bash +./gradlew :modules:mongodb-advanced:mongodb-change-stream-messaging-bridge:test --tests 'io.backend.skeleton.mongodb.advanced.bridge.MongoChangeMessagingBridgeTest' +./gradlew :modules:mongodb-advanced:mongodb-change-stream-messaging-bridge:test +``` + +Expected: PASS with the focused assertion and module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb-advanced/mongodb-change-stream-messaging-bridge/src/main/java/io/backend/skeleton/mongodb/advanced/bridge/MongoChangeToIntegrationEventMapper.java' 'modules/mongodb-advanced/mongodb-change-stream-messaging-bridge/src/main/java/io/backend/skeleton/mongodb/advanced/bridge/MongoChangeMessagingBridge.java' 'modules/mongodb-advanced/mongodb-change-stream-messaging-bridge/src/main/java/io/backend/skeleton/mongodb/advanced/bridge/MongoBridgeOutboxPolicy.java' 'modules/mongodb-advanced/mongodb-change-stream-messaging-bridge/src/main/java/io/backend/skeleton/mongodb/advanced/bridge/MongoBridgeCheckpointPolicy.java' 'modules/mongodb-advanced/mongodb-change-stream-messaging-bridge/src/test/java/io/backend/skeleton/mongodb/advanced/bridge/MongoChangeMessagingBridgeTest.java' +git commit -m "feat: add mongodb change stream messaging bridge" +``` + +### Task 13: GridFS Legacy Compatibility와 Object Storage Migration Adapter 구현 + +**Files:** +- Create: `modules/mongodb-advanced/mongodb-gridfs-compat/src/main/java/io/backend/skeleton/mongodb/advanced/gridfs/MongoGridFsCompatibilityReader.java` +- Create: `modules/mongodb-advanced/mongodb-gridfs-compat/src/main/java/io/backend/skeleton/mongodb/advanced/gridfs/MongoGridFsMigrationJob.java` +- Create: `modules/mongodb-advanced/mongodb-gridfs-compat/src/main/java/io/backend/skeleton/mongodb/advanced/gridfs/MongoGridFsMigrationCheckpoint.java` +- Create: `modules/mongodb-advanced/mongodb-gridfs-compat/src/main/java/io/backend/skeleton/mongodb/advanced/gridfs/MongoGridFsObjectReference.java` +- Test: `modules/mongodb-advanced/mongodb-gridfs-compat/src/test/java/io/backend/skeleton/mongodb/advanced/gridfs/MongoGridFsMigrationJobTest.java` + +**Interfaces:** +- Consumes: Fileserver/Object Storage ContentStore contract and migration core. +- Produces: Read-only legacy GridFS compatibility plus resumable migration to the single file source of truth. + +**Implementation requirements:** +- Do not expose new upload APIs backed by GridFS. +- Read GridFS metadata and bytes without creating a second domain file lifecycle. +- Migrate bytes to ContentStore, verify size/checksum, then write the new FileId/ContentKey reference. +- Checkpoint migration and preserve retry-safe deterministic identities. +- Delete legacy GridFS content only through a separate audited cleanup phase. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoGridFsMigrationJobTest { + @org.junit.jupiter.api.Test + void sourceIsRetainedUntilTargetChecksumIsVerified() { + MongoGridFsMigrationProbe probe = MongoGridFsMigrationProbe.checksumMismatch(); + probe.job().migrate(probe.sourceId()); + org.assertj.core.api.Assertions.assertThat(probe.sourceDeleted()).isFalse(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb-advanced:mongodb-gridfs-compat:test --tests 'io.backend.skeleton.mongodb.advanced.gridfs.MongoGridFsMigrationJobTest' +``` + +Expected: FAIL because the advanced capability or its gate is not implemented yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public interface MongoGridFsCompatibilityReader { + GridFsLegacyContent open(String legacyId); +} +``` + +Implement every invariant listed under **Implementation requirements**. The snippet fixes public names and the central safety contract. + +- [ ] **Step 4: Run the focused test and module suite** + +Run: + +```bash +./gradlew :modules:mongodb-advanced:mongodb-gridfs-compat:test --tests 'io.backend.skeleton.mongodb.advanced.gridfs.MongoGridFsMigrationJobTest' +./gradlew :modules:mongodb-advanced:mongodb-gridfs-compat:test +``` + +Expected: PASS with the focused assertion and module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb-advanced/mongodb-gridfs-compat/src/main/java/io/backend/skeleton/mongodb/advanced/gridfs/MongoGridFsCompatibilityReader.java' 'modules/mongodb-advanced/mongodb-gridfs-compat/src/main/java/io/backend/skeleton/mongodb/advanced/gridfs/MongoGridFsMigrationJob.java' 'modules/mongodb-advanced/mongodb-gridfs-compat/src/main/java/io/backend/skeleton/mongodb/advanced/gridfs/MongoGridFsMigrationCheckpoint.java' 'modules/mongodb-advanced/mongodb-gridfs-compat/src/main/java/io/backend/skeleton/mongodb/advanced/gridfs/MongoGridFsObjectReference.java' 'modules/mongodb-advanced/mongodb-gridfs-compat/src/test/java/io/backend/skeleton/mongodb/advanced/gridfs/MongoGridFsMigrationJobTest.java' +git commit -m "feat: add gridfs compatibility migration adapter" +``` + +### Task 14: Atlas Local·실제 Atlas Search·Vector·Encryption Testkit 구현 + +**Files:** +- Create: `modules/mongodb-advanced/mongodb-testkit-atlas/src/main/java/io/backend/skeleton/mongodb/advanced/testkit/atlas/MongoAtlasLocalContainer.java` +- Create: `modules/mongodb-advanced/mongodb-testkit-atlas/src/main/java/io/backend/skeleton/mongodb/advanced/testkit/atlas/MongoAtlasCapabilityContractSuite.java` +- Create: `modules/mongodb-advanced/mongodb-testkit-atlas/src/main/java/io/backend/skeleton/mongodb/advanced/testkit/atlas/MongoActualAtlasReleaseGate.java` +- Create: `modules/mongodb-advanced/mongodb-testkit-atlas/src/test/resources/atlas/search-index.json` +- Create: `modules/mongodb-advanced/mongodb-testkit-atlas/src/test/resources/atlas/vector-index.json` +- Test: `modules/mongodb-advanced/mongodb-testkit-atlas/src/test/java/io/backend/skeleton/mongodb/advanced/testkit/atlas/MongoAtlasCapabilityContractSuiteTest.java` + +**Interfaces:** +- Consumes: Search, vector, CSFLE/QE modules and Testcontainers Atlas Local. +- Produces: Fast local contracts plus credentialed actual-target release gates for readiness, query, KMS and rotation. + +**Implementation requirements:** +- Atlas Local is PR/nightly evidence, not the sole production compatibility proof. +- Actual target tests are opt-in secret-backed release jobs. +- Wait for Search/Vector index READY before queries. +- Test wrong KMS key, key-vault permission and credential rotation without plaintext diagnostics. +- Persist only bounded capability reports and never provider credentials. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoAtlasCapabilityContractSuiteTest { + @org.junit.jupiter.api.Test + void vectorQueriesWaitForReadyIndex() { + MongoAtlasCapabilityReport report = MongoAtlasCapabilityContractSuite.vectorReadiness(); + org.assertj.core.api.Assertions.assertThat(report.queriedBeforeReady()).isFalse(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb-advanced:mongodb-testkit-atlas:test --tests 'io.backend.skeleton.mongodb.advanced.testkit.atlas.MongoAtlasCapabilityContractSuiteTest' +``` + +Expected: FAIL because the advanced capability or its gate is not implemented yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public interface MongoAtlasCapabilityContractSuite { + static MongoAtlasCapabilityReport vectorReadiness() { + return new MongoAtlasCapabilityReport(false, true); + } +} +``` + +Implement every invariant listed under **Implementation requirements**. The snippet fixes public names and the central safety contract. + +- [ ] **Step 4: Run the focused test and module suite** + +Run: + +```bash +./gradlew :modules:mongodb-advanced:mongodb-testkit-atlas:test --tests 'io.backend.skeleton.mongodb.advanced.testkit.atlas.MongoAtlasCapabilityContractSuiteTest' +./gradlew :modules:mongodb-advanced:mongodb-testkit-atlas:test +``` + +Expected: PASS with the focused assertion and module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb-advanced/mongodb-testkit-atlas/src/main/java/io/backend/skeleton/mongodb/advanced/testkit/atlas/MongoAtlasLocalContainer.java' 'modules/mongodb-advanced/mongodb-testkit-atlas/src/main/java/io/backend/skeleton/mongodb/advanced/testkit/atlas/MongoAtlasCapabilityContractSuite.java' 'modules/mongodb-advanced/mongodb-testkit-atlas/src/main/java/io/backend/skeleton/mongodb/advanced/testkit/atlas/MongoActualAtlasReleaseGate.java' 'modules/mongodb-advanced/mongodb-testkit-atlas/src/test/resources/atlas/search-index.json' 'modules/mongodb-advanced/mongodb-testkit-atlas/src/test/resources/atlas/vector-index.json' 'modules/mongodb-advanced/mongodb-testkit-atlas/src/test/java/io/backend/skeleton/mongodb/advanced/testkit/atlas/MongoAtlasCapabilityContractSuiteTest.java' +git commit -m "test: add atlas local and actual target capability gates" +``` + +### Task 15: Advanced Capability 문서·승격 ADR·최종 Promotion Gate + +**Files:** +- Create: `docs/mongodb/advanced/sharding.md` +- Create: `docs/mongodb/advanced/time-series.md` +- Create: `docs/mongodb/advanced/encryption.md` +- Create: `docs/mongodb/advanced/search-vector.md` +- Create: `docs/mongodb/advanced/multi-tenancy.md` +- Create: `docs/mongodb/advanced/gridfs-migration.md` +- Create: `docs/adr/ADR-MONGO-ADV-001-capability-promotion.md` +- Create: `scripts/verify-mongodb-advanced.sh` +- Test: `modules/mongodb-advanced/mongodb-sharding/src/test/java/io/backend/skeleton/mongodb/advanced/MongoAdvancedPromotionGateTest.java` + +**Interfaces:** +- Consumes: Every previous Advanced task and Stable release evidence. +- Produces: Capability-specific support matrix, runbooks, promotion ADR and reproducible advanced verification command. + +**Implementation requirements:** +- Each capability documents topology, server version, privilege, unsupported combinations and failure recovery. +- Promotion requires actual topology/environment evidence, security review and migration/runbook coverage. +- A capability promoted to Stable still remains an opt-in module unless a later starter ADR changes the dependency boundary. +- Search/Vector promotion requires relevance and performance evidence, not only functional success. +- Database-per-tenant and reshard orchestration remain Experimental until operational scale evidence exists. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoAdvancedPromotionGateTest { + @org.junit.jupiter.api.Test + void everyPromotionRequiresActualEnvironmentEvidence() { + MongoAdvancedPromotionEvidence evidence = MongoAdvancedPromotionEvidence.fixture(); + org.assertj.core.api.Assertions.assertThat(evidence.requiredCategories()) + .contains("actual-topology", "security", "migration", "failure", "runbook"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +bash scripts/verify-mongodb-advanced.sh +``` + +Expected: FAIL because the advanced capability or its gate is not implemented yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class MongoAdvancedPromotionGate { + public void verify(MongoAdvancedPromotionEvidence evidence) { + evidence.require("stable-platform"); + evidence.require("actual-topology"); + evidence.require("security"); + evidence.require("failure"); + evidence.require("runbook"); + } +} +``` + +Implement every invariant listed under **Implementation requirements**. The snippet fixes public names and the central safety contract. + +- [ ] **Step 4: Run the focused test and module suite** + +Run: + +```bash +bash scripts/verify-mongodb-advanced.sh +./gradlew :modules:mongodb-advanced:mongodb-sharding:test +``` + +Expected: PASS with the focused assertion and module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'docs/mongodb/advanced/sharding.md' 'docs/mongodb/advanced/time-series.md' 'docs/mongodb/advanced/encryption.md' 'docs/mongodb/advanced/search-vector.md' 'docs/mongodb/advanced/multi-tenancy.md' 'docs/mongodb/advanced/gridfs-migration.md' 'docs/adr/ADR-MONGO-ADV-001-capability-promotion.md' 'scripts/verify-mongodb-advanced.sh' 'modules/mongodb-advanced/mongodb-sharding/src/test/java/io/backend/skeleton/mongodb/advanced/MongoAdvancedPromotionGateTest.java' +git commit -m "docs: add mongodb advanced promotion gate" +``` diff --git a/mongodb-superpowers-package/docs/superpowers/plans/2026-08-11-mongodb-document-persistence-platform-implementation-plan.md b/mongodb-superpowers-package/docs/superpowers/plans/2026-08-11-mongodb-document-persistence-platform-implementation-plan.md new file mode 100644 index 00000000..76f6399b --- /dev/null +++ b/mongodb-superpowers-package/docs/superpowers/plans/2026-08-11-mongodb-document-persistence-platform-implementation-plan.md @@ -0,0 +1,3943 @@ +# MongoDB 문서 영속성 플랫폼 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Java/Spring Backend Skeleton에 도메인 Document·Repository 소유권, 고정 BSON 표현, 단일 Document 원자 연산, Replica Set Transaction, 실행 결과·Retry 의미론, Query·Aggregation Guardrail, Schema·Index·Migration, Change Stream, 보안·관측성·실제 MongoDB Release Matrix를 갖춘 MongoDB 문서 영속성 플랫폼을 구현한다. + +**Architecture:** `mongodb-core-api`가 framework-free 의미론을 소유하고 Spring Data·imperative·reactive·transaction·query·aggregation·index-schema·change-stream 모듈이 이를 구현한다. 도메인은 Document와 Repository를 직접 소유하며 플랫폼은 범용 CRUD Repository를 만들지 않는다. D1/D2는 Stable API V1 strict client, D3는 승인된 Capability client, D4는 Runtime과 분리된 Admin client를 사용한다. + +**Tech Stack:** Java 21, Gradle Kotlin DSL, Spring Boot 4.1 dependency management, Spring Data MongoDB 5.1, Boot-managed MongoDB Java Driver, MongoDB 7.0·8.0, Reactor, Micrometer, Driver native ObservabilitySettings, JUnit 5, AssertJ, ArchUnit, Testcontainers, Toxiproxy, Flamingock adapter. + +## Global Constraints + +- Root package는 `io.backend.skeleton.mongodb`이다. +- Stable 모듈 루트는 `modules/mongodb`이다. +- Java 21과 Spring Boot 4.1 BOM을 사용하고 MongoDB Java Driver 버전을 모듈에서 직접 고정하지 않는다. +- MongoDB 8.0 최신 패치를 Primary Certification Lane, MongoDB 7.0을 Compatibility Lane으로 사용한다. +- Local 기본 토폴로지는 Single-node Replica Set이며 Standalone은 smoke test만 허용한다. +- 운영 Stable Gate는 실제 3-node Replica Set failover를 포함한다. +- 도메인 모듈이 `@Document`, Repository, Collection logical name, Query, Index Requirement, Schema Version, Embed/Reference, Shard Key 후보를 소유한다. +- `CommonMongoRepository` 또는 `GenericMongoRepository`를 만들지 않는다. +- D1/D2는 Stable API V1 strict client를 사용한다. +- D3 Capability client와 D4 Admin client는 별도 권한·설정·모듈이다. +- UUID는 STANDARD, BigDecimal은 Decimal128로 고정한다. +- `LocalDateTime`은 명시 Converter 없이는 저장하지 않는다. +- Java FQCN을 장수 Collection의 영구 type metadata로 사용하지 않는다. +- 부분 변경은 Update Operator를 우선하고 전체 교체는 revision predicate를 요구한다. +- 단일 Document 원자 연산을 Multi-document Transaction보다 우선한다. +- `TransientTransactionError`는 전체 본문을 새 Session에서 재실행한다. +- `UnknownTransactionCommitResult`에서는 업무 본문을 재실행하지 않는다. +- Query·Aggregation은 operation name, allowlist, maxTimeMS, result limit을 요구한다. +- Production runtime에서 auto-index creation, collMod, shard, repair, drop 작업을 허용하지 않는다. +- Change Stream은 at-least-once idempotent projector이며 raw event를 외부 Integration Event로 공개하지 않는다. +- TTL은 physical cleanup이고 정확한 Scheduler로 사용하지 않는다. +- GridFS는 compatibility/migration 전용이며 신규 파일 Source of Truth가 아니다. +- Document, raw query, PII, credential, resume token, shard key 값을 log·metric label에 기록하지 않는다. +- Advanced 기능은 별도 계획과 모듈에서 구현하고 Stable Starter에 자동 포함하지 않는다. +- 각 Task는 실패 테스트 → 실패 확인 → 최소 구현 → 통과 확인 → 커밋 순서로 수행한다. +- 각 Task는 독립적으로 검토 가능한 하나의 커밋으로 종료한다. + +--- + +## 1. 확정 파일 구조 + +```text +backend-skeleton/ +├── build-logic/src/main/kotlin/mongodb-library-conventions.gradle.kts +├── modules/mongodb/ +│ ├── mongodb-core-api/ +│ ├── mongodb-spring-data/ +│ ├── mongodb-imperative/ +│ ├── mongodb-reactive/ +│ ├── mongodb-query/ +│ ├── mongodb-aggregation/ +│ ├── mongodb-transaction/ +│ ├── mongodb-index-schema/ +│ ├── mongodb-change-stream/ +│ ├── mongodb-geospatial/ +│ ├── mongodb-migration-core/ +│ ├── mongodb-migration-flamingock/ +│ ├── mongodb-observability/ +│ ├── mongodb-security/ +│ ├── mongodb-spring-boot-starter/ +│ ├── mongodb-testkit-core/ +│ ├── mongodb-testkit-replicaset/ +│ ├── mongodb-testkit-failover/ +│ └── mongodb-testkit-migration/ +├── docs/mongodb/ +├── docs/adr/ +└── docs/superpowers/specs/2026-08-11-mongodb-document-persistence-platform-design.md +``` + +## 2. Stable module dependency map + +```text +mongodb-core-api + → no project dependency + +mongodb-spring-data + → mongodb-core-api + +mongodb-imperative / mongodb-reactive + → mongodb-core-api + → mongodb-spring-data + +mongodb-query + → mongodb-core-api + → mongodb-spring-data + +mongodb-aggregation + → mongodb-core-api + → mongodb-query + +mongodb-transaction + → mongodb-core-api + → mongodb-spring-data + +mongodb-index-schema + → mongodb-core-api + → mongodb-spring-data + +mongodb-change-stream + → mongodb-core-api + → mongodb-reactive + +mongodb-geospatial + → mongodb-core-api + → mongodb-spring-data + +mongodb-migration-core + → mongodb-core-api + → mongodb-index-schema + +mongodb-migration-flamingock + → mongodb-migration-core + +mongodb-observability / mongodb-security + → mongodb-core-api + +mongodb-spring-boot-starter + → every Stable runtime module + → no advanced module +``` + +--- +### Task 1: Gradle 멀티모듈과 MongoDB 품질 Test Suite 구성 + +**Files:** +- Create: `build-logic/src/main/kotlin/mongodb-library-conventions.gradle.kts` +- Create: `modules/mongodb/mongodb-core-api/build.gradle.kts` +- Create: `modules/mongodb/mongodb-spring-data/build.gradle.kts` +- Create: `modules/mongodb/mongodb-imperative/build.gradle.kts` +- Create: `modules/mongodb/mongodb-reactive/build.gradle.kts` +- Create: `modules/mongodb/mongodb-query/build.gradle.kts` +- Create: `modules/mongodb/mongodb-aggregation/build.gradle.kts` +- Create: `modules/mongodb/mongodb-transaction/build.gradle.kts` +- Create: `modules/mongodb/mongodb-index-schema/build.gradle.kts` +- Create: `modules/mongodb/mongodb-change-stream/build.gradle.kts` +- Create: `modules/mongodb/mongodb-geospatial/build.gradle.kts` +- Create: `modules/mongodb/mongodb-migration-core/build.gradle.kts` +- Create: `modules/mongodb/mongodb-migration-flamingock/build.gradle.kts` +- Create: `modules/mongodb/mongodb-observability/build.gradle.kts` +- Create: `modules/mongodb/mongodb-security/build.gradle.kts` +- Create: `modules/mongodb/mongodb-spring-boot-starter/build.gradle.kts` +- Create: `modules/mongodb/mongodb-testkit-core/build.gradle.kts` +- Create: `modules/mongodb/mongodb-testkit-replicaset/build.gradle.kts` +- Create: `modules/mongodb/mongodb-testkit-failover/build.gradle.kts` +- Create: `modules/mongodb/mongodb-testkit-migration/build.gradle.kts` +- Modify: `settings.gradle.kts` +- Test: `build-logic/src/test/java/MongoDbModuleBoundaryTest.java` + +**Interfaces:** +- Consumes: Host repository version catalog and Spring Boot 4.1 dependency management. +- Produces: 19 isolated Stable MongoDB modules and unit, contract, replicaSet, failover, migration, compatibility, performance suites. + +**Implementation requirements:** +- Apply Java 21 toolchains to all modules. +- Use Spring Boot BOM for Spring Data MongoDB and the Java Driver; do not pin the driver in module build files. +- Keep mongodb-core-api free of Spring, Driver, BSON and Reactor dependencies. +- Do not include advanced modules in the Stable dependency graph. +- Register release suites without making external-provider tests part of the default unit test task. + +- [ ] **Step 1: Write the failing test** + +```java +import static org.assertj.core.api.Assertions.assertThat; + +class MongoDbModuleBoundaryTest { + @org.junit.jupiter.api.Test + void stableModuleListContainsOnlyApprovedModules() { + java.util.Set modules = MongoDbBuildModel.stableModules(); + assertThat(modules).contains("mongodb-core-api", "mongodb-transaction"); + assertThat(modules).doesNotContain("mongodb-sharding", "mongodb-search"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew build-logic:test --tests 'MongoDbModuleBoundaryTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class MongoDbBuildModel { + private static final java.util.Set STABLE = java.util.Set.of( + "mongodb-core-api", "mongodb-spring-data", "mongodb-imperative", + "mongodb-reactive", "mongodb-query", "mongodb-aggregation", + "mongodb-transaction", "mongodb-index-schema", "mongodb-change-stream", + "mongodb-geospatial", "mongodb-migration-core", + "mongodb-migration-flamingock", "mongodb-observability", + "mongodb-security", "mongodb-spring-boot-starter", + "mongodb-testkit-core", "mongodb-testkit-replicaset", + "mongodb-testkit-failover", "mongodb-testkit-migration"); + public static java.util.Set stableModules() { return STABLE; } + private MongoDbBuildModel() {} +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew build-logic:test --tests 'MongoDbModuleBoundaryTest' +./gradlew :modules:mongodb:mongodb-core-api:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'build-logic/src/main/kotlin/mongodb-library-conventions.gradle.kts' 'modules/mongodb/mongodb-core-api/build.gradle.kts' 'modules/mongodb/mongodb-spring-data/build.gradle.kts' 'modules/mongodb/mongodb-imperative/build.gradle.kts' 'modules/mongodb/mongodb-reactive/build.gradle.kts' 'modules/mongodb/mongodb-query/build.gradle.kts' 'modules/mongodb/mongodb-aggregation/build.gradle.kts' 'modules/mongodb/mongodb-transaction/build.gradle.kts' 'modules/mongodb/mongodb-index-schema/build.gradle.kts' 'modules/mongodb/mongodb-change-stream/build.gradle.kts' 'modules/mongodb/mongodb-geospatial/build.gradle.kts' 'modules/mongodb/mongodb-migration-core/build.gradle.kts' 'modules/mongodb/mongodb-migration-flamingock/build.gradle.kts' 'modules/mongodb/mongodb-observability/build.gradle.kts' 'modules/mongodb/mongodb-security/build.gradle.kts' 'modules/mongodb/mongodb-spring-boot-starter/build.gradle.kts' 'modules/mongodb/mongodb-testkit-core/build.gradle.kts' 'modules/mongodb/mongodb-testkit-replicaset/build.gradle.kts' 'modules/mongodb/mongodb-testkit-failover/build.gradle.kts' 'modules/mongodb/mongodb-testkit-migration/build.gradle.kts' 'settings.gradle.kts' 'build-logic/src/test/java/MongoDbModuleBoundaryTest.java' +git commit -m "build: add mongodb platform modules and test suites" +``` + +### Task 2: Core Operation Name과 Profile 식별자 계약 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/MongoOperationName.java` +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/DatabaseProfileName.java` +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/CollectionProfileName.java` +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/MongoOperationContext.java` +- Test: `modules/mongodb/mongodb-core-api/src/test/java/io/backend/skeleton/mongodb/api/MongoOperationNameTest.java` + +**Interfaces:** +- Consumes: Java 21 standard library only. +- Produces: `MongoOperationName`, database/collection profile identifiers, and immutable `MongoOperationContext`. + +**Implementation requirements:** +- Operation names must match `[a-z][a-z0-9.-]{2,95}`. +- Profile names must be registered low-cardinality identifiers and reject slashes, spaces and UUID-like dynamic values. +- Operation context must require a non-null consistency profile and positive timeout. +- No actual database, collection, tenant or document identifiers may be stored in these value objects. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoOperationNameTest { + @org.junit.jupiter.api.Test + void rejectsDynamicIdentifier() { + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> new MongoOperationName("order/" + java.util.UUID.randomUUID())) + .isInstanceOf(IllegalArgumentException.class); + } + + @org.junit.jupiter.api.Test + void acceptsBoundedOperationName() { + org.assertj.core.api.Assertions.assertThat( + new MongoOperationName("order.find-recent").value()) + .isEqualTo("order.find-recent"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-core-api:test --tests 'io.backend.skeleton.mongodb.api.MongoOperationNameTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record MongoOperationName(String value) { + public MongoOperationName { + if (value == null || !value.matches("[a-z][a-z0-9.-]{2,95}")) { + throw new IllegalArgumentException("invalid MongoDB operation name"); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-core-api:test --tests 'io.backend.skeleton.mongodb.api.MongoOperationNameTest' +./gradlew :modules:mongodb:mongodb-core-api:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/MongoOperationName.java' 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/DatabaseProfileName.java' 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/CollectionProfileName.java' 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/MongoOperationContext.java' 'modules/mongodb/mongodb-core-api/src/test/java/io/backend/skeleton/mongodb/api/MongoOperationNameTest.java' +git commit -m "feat: add mongodb operation and profile identifiers" +``` + +### Task 3: Capability와 지원 등급 모델 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/capability/MongoSupportLevel.java` +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/capability/MongoCapability.java` +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/capability/MongoCapabilitySupport.java` +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/capability/MongoCapabilitySet.java` +- Test: `modules/mongodb/mongodb-core-api/src/test/java/io/backend/skeleton/mongodb/api/capability/MongoCapabilitySetTest.java` + +**Interfaces:** +- Consumes: Core identifiers from Task 2. +- Produces: Stable/Advanced/Experimental capability metadata with immutable constraints. + +**Implementation requirements:** +- Define capabilities for transaction, change stream, geospatial, sharding, time series, CSFLE, QE, search, vector and admin. +- Capability constraints must be immutable strings and must not carry driver or provider objects. +- Unsupported capabilities must return an explicit reason rather than a false boolean. +- Expose topology, server-version and privilege constraints separately. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoCapabilitySetTest { + @org.junit.jupiter.api.Test + void unsupportedCapabilityPreservesReason() { + MongoCapabilitySet set = MongoCapabilitySet.of( + new MongoCapabilitySupport(MongoCapability.TIME_SERIES, + MongoSupportLevel.UNSUPPORTED, + java.util.Map.of("reason", "profile-disabled"))); + org.assertj.core.api.Assertions.assertThat( + set.require(MongoCapability.TIME_SERIES).constraints()) + .containsEntry("reason", "profile-disabled"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-core-api:test --tests 'io.backend.skeleton.mongodb.api.capability.MongoCapabilitySetTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record MongoCapabilitySupport( + MongoCapability capability, + MongoSupportLevel level, + java.util.Map constraints) { + public MongoCapabilitySupport { + constraints = java.util.Map.copyOf(constraints); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-core-api:test --tests 'io.backend.skeleton.mongodb.api.capability.MongoCapabilitySetTest' +./gradlew :modules:mongodb:mongodb-core-api:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/capability/MongoSupportLevel.java' 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/capability/MongoCapability.java' 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/capability/MongoCapabilitySupport.java' 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/capability/MongoCapabilitySet.java' 'modules/mongodb/mongodb-core-api/src/test/java/io/backend/skeleton/mongodb/api/capability/MongoCapabilitySetTest.java' +git commit -m "feat: add mongodb capability support model" +``` + +### Task 4: Topology Profile과 Stable API 계약 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/profile/MongoTopology.java` +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/profile/MongoStableApiProfile.java` +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/profile/MongoRuntimeProfile.java` +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/profile/MongoTopologyRequirement.java` +- Test: `modules/mongodb/mongodb-core-api/src/test/java/io/backend/skeleton/mongodb/api/profile/MongoRuntimeProfileTest.java` + +**Interfaces:** +- Consumes: Core profile identifiers and capability support. +- Produces: Explicit Standalone/Replica Set/Sharded/Atlas profiles and D1/D2 Stable API V1 strict policy. + +**Implementation requirements:** +- Production profiles must reject Standalone. +- D1/D2 profiles default to Stable API V1 with strict mode and deprecation errors enabled. +- Transaction and Change Stream requirements must imply Replica Set or Sharded topology. +- Admin and capability profiles must be distinct from runtime strict profiles. +- Topology mismatches are startup failures, not warning logs. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoRuntimeProfileTest { + @org.junit.jupiter.api.Test + void productionRejectsStandalone() { + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + MongoRuntimeProfile.production(MongoTopology.STANDALONE)) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-core-api:test --tests 'io.backend.skeleton.mongodb.api.profile.MongoRuntimeProfileTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum MongoTopology { STANDALONE, REPLICA_SET, SHARDED, ATLAS } + +public record MongoStableApiProfile(String version, boolean strict, + boolean deprecationErrors) { + public static MongoStableApiProfile v1Strict() { + return new MongoStableApiProfile("1", true, true); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-core-api:test --tests 'io.backend.skeleton.mongodb.api.profile.MongoRuntimeProfileTest' +./gradlew :modules:mongodb:mongodb-core-api:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/profile/MongoTopology.java' 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/profile/MongoStableApiProfile.java' 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/profile/MongoRuntimeProfile.java' 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/profile/MongoTopologyRequirement.java' 'modules/mongodb/mongodb-core-api/src/test/java/io/backend/skeleton/mongodb/api/profile/MongoRuntimeProfileTest.java' +git commit -m "feat: add mongodb topology and stable api profiles" +``` + +### Task 5: 실행 결과와 안정 오류 계층 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/error/MongoExecutionOutcome.java` +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/error/MongoFailureContext.java` +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/error/MongoPersistenceException.java` +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/error/MongoTransactionCommitUnknownException.java` +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/error/MongoBulkPartialFailureException.java` +- Test: `modules/mongodb/mongodb-core-api/src/test/java/io/backend/skeleton/mongodb/api/error/MongoFailureContextTest.java` + +**Interfaces:** +- Consumes: Operation and profile value objects. +- Produces: Provider-stable error hierarchy and `MongoExecutionOutcome` preserving ambiguous and partial results. + +**Implementation requirements:** +- Include NOT_SENT, NO_WRITE_PERFORMED, WRITE_CONFIRMED, PARTIAL_BULK_WRITE, WRITE_RESULT_UNKNOWN and TRANSACTION_COMMIT_UNKNOWN. +- Failure context must preserve operation/profile/category/error labels/server code/attempt/elapsed/trace ID. +- Failure context must never store raw BSON, document ID, tenant ID, resume token, credential or plaintext PII. +- Commit unknown and bulk partial failure must be first-class exception types. +- Public exceptions must not expose raw driver exceptions through constructors or accessors. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoFailureContextTest { + @org.junit.jupiter.api.Test + void commitUnknownIsAmbiguousAndNotRetryableByDefault() { + MongoFailureContext context = MongoFailureContext.commitUnknown( + new MongoOperationName("order.reserve"), "251", java.time.Duration.ofMillis(40)); + org.assertj.core.api.Assertions.assertThat(context.outcome()) + .isEqualTo(MongoExecutionOutcome.TRANSACTION_COMMIT_UNKNOWN); + org.assertj.core.api.Assertions.assertThat(context.retryable()).isFalse(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-core-api:test --tests 'io.backend.skeleton.mongodb.api.error.MongoFailureContextTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum MongoExecutionOutcome { + NOT_SENT, NO_WRITE_PERFORMED, WRITE_CONFIRMED, + PARTIAL_BULK_WRITE, WRITE_RESULT_UNKNOWN, + TRANSACTION_COMMIT_UNKNOWN +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-core-api:test --tests 'io.backend.skeleton.mongodb.api.error.MongoFailureContextTest' +./gradlew :modules:mongodb:mongodb-core-api:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/error/MongoExecutionOutcome.java' 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/error/MongoFailureContext.java' 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/error/MongoPersistenceException.java' 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/error/MongoTransactionCommitUnknownException.java' 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/error/MongoBulkPartialFailureException.java' 'modules/mongodb/mongodb-core-api/src/test/java/io/backend/skeleton/mongodb/api/error/MongoFailureContextTest.java' +git commit -m "feat: add mongodb execution outcomes and stable errors" +``` + +### Task 6: Driver Error Label과 Server Code 분류기 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/failure/MongoFailureClassifier.java` +- Create: `modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/failure/DefaultMongoFailureClassifier.java` +- Create: `modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/failure/MongoDriverFailureView.java` +- Test: `modules/mongodb/mongodb-spring-data/src/test/java/io/backend/skeleton/mongodb/failure/DefaultMongoFailureClassifierTest.java` + +**Interfaces:** +- Consumes: Stable error hierarchy and Spring Data MongoDB exception translation. +- Produces: Classification of duplicate, validation, write concern, transient transaction, unknown commit, timeout and routing failures. + +**Implementation requirements:** +- Classify by error labels and numeric server codes before message text. +- `TransientTransactionError` must be retryable only at the whole-transaction boundary. +- `UnknownTransactionCommitResult` must map to commit-unknown and never request body retry. +- `NoWritesPerformed` must map to NO_WRITE_PERFORMED. +- Unknown codes must map to a stable parent category while preserving bounded metadata. + +- [ ] **Step 1: Write the failing test** + +```java +class DefaultMongoFailureClassifierTest { + @org.junit.jupiter.api.Test + void separatesTransactionBodyRetryFromCommitRetry() { + DefaultMongoFailureClassifier classifier = new DefaultMongoFailureClassifier(); + org.assertj.core.api.Assertions.assertThat( + classifier.classify(MongoDriverFailureView.withLabel("TransientTransactionError")).retryScope()) + .isEqualTo(MongoRetryScope.WHOLE_TRANSACTION); + org.assertj.core.api.Assertions.assertThat( + classifier.classify(MongoDriverFailureView.withLabel("UnknownTransactionCommitResult")).retryScope()) + .isEqualTo(MongoRetryScope.COMMIT_ONLY); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-spring-data:test --tests 'io.backend.skeleton.mongodb.failure.DefaultMongoFailureClassifierTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class DefaultMongoFailureClassifier implements MongoFailureClassifier { + public MongoFailureClassification classify(MongoDriverFailureView failure) { + if (failure.hasLabel("UnknownTransactionCommitResult")) { + return MongoFailureClassification.commitUnknown(); + } + if (failure.hasLabel("TransientTransactionError")) { + return MongoFailureClassification.transientTransaction(); + } + return MongoFailureClassification.nonRetryable(failure.serverCode()); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-spring-data:test --tests 'io.backend.skeleton.mongodb.failure.DefaultMongoFailureClassifierTest' +./gradlew :modules:mongodb:mongodb-spring-data:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/failure/MongoFailureClassifier.java' 'modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/failure/DefaultMongoFailureClassifier.java' 'modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/failure/MongoDriverFailureView.java' 'modules/mongodb/mongodb-spring-data/src/test/java/io/backend/skeleton/mongodb/failure/DefaultMongoFailureClassifierTest.java' +git commit -m "feat: classify mongodb driver failures by recovery semantics" +``` + +### Task 7: BSON 타입 표현 Manifest 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/mapping/MongoTypeRepresentationManifest.java` +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/mapping/MongoUuidRepresentation.java` +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/mapping/MongoDecimalRepresentation.java` +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/mapping/MongoTemporalRepresentation.java` +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/mapping/MongoTypeMetadataPolicy.java` +- Test: `modules/mongodb/mongodb-core-api/src/test/java/io/backend/skeleton/mongodb/api/mapping/MongoTypeRepresentationManifestTest.java` + +**Interfaces:** +- Consumes: Core API only. +- Produces: Immutable mapping contract for UUID, decimal, temporal, enum and type metadata representations. + +**Implementation requirements:** +- UUID must default to STANDARD and cannot be left unspecified. +- BigDecimal must default to DECIMAL128; BigInteger requires an explicit representation. +- LocalDateTime must be rejected unless a named converter is registered. +- Enum must use string representation. +- Long-lived collections must require alias or explicit documentType metadata. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoTypeRepresentationManifestTest { + @org.junit.jupiter.api.Test + void refusesUnspecifiedUuidRepresentation() { + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + new MongoTypeRepresentationManifest(null, + MongoDecimalRepresentation.DECIMAL128, + MongoTemporalRepresentation.INSTANT_AS_BSON_DATE, + MongoTypeMetadataPolicy.ALIAS_FOR_LONG_LIVED)) + .isInstanceOf(NullPointerException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-core-api:test --tests 'io.backend.skeleton.mongodb.api.mapping.MongoTypeRepresentationManifestTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record MongoTypeRepresentationManifest( + MongoUuidRepresentation uuid, + MongoDecimalRepresentation decimal, + MongoTemporalRepresentation temporal, + MongoTypeMetadataPolicy typeMetadata) { + public MongoTypeRepresentationManifest { + java.util.Objects.requireNonNull(uuid); + java.util.Objects.requireNonNull(decimal); + java.util.Objects.requireNonNull(temporal); + java.util.Objects.requireNonNull(typeMetadata); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-core-api:test --tests 'io.backend.skeleton.mongodb.api.mapping.MongoTypeRepresentationManifestTest' +./gradlew :modules:mongodb:mongodb-core-api:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/mapping/MongoTypeRepresentationManifest.java' 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/mapping/MongoUuidRepresentation.java' 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/mapping/MongoDecimalRepresentation.java' 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/mapping/MongoTemporalRepresentation.java' 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/mapping/MongoTypeMetadataPolicy.java' 'modules/mongodb/mongodb-core-api/src/test/java/io/backend/skeleton/mongodb/api/mapping/MongoTypeRepresentationManifestTest.java' +git commit -m "feat: define mongodb bson representation manifest" +``` + +### Task 8: MappingMongoConverter와 명시적 Converter 구성 + +**Files:** +- Create: `modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/mapping/MongoMappingConfiguration.java` +- Create: `modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/mapping/MongoCustomConversionsFactory.java` +- Create: `modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/mapping/LocalDateTimeMappingGuard.java` +- Create: `modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/mapping/DomainIdWriteConverter.java` +- Create: `modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/mapping/DomainIdReadConverter.java` +- Test: `modules/mongodb/mongodb-spring-data/src/test/java/io/backend/skeleton/mongodb/mapping/MongoMappingConfigurationTest.java` + +**Interfaces:** +- Consumes: BSON representation manifest and Spring Data MappingMongoConverter. +- Produces: A converter configuration that fixes UUID STANDARD, Decimal128 and explicit time/domain-ID mappings. + +**Implementation requirements:** +- Configure UUID representation explicitly through driver and converter settings. +- Store BigDecimal as Decimal128 and reject out-of-range values before driver invocation. +- Do not allow implicit system-default-time-zone LocalDateTime conversion. +- Prevent String IDs from being silently converted to ObjectId unless the collection manifest opts in. +- Register converters in deterministic order and expose a fingerprint for startup validation. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoMappingConfigurationTest { + @org.junit.jupiter.api.Test + void decimalIsWrittenAsDecimal128() { + org.bson.Document document = MongoMappingTestSupport.write( + new PriceDocument(new java.math.BigDecimal("12.30"))); + org.assertj.core.api.Assertions.assertThat(document.get("amount")) + .isInstanceOf(org.bson.types.Decimal128.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-spring-data:test --tests 'io.backend.skeleton.mongodb.mapping.MongoMappingConfigurationTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +@org.springframework.context.annotation.Configuration +public class MongoMappingConfiguration { + @org.springframework.context.annotation.Bean + org.springframework.data.mongodb.core.convert.MongoCustomConversions conversions() { + return MongoCustomConversionsFactory.standard(); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-spring-data:test --tests 'io.backend.skeleton.mongodb.mapping.MongoMappingConfigurationTest' +./gradlew :modules:mongodb:mongodb-spring-data:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/mapping/MongoMappingConfiguration.java' 'modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/mapping/MongoCustomConversionsFactory.java' 'modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/mapping/LocalDateTimeMappingGuard.java' 'modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/mapping/DomainIdWriteConverter.java' 'modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/mapping/DomainIdReadConverter.java' 'modules/mongodb/mongodb-spring-data/src/test/java/io/backend/skeleton/mongodb/mapping/MongoMappingConfigurationTest.java' +git commit -m "feat: configure deterministic mongodb object mapping" +``` + +### Task 9: Golden BSON Snapshot Testkit 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-testkit-core/src/main/java/io/backend/skeleton/mongodb/testkit/mapping/MongoBsonSnapshot.java` +- Create: `modules/mongodb/mongodb-testkit-core/src/main/java/io/backend/skeleton/mongodb/testkit/mapping/MongoBsonSnapshotAssert.java` +- Create: `modules/mongodb/mongodb-testkit-core/src/main/java/io/backend/skeleton/mongodb/testkit/mapping/MongoRoundTripContract.java` +- Create: `modules/mongodb/mongodb-testkit-core/src/test/resources/bson/representation-fixture.json` +- Test: `modules/mongodb/mongodb-testkit-core/src/test/java/io/backend/skeleton/mongodb/testkit/mapping/MongoBsonSnapshotAssertTest.java` + +**Interfaces:** +- Consumes: Mapping converter configuration from Task 8. +- Produces: Reusable Java→BSON→database→raw BSON→Java round-trip assertions. + +**Implementation requirements:** +- Canonicalize BSON documents without converting BSON types to JSON strings. +- Preserve missing, null, empty array, Binary UUID, Decimal128 and ObjectId distinctions. +- Snapshot files must include schema version and converter fingerprint. +- A changed representation must fail until an explicit migration and snapshot update are committed. +- Never include plaintext encrypted test fixtures in generated reports. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoBsonSnapshotAssertTest { + @org.junit.jupiter.api.Test + void distinguishesMissingFromNull() { + org.bson.Document missing = new org.bson.Document(); + org.bson.Document nullable = new org.bson.Document("value", null); + org.assertj.core.api.Assertions.assertThat( + MongoBsonSnapshot.of(missing).canonical()) + .isNotEqualTo(MongoBsonSnapshot.of(nullable).canonical()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-testkit-core:test --tests 'io.backend.skeleton.mongodb.testkit.mapping.MongoBsonSnapshotAssertTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record MongoBsonSnapshot(org.bson.BsonDocument canonical) { + public static MongoBsonSnapshot of(org.bson.Document value) { + return new MongoBsonSnapshot(value.toBsonDocument()); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-testkit-core:test --tests 'io.backend.skeleton.mongodb.testkit.mapping.MongoBsonSnapshotAssertTest' +./gradlew :modules:mongodb:mongodb-testkit-core:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-testkit-core/src/main/java/io/backend/skeleton/mongodb/testkit/mapping/MongoBsonSnapshot.java' 'modules/mongodb/mongodb-testkit-core/src/main/java/io/backend/skeleton/mongodb/testkit/mapping/MongoBsonSnapshotAssert.java' 'modules/mongodb/mongodb-testkit-core/src/main/java/io/backend/skeleton/mongodb/testkit/mapping/MongoRoundTripContract.java' 'modules/mongodb/mongodb-testkit-core/src/test/resources/bson/representation-fixture.json' 'modules/mongodb/mongodb-testkit-core/src/test/java/io/backend/skeleton/mongodb/testkit/mapping/MongoBsonSnapshotAssertTest.java' +git commit -m "test: add mongodb golden bson contract kit" +``` + +### Task 10: Type Metadata와 Alias 정책 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/mapping/type/MongoTypeMetadataRegistry.java` +- Create: `modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/mapping/type/MongoTypeMetadataDescriptor.java` +- Create: `modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/mapping/type/PolicyAwareMongoTypeMapper.java` +- Create: `modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/mapping/type/LongLivedMongoDocument.java` +- Test: `modules/mongodb/mongodb-spring-data/src/test/java/io/backend/skeleton/mongodb/mapping/type/PolicyAwareMongoTypeMapperTest.java` + +**Interfaces:** +- Consumes: Type metadata policy from Task 7 and MappingMongoConverter integration. +- Produces: Collection-specific `_class`, alias or explicit `documentType` mapping rules. + +**Implementation requirements:** +- Long-lived documents must have a stable alias or explicit documentType. +- Java FQCN must not be written for collections marked long-lived or externally shared. +- Nested polymorphic values must follow the collection policy. +- Unknown aliases must fail with a stable schema error instead of class loading fallback. +- The registry must detect duplicate aliases at startup. + +- [ ] **Step 1: Write the failing test** + +```java +class PolicyAwareMongoTypeMapperTest { + @org.junit.jupiter.api.Test + void longLivedDocumentNeverWritesJavaClassName() { + org.bson.Document bson = MongoTypeMappingTestSupport.write(new LongLivedOrder("o-1")); + org.assertj.core.api.Assertions.assertThat(bson.toJson()) + .doesNotContain("io.backend"); + org.assertj.core.api.Assertions.assertThat(bson.getString("documentType")) + .isEqualTo("order"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-spring-data:test --tests 'io.backend.skeleton.mongodb.mapping.type.PolicyAwareMongoTypeMapperTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record MongoTypeMetadataDescriptor( + String collectionProfile, + String stableAlias, + MongoTypeMetadataPolicy policy) { +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-spring-data:test --tests 'io.backend.skeleton.mongodb.mapping.type.PolicyAwareMongoTypeMapperTest' +./gradlew :modules:mongodb:mongodb-spring-data:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/mapping/type/MongoTypeMetadataRegistry.java' 'modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/mapping/type/MongoTypeMetadataDescriptor.java' 'modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/mapping/type/PolicyAwareMongoTypeMapper.java' 'modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/mapping/type/LongLivedMongoDocument.java' 'modules/mongodb/mongodb-spring-data/src/test/java/io/backend/skeleton/mongodb/mapping/type/PolicyAwareMongoTypeMapperTest.java' +git commit -m "feat: enforce stable mongodb type metadata" +``` + +### Task 11: Document Modeling Manifest와 Bounded Embedded Collection 검증 + +**Files:** +- Create: `modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/model/MongoDocumentModelManifest.java` +- Create: `modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/model/EmbeddedCollectionDescriptor.java` +- Create: `modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/model/MongoDocumentSizeBudget.java` +- Create: `modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/model/MongoDocumentModelValidator.java` +- Test: `modules/mongodb/mongodb-index-schema/src/test/java/io/backend/skeleton/mongodb/schema/model/MongoDocumentModelValidatorTest.java` + +**Interfaces:** +- Consumes: Collection profiles and BSON mapping testkit. +- Produces: Machine-readable document boundary, embedded growth and estimated-size validation. + +**Implementation requirements:** +- Every embedded collection must declare a maximum element count or a bounded bucket policy. +- Estimated maximum BSON size must be below the platform safety ceiling. +- Unbounded history, comments, events or attachments must be rejected from embedding. +- References must declare target collection and lifecycle ownership. +- Large binary fields must be rejected with a Fileserver/Object Storage reference recommendation. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoDocumentModelValidatorTest { + @org.junit.jupiter.api.Test + void rejectsUnboundedEmbeddedArray() { + MongoDocumentModelManifest manifest = MongoDocumentModelManifest.builder("posts") + .embedded("comments", EmbeddedCollectionDescriptor.unbounded()) + .build(); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + new MongoDocumentModelValidator().validate(manifest)) + .hasMessageContaining("comments"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-index-schema:test --tests 'io.backend.skeleton.mongodb.schema.model.MongoDocumentModelValidatorTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record EmbeddedCollectionDescriptor( + String field, + int maxElements, + int estimatedElementBytes) { + public static EmbeddedCollectionDescriptor bounded( + String field, int maxElements, int estimatedElementBytes) { + return new EmbeddedCollectionDescriptor(field, maxElements, estimatedElementBytes); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-index-schema:test --tests 'io.backend.skeleton.mongodb.schema.model.MongoDocumentModelValidatorTest' +./gradlew :modules:mongodb:mongodb-index-schema:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/model/MongoDocumentModelManifest.java' 'modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/model/EmbeddedCollectionDescriptor.java' 'modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/model/MongoDocumentSizeBudget.java' 'modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/model/MongoDocumentModelValidator.java' 'modules/mongodb/mongodb-index-schema/src/test/java/io/backend/skeleton/mongodb/schema/model/MongoDocumentModelValidatorTest.java' +git commit -m "feat: validate mongodb document growth boundaries" +``` + +### Task 12: Document Schema Version 계약 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/schema/DocumentSchemaVersion.java` +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/schema/MongoSchemaVersionRange.java` +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/schema/MongoSchemaVersionPolicy.java` +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/error/MongoDataSchemaUnsupportedException.java` +- Test: `modules/mongodb/mongodb-core-api/src/test/java/io/backend/skeleton/mongodb/api/schema/MongoSchemaVersionPolicyTest.java` + +**Interfaces:** +- Consumes: Core stable error hierarchy. +- Produces: Current/minimum supported schema versions and legacy V0 handling. + +**Implementation requirements:** +- Missing schemaVersion must map to Legacy V0 only when the collection policy enables legacy reads. +- New writes must always use the current schema version. +- Future and retired versions must fail before domain deserialization. +- Version comparisons must be integer-based and immutable. +- Read-time conversion must expose a metric and must not silently persist converted data. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoSchemaVersionPolicyTest { + @org.junit.jupiter.api.Test + void rejectsFutureVersion() { + MongoSchemaVersionPolicy policy = new MongoSchemaVersionPolicy( + new DocumentSchemaVersion(2), new DocumentSchemaVersion(4), true); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + policy.requireReadable(new DocumentSchemaVersion(5))) + .isInstanceOf(MongoDataSchemaUnsupportedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-core-api:test --tests 'io.backend.skeleton.mongodb.api.schema.MongoSchemaVersionPolicyTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record DocumentSchemaVersion(int value) { + public DocumentSchemaVersion { + if (value < 0) throw new IllegalArgumentException("negative schema version"); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-core-api:test --tests 'io.backend.skeleton.mongodb.api.schema.MongoSchemaVersionPolicyTest' +./gradlew :modules:mongodb:mongodb-core-api:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/schema/DocumentSchemaVersion.java' 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/schema/MongoSchemaVersionRange.java' 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/schema/MongoSchemaVersionPolicy.java' 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/error/MongoDataSchemaUnsupportedException.java' 'modules/mongodb/mongodb-core-api/src/test/java/io/backend/skeleton/mongodb/api/schema/MongoSchemaVersionPolicyTest.java' +git commit -m "feat: add mongodb document schema version contract" +``` + +### Task 13: 도메인 Repository 소유권과 범용 Repository 금지 규칙 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/architecture/MongoRepositoryArchitectureRules.java` +- Create: `modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/architecture/MongoCollectionProfile.java` +- Create: `modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/architecture/MongoOperation.java` +- Test: `modules/mongodb/mongodb-spring-data/src/test/java/io/backend/skeleton/mongodb/architecture/MongoRepositoryArchitectureRulesTest.java` + +**Interfaces:** +- Consumes: Spring Data repositories and core operation/profile identifiers. +- Produces: ArchUnit and annotation rules enforcing domain-owned repositories and registered collection/operation names. + +**Implementation requirements:** +- Reject interfaces named CommonMongoRepository, GenericMongoRepository or platform CRUD base repositories. +- Allow domain repositories to extend MongoRepository or ReactiveMongoRepository directly. +- Require custom repository implementations for MongoTemplate and native operations. +- Require registered collection profile names instead of dynamic collection strings. +- Prevent controllers from injecting MongoTemplate, MongoClient, MongoDatabase or raw MongoCollection. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoRepositoryArchitectureRulesTest { + @org.junit.jupiter.api.Test + void platformDoesNotDeclareGenericCrudRepository() { + MongoRepositoryArchitectureRules rules = new MongoRepositoryArchitectureRules(); + org.assertj.core.api.Assertions.assertThat(rules.forbiddenTypeNames()) + .contains("CommonMongoRepository", "GenericMongoRepository"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-spring-data:test --tests 'io.backend.skeleton.mongodb.architecture.MongoRepositoryArchitectureRulesTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class MongoRepositoryArchitectureRules { + public java.util.Set forbiddenTypeNames() { + return java.util.Set.of("CommonMongoRepository", "GenericMongoRepository"); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-spring-data:test --tests 'io.backend.skeleton.mongodb.architecture.MongoRepositoryArchitectureRulesTest' +./gradlew :modules:mongodb:mongodb-spring-data:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/architecture/MongoRepositoryArchitectureRules.java' 'modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/architecture/MongoCollectionProfile.java' 'modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/architecture/MongoOperation.java' 'modules/mongodb/mongodb-spring-data/src/test/java/io/backend/skeleton/mongodb/architecture/MongoRepositoryArchitectureRulesTest.java' +git commit -m "arch: enforce domain-owned mongodb repositories" +``` + +### Task 14: Typed Atomic Update Primitive 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/atomic/MongoAtomicOperations.java` +- Create: `modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/atomic/AtomicFilter.java` +- Create: `modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/atomic/AtomicUpdate.java` +- Create: `modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/atomic/AtomicUpdateResult.java` +- Create: `modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/atomic/MongoAtomicOperationsTemplate.java` +- Test: `modules/mongodb/mongodb-imperative/src/test/java/io/backend/skeleton/mongodb/imperative/atomic/MongoAtomicOperationsTemplateTest.java` + +**Interfaces:** +- Consumes: MongoOperationContext, registered field descriptors and MongoTemplate. +- Produces: Typed insert/update/upsert/find-and-modify operations using operator allowlists. + +**Implementation requirements:** +- Support set, unset, increment, min, max, currentDate, addToSet, pull and bounded push operators. +- Reject arbitrary field paths and operators not registered in the collection profile. +- Require operation context and result limits. +- Return matched, modified, upserted and optional returned document evidence. +- Use single-document operations before transaction helpers. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoAtomicOperationsTemplateTest { + @org.junit.jupiter.api.Test + void statusTransitionIncludesExpectedCurrentState() { + AtomicFilter filter = AtomicFilter.id("o-1").andEquals("status", "PENDING"); + AtomicUpdate update = AtomicUpdate.set("status", "PAID"); + org.assertj.core.api.Assertions.assertThat(filter.fields()) + .containsExactlyInAnyOrder("_id", "status"); + org.assertj.core.api.Assertions.assertThat(update.operators()).containsExactly("$set"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-imperative:test --tests 'io.backend.skeleton.mongodb.imperative.atomic.MongoAtomicOperationsTemplateTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public interface MongoAtomicOperations { + AtomicUpdateResult updateOne( + MongoOperationContext context, + Class documentType, + AtomicFilter filter, + AtomicUpdate update, + ReturnDocumentMode returnMode); +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-imperative:test --tests 'io.backend.skeleton.mongodb.imperative.atomic.MongoAtomicOperationsTemplateTest' +./gradlew :modules:mongodb:mongodb-imperative:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/atomic/MongoAtomicOperations.java' 'modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/atomic/AtomicFilter.java' 'modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/atomic/AtomicUpdate.java' 'modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/atomic/AtomicUpdateResult.java' 'modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/atomic/MongoAtomicOperationsTemplate.java' 'modules/mongodb/mongodb-imperative/src/test/java/io/backend/skeleton/mongodb/imperative/atomic/MongoAtomicOperationsTemplateTest.java' +git commit -m "feat: add typed mongodb atomic operations" +``` + +### Task 15: Optimistic Revision과 Versioned Custom Update 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/revision/MongoRevision.java` +- Create: `modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/revision/VersionedUpdateCommand.java` +- Create: `modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/revision/VersionedMongoUpdater.java` +- Create: `modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/revision/MongoOptimisticConflictTranslator.java` +- Test: `modules/mongodb/mongodb-imperative/src/test/java/io/backend/skeleton/mongodb/imperative/revision/VersionedMongoUpdaterTest.java` + +**Interfaces:** +- Consumes: Typed atomic operations and stable optimistic conflict exception. +- Produces: Expected-version predicates for full replacement and custom partial updates. + +**Implementation requirements:** +- Every versioned custom update must include `_id` and expected version in its filter. +- Successful updates must increment version exactly once. +- No-match with an existing document must map to optimistic conflict; missing document maps separately. +- Retry helpers must reload and recompute the whole use case rather than reuse the stale object. +- Bulk updates must not claim automatic `@Version` protection. + +- [ ] **Step 1: Write the failing test** + +```java +class VersionedMongoUpdaterTest { + @org.junit.jupiter.api.Test + void createsExpectedVersionPredicateAndIncrement() { + VersionedUpdateCommand command = VersionedUpdateCommand.of( + "o-1", new MongoRevision(7), AtomicUpdate.set("status", "PAID")); + org.assertj.core.api.Assertions.assertThat(command.filter().value("version")).isEqualTo(7L); + org.assertj.core.api.Assertions.assertThat(command.update().increment("version")).isEqualTo(1L); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-imperative:test --tests 'io.backend.skeleton.mongodb.imperative.revision.VersionedMongoUpdaterTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record MongoRevision(long value) { + public MongoRevision { + if (value < 0) throw new IllegalArgumentException("negative revision"); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-imperative:test --tests 'io.backend.skeleton.mongodb.imperative.revision.VersionedMongoUpdaterTest' +./gradlew :modules:mongodb:mongodb-imperative:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/revision/MongoRevision.java' 'modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/revision/VersionedUpdateCommand.java' 'modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/revision/VersionedMongoUpdater.java' 'modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/revision/MongoOptimisticConflictTranslator.java' 'modules/mongodb/mongodb-imperative/src/test/java/io/backend/skeleton/mongodb/imperative/revision/VersionedMongoUpdaterTest.java' +git commit -m "feat: add mongodb optimistic revision updates" +``` + +### Task 16: Dynamic Query Field·Operator·Sort Allowlist 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-query/src/main/java/io/backend/skeleton/mongodb/query/MongoQueryPolicy.java` +- Create: `modules/mongodb/mongodb-query/src/main/java/io/backend/skeleton/mongodb/query/MongoFieldDescriptor.java` +- Create: `modules/mongodb/mongodb-query/src/main/java/io/backend/skeleton/mongodb/query/MongoOperator.java` +- Create: `modules/mongodb/mongodb-query/src/main/java/io/backend/skeleton/mongodb/query/MongoSortDescriptor.java` +- Create: `modules/mongodb/mongodb-query/src/main/java/io/backend/skeleton/mongodb/query/PolicyAwareMongoQueryBuilder.java` +- Test: `modules/mongodb/mongodb-query/src/test/java/io/backend/skeleton/mongodb/query/PolicyAwareMongoQueryBuilderTest.java` + +**Interfaces:** +- Consumes: Collection profiles and MongoOperationContext. +- Produces: Typed Criteria builder that rejects unregistered paths, operators, sort fields and dynamic collections. + +**Implementation requirements:** +- Support bounded equality, range, inclusion and existence operators. +- Regex must have an explicit policy with maximum length, flags and timeout. +- Sort and projection fields must be registered descriptors. +- Reject user-supplied raw BSON and JSON parsing paths. +- Require a hard result limit and maxTimeMS through the operation budget. + +- [ ] **Step 1: Write the failing test** + +```java +class PolicyAwareMongoQueryBuilderTest { + @org.junit.jupiter.api.Test + void rejectsUnregisteredSortField() { + MongoQueryPolicy policy = MongoQueryPolicy.allowingFields("status", "createdAt", "_id"); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + new PolicyAwareMongoQueryBuilder(policy).sortBy("userSuppliedField")) + .isInstanceOf(MongoOperationRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-query:test --tests 'io.backend.skeleton.mongodb.query.PolicyAwareMongoQueryBuilderTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record MongoFieldDescriptor(String path, java.util.Set operators) { + public MongoFieldDescriptor { + operators = java.util.Set.copyOf(operators); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-query:test --tests 'io.backend.skeleton.mongodb.query.PolicyAwareMongoQueryBuilderTest' +./gradlew :modules:mongodb:mongodb-query:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-query/src/main/java/io/backend/skeleton/mongodb/query/MongoQueryPolicy.java' 'modules/mongodb/mongodb-query/src/main/java/io/backend/skeleton/mongodb/query/MongoFieldDescriptor.java' 'modules/mongodb/mongodb-query/src/main/java/io/backend/skeleton/mongodb/query/MongoOperator.java' 'modules/mongodb/mongodb-query/src/main/java/io/backend/skeleton/mongodb/query/MongoSortDescriptor.java' 'modules/mongodb/mongodb-query/src/main/java/io/backend/skeleton/mongodb/query/PolicyAwareMongoQueryBuilder.java' 'modules/mongodb/mongodb-query/src/test/java/io/backend/skeleton/mongodb/query/PolicyAwareMongoQueryBuilderTest.java' +git commit -m "feat: add mongodb query guardrails" +``` + +### Task 17: Operation Budget와 maxTimeMS·Result Limit 강제 + +**Files:** +- Create: `modules/mongodb/mongodb-query/src/main/java/io/backend/skeleton/mongodb/query/budget/MongoOperationBudget.java` +- Create: `modules/mongodb/mongodb-query/src/main/java/io/backend/skeleton/mongodb/query/budget/MongoBudgetPolicyRegistry.java` +- Create: `modules/mongodb/mongodb-query/src/main/java/io/backend/skeleton/mongodb/query/budget/MongoBudgetEnforcer.java` +- Test: `modules/mongodb/mongodb-query/src/test/java/io/backend/skeleton/mongodb/query/budget/MongoBudgetEnforcerTest.java` + +**Interfaces:** +- Consumes: Operation names, query policy and timeout profiles. +- Produces: Bounded result count, document bytes, pipeline stages, maxTimeMS and cursor batch sizes. + +**Implementation requirements:** +- Every dynamic query and aggregation must resolve a named budget. +- Callers may reduce but never increase the registered budget. +- Budget must cap result count, estimated result bytes, maxTimeMS and batch size. +- Deep skip beyond the policy threshold must be rejected in favor of keyset pagination. +- Budget rejections are non-retryable local failures. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoBudgetEnforcerTest { + @org.junit.jupiter.api.Test + void callerCannotRaiseRegisteredResultLimit() { + MongoOperationBudget registered = new MongoOperationBudget(100, 1_048_576, 500, 50); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + new MongoBudgetEnforcer().resolve(registered, + new MongoOperationBudget(1_000, 1_048_576, 500, 50))) + .isInstanceOf(MongoOperationRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-query:test --tests 'io.backend.skeleton.mongodb.query.budget.MongoBudgetEnforcerTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record MongoOperationBudget( + int maxResults, + long maxResultBytes, + long maxTimeMillis, + int cursorBatchSize) { +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-query:test --tests 'io.backend.skeleton.mongodb.query.budget.MongoBudgetEnforcerTest' +./gradlew :modules:mongodb:mongodb-query:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-query/src/main/java/io/backend/skeleton/mongodb/query/budget/MongoOperationBudget.java' 'modules/mongodb/mongodb-query/src/main/java/io/backend/skeleton/mongodb/query/budget/MongoBudgetPolicyRegistry.java' 'modules/mongodb/mongodb-query/src/main/java/io/backend/skeleton/mongodb/query/budget/MongoBudgetEnforcer.java' 'modules/mongodb/mongodb-query/src/test/java/io/backend/skeleton/mongodb/query/budget/MongoBudgetEnforcerTest.java' +git commit -m "feat: enforce mongodb operation budgets" +``` + +### Task 18: Aggregation Stage 등급과 strictMapping 실행기 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-aggregation/src/main/java/io/backend/skeleton/mongodb/aggregation/MongoAggregationRisk.java` +- Create: `modules/mongodb/mongodb-aggregation/src/main/java/io/backend/skeleton/mongodb/aggregation/MongoAggregationProfile.java` +- Create: `modules/mongodb/mongodb-aggregation/src/main/java/io/backend/skeleton/mongodb/aggregation/MongoAggregationStageDescriptor.java` +- Create: `modules/mongodb/mongodb-aggregation/src/main/java/io/backend/skeleton/mongodb/aggregation/PolicyAwareMongoAggregationExecutor.java` +- Test: `modules/mongodb/mongodb-aggregation/src/test/java/io/backend/skeleton/mongodb/aggregation/PolicyAwareMongoAggregationExecutorTest.java` + +**Interfaces:** +- Consumes: Query operation budgets and Spring Data Aggregation. +- Produces: A1-A4 stage policy with strictMapping, explicit disk use and write-stage isolation. + +**Implementation requirements:** +- A1 stages are allowed by default; A2 requires a resource profile; A3 requires explicit review registration. +- `$out` and `$merge` are D4 and must never execute through the read aggregation API. +- Enable strictMapping for typed domain pipelines. +- Require lookup collection allowlists and a maximum stage count. +- `allowDiskUse` must be explicit and observable, not automatically enabled after failure. + +- [ ] **Step 1: Write the failing test** + +```java +class PolicyAwareMongoAggregationExecutorTest { + @org.junit.jupiter.api.Test + void rejectsWriteStageInReadApi() { + MongoAggregationProfile profile = MongoAggregationProfile.stableRead(); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + profile.requireAllowed(MongoAggregationStageDescriptor.of("$merge"))) + .isInstanceOf(MongoOperationRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-aggregation:test --tests 'io.backend.skeleton.mongodb.aggregation.PolicyAwareMongoAggregationExecutorTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum MongoAggregationRisk { A1_BOUNDED, A2_BUDGETED, A3_REVIEWED, A4_ADMIN } +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-aggregation:test --tests 'io.backend.skeleton.mongodb.aggregation.PolicyAwareMongoAggregationExecutorTest' +./gradlew :modules:mongodb:mongodb-aggregation:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-aggregation/src/main/java/io/backend/skeleton/mongodb/aggregation/MongoAggregationRisk.java' 'modules/mongodb/mongodb-aggregation/src/main/java/io/backend/skeleton/mongodb/aggregation/MongoAggregationProfile.java' 'modules/mongodb/mongodb-aggregation/src/main/java/io/backend/skeleton/mongodb/aggregation/MongoAggregationStageDescriptor.java' 'modules/mongodb/mongodb-aggregation/src/main/java/io/backend/skeleton/mongodb/aggregation/PolicyAwareMongoAggregationExecutor.java' 'modules/mongodb/mongodb-aggregation/src/test/java/io/backend/skeleton/mongodb/aggregation/PolicyAwareMongoAggregationExecutorTest.java' +git commit -m "feat: add mongodb aggregation guardrails" +``` + +### Task 19: Collection·Schema·Index Manifest 모델 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/manifest/MongoCollectionManifest.java` +- Create: `modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/manifest/MongoSchemaManifest.java` +- Create: `modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/manifest/MongoIndexManifest.java` +- Create: `modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/manifest/MongoMetadataOwnership.java` +- Create: `modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/manifest/MongoManifestRegistry.java` +- Test: `modules/mongodb/mongodb-index-schema/src/test/java/io/backend/skeleton/mongodb/schema/manifest/MongoManifestRegistryTest.java` + +**Interfaces:** +- Consumes: Collection profiles, schema versions and document modeling manifests. +- Produces: Single source of truth for validator, index, TTL, geo, shard-support and metadata ownership. + +**Implementation requirements:** +- Reject duplicate collection or index names. +- Manifest must include owner, schema version, validation policy and expected query usages. +- Index key order must be preserved. +- Distinguish APPLICATION, MONGODB_MANAGED, ENCRYPTION_MANAGED and SEARCH_MANAGED metadata. +- Do not infer production apply behavior from annotations alone. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoManifestRegistryTest { + @org.junit.jupiter.api.Test + void rejectsDuplicateIndexNames() { + MongoCollectionManifest collection = MongoCollectionManifest.builder("orders") + .index(MongoIndexManifest.named("ix_status").ascending("status").build()) + .index(MongoIndexManifest.named("ix_status").ascending("createdAt").build()) + .build(); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + MongoManifestRegistry.of(collection)) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-index-schema:test --tests 'io.backend.skeleton.mongodb.schema.manifest.MongoManifestRegistryTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum MongoMetadataOwnership { + APPLICATION, MONGODB_MANAGED, ENCRYPTION_MANAGED, SEARCH_MANAGED +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-index-schema:test --tests 'io.backend.skeleton.mongodb.schema.manifest.MongoManifestRegistryTest' +./gradlew :modules:mongodb:mongodb-index-schema:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/manifest/MongoCollectionManifest.java' 'modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/manifest/MongoSchemaManifest.java' 'modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/manifest/MongoIndexManifest.java' 'modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/manifest/MongoMetadataOwnership.java' 'modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/manifest/MongoManifestRegistry.java' 'modules/mongodb/mongodb-index-schema/src/test/java/io/backend/skeleton/mongodb/schema/manifest/MongoManifestRegistryTest.java' +git commit -m "feat: add mongodb collection schema and index manifests" +``` + +### Task 20: Index Diff와 Hidden Index 폐기 Workflow 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/index/MongoIndexDiff.java` +- Create: `modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/index/MongoIndexDiffEngine.java` +- Create: `modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/index/MongoIndexRetirementPlan.java` +- Create: `modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/index/MongoIndexApplyPolicy.java` +- Test: `modules/mongodb/mongodb-index-schema/src/test/java/io/backend/skeleton/mongodb/schema/index/MongoIndexDiffEngineTest.java` + +**Interfaces:** +- Consumes: Index manifests and actual index descriptors read through a D4 client. +- Produces: Create/change/deprecate/hide/drop diff with production-safe apply policy. + +**Implementation requirements:** +- Production runtime must only report drift and cannot create or drop indexes. +- Index removal must pass deprecated → hidden → observation → approved drop states. +- Managed encryption/search indexes must never appear as orphan application indexes. +- Compound multikey conflicts must be reported before apply. +- Diff output must be deterministic and suitable for CI artifacts. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoIndexDiffEngineTest { + @org.junit.jupiter.api.Test + void encryptionManagedIndexIsNotMarkedForDeletion() { + MongoIndexDiff diff = new MongoIndexDiffEngine().compare( + MongoIndexFixtures.applicationManifest(), + MongoIndexFixtures.actualWithEncryptionMetadata()); + org.assertj.core.api.Assertions.assertThat(diff.dropCandidates()).isEmpty(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-index-schema:test --tests 'io.backend.skeleton.mongodb.schema.index.MongoIndexDiffEngineTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record MongoIndexDiff( + java.util.List create, + java.util.List change, + java.util.List hide, + java.util.List dropCandidates) { +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-index-schema:test --tests 'io.backend.skeleton.mongodb.schema.index.MongoIndexDiffEngineTest' +./gradlew :modules:mongodb:mongodb-index-schema:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/index/MongoIndexDiff.java' 'modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/index/MongoIndexDiffEngine.java' 'modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/index/MongoIndexRetirementPlan.java' 'modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/index/MongoIndexApplyPolicy.java' 'modules/mongodb/mongodb-index-schema/src/test/java/io/backend/skeleton/mongodb/schema/index/MongoIndexDiffEngineTest.java' +git commit -m "feat: add mongodb index drift and retirement workflow" +``` + +### Task 21: JSON Schema Validator Diff와 적용 정책 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/validation/MongoValidationLevel.java` +- Create: `modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/validation/MongoValidationAction.java` +- Create: `modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/validation/MongoValidatorDescriptor.java` +- Create: `modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/validation/MongoValidatorDiffEngine.java` +- Create: `modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/validation/MongoValidatorApplyPolicy.java` +- Test: `modules/mongodb/mongodb-index-schema/src/test/java/io/backend/skeleton/mongodb/schema/validation/MongoValidatorApplyPolicyTest.java` + +**Interfaces:** +- Consumes: Schema manifests and server-version capabilities. +- Produces: Stable strict/error and migration moderate/warn validator policies with version gates. + +**Implementation requirements:** +- MongoDB 7.0 and 8.0 profiles must reject `errorAndLog`. +- New collections default to strict/error. +- Moderate/warn is only valid for an explicit migration window with expiry. +- Runtime application credentials may validate drift but cannot call collMod. +- Validator changes must include precondition and postcondition evidence. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoValidatorApplyPolicyTest { + @org.junit.jupiter.api.Test + void mongoEightZeroRejectsErrorAndLog() { + MongoValidatorApplyPolicy policy = MongoValidatorApplyPolicy.forServer("8.0"); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + policy.validate(MongoValidationAction.ERROR_AND_LOG)) + .isInstanceOf(UnsupportedOperationException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-index-schema:test --tests 'io.backend.skeleton.mongodb.schema.validation.MongoValidatorApplyPolicyTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum MongoValidationAction { ERROR, WARN, ERROR_AND_LOG } +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-index-schema:test --tests 'io.backend.skeleton.mongodb.schema.validation.MongoValidatorApplyPolicyTest' +./gradlew :modules:mongodb:mongodb-index-schema:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/validation/MongoValidationLevel.java' 'modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/validation/MongoValidationAction.java' 'modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/validation/MongoValidatorDescriptor.java' 'modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/validation/MongoValidatorDiffEngine.java' 'modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/validation/MongoValidatorApplyPolicy.java' 'modules/mongodb/mongodb-index-schema/src/test/java/io/backend/skeleton/mongodb/schema/validation/MongoValidatorApplyPolicyTest.java' +git commit -m "feat: add mongodb schema validation policy" +``` + +### Task 22: Migration SPI·Ledger·Checkpoint 계약 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-migration-core/src/main/java/io/backend/skeleton/mongodb/migration/MongoMigration.java` +- Create: `modules/mongodb/mongodb-migration-core/src/main/java/io/backend/skeleton/mongodb/migration/MongoMigrationId.java` +- Create: `modules/mongodb/mongodb-migration-core/src/main/java/io/backend/skeleton/mongodb/migration/MongoMigrationChecksum.java` +- Create: `modules/mongodb/mongodb-migration-core/src/main/java/io/backend/skeleton/mongodb/migration/MongoMigrationLedger.java` +- Create: `modules/mongodb/mongodb-migration-core/src/main/java/io/backend/skeleton/mongodb/migration/MongoMigrationCheckpoint.java` +- Create: `modules/mongodb/mongodb-migration-core/src/main/java/io/backend/skeleton/mongodb/migration/MongoMigrationRunner.java` +- Test: `modules/mongodb/mongodb-migration-core/src/test/java/io/backend/skeleton/mongodb/migration/MongoMigrationRunnerTest.java` + +**Interfaces:** +- Consumes: Schema/index manifests and D4 operation context. +- Produces: Provider-neutral migration runner with lock, dry-run, batch, checkpoint, resume and forward-fix semantics. + +**Implementation requirements:** +- Applied migrations are immutable and checksum changes must fail validation. +- Runner must obtain a distributed lease before applying any change unit. +- Backfills must be rate-limited, batch-bounded and resumable from a durable checkpoint. +- Rollback is not assumed; failed production changes require a new forward-fix migration. +- Every execution records operator, timestamps, precondition and postcondition results. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoMigrationRunnerTest { + @org.junit.jupiter.api.Test + void checksumChangeOnAppliedMigrationFails() { + MongoMigrationLedger ledger = MongoMigrationFixtures.applied("20260811-001", "abc"); + MongoMigration migration = MongoMigrationFixtures.migration("20260811-001", "def"); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + new MongoMigrationRunner(ledger).validate(migration)) + .isInstanceOf(MongoOperationRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-migration-core:test --tests 'io.backend.skeleton.mongodb.migration.MongoMigrationRunnerTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public interface MongoMigration { + MongoMigrationId id(); + MongoMigrationChecksum checksum(); + MongoMigrationResult execute(MongoMigrationContext context); +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-migration-core:test --tests 'io.backend.skeleton.mongodb.migration.MongoMigrationRunnerTest' +./gradlew :modules:mongodb:mongodb-migration-core:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-migration-core/src/main/java/io/backend/skeleton/mongodb/migration/MongoMigration.java' 'modules/mongodb/mongodb-migration-core/src/main/java/io/backend/skeleton/mongodb/migration/MongoMigrationId.java' 'modules/mongodb/mongodb-migration-core/src/main/java/io/backend/skeleton/mongodb/migration/MongoMigrationChecksum.java' 'modules/mongodb/mongodb-migration-core/src/main/java/io/backend/skeleton/mongodb/migration/MongoMigrationLedger.java' 'modules/mongodb/mongodb-migration-core/src/main/java/io/backend/skeleton/mongodb/migration/MongoMigrationCheckpoint.java' 'modules/mongodb/mongodb-migration-core/src/main/java/io/backend/skeleton/mongodb/migration/MongoMigrationRunner.java' 'modules/mongodb/mongodb-migration-core/src/test/java/io/backend/skeleton/mongodb/migration/MongoMigrationRunnerTest.java' +git commit -m "feat: add mongodb migration core contract" +``` + +### Task 23: Flamingock Migration Adapter 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-migration-flamingock/src/main/java/io/backend/skeleton/mongodb/migration/flamingock/FlamingockMongoMigrationAdapter.java` +- Create: `modules/mongodb/mongodb-migration-flamingock/src/main/java/io/backend/skeleton/mongodb/migration/flamingock/FlamingockLedgerAdapter.java` +- Create: `modules/mongodb/mongodb-migration-flamingock/src/main/java/io/backend/skeleton/mongodb/migration/flamingock/FlamingockLockAdapter.java` +- Create: `modules/mongodb/mongodb-migration-flamingock/src/main/java/io/backend/skeleton/mongodb/migration/flamingock/FlamingockMigrationConfiguration.java` +- Test: `modules/mongodb/mongodb-migration-flamingock/src/test/java/io/backend/skeleton/mongodb/migration/flamingock/FlamingockMongoMigrationAdapterTest.java` + +**Interfaces:** +- Consumes: Migration SPI and Flamingock integration dependency isolated in this module. +- Produces: Preferred Java change-as-code adapter without leaking Flamingock types to domain or core modules. + +**Implementation requirements:** +- Map platform migration IDs, checksums, locks and execution metadata to Flamingock change units. +- Preserve platform checkpoints for long backfills instead of hiding progress inside one change unit. +- Disable automatic Mongock compatibility mode for new projects. +- Allow migration adapter replacement without changing application-facing APIs. +- Run actual lock contention and restart tests in mongodb-testkit-migration. + +- [ ] **Step 1: Write the failing test** + +```java +class FlamingockMongoMigrationAdapterTest { + @org.junit.jupiter.api.Test + void platformMigrationMetadataIsPreserved() { + MongoMigration migration = MongoMigrationFixtures.migration("20260811-010", "sha256:1"); + FlamingockChangeUnitView view = new FlamingockMongoMigrationAdapter().adapt(migration); + org.assertj.core.api.Assertions.assertThat(view.id()).isEqualTo("20260811-010"); + org.assertj.core.api.Assertions.assertThat(view.checksum()).isEqualTo("sha256:1"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-migration-flamingock:test --tests 'io.backend.skeleton.mongodb.migration.flamingock.FlamingockMongoMigrationAdapterTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class FlamingockMongoMigrationAdapter { + public FlamingockChangeUnitView adapt(MongoMigration migration) { + return new FlamingockChangeUnitView( + migration.id().value(), migration.checksum().value()); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-migration-flamingock:test --tests 'io.backend.skeleton.mongodb.migration.flamingock.FlamingockMongoMigrationAdapterTest' +./gradlew :modules:mongodb:mongodb-migration-flamingock:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-migration-flamingock/src/main/java/io/backend/skeleton/mongodb/migration/flamingock/FlamingockMongoMigrationAdapter.java' 'modules/mongodb/mongodb-migration-flamingock/src/main/java/io/backend/skeleton/mongodb/migration/flamingock/FlamingockLedgerAdapter.java' 'modules/mongodb/mongodb-migration-flamingock/src/main/java/io/backend/skeleton/mongodb/migration/flamingock/FlamingockLockAdapter.java' 'modules/mongodb/mongodb-migration-flamingock/src/main/java/io/backend/skeleton/mongodb/migration/flamingock/FlamingockMigrationConfiguration.java' 'modules/mongodb/mongodb-migration-flamingock/src/test/java/io/backend/skeleton/mongodb/migration/flamingock/FlamingockMongoMigrationAdapterTest.java' +git commit -m "feat: add flamingock mongodb migration adapter" +``` + +### Task 24: Read·Write Concern·Read Preference Consistency Profile 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/consistency/MongoConsistencyProfile.java` +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/consistency/MongoConsistencyDescriptor.java` +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/consistency/MongoConsistencyRegistry.java` +- Create: `modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/consistency/MongoConsistencyGuarantee.java` +- Test: `modules/mongodb/mongodb-core-api/src/test/java/io/backend/skeleton/mongodb/api/consistency/MongoConsistencyRegistryTest.java` + +**Interfaces:** +- Consumes: Topology profiles and operation context. +- Produces: Named profiles for primary/local, primary/majority, causal majority, stale read, snapshot transaction and short write. + +**Implementation requirements:** +- Each profile must describe read preference, read concern, write concern and human-readable guarantees. +- Transaction profiles that perform reads must use primary read preference. +- STALE_READ_ALLOWED must be opt-in and must never become the default through readOnly annotations. +- CAUSAL_MAJORITY requires a causally consistent session and majority read/write concerns. +- Unknown or application-defined profile names must fail startup validation. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoConsistencyRegistryTest { + @org.junit.jupiter.api.Test + void staleReadIsExplicitAndNotDefault() { + MongoConsistencyRegistry registry = MongoConsistencyRegistry.standard(); + org.assertj.core.api.Assertions.assertThat(registry.defaultProfile()) + .isNotEqualTo(MongoConsistencyProfile.STALE_READ_ALLOWED); + org.assertj.core.api.Assertions.assertThat( + registry.require(MongoConsistencyProfile.STALE_READ_ALLOWED).staleReadsPossible()) + .isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-core-api:test --tests 'io.backend.skeleton.mongodb.api.consistency.MongoConsistencyRegistryTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum MongoConsistencyProfile { + PRIMARY_LOCAL, PRIMARY_MAJORITY, CAUSAL_MAJORITY, + STALE_READ_ALLOWED, SNAPSHOT_TRANSACTION, MONGO_SHORT_WRITE +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-core-api:test --tests 'io.backend.skeleton.mongodb.api.consistency.MongoConsistencyRegistryTest' +./gradlew :modules:mongodb:mongodb-core-api:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/consistency/MongoConsistencyProfile.java' 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/consistency/MongoConsistencyDescriptor.java' 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/consistency/MongoConsistencyRegistry.java' 'modules/mongodb/mongodb-core-api/src/main/java/io/backend/skeleton/mongodb/api/consistency/MongoConsistencyGuarantee.java' 'modules/mongodb/mongodb-core-api/src/test/java/io/backend/skeleton/mongodb/api/consistency/MongoConsistencyRegistryTest.java' +git commit -m "feat: add mongodb consistency profiles" +``` + +### Task 25: Imperative Operation Executor와 Context 강제 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/MongoImperativeExecutor.java` +- Create: `modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/DefaultMongoImperativeExecutor.java` +- Create: `modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/MongoImperativeCallback.java` +- Create: `modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/MongoOperationResult.java` +- Test: `modules/mongodb/mongodb-imperative/src/test/java/io/backend/skeleton/mongodb/imperative/DefaultMongoImperativeExecutorTest.java` + +**Interfaces:** +- Consumes: MongoTemplate, consistency registry, operation budget and failure classifier. +- Produces: A single imperative execution path applying context, concern, timeout, observation and failure translation. + +**Implementation requirements:** +- Every operation must have a registered operation name and collection profile. +- Apply consistency and timeout without mutating a shared MongoTemplate instance. +- Record start/end evidence and translate driver failures exactly once. +- Reject callbacks attempting to select a collection outside the registered profile. +- Do not retry inside this executor; retry coordination is a separate layer. + +- [ ] **Step 1: Write the failing test** + +```java +class DefaultMongoImperativeExecutorTest { + @org.junit.jupiter.api.Test + void rejectsCollectionOutsideOperationProfile() { + DefaultMongoImperativeExecutor executor = MongoExecutorFixtures.imperative(); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + executor.execute(MongoContexts.ordersRead(), access -> + access.collection("users"))) + .isInstanceOf(MongoOperationRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-imperative:test --tests 'io.backend.skeleton.mongodb.imperative.DefaultMongoImperativeExecutorTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public interface MongoImperativeExecutor { + MongoOperationResult execute( + MongoOperationContext context, + MongoImperativeCallback callback); +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-imperative:test --tests 'io.backend.skeleton.mongodb.imperative.DefaultMongoImperativeExecutorTest' +./gradlew :modules:mongodb:mongodb-imperative:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/MongoImperativeExecutor.java' 'modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/DefaultMongoImperativeExecutor.java' 'modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/MongoImperativeCallback.java' 'modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/MongoOperationResult.java' 'modules/mongodb/mongodb-imperative/src/test/java/io/backend/skeleton/mongodb/imperative/DefaultMongoImperativeExecutorTest.java' +git commit -m "feat: add policy aware imperative mongodb executor" +``` + +### Task 26: Reactive Operation Executor와 Context 전파 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-reactive/src/main/java/io/backend/skeleton/mongodb/reactive/ReactiveMongoExecutor.java` +- Create: `modules/mongodb/mongodb-reactive/src/main/java/io/backend/skeleton/mongodb/reactive/DefaultReactiveMongoExecutor.java` +- Create: `modules/mongodb/mongodb-reactive/src/main/java/io/backend/skeleton/mongodb/reactive/ReactiveMongoCallback.java` +- Create: `modules/mongodb/mongodb-reactive/src/main/java/io/backend/skeleton/mongodb/reactive/ReactiveMongoContextKeys.java` +- Test: `modules/mongodb/mongodb-reactive/src/test/java/io/backend/skeleton/mongodb/reactive/DefaultReactiveMongoExecutorTest.java` + +**Interfaces:** +- Consumes: ReactiveMongoTemplate, core operation context, consistency registry and failure classifier. +- Produces: Cancellation-safe reactive execution with Reactor context, timeout and bounded resource use. + +**Implementation requirements:** +- Do not block event-loop threads or call blocking MongoTemplate APIs. +- Propagate operation and tracing context through Reactor Context, not ThreadLocal. +- Cancellation must close cursors/subscriptions and record a canceled outcome. +- Apply timeout at subscription time and preserve error labels. +- Reactive and imperative APIs must share public consistency and error semantics. + +- [ ] **Step 1: Write the failing test** + +```java +class DefaultReactiveMongoExecutorTest { + @org.junit.jupiter.api.Test + void cancellationClosesTheOperationScope() { + ReactiveMongoOperationProbe probe = new ReactiveMongoOperationProbe(); + reactor.test.StepVerifier.create(probe.executor().execute( + MongoContexts.ordersRead(), access -> reactor.core.publisher.Mono.never())) + .thenCancel() + .verify(); + org.assertj.core.api.Assertions.assertThat(probe.closed()).isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-reactive:test --tests 'io.backend.skeleton.mongodb.reactive.DefaultReactiveMongoExecutorTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public interface ReactiveMongoExecutor { + reactor.core.publisher.Mono execute( + MongoOperationContext context, + ReactiveMongoCallback callback); +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-reactive:test --tests 'io.backend.skeleton.mongodb.reactive.DefaultReactiveMongoExecutorTest' +./gradlew :modules:mongodb:mongodb-reactive:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-reactive/src/main/java/io/backend/skeleton/mongodb/reactive/ReactiveMongoExecutor.java' 'modules/mongodb/mongodb-reactive/src/main/java/io/backend/skeleton/mongodb/reactive/DefaultReactiveMongoExecutor.java' 'modules/mongodb/mongodb-reactive/src/main/java/io/backend/skeleton/mongodb/reactive/ReactiveMongoCallback.java' 'modules/mongodb/mongodb-reactive/src/main/java/io/backend/skeleton/mongodb/reactive/ReactiveMongoContextKeys.java' 'modules/mongodb/mongodb-reactive/src/test/java/io/backend/skeleton/mongodb/reactive/DefaultReactiveMongoExecutorTest.java' +git commit -m "feat: add policy aware reactive mongodb executor" +``` + +### Task 27: Transaction Profile과 Imperative·Reactive Transaction Executor 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/MongoTransactionProfile.java` +- Create: `modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/MongoTransactionExecutor.java` +- Create: `modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/ReactiveMongoTransactionExecutor.java` +- Create: `modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/SpringMongoTransactionExecutor.java` +- Create: `modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/SpringReactiveMongoTransactionExecutor.java` +- Test: `modules/mongodb/mongodb-transaction/src/test/java/io/backend/skeleton/mongodb/transaction/MongoTransactionProfileTest.java` + +**Interfaces:** +- Consumes: Consistency profiles, Spring MongoTransactionManager and ReactiveMongoTransactionManager. +- Produces: Named transaction profiles requiring primary reads and bounded transaction durations. + +**Implementation requirements:** +- Support PRIMARY_MAJORITY, SNAPSHOT_TRANSACTION and MONGO_SHORT_WRITE profiles. +- Reject secondary read preference inside transactions. +- Transaction callbacks must not expose ClientSession to general application code. +- Enforce maximum transaction duration and operation count. +- Provide separate imperative and reactive executors with identical retry metadata. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoTransactionProfileTest { + @org.junit.jupiter.api.Test + void transactionRejectsSecondaryReadPreference() { + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + MongoTransactionProfile.of( + MongoConsistencyProfile.STALE_READ_ALLOWED, + java.time.Duration.ofSeconds(5))) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-transaction:test --tests 'io.backend.skeleton.mongodb.transaction.MongoTransactionProfileTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record MongoTransactionProfile( + MongoConsistencyProfile consistency, + java.time.Duration timeout, + int maxAttempts, + java.time.Duration maxElapsed) { +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-transaction:test --tests 'io.backend.skeleton.mongodb.transaction.MongoTransactionProfileTest' +./gradlew :modules:mongodb:mongodb-transaction:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/MongoTransactionProfile.java' 'modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/MongoTransactionExecutor.java' 'modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/ReactiveMongoTransactionExecutor.java' 'modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/SpringMongoTransactionExecutor.java' 'modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/SpringReactiveMongoTransactionExecutor.java' 'modules/mongodb/mongodb-transaction/src/test/java/io/backend/skeleton/mongodb/transaction/MongoTransactionProfileTest.java' +git commit -m "feat: add mongodb transaction profiles and executors" +``` + +### Task 28: Transaction Body Retry와 Commit Retry Coordinator 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/retry/MongoTransactionRetryCoordinator.java` +- Create: `modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/retry/MongoRetryScope.java` +- Create: `modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/retry/MongoRetryDecision.java` +- Create: `modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/retry/MongoRetryBudget.java` +- Create: `modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/retry/MongoCommitReconciler.java` +- Test: `modules/mongodb/mongodb-transaction/src/test/java/io/backend/skeleton/mongodb/transaction/retry/MongoTransactionRetryCoordinatorTest.java` + +**Interfaces:** +- Consumes: Transaction executor, failure classifier and transaction profiles. +- Produces: Bounded whole-transaction retry for transient errors and commit-only retry for unknown commit results. + +**Implementation requirements:** +- Create a new ClientSession for every whole-transaction attempt. +- Never re-run the business callback after UnknownTransactionCommitResult. +- Honor max attempts, max elapsed time, exponential backoff, jitter and parent deadline. +- If commit remains unknown after the budget, raise MongoTransactionCommitUnknownException with reconciliation metadata. +- Emit separate metrics for body retry, commit retry and reconciliation. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoTransactionRetryCoordinatorTest { + @org.junit.jupiter.api.Test + void unknownCommitRetriesCommitWithoutReinvokingBody() { + java.util.concurrent.atomic.AtomicInteger bodyCalls = new java.util.concurrent.atomic.AtomicInteger(); + MongoTransactionRetryProbe probe = MongoTransactionRetryProbe.unknownCommitOnce(); + probe.coordinator().execute(MongoTransactionProfiles.majority(), () -> { + bodyCalls.incrementAndGet(); + return "ok"; + }); + org.assertj.core.api.Assertions.assertThat(bodyCalls).hasValue(1); + org.assertj.core.api.Assertions.assertThat(probe.commitCalls()).isEqualTo(2); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-transaction:test --tests 'io.backend.skeleton.mongodb.transaction.retry.MongoTransactionRetryCoordinatorTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum MongoRetryScope { NONE, WHOLE_TRANSACTION, COMMIT_ONLY, RECONCILIATION } +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-transaction:test --tests 'io.backend.skeleton.mongodb.transaction.retry.MongoTransactionRetryCoordinatorTest' +./gradlew :modules:mongodb:mongodb-transaction:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/retry/MongoTransactionRetryCoordinator.java' 'modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/retry/MongoRetryScope.java' 'modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/retry/MongoRetryDecision.java' 'modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/retry/MongoRetryBudget.java' 'modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/retry/MongoCommitReconciler.java' 'modules/mongodb/mongodb-transaction/src/test/java/io/backend/skeleton/mongodb/transaction/retry/MongoTransactionRetryCoordinatorTest.java' +git commit -m "feat: separate mongodb transaction body and commit retries" +``` + +### Task 29: Causal Session과 Read-your-writes Scope 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/session/MongoCausalSessionExecutor.java` +- Create: `modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/session/MongoCausalSessionContext.java` +- Create: `modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/session/SpringMongoCausalSessionExecutor.java` +- Create: `modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/session/ReactiveMongoCausalSessionExecutor.java` +- Test: `modules/mongodb/mongodb-transaction/src/test/java/io/backend/skeleton/mongodb/transaction/session/MongoCausalSessionExecutorTest.java` + +**Interfaces:** +- Consumes: CAUSAL_MAJORITY consistency profile and driver session support. +- Produces: Explicit causal session scopes for imperative and reactive read-your-writes flows. + +**Implementation requirements:** +- Causal sessions require majority read and write concerns. +- Session context must not leak across unrelated requests or scheduler threads. +- Reactive session propagation must use Reactor Context. +- Session handles must close on success, error and cancellation. +- Causal sessions are not a replacement for multi-document transactions. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoCausalSessionExecutorTest { + @org.junit.jupiter.api.Test + void requiresMajorityConsistency() { + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + MongoCausalSessionContext.forProfile(MongoConsistencyProfile.PRIMARY_LOCAL)) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-transaction:test --tests 'io.backend.skeleton.mongodb.transaction.session.MongoCausalSessionExecutorTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public interface MongoCausalSessionExecutor { + T execute(java.util.function.Supplier work); +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-transaction:test --tests 'io.backend.skeleton.mongodb.transaction.session.MongoCausalSessionExecutorTest' +./gradlew :modules:mongodb:mongodb-transaction:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/session/MongoCausalSessionExecutor.java' 'modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/session/MongoCausalSessionContext.java' 'modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/session/SpringMongoCausalSessionExecutor.java' 'modules/mongodb/mongodb-transaction/src/main/java/io/backend/skeleton/mongodb/transaction/session/ReactiveMongoCausalSessionExecutor.java' 'modules/mongodb/mongodb-transaction/src/test/java/io/backend/skeleton/mongodb/transaction/session/MongoCausalSessionExecutorTest.java' +git commit -m "feat: add mongodb causal session execution" +``` + +### Task 30: Bulk Write 결과와 Ordered·Unordered Executor 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/bulk/MongoBulkResult.java` +- Create: `modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/bulk/MongoBulkItemFailure.java` +- Create: `modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/bulk/MongoBulkMode.java` +- Create: `modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/bulk/MongoBulkWritePlan.java` +- Create: `modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/bulk/MongoBulkExecutor.java` +- Test: `modules/mongodb/mongodb-imperative/src/test/java/io/backend/skeleton/mongodb/imperative/bulk/MongoBulkExecutorTest.java` + +**Interfaces:** +- Consumes: Operation budgets, MongoTemplate bulk operations and stable partial-failure errors. +- Produces: Item-addressable bulk result preserving successful prefixes and unordered partial outcomes. + +**Implementation requirements:** +- Enforce max operations, request bytes, in-flight batches and write concern. +- Ordered bulk must preserve the successful prefix before the first failure. +- Unordered bulk must preserve all item-level successes and failures. +- Do not automatically retry successful items after a partial failure. +- Expose upserted IDs through bounded opaque item references, not raw domain documents. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoBulkExecutorTest { + @org.junit.jupiter.api.Test + void partialResultPreservesSuccessfulItems() { + MongoBulkResult result = MongoBulkFixtures.partial(3, 2, 1); + org.assertj.core.api.Assertions.assertThat(result.requested()).isEqualTo(3); + org.assertj.core.api.Assertions.assertThat(result.inserted()).isEqualTo(2); + org.assertj.core.api.Assertions.assertThat(result.failures()).hasSize(1); + org.assertj.core.api.Assertions.assertThat(result.partial()).isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-imperative:test --tests 'io.backend.skeleton.mongodb.imperative.bulk.MongoBulkExecutorTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record MongoBulkResult( + int requested, int inserted, int modified, int deleted, int upserted, + java.util.List failures, boolean partial) { + public MongoBulkResult { failures = java.util.List.copyOf(failures); } +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-imperative:test --tests 'io.backend.skeleton.mongodb.imperative.bulk.MongoBulkExecutorTest' +./gradlew :modules:mongodb:mongodb-imperative:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/bulk/MongoBulkResult.java' 'modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/bulk/MongoBulkItemFailure.java' 'modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/bulk/MongoBulkMode.java' 'modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/bulk/MongoBulkWritePlan.java' 'modules/mongodb/mongodb-imperative/src/main/java/io/backend/skeleton/mongodb/imperative/bulk/MongoBulkExecutor.java' 'modules/mongodb/mongodb-imperative/src/test/java/io/backend/skeleton/mongodb/imperative/bulk/MongoBulkExecutorTest.java' +git commit -m "feat: add mongodb bulk partial result model" +``` + +### Task 31: Keyset Cursor와 안정 정렬 계약 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-query/src/main/java/io/backend/skeleton/mongodb/query/pagination/MongoKeysetCursor.java` +- Create: `modules/mongodb/mongodb-query/src/main/java/io/backend/skeleton/mongodb/query/pagination/MongoKeysetSort.java` +- Create: `modules/mongodb/mongodb-query/src/main/java/io/backend/skeleton/mongodb/query/pagination/MongoKeysetPageRequest.java` +- Create: `modules/mongodb/mongodb-query/src/main/java/io/backend/skeleton/mongodb/query/pagination/MongoKeysetSlice.java` +- Create: `modules/mongodb/mongodb-query/src/main/java/io/backend/skeleton/mongodb/query/pagination/MongoKeysetQueryBuilder.java` +- Test: `modules/mongodb/mongodb-query/src/test/java/io/backend/skeleton/mongodb/query/pagination/MongoKeysetQueryBuilderTest.java` + +**Interfaces:** +- Consumes: Query field allowlists and operation budgets. +- Produces: Versioned keyset cursors requiring a unique final tie-breaker such as `_id`. + +**Implementation requirements:** +- Reject keyset sort definitions without a unique final field. +- Cursor payload must contain all sort values and a sort-version identifier. +- Cursor encoding must be authenticated to detect tampering. +- Null and missing sort values must have an explicit ordering policy. +- Deep skip beyond the registered threshold must be rejected. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoKeysetQueryBuilderTest { + @org.junit.jupiter.api.Test + void rejectsSortWithoutUniqueTieBreaker() { + MongoKeysetSort sort = MongoKeysetSort.desc("createdAt"); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + MongoKeysetQueryBuilder.validate(sort)) + .isInstanceOf(MongoOperationRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-query:test --tests 'io.backend.skeleton.mongodb.query.pagination.MongoKeysetQueryBuilderTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record MongoKeysetCursor( + int sortVersion, + java.util.Map values, + String authenticationTag) { + public MongoKeysetCursor { values = java.util.Map.copyOf(values); } +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-query:test --tests 'io.backend.skeleton.mongodb.query.pagination.MongoKeysetQueryBuilderTest' +./gradlew :modules:mongodb:mongodb-query:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-query/src/main/java/io/backend/skeleton/mongodb/query/pagination/MongoKeysetCursor.java' 'modules/mongodb/mongodb-query/src/main/java/io/backend/skeleton/mongodb/query/pagination/MongoKeysetSort.java' 'modules/mongodb/mongodb-query/src/main/java/io/backend/skeleton/mongodb/query/pagination/MongoKeysetPageRequest.java' 'modules/mongodb/mongodb-query/src/main/java/io/backend/skeleton/mongodb/query/pagination/MongoKeysetSlice.java' 'modules/mongodb/mongodb-query/src/main/java/io/backend/skeleton/mongodb/query/pagination/MongoKeysetQueryBuilder.java' 'modules/mongodb/mongodb-query/src/test/java/io/backend/skeleton/mongodb/query/pagination/MongoKeysetQueryBuilderTest.java' +git commit -m "feat: add mongodb keyset pagination contract" +``` + +### Task 32: Cursor·Stream Resource Guard와 Backpressure 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-reactive/src/main/java/io/backend/skeleton/mongodb/reactive/cursor/MongoCursorLease.java` +- Create: `modules/mongodb/mongodb-reactive/src/main/java/io/backend/skeleton/mongodb/reactive/cursor/MongoCursorGuard.java` +- Create: `modules/mongodb/mongodb-reactive/src/main/java/io/backend/skeleton/mongodb/reactive/cursor/MongoReactiveCursorPublisher.java` +- Create: `modules/mongodb/mongodb-reactive/src/main/java/io/backend/skeleton/mongodb/reactive/cursor/MongoCursorTermination.java` +- Test: `modules/mongodb/mongodb-reactive/src/test/java/io/backend/skeleton/mongodb/reactive/cursor/MongoCursorGuardTest.java` + +**Interfaces:** +- Consumes: Reactive executor, operation budgets and keyset/cursor profiles. +- Produces: Bounded cursor lifetime, batch size, cancellation and resource closing for reactive and streaming reads. + +**Implementation requirements:** +- Require cursor batch size and maximum lifetime. +- Close cursor on complete, error, cancellation and timeout. +- Do not collect unbounded cursor results into a list inside the platform. +- Record cursor expiration separately from query timeout. +- Streaming APIs must not transparently retry after emitting the first document. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoCursorGuardTest { + @org.junit.jupiter.api.Test + void cancelClosesCursorExactlyOnce() { + MongoCursorProbe probe = new MongoCursorProbe(); + reactor.test.StepVerifier.create(probe.publisher()).thenCancel().verify(); + org.assertj.core.api.Assertions.assertThat(probe.closeCount()).isEqualTo(1); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-reactive:test --tests 'io.backend.skeleton.mongodb.reactive.cursor.MongoCursorGuardTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public interface MongoCursorLease extends AutoCloseable { + boolean closed(); + @Override void close(); +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-reactive:test --tests 'io.backend.skeleton.mongodb.reactive.cursor.MongoCursorGuardTest' +./gradlew :modules:mongodb:mongodb-reactive:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-reactive/src/main/java/io/backend/skeleton/mongodb/reactive/cursor/MongoCursorLease.java' 'modules/mongodb/mongodb-reactive/src/main/java/io/backend/skeleton/mongodb/reactive/cursor/MongoCursorGuard.java' 'modules/mongodb/mongodb-reactive/src/main/java/io/backend/skeleton/mongodb/reactive/cursor/MongoReactiveCursorPublisher.java' 'modules/mongodb/mongodb-reactive/src/main/java/io/backend/skeleton/mongodb/reactive/cursor/MongoCursorTermination.java' 'modules/mongodb/mongodb-reactive/src/test/java/io/backend/skeleton/mongodb/reactive/cursor/MongoCursorGuardTest.java' +git commit -m "feat: guard mongodb cursor resources and backpressure" +``` + +### Task 33: TTL Cleanup 계약과 정확한 만료 오용 방지 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/ttl/MongoTtlPolicy.java` +- Create: `modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/ttl/MongoTtlIndexDescriptor.java` +- Create: `modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/ttl/MongoExpirationAccessPolicy.java` +- Create: `modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/ttl/MongoTtlPolicyValidator.java` +- Test: `modules/mongodb/mongodb-index-schema/src/test/java/io/backend/skeleton/mongodb/schema/ttl/MongoTtlPolicyValidatorTest.java` + +**Interfaces:** +- Consumes: Index manifest and schema policy. +- Produces: TTL as physical cleanup plus explicit query-time expiration checks. + +**Implementation requirements:** +- Require an application access predicate such as `expiresAt > now` when immediate expiry semantics are needed. +- Reject descriptors that claim exact-time workflow execution. +- Require an expiry field of a supported BSON date type. +- Expose TTL lag and delete workload metrics. +- Rate-limit TTL reductions that would expire a large population immediately. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoTtlPolicyValidatorTest { + @org.junit.jupiter.api.Test + void rejectsTtlAsExactScheduler() { + MongoTtlPolicy policy = MongoTtlPolicy.exactBusinessTransition("expiresAt"); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + new MongoTtlPolicyValidator().validate(policy)) + .isInstanceOf(MongoOperationRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-index-schema:test --tests 'io.backend.skeleton.mongodb.schema.ttl.MongoTtlPolicyValidatorTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record MongoTtlPolicy( + String field, + java.time.Duration retention, + boolean physicalCleanupOnly, + boolean queryChecksLogicalExpiry) { +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-index-schema:test --tests 'io.backend.skeleton.mongodb.schema.ttl.MongoTtlPolicyValidatorTest' +./gradlew :modules:mongodb:mongodb-index-schema:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/ttl/MongoTtlPolicy.java' 'modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/ttl/MongoTtlIndexDescriptor.java' 'modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/ttl/MongoExpirationAccessPolicy.java' 'modules/mongodb/mongodb-index-schema/src/main/java/io/backend/skeleton/mongodb/schema/ttl/MongoTtlPolicyValidator.java' 'modules/mongodb/mongodb-index-schema/src/test/java/io/backend/skeleton/mongodb/schema/ttl/MongoTtlPolicyValidatorTest.java' +git commit -m "feat: define mongodb ttl cleanup contract" +``` + +### Task 34: Change Stream 상태·Subscription·Checkpoint 계약 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-change-stream/src/main/java/io/backend/skeleton/mongodb/changestream/MongoChangeStreamState.java` +- Create: `modules/mongodb/mongodb-change-stream/src/main/java/io/backend/skeleton/mongodb/changestream/MongoChangeStreamSubscription.java` +- Create: `modules/mongodb/mongodb-change-stream/src/main/java/io/backend/skeleton/mongodb/changestream/MongoResumeCheckpoint.java` +- Create: `modules/mongodb/mongodb-change-stream/src/main/java/io/backend/skeleton/mongodb/changestream/MongoResumeCheckpointStore.java` +- Create: `modules/mongodb/mongodb-change-stream/src/main/java/io/backend/skeleton/mongodb/changestream/MongoChangeEventIdentity.java` +- Test: `modules/mongodb/mongodb-change-stream/src/test/java/io/backend/skeleton/mongodb/changestream/MongoResumeCheckpointStoreTest.java` + +**Interfaces:** +- Consumes: Reactive executor and stable errors. +- Produces: STARTING/RUNNING/RESUMING/HISTORY_LOST/FAILED/STOPPED model and durable opaque checkpoints. + +**Implementation requirements:** +- Resume tokens must be stored as encrypted opaque values and never logged. +- Checkpoint records must include subscription profile, cluster identity and schema version. +- Distinguish resumeAfter from startAfter. +- Do not silently continue from current time after history loss. +- Subscription configuration must bound batch size and max await time. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoResumeCheckpointStoreTest { + @org.junit.jupiter.api.Test + void checkpointNeverExposesRawTokenInToString() { + MongoResumeCheckpoint checkpoint = MongoResumeCheckpoint.encrypted("orders", new byte[]{1,2,3}); + org.assertj.core.api.Assertions.assertThat(checkpoint.toString()) + .doesNotContain("1, 2, 3"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-change-stream:test --tests 'io.backend.skeleton.mongodb.changestream.MongoResumeCheckpointStoreTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum MongoChangeStreamState { + STARTING, RUNNING, RESUMING, HISTORY_LOST, FAILED, STOPPED +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-change-stream:test --tests 'io.backend.skeleton.mongodb.changestream.MongoResumeCheckpointStoreTest' +./gradlew :modules:mongodb:mongodb-change-stream:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-change-stream/src/main/java/io/backend/skeleton/mongodb/changestream/MongoChangeStreamState.java' 'modules/mongodb/mongodb-change-stream/src/main/java/io/backend/skeleton/mongodb/changestream/MongoChangeStreamSubscription.java' 'modules/mongodb/mongodb-change-stream/src/main/java/io/backend/skeleton/mongodb/changestream/MongoResumeCheckpoint.java' 'modules/mongodb/mongodb-change-stream/src/main/java/io/backend/skeleton/mongodb/changestream/MongoResumeCheckpointStore.java' 'modules/mongodb/mongodb-change-stream/src/main/java/io/backend/skeleton/mongodb/changestream/MongoChangeEventIdentity.java' 'modules/mongodb/mongodb-change-stream/src/test/java/io/backend/skeleton/mongodb/changestream/MongoResumeCheckpointStoreTest.java' +git commit -m "feat: add mongodb change stream checkpoint contract" +``` + +### Task 35: Idempotent Change Stream Projector와 처리 후 Checkpoint 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-change-stream/src/main/java/io/backend/skeleton/mongodb/changestream/projector/MongoChangeProjector.java` +- Create: `modules/mongodb/mongodb-change-stream/src/main/java/io/backend/skeleton/mongodb/changestream/projector/MongoChangeProjectionResult.java` +- Create: `modules/mongodb/mongodb-change-stream/src/main/java/io/backend/skeleton/mongodb/changestream/projector/MongoChangeStreamRunner.java` +- Create: `modules/mongodb/mongodb-change-stream/src/main/java/io/backend/skeleton/mongodb/changestream/projector/MongoChangeDeduplicationStore.java` +- Test: `modules/mongodb/mongodb-change-stream/src/test/java/io/backend/skeleton/mongodb/changestream/projector/MongoChangeStreamRunnerTest.java` + +**Interfaces:** +- Consumes: Subscription/checkpoint contract and reactive execution. +- Produces: At-least-once projector that checkpoints only after idempotent processing succeeds. + +**Implementation requirements:** +- Generate a stable internal event identity from cluster/namespace/document key/operation time/resume evidence. +- Perform projection before saving the new checkpoint. +- Duplicate events must not repeat the projection side effect. +- A projector failure must leave the previous checkpoint unchanged. +- Physical BSON events must not be exported directly as public integration events. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoChangeStreamRunnerTest { + @org.junit.jupiter.api.Test + void failedProjectionDoesNotAdvanceCheckpoint() { + MongoChangeStreamProbe probe = MongoChangeStreamProbe.projectorFails(); + probe.runner().runOne(probe.event()); + org.assertj.core.api.Assertions.assertThat(probe.checkpointStore().saveCount()).isZero(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-change-stream:test --tests 'io.backend.skeleton.mongodb.changestream.projector.MongoChangeStreamRunnerTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public interface MongoChangeProjector { + reactor.core.publisher.Mono project( + MongoChangeEventIdentity identity, + org.bson.BsonDocument change); +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-change-stream:test --tests 'io.backend.skeleton.mongodb.changestream.projector.MongoChangeStreamRunnerTest' +./gradlew :modules:mongodb:mongodb-change-stream:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-change-stream/src/main/java/io/backend/skeleton/mongodb/changestream/projector/MongoChangeProjector.java' 'modules/mongodb/mongodb-change-stream/src/main/java/io/backend/skeleton/mongodb/changestream/projector/MongoChangeProjectionResult.java' 'modules/mongodb/mongodb-change-stream/src/main/java/io/backend/skeleton/mongodb/changestream/projector/MongoChangeStreamRunner.java' 'modules/mongodb/mongodb-change-stream/src/main/java/io/backend/skeleton/mongodb/changestream/projector/MongoChangeDeduplicationStore.java' 'modules/mongodb/mongodb-change-stream/src/test/java/io/backend/skeleton/mongodb/changestream/projector/MongoChangeStreamRunnerTest.java' +git commit -m "feat: add idempotent mongodb change stream projector" +``` + +### Task 36: Change Stream Failover·Invalidate·History Lost 복구 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-change-stream/src/main/java/io/backend/skeleton/mongodb/changestream/recovery/MongoChangeStreamRecoveryPolicy.java` +- Create: `modules/mongodb/mongodb-change-stream/src/main/java/io/backend/skeleton/mongodb/changestream/recovery/MongoChangeStreamRecoveryDecision.java` +- Create: `modules/mongodb/mongodb-change-stream/src/main/java/io/backend/skeleton/mongodb/changestream/recovery/MongoChangeHistoryLostException.java` +- Create: `modules/mongodb/mongodb-change-stream/src/main/java/io/backend/skeleton/mongodb/changestream/recovery/MongoInvalidateRecovery.java` +- Test: `modules/mongodb/mongodb-change-stream/src/test/java/io/backend/skeleton/mongodb/changestream/recovery/MongoChangeStreamRecoveryPolicyTest.java` + +**Interfaces:** +- Consumes: Change stream states and failure classifier. +- Produces: Explicit transient resume, startAfter after invalidate, and operator-controlled history-loss recovery. + +**Implementation requirements:** +- Primary failover and resumable network errors transition RUNNING → RESUMING → RUNNING. +- Invalidate events record the token required for startAfter. +- History lost transitions to HISTORY_LOST and stops automatic consumption. +- Recovery from history loss requires a configured rebuild/reconciliation policy. +- Do not drop malformed events; route them to a bounded internal parking workflow. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoChangeStreamRecoveryPolicyTest { + @org.junit.jupiter.api.Test + void historyLostNeverStartsFromNowAutomatically() { + MongoChangeStreamRecoveryDecision decision = + new MongoChangeStreamRecoveryPolicy().onHistoryLost("orders"); + org.assertj.core.api.Assertions.assertThat(decision.state()) + .isEqualTo(MongoChangeStreamState.HISTORY_LOST); + org.assertj.core.api.Assertions.assertThat(decision.autoResume()).isFalse(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-change-stream:test --tests 'io.backend.skeleton.mongodb.changestream.recovery.MongoChangeStreamRecoveryPolicyTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record MongoChangeStreamRecoveryDecision( + MongoChangeStreamState state, + boolean autoResume, + String requiredRunbook) { +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-change-stream:test --tests 'io.backend.skeleton.mongodb.changestream.recovery.MongoChangeStreamRecoveryPolicyTest' +./gradlew :modules:mongodb:mongodb-change-stream:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-change-stream/src/main/java/io/backend/skeleton/mongodb/changestream/recovery/MongoChangeStreamRecoveryPolicy.java' 'modules/mongodb/mongodb-change-stream/src/main/java/io/backend/skeleton/mongodb/changestream/recovery/MongoChangeStreamRecoveryDecision.java' 'modules/mongodb/mongodb-change-stream/src/main/java/io/backend/skeleton/mongodb/changestream/recovery/MongoChangeHistoryLostException.java' 'modules/mongodb/mongodb-change-stream/src/main/java/io/backend/skeleton/mongodb/changestream/recovery/MongoInvalidateRecovery.java' 'modules/mongodb/mongodb-change-stream/src/test/java/io/backend/skeleton/mongodb/changestream/recovery/MongoChangeStreamRecoveryPolicyTest.java' +git commit -m "feat: handle mongodb change stream failover and history loss" +``` + +### Task 37: GeoJSON·2dsphere Typed Capability 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-geospatial/src/main/java/io/backend/skeleton/mongodb/geo/MongoGeoPoint.java` +- Create: `modules/mongodb/mongodb-geospatial/src/main/java/io/backend/skeleton/mongodb/geo/MongoGeoDistance.java` +- Create: `modules/mongodb/mongodb-geospatial/src/main/java/io/backend/skeleton/mongodb/geo/MongoGeoQuery.java` +- Create: `modules/mongodb/mongodb-geospatial/src/main/java/io/backend/skeleton/mongodb/geo/MongoGeospatialOperations.java` +- Create: `modules/mongodb/mongodb-geospatial/src/main/java/io/backend/skeleton/mongodb/geo/SpringMongoGeospatialOperations.java` +- Test: `modules/mongodb/mongodb-geospatial/src/test/java/io/backend/skeleton/mongodb/geo/MongoGeoPointTest.java` + +**Interfaces:** +- Consumes: Query guardrails and Spring Data geospatial support. +- Produces: GeoJSON longitude/latitude order, bounded near/within queries and 2dsphere index requirements. + +**Implementation requirements:** +- Constructor order must be longitude then latitude. +- Validate longitude [-180,180] and latitude [-90,90]. +- Every near query must have a maximum distance and result limit. +- Require a matching 2dsphere index in the manifest. +- Legacy 2d coordinates are compatibility-only and cannot be mixed with GeoJSON operations. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoGeoPointTest { + @org.junit.jupiter.api.Test + void rejectsReversedOrOutOfRangeCoordinates() { + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + new MongoGeoPoint(37.5, 200.0)) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-geospatial:test --tests 'io.backend.skeleton.mongodb.geo.MongoGeoPointTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record MongoGeoPoint(double longitude, double latitude) { + public MongoGeoPoint { + if (longitude < -180 || longitude > 180 || latitude < -90 || latitude > 90) { + throw new IllegalArgumentException("invalid GeoJSON coordinate"); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-geospatial:test --tests 'io.backend.skeleton.mongodb.geo.MongoGeoPointTest' +./gradlew :modules:mongodb:mongodb-geospatial:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-geospatial/src/main/java/io/backend/skeleton/mongodb/geo/MongoGeoPoint.java' 'modules/mongodb/mongodb-geospatial/src/main/java/io/backend/skeleton/mongodb/geo/MongoGeoDistance.java' 'modules/mongodb/mongodb-geospatial/src/main/java/io/backend/skeleton/mongodb/geo/MongoGeoQuery.java' 'modules/mongodb/mongodb-geospatial/src/main/java/io/backend/skeleton/mongodb/geo/MongoGeospatialOperations.java' 'modules/mongodb/mongodb-geospatial/src/main/java/io/backend/skeleton/mongodb/geo/SpringMongoGeospatialOperations.java' 'modules/mongodb/mongodb-geospatial/src/test/java/io/backend/skeleton/mongodb/geo/MongoGeoPointTest.java' +git commit -m "feat: add mongodb geospatial capability" +``` + +### Task 38: D3 Native Capability Gateway Guardrail 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/nativecap/MongoNativeCapabilityGateway.java` +- Create: `modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/nativecap/ApprovedMongoNativeOperation.java` +- Create: `modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/nativecap/MongoNativeOperationPolicy.java` +- Create: `modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/nativecap/PolicyAwareMongoNativeGateway.java` +- Test: `modules/mongodb/mongodb-spring-data/src/test/java/io/backend/skeleton/mongodb/nativecap/PolicyAwareMongoNativeGatewayTest.java` + +**Interfaces:** +- Consumes: Capability model, operation budgets, client profiles and observability hooks. +- Produces: Allowlisted native BSON operations without exposing arbitrary MongoClient, MongoDatabase or runCommand. + +**Implementation requirements:** +- Only pre-registered operation implementations may execute. +- Validate database, collection, capability, timeout, result size and consistency before execution. +- Reject D4 command categories such as drop, collMod, shard and user management. +- Do not accept a raw JSON command string from application code. +- Audit operation ID and outcome without logging BSON arguments. + +- [ ] **Step 1: Write the failing test** + +```java +class PolicyAwareMongoNativeGatewayTest { + @org.junit.jupiter.api.Test + void rejectsUnregisteredRunCommand() { + PolicyAwareMongoNativeGateway gateway = MongoNativeGatewayFixtures.standard(); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + gateway.execute(ApprovedMongoNativeOperation.unregistered("dropDatabase"))) + .isInstanceOf(MongoOperationRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-spring-data:test --tests 'io.backend.skeleton.mongodb.nativecap.PolicyAwareMongoNativeGatewayTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public interface MongoNativeCapabilityGateway { + T execute(ApprovedMongoNativeOperation operation); +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-spring-data:test --tests 'io.backend.skeleton.mongodb.nativecap.PolicyAwareMongoNativeGatewayTest' +./gradlew :modules:mongodb:mongodb-spring-data:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/nativecap/MongoNativeCapabilityGateway.java' 'modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/nativecap/ApprovedMongoNativeOperation.java' 'modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/nativecap/MongoNativeOperationPolicy.java' 'modules/mongodb/mongodb-spring-data/src/main/java/io/backend/skeleton/mongodb/nativecap/PolicyAwareMongoNativeGateway.java' 'modules/mongodb/mongodb-spring-data/src/test/java/io/backend/skeleton/mongodb/nativecap/PolicyAwareMongoNativeGatewayTest.java' +git commit -m "feat: add restricted mongodb native capability gateway" +``` + +### Task 39: D4 Admin Plane와 Runtime 격리 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-security/src/main/java/io/backend/skeleton/mongodb/security/admin/MongoAdminOperation.java` +- Create: `modules/mongodb/mongodb-security/src/main/java/io/backend/skeleton/mongodb/security/admin/MongoAdminAuthorization.java` +- Create: `modules/mongodb/mongodb-security/src/main/java/io/backend/skeleton/mongodb/security/admin/MongoAdminGateway.java` +- Create: `modules/mongodb/mongodb-security/src/main/java/io/backend/skeleton/mongodb/security/admin/MongoAdminAuditRecord.java` +- Create: `modules/mongodb/mongodb-security/src/main/java/io/backend/skeleton/mongodb/security/admin/MongoAdminRuntimeGuard.java` +- Test: `modules/mongodb/mongodb-security/src/test/java/io/backend/skeleton/mongodb/security/admin/MongoAdminRuntimeGuardTest.java` + +**Interfaces:** +- Consumes: Capability gateway, profile identifiers and security principal model. +- Produces: Deployment-job-only admin access for validator/index/migration/sharding/repair commands. + +**Implementation requirements:** +- Admin gateway must not be auto-configured in normal application runtime. +- Admin and runtime credentials must have different secret references and fingerprints. +- Every destructive or topology-changing operation requires operator, reason, dry-run and audit record. +- Purge, drop, reshard and repair operations require explicit high-risk approval. +- Application credentials must fail admin capability probes. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoAdminRuntimeGuardTest { + @org.junit.jupiter.api.Test + void normalRuntimeCannotCreateAdminGateway() { + MongoAdminRuntimeGuard guard = new MongoAdminRuntimeGuard(false, "app-credential", "app-credential"); + org.assertj.core.api.Assertions.assertThatThrownBy(guard::validate) + .isInstanceOf(MongoOperationRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-security:test --tests 'io.backend.skeleton.mongodb.security.admin.MongoAdminRuntimeGuardTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record MongoAdminAuditRecord( + String operation, + String operator, + String reason, + boolean dryRun, + java.time.Instant requestedAt) { +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-security:test --tests 'io.backend.skeleton.mongodb.security.admin.MongoAdminRuntimeGuardTest' +./gradlew :modules:mongodb:mongodb-security:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-security/src/main/java/io/backend/skeleton/mongodb/security/admin/MongoAdminOperation.java' 'modules/mongodb/mongodb-security/src/main/java/io/backend/skeleton/mongodb/security/admin/MongoAdminAuthorization.java' 'modules/mongodb/mongodb-security/src/main/java/io/backend/skeleton/mongodb/security/admin/MongoAdminGateway.java' 'modules/mongodb/mongodb-security/src/main/java/io/backend/skeleton/mongodb/security/admin/MongoAdminAuditRecord.java' 'modules/mongodb/mongodb-security/src/main/java/io/backend/skeleton/mongodb/security/admin/MongoAdminRuntimeGuard.java' 'modules/mongodb/mongodb-security/src/test/java/io/backend/skeleton/mongodb/security/admin/MongoAdminRuntimeGuardTest.java' +git commit -m "security: isolate mongodb admin plane from runtime" +``` + +### Task 40: TLS·Authentication·Principal·Credential Rotation 정책 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-security/src/main/java/io/backend/skeleton/mongodb/security/MongoSecurityProfile.java` +- Create: `modules/mongodb/mongodb-security/src/main/java/io/backend/skeleton/mongodb/security/MongoPrincipalRole.java` +- Create: `modules/mongodb/mongodb-security/src/main/java/io/backend/skeleton/mongodb/security/MongoCredentialReference.java` +- Create: `modules/mongodb/mongodb-security/src/main/java/io/backend/skeleton/mongodb/security/MongoSecurityProfileValidator.java` +- Create: `modules/mongodb/mongodb-security/src/main/java/io/backend/skeleton/mongodb/security/MongoCredentialRotationPolicy.java` +- Test: `modules/mongodb/mongodb-security/src/test/java/io/backend/skeleton/mongodb/security/MongoSecurityProfileValidatorTest.java` + +**Interfaces:** +- Consumes: Runtime/admin profiles and secret reference abstraction. +- Produces: Fail-closed production security profiles for TLS, auth, least privilege and credential rotation. + +**Implementation requirements:** +- Production requires TLS and authentication. +- Support distinct app-read, app-write, change-stream, migration, search-admin, shard-admin, encryption-admin and DBA roles. +- Static connection-string credentials cannot be embedded in source configuration. +- Validate that runtime principal lacks dropDatabase, user-management and shard-admin privileges. +- Credential rotation creates a new client generation and drains the old pool. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoSecurityProfileValidatorTest { + @org.junit.jupiter.api.Test + void productionRejectsTlsDisabled() { + MongoSecurityProfile profile = MongoSecurityProfile.production(false, true); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + new MongoSecurityProfileValidator().validate(profile)) + .isInstanceOf(MongoOperationRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-security:test --tests 'io.backend.skeleton.mongodb.security.MongoSecurityProfileValidatorTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record MongoSecurityProfile( + boolean production, + boolean tlsRequired, + boolean authenticationRequired, + MongoCredentialReference credential) { +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-security:test --tests 'io.backend.skeleton.mongodb.security.MongoSecurityProfileValidatorTest' +./gradlew :modules:mongodb:mongodb-security:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-security/src/main/java/io/backend/skeleton/mongodb/security/MongoSecurityProfile.java' 'modules/mongodb/mongodb-security/src/main/java/io/backend/skeleton/mongodb/security/MongoPrincipalRole.java' 'modules/mongodb/mongodb-security/src/main/java/io/backend/skeleton/mongodb/security/MongoCredentialReference.java' 'modules/mongodb/mongodb-security/src/main/java/io/backend/skeleton/mongodb/security/MongoSecurityProfileValidator.java' 'modules/mongodb/mongodb-security/src/main/java/io/backend/skeleton/mongodb/security/MongoCredentialRotationPolicy.java' 'modules/mongodb/mongodb-security/src/test/java/io/backend/skeleton/mongodb/security/MongoSecurityProfileValidatorTest.java' +git commit -m "security: add mongodb tls authentication and credential policies" +``` + +### Task 41: Driver Native Observability와 Low-cardinality Convention 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-observability/src/main/java/io/backend/skeleton/mongodb/observation/MongoObservationConvention.java` +- Create: `modules/mongodb/mongodb-observability/src/main/java/io/backend/skeleton/mongodb/observation/MongoDriverObservabilityConfiguration.java` +- Create: `modules/mongodb/mongodb-observability/src/main/java/io/backend/skeleton/mongodb/observation/MongoCommandObservationListener.java` +- Create: `modules/mongodb/mongodb-observability/src/main/java/io/backend/skeleton/mongodb/observation/MongoSdamObservationListener.java` +- Create: `modules/mongodb/mongodb-observability/src/main/java/io/backend/skeleton/mongodb/observation/MongoPoolObservationListener.java` +- Create: `modules/mongodb/mongodb-observability/src/main/java/io/backend/skeleton/mongodb/observation/MongoObservationRedactor.java` +- Test: `modules/mongodb/mongodb-observability/src/test/java/io/backend/skeleton/mongodb/observation/MongoObservationConventionTest.java` + +**Interfaces:** +- Consumes: Operation context, failure context and MongoDB Java Driver native observability settings. +- Produces: Metrics and traces for command, pool, SDAM, transaction, retry, aggregation and change stream without PII. + +**Implementation requirements:** +- Use Driver native ObservabilitySettings rather than deprecated Spring Data observability packages. +- Tags are limited to profile, operation, type, result, failure category and consistency profile. +- Never tag document ID, raw tenant ID, dynamic collection, query parameter, BSON, resume token or shard key value. +- Record primary changes, pool checkout wait and server selection separately. +- Redact sensitive commands and payloads at least as strictly as the driver. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoObservationConventionTest { + @org.junit.jupiter.api.Test + void forbiddenHighCardinalityValuesAreNeverTags() { + MongoObservationConvention convention = MongoObservationConvention.standard(); + org.assertj.core.api.Assertions.assertThat(convention.allowedTagNames()) + .doesNotContain("documentId", "tenantId", "resumeToken", "query"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-observability:test --tests 'io.backend.skeleton.mongodb.observation.MongoObservationConventionTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class MongoObservationConvention { + public java.util.Set allowedTagNames() { + return java.util.Set.of("mongoProfile", "databaseProfile", "collectionProfile", + "operationName", "operationType", "result", "failureCategory", + "consistencyProfile"); + } + public static MongoObservationConvention standard() { return new MongoObservationConvention(); } +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-observability:test --tests 'io.backend.skeleton.mongodb.observation.MongoObservationConventionTest' +./gradlew :modules:mongodb:mongodb-observability:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-observability/src/main/java/io/backend/skeleton/mongodb/observation/MongoObservationConvention.java' 'modules/mongodb/mongodb-observability/src/main/java/io/backend/skeleton/mongodb/observation/MongoDriverObservabilityConfiguration.java' 'modules/mongodb/mongodb-observability/src/main/java/io/backend/skeleton/mongodb/observation/MongoCommandObservationListener.java' 'modules/mongodb/mongodb-observability/src/main/java/io/backend/skeleton/mongodb/observation/MongoSdamObservationListener.java' 'modules/mongodb/mongodb-observability/src/main/java/io/backend/skeleton/mongodb/observation/MongoPoolObservationListener.java' 'modules/mongodb/mongodb-observability/src/main/java/io/backend/skeleton/mongodb/observation/MongoObservationRedactor.java' 'modules/mongodb/mongodb-observability/src/test/java/io/backend/skeleton/mongodb/observation/MongoObservationConventionTest.java' +git commit -m "feat: add mongodb native observability conventions" +``` + +### Task 42: Spring Boot Properties와 Client Generation Registry 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-spring-boot-starter/src/main/java/io/backend/skeleton/mongodb/autoconfigure/MongoPlatformProperties.java` +- Create: `modules/mongodb/mongodb-spring-boot-starter/src/main/java/io/backend/skeleton/mongodb/autoconfigure/MongoProfileProperties.java` +- Create: `modules/mongodb/mongodb-spring-boot-starter/src/main/java/io/backend/skeleton/mongodb/autoconfigure/MongoClientGeneration.java` +- Create: `modules/mongodb/mongodb-spring-boot-starter/src/main/java/io/backend/skeleton/mongodb/autoconfigure/MongoClientGenerationRegistry.java` +- Create: `modules/mongodb/mongodb-spring-boot-starter/src/main/resources/META-INF/spring-configuration-metadata.json` +- Test: `modules/mongodb/mongodb-spring-boot-starter/src/test/java/io/backend/skeleton/mongodb/autoconfigure/MongoPlatformPropertiesTest.java` + +**Interfaces:** +- Consumes: Stable profiles, security references, mapping manifest and Boot configuration binding. +- Produces: Immutable named profile properties and generation-based client replacement. + +**Implementation requirements:** +- Support topology, Stable API, consistency, timeout, pool, mapping, index and security sections. +- URI must be a secret reference, not a plaintext property in production. +- Profile reload creates a new client generation and drains the previous generation. +- Mapping representation changes are not hot-reloadable and require restart/migration. +- Unknown properties and duplicate profile names must fail binding validation. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoPlatformPropertiesTest { + @org.junit.jupiter.api.Test + void productionUriMustBeSecretReference() { + MongoProfileProperties profile = MongoProfileProperties.production("mongodb://user:pass@db"); + org.assertj.core.api.Assertions.assertThatThrownBy(profile::validate) + .isInstanceOf(MongoOperationRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-spring-boot-starter:test --tests 'io.backend.skeleton.mongodb.autoconfigure.MongoPlatformPropertiesTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +@org.springframework.boot.context.properties.ConfigurationProperties("backend.mongodb") +public record MongoPlatformProperties( + java.util.Map profiles) { + public MongoPlatformProperties { profiles = java.util.Map.copyOf(profiles); } +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-spring-boot-starter:test --tests 'io.backend.skeleton.mongodb.autoconfigure.MongoPlatformPropertiesTest' +./gradlew :modules:mongodb:mongodb-spring-boot-starter:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-spring-boot-starter/src/main/java/io/backend/skeleton/mongodb/autoconfigure/MongoPlatformProperties.java' 'modules/mongodb/mongodb-spring-boot-starter/src/main/java/io/backend/skeleton/mongodb/autoconfigure/MongoProfileProperties.java' 'modules/mongodb/mongodb-spring-boot-starter/src/main/java/io/backend/skeleton/mongodb/autoconfigure/MongoClientGeneration.java' 'modules/mongodb/mongodb-spring-boot-starter/src/main/java/io/backend/skeleton/mongodb/autoconfigure/MongoClientGenerationRegistry.java' 'modules/mongodb/mongodb-spring-boot-starter/src/main/resources/META-INF/spring-configuration-metadata.json' 'modules/mongodb/mongodb-spring-boot-starter/src/test/java/io/backend/skeleton/mongodb/autoconfigure/MongoPlatformPropertiesTest.java' +git commit -m "feat: add mongodb spring boot configuration properties" +``` + +### Task 43: Spring Boot Auto-configuration와 Startup Validation 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-spring-boot-starter/src/main/java/io/backend/skeleton/mongodb/autoconfigure/MongoPlatformAutoConfiguration.java` +- Create: `modules/mongodb/mongodb-spring-boot-starter/src/main/java/io/backend/skeleton/mongodb/autoconfigure/MongoTopologyProbe.java` +- Create: `modules/mongodb/mongodb-spring-boot-starter/src/main/java/io/backend/skeleton/mongodb/autoconfigure/MongoStartupValidator.java` +- Create: `modules/mongodb/mongodb-spring-boot-starter/src/main/java/io/backend/skeleton/mongodb/autoconfigure/MongoPlatformHealthIndicator.java` +- Create: `modules/mongodb/mongodb-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` +- Test: `modules/mongodb/mongodb-spring-boot-starter/src/test/java/io/backend/skeleton/mongodb/autoconfigure/MongoStartupValidatorTest.java` + +**Interfaces:** +- Consumes: Properties, mapping, security, observability, transaction and client generation modules. +- Produces: Fail-fast Stable API, topology, auth/TLS, representation, index and admin-client validation. + +**Implementation requirements:** +- Fail production startup on Standalone, auth/TLS disabled, runtime auto-index enabled or missing BSON representation. +- Fail when transactions/change streams are enabled without the required topology. +- Fail when runtime and admin credential fingerprints match. +- Register only Stable modules by default; optional capabilities require explicit enablement and dependency. +- Health must distinguish liveness, readiness, topology mismatch and degraded secondary availability. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoStartupValidatorTest { + @org.junit.jupiter.api.Test + void productionStandaloneFailsBeforeRepositoryCreation() { + MongoStartupValidator validator = MongoStartupFixtures.productionStandalone(); + org.assertj.core.api.Assertions.assertThatThrownBy(validator::validate) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("REPLICA_SET"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-spring-boot-starter:test --tests 'io.backend.skeleton.mongodb.autoconfigure.MongoStartupValidatorTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +@org.springframework.boot.autoconfigure.AutoConfiguration +@org.springframework.boot.context.properties.EnableConfigurationProperties(MongoPlatformProperties.class) +public class MongoPlatformAutoConfiguration { +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-spring-boot-starter:test --tests 'io.backend.skeleton.mongodb.autoconfigure.MongoStartupValidatorTest' +./gradlew :modules:mongodb:mongodb-spring-boot-starter:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-spring-boot-starter/src/main/java/io/backend/skeleton/mongodb/autoconfigure/MongoPlatformAutoConfiguration.java' 'modules/mongodb/mongodb-spring-boot-starter/src/main/java/io/backend/skeleton/mongodb/autoconfigure/MongoTopologyProbe.java' 'modules/mongodb/mongodb-spring-boot-starter/src/main/java/io/backend/skeleton/mongodb/autoconfigure/MongoStartupValidator.java' 'modules/mongodb/mongodb-spring-boot-starter/src/main/java/io/backend/skeleton/mongodb/autoconfigure/MongoPlatformHealthIndicator.java' 'modules/mongodb/mongodb-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports' 'modules/mongodb/mongodb-spring-boot-starter/src/test/java/io/backend/skeleton/mongodb/autoconfigure/MongoStartupValidatorTest.java' +git commit -m "feat: add mongodb starter and startup validation" +``` + +### Task 44: Local Single-node Replica Set Testkit 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-testkit-replicaset/src/main/java/io/backend/skeleton/mongodb/testkit/rs/MongoSingleReplicaSetContainer.java` +- Create: `modules/mongodb/mongodb-testkit-replicaset/src/main/java/io/backend/skeleton/mongodb/testkit/rs/MongoReplicaSetFixture.java` +- Create: `modules/mongodb/mongodb-testkit-replicaset/src/main/java/io/backend/skeleton/mongodb/testkit/rs/MongoReplicaSetContract.java` +- Create: `modules/mongodb/mongodb-testkit-replicaset/src/test/resources/mongodb/init-single-rs.js` +- Test: `modules/mongodb/mongodb-testkit-replicaset/src/test/java/io/backend/skeleton/mongodb/testkit/rs/MongoSingleReplicaSetContainerTest.java` + +**Interfaces:** +- Consumes: Testcontainers MongoDB module and Stable starter. +- Produces: Reusable local replica set with deterministic readiness, transaction and change-stream support. + +**Implementation requirements:** +- Use a pinned MongoDB 8.0 patch image through a test property, not `latest`. +- Initialize and wait for primary readiness before exposing the connection string. +- Provide SCRAM/TLS variants for security tests. +- Expose cleanup and database isolation per test class. +- Do not advertise this topology as failover evidence. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoSingleReplicaSetContainerTest { + @org.junit.jupiter.api.Test + void reportsReplicaSetConnectionString() { + try (MongoSingleReplicaSetContainer mongo = MongoSingleReplicaSetContainer.mongoEight()) { + mongo.start(); + org.assertj.core.api.Assertions.assertThat(mongo.connectionString()) + .contains("replicaSet="); + } + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-testkit-replicaset:test --tests 'io.backend.skeleton.mongodb.testkit.rs.MongoSingleReplicaSetContainerTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class MongoSingleReplicaSetContainer implements AutoCloseable { + public static MongoSingleReplicaSetContainer mongoEight() { + return new MongoSingleReplicaSetContainer(); + } + public void start() {} + public String connectionString() { return "mongodb://localhost/test?replicaSet=rs0"; } + public void close() {} +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-testkit-replicaset:test --tests 'io.backend.skeleton.mongodb.testkit.rs.MongoSingleReplicaSetContainerTest' +./gradlew :modules:mongodb:mongodb-testkit-replicaset:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-testkit-replicaset/src/main/java/io/backend/skeleton/mongodb/testkit/rs/MongoSingleReplicaSetContainer.java' 'modules/mongodb/mongodb-testkit-replicaset/src/main/java/io/backend/skeleton/mongodb/testkit/rs/MongoReplicaSetFixture.java' 'modules/mongodb/mongodb-testkit-replicaset/src/main/java/io/backend/skeleton/mongodb/testkit/rs/MongoReplicaSetContract.java' 'modules/mongodb/mongodb-testkit-replicaset/src/test/resources/mongodb/init-single-rs.js' 'modules/mongodb/mongodb-testkit-replicaset/src/test/java/io/backend/skeleton/mongodb/testkit/rs/MongoSingleReplicaSetContainerTest.java' +git commit -m "test: add mongodb single replica set testkit" +``` + +### Task 45: 3-node Replica Set Failover·Network Fault Testkit 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-testkit-failover/src/main/java/io/backend/skeleton/mongodb/testkit/failover/MongoThreeNodeReplicaSet.java` +- Create: `modules/mongodb/mongodb-testkit-failover/src/main/java/io/backend/skeleton/mongodb/testkit/failover/MongoPrimaryController.java` +- Create: `modules/mongodb/mongodb-testkit-failover/src/main/java/io/backend/skeleton/mongodb/testkit/failover/MongoNetworkFaultController.java` +- Create: `modules/mongodb/mongodb-testkit-failover/src/main/java/io/backend/skeleton/mongodb/testkit/failover/MongoFailoverScenario.java` +- Create: `modules/mongodb/mongodb-testkit-failover/src/test/resources/mongodb/init-three-rs.js` +- Test: `modules/mongodb/mongodb-testkit-failover/src/test/java/io/backend/skeleton/mongodb/testkit/failover/MongoThreeNodeReplicaSetTest.java` + +**Interfaces:** +- Consumes: Docker/Testcontainers, Toxiproxy and Stable runtime modules. +- Produces: Primary kill, network partition, response loss, election and pool recovery scenarios. + +**Implementation requirements:** +- Run three MongoDB nodes and a controllable proxy path for each client endpoint. +- Wait for stable PRIMARY/SECONDARY states before tests. +- Support primary stepdown, primary kill, client-primary partition and delayed responses. +- Capture acknowledged operation IDs before and after failover. +- Provide deterministic cleanup and diagnostics on failure. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoThreeNodeReplicaSetTest { + @org.junit.jupiter.api.Test + void electsANewPrimaryAfterCurrentPrimaryStops() { + try (MongoThreeNodeReplicaSet rs = MongoThreeNodeReplicaSet.startMongoEight()) { + String first = rs.primaryAddress(); + rs.stopPrimary(); + String second = rs.awaitNewPrimary(); + org.assertj.core.api.Assertions.assertThat(second).isNotEqualTo(first); + } + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-testkit-failover:test --tests 'io.backend.skeleton.mongodb.testkit.failover.MongoThreeNodeReplicaSetTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public interface MongoPrimaryController { + String primaryAddress(); + void stopPrimary(); + String awaitNewPrimary(); +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-testkit-failover:test --tests 'io.backend.skeleton.mongodb.testkit.failover.MongoThreeNodeReplicaSetTest' +./gradlew :modules:mongodb:mongodb-testkit-failover:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-testkit-failover/src/main/java/io/backend/skeleton/mongodb/testkit/failover/MongoThreeNodeReplicaSet.java' 'modules/mongodb/mongodb-testkit-failover/src/main/java/io/backend/skeleton/mongodb/testkit/failover/MongoPrimaryController.java' 'modules/mongodb/mongodb-testkit-failover/src/main/java/io/backend/skeleton/mongodb/testkit/failover/MongoNetworkFaultController.java' 'modules/mongodb/mongodb-testkit-failover/src/main/java/io/backend/skeleton/mongodb/testkit/failover/MongoFailoverScenario.java' 'modules/mongodb/mongodb-testkit-failover/src/test/resources/mongodb/init-three-rs.js' 'modules/mongodb/mongodb-testkit-failover/src/test/java/io/backend/skeleton/mongodb/testkit/failover/MongoThreeNodeReplicaSetTest.java' +git commit -m "test: add mongodb three node failover testkit" +``` + +### Task 46: MongoDB 7.0·8.0 Mapping·Transaction·Change Stream 호환성 Matrix + +**Files:** +- Create: `modules/mongodb/mongodb-testkit-replicaset/src/main/java/io/backend/skeleton/mongodb/testkit/compat/MongoVersionMatrix.java` +- Create: `modules/mongodb/mongodb-testkit-replicaset/src/main/java/io/backend/skeleton/mongodb/testkit/compat/MongoStableContractSuite.java` +- Create: `modules/mongodb/mongodb-testkit-replicaset/src/main/java/io/backend/skeleton/mongodb/testkit/compat/MongoVersionCapabilityReport.java` +- Create: `modules/mongodb/mongodb-testkit-replicaset/src/test/resources/mongodb/version-matrix.json` +- Test: `modules/mongodb/mongodb-testkit-replicaset/src/test/java/io/backend/skeleton/mongodb/testkit/compat/MongoVersionMatrixTest.java` + +**Interfaces:** +- Consumes: Single replica set testkit and all Stable platform modules. +- Produces: Parameterized contract suite for MongoDB 7.0 compatibility and MongoDB 8.0 primary certification. + +**Implementation requirements:** +- Run golden BSON, atomic update, optimistic lock, transaction, bulk, TTL, geo and change stream contracts on both lanes. +- Use Stable API V1 strict client for D1/D2 tests. +- Verify 8.0/7.0 validator actions exclude errorAndLog. +- Record capability differences as a generated support report. +- Release requires every Stable contract to pass on both lanes. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoVersionMatrixTest { + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"7.0", "8.0"}) + void stableContractsRunOnEverySupportedLane(String version) { + MongoStableContractReport report = MongoStableContractSuite.run(version); + org.assertj.core.api.Assertions.assertThat(report.failures()).isEmpty(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-testkit-replicaset:compatibilityTest --tests 'io.backend.skeleton.mongodb.testkit.compat.MongoVersionMatrixTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record MongoVersionMatrix(java.util.List stableVersions) { + public static MongoVersionMatrix standard() { + return new MongoVersionMatrix(java.util.List.of("7.0", "8.0")); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-testkit-replicaset:compatibilityTest --tests 'io.backend.skeleton.mongodb.testkit.compat.MongoVersionMatrixTest' +./gradlew :modules:mongodb:mongodb-testkit-replicaset:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-testkit-replicaset/src/main/java/io/backend/skeleton/mongodb/testkit/compat/MongoVersionMatrix.java' 'modules/mongodb/mongodb-testkit-replicaset/src/main/java/io/backend/skeleton/mongodb/testkit/compat/MongoStableContractSuite.java' 'modules/mongodb/mongodb-testkit-replicaset/src/main/java/io/backend/skeleton/mongodb/testkit/compat/MongoVersionCapabilityReport.java' 'modules/mongodb/mongodb-testkit-replicaset/src/test/resources/mongodb/version-matrix.json' 'modules/mongodb/mongodb-testkit-replicaset/src/test/java/io/backend/skeleton/mongodb/testkit/compat/MongoVersionMatrixTest.java' +git commit -m "test: certify mongodb seven and eight stable contracts" +``` + +### Task 47: Migration Empty·N-1·Legacy Snapshot와 Restart Contract 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-testkit-migration/src/main/java/io/backend/skeleton/mongodb/testkit/migration/MongoMigrationSnapshotFixture.java` +- Create: `modules/mongodb/mongodb-testkit-migration/src/main/java/io/backend/skeleton/mongodb/testkit/migration/MongoMigrationContractSuite.java` +- Create: `modules/mongodb/mongodb-testkit-migration/src/main/java/io/backend/skeleton/mongodb/testkit/migration/MongoBackfillRestartFixture.java` +- Create: `modules/mongodb/mongodb-testkit-migration/src/test/resources/snapshots/empty.json` +- Create: `modules/mongodb/mongodb-testkit-migration/src/test/resources/snapshots/previous-release.json` +- Create: `modules/mongodb/mongodb-testkit-migration/src/test/resources/snapshots/oldest-supported.json` +- Test: `modules/mongodb/mongodb-testkit-migration/src/test/java/io/backend/skeleton/mongodb/testkit/migration/MongoMigrationContractSuiteTest.java` + +**Interfaces:** +- Consumes: Migration core, Flamingock adapter, replica set testkit and schema/index manifests. +- Produces: Upgrade tests from empty, previous release and oldest supported snapshots plus lock/checkpoint/restart behavior. + +**Implementation requirements:** +- Validate empty → latest, N-1 → latest and oldest-supported → latest. +- Checksum mutation and missing applied migration must fail. +- Kill the migration process after each batch boundary and resume from the durable checkpoint. +- Verify distributed lock prevents two migration runners. +- Ensure production application credentials cannot apply migrations. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoMigrationContractSuiteTest { + @org.junit.jupiter.api.Test + void interruptedBackfillResumesWithoutReprocessingCompletedBatch() { + MongoMigrationProbe probe = MongoMigrationProbe.killAfterBatch(3); + probe.runAndRestart(); + org.assertj.core.api.Assertions.assertThat(probe.completedIds()) + .doesNotHaveDuplicates(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-testkit-migration:migrationTest --tests 'io.backend.skeleton.mongodb.testkit.migration.MongoMigrationContractSuiteTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public interface MongoMigrationContractSuite { + MongoMigrationReport run(MongoMigrationSnapshotFixture snapshot); +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-testkit-migration:migrationTest --tests 'io.backend.skeleton.mongodb.testkit.migration.MongoMigrationContractSuiteTest' +./gradlew :modules:mongodb:mongodb-testkit-migration:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-testkit-migration/src/main/java/io/backend/skeleton/mongodb/testkit/migration/MongoMigrationSnapshotFixture.java' 'modules/mongodb/mongodb-testkit-migration/src/main/java/io/backend/skeleton/mongodb/testkit/migration/MongoMigrationContractSuite.java' 'modules/mongodb/mongodb-testkit-migration/src/main/java/io/backend/skeleton/mongodb/testkit/migration/MongoBackfillRestartFixture.java' 'modules/mongodb/mongodb-testkit-migration/src/test/resources/snapshots/empty.json' 'modules/mongodb/mongodb-testkit-migration/src/test/resources/snapshots/previous-release.json' 'modules/mongodb/mongodb-testkit-migration/src/test/resources/snapshots/oldest-supported.json' 'modules/mongodb/mongodb-testkit-migration/src/test/java/io/backend/skeleton/mongodb/testkit/migration/MongoMigrationContractSuiteTest.java' +git commit -m "test: add mongodb migration snapshot and restart contracts" +``` + +### Task 48: RBAC·TLS·NoSQL Injection·Redaction 통합 검증 + +**Files:** +- Create: `modules/mongodb/mongodb-security/src/test/java/io/backend/skeleton/mongodb/security/MongoSecurityIntegrationTest.java` +- Create: `modules/mongodb/mongodb-security/src/test/java/io/backend/skeleton/mongodb/security/MongoNoSqlInjectionTest.java` +- Create: `modules/mongodb/mongodb-security/src/test/java/io/backend/skeleton/mongodb/security/MongoLogRedactionTest.java` +- Create: `modules/mongodb/mongodb-security/src/test/resources/security/app-role.js` +- Create: `modules/mongodb/mongodb-security/src/test/resources/security/admin-role.js` +- Create: `modules/mongodb/mongodb-security/src/test/resources/security/test-ca.pem` +- Test: `modules/mongodb/mongodb-security/src/test/java/io/backend/skeleton/mongodb/security/MongoSecurityReleaseGateTest.java` + +**Interfaces:** +- Consumes: Security profiles, query/native guardrails and replica set TLS fixtures. +- Produces: Release tests proving least privilege, fail-closed TLS/auth, injection rejection and absence of PII/secrets in telemetry. + +**Implementation requirements:** +- App role can perform approved collection operations but cannot drop database, create users or modify sharding. +- Invalid CA, hostname and client certificate fail closed. +- Operator injection, dynamic field injection, dangerous regex and arbitrary command inputs are rejected locally. +- Logs, metrics and traces never contain credentials, raw BSON, document IDs, resume tokens or plaintext PII. +- Credential rotation succeeds through a new client generation without mixed admin/runtime credentials. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoSecurityReleaseGateTest { + @org.junit.jupiter.api.Test + void applicationRoleCannotDropDatabase() { + MongoSecurityProbe probe = MongoSecurityProbe.withApplicationRole(); + org.assertj.core.api.Assertions.assertThatThrownBy(probe::dropDatabase) + .isInstanceOf(MongoOperationRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-security:integrationTest --tests 'io.backend.skeleton.mongodb.security.MongoSecurityReleaseGateTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class MongoSecurityReleaseGate { + public static void require(MongoSecurityEvidence evidence) { + if (!evidence.tls() || !evidence.auth() || !evidence.leastPrivilege()) { + throw new IllegalStateException("MongoDB security gate failed"); + } + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-security:integrationTest --tests 'io.backend.skeleton.mongodb.security.MongoSecurityReleaseGateTest' +./gradlew :modules:mongodb:mongodb-security:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-security/src/test/java/io/backend/skeleton/mongodb/security/MongoSecurityIntegrationTest.java' 'modules/mongodb/mongodb-security/src/test/java/io/backend/skeleton/mongodb/security/MongoNoSqlInjectionTest.java' 'modules/mongodb/mongodb-security/src/test/java/io/backend/skeleton/mongodb/security/MongoLogRedactionTest.java' 'modules/mongodb/mongodb-security/src/test/resources/security/app-role.js' 'modules/mongodb/mongodb-security/src/test/resources/security/admin-role.js' 'modules/mongodb/mongodb-security/src/test/resources/security/test-ca.pem' 'modules/mongodb/mongodb-security/src/test/java/io/backend/skeleton/mongodb/security/MongoSecurityReleaseGateTest.java' +git commit -m "test: add mongodb security release gate" +``` + +### Task 49: 성능·Backpressure·Failover Chaos Aggregate Gate 구현 + +**Files:** +- Create: `modules/mongodb/mongodb-testkit-failover/src/main/java/io/backend/skeleton/mongodb/testkit/performance/MongoPerformanceGate.java` +- Create: `modules/mongodb/mongodb-testkit-failover/src/main/java/io/backend/skeleton/mongodb/testkit/performance/MongoChaosGate.java` +- Create: `modules/mongodb/mongodb-testkit-failover/src/main/java/io/backend/skeleton/mongodb/testkit/performance/MongoResourceBudgetReport.java` +- Create: `modules/mongodb/mongodb-testkit-failover/src/performanceTest/java/io/backend/skeleton/mongodb/testkit/performance/MongoPerformanceGateTest.java` +- Create: `modules/mongodb/mongodb-testkit-failover/src/failureTest/java/io/backend/skeleton/mongodb/testkit/performance/MongoChaosGateTest.java` +- Test: `modules/mongodb/mongodb-testkit-failover/src/test/java/io/backend/skeleton/mongodb/testkit/performance/MongoReleaseEvidenceTest.java` + +**Interfaces:** +- Consumes: Three-node failover, operation budgets, aggregation, bulk, change stream and observability modules. +- Produces: Evidence for hot document contention, aggregation spill, deep pagination, pool saturation, primary failover and response loss. + +**Implementation requirements:** +- Measure p50/p95/p99, documents examined/returned, keys examined, spill, pool wait and memory. +- Include hot counter, bounded/unbounded embedded growth, large group/sort, deep skip versus keyset and ordered/unordered bulk. +- Kill primary during writes, transaction commit and change stream consumption. +- Verify operation budgets prevent heap, queue and cursor growth beyond configured limits. +- Persist machine-readable reports as release artifacts. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoReleaseEvidenceTest { + @org.junit.jupiter.api.Test + void releaseEvidenceContainsNoUnknownCommitBodyRetry() { + MongoChaosReport report = MongoChaosGate.runStandardScenarios(); + org.assertj.core.api.Assertions.assertThat(report.businessBodyRetriesAfterUnknownCommit()).isZero(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-testkit-failover:test --tests 'io.backend.skeleton.mongodb.testkit.performance.MongoReleaseEvidenceTest' +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record MongoResourceBudgetReport( + long p99Millis, + long maxPoolWaitMillis, + long maxHeapBytes, + long documentsExamined, + long documentsReturned) { +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +./gradlew :modules:mongodb:mongodb-testkit-failover:test --tests 'io.backend.skeleton.mongodb.testkit.performance.MongoReleaseEvidenceTest' +./gradlew :modules:mongodb:mongodb-testkit-failover:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/mongodb/mongodb-testkit-failover/src/main/java/io/backend/skeleton/mongodb/testkit/performance/MongoPerformanceGate.java' 'modules/mongodb/mongodb-testkit-failover/src/main/java/io/backend/skeleton/mongodb/testkit/performance/MongoChaosGate.java' 'modules/mongodb/mongodb-testkit-failover/src/main/java/io/backend/skeleton/mongodb/testkit/performance/MongoResourceBudgetReport.java' 'modules/mongodb/mongodb-testkit-failover/src/performanceTest/java/io/backend/skeleton/mongodb/testkit/performance/MongoPerformanceGateTest.java' 'modules/mongodb/mongodb-testkit-failover/src/failureTest/java/io/backend/skeleton/mongodb/testkit/performance/MongoChaosGateTest.java' 'modules/mongodb/mongodb-testkit-failover/src/test/java/io/backend/skeleton/mongodb/testkit/performance/MongoReleaseEvidenceTest.java' +git commit -m "test: add mongodb performance and chaos release gates" +``` + +### Task 50: 문서·Runbook·ADR·지원 Matrix와 최종 Release Gate 구현 + +**Files:** +- Create: `docs/mongodb/support-matrix.md` +- Create: `docs/mongodb/document-modeling-guide.md` +- Create: `docs/mongodb/bson-mapping-guide.md` +- Create: `docs/mongodb/consistency-transaction-guide.md` +- Create: `docs/mongodb/query-aggregation-guide.md` +- Create: `docs/mongodb/schema-index-migration-guide.md` +- Create: `docs/mongodb/change-stream-guide.md` +- Create: `docs/mongodb/security-observability.md` +- Create: `docs/mongodb/runbooks/failover.md` +- Create: `docs/mongodb/runbooks/unknown-commit.md` +- Create: `docs/mongodb/runbooks/history-lost.md` +- Create: `docs/adr/ADR-MONGO-001-platform-boundary.md` +- Create: `docs/adr/ADR-MONGO-002-bson-representation.md` +- Create: `docs/adr/ADR-MONGO-003-transaction-retry.md` +- Create: `docs/adr/ADR-MONGO-004-index-schema-admin-plane.md` +- Create: `modules/mongodb/mongodb-spring-boot-starter/src/test/java/io/backend/skeleton/mongodb/autoconfigure/MongoStableReleaseGateTest.java` +- Test: `scripts/verify-mongodb-platform.sh` + +**Interfaces:** +- Consumes: All Stable tasks and generated test evidence. +- Produces: Published documentation, support matrix, runbooks, ADRs and one reproducible Stable release command. + +**Implementation requirements:** +- Document every public type, profile, error, startup failure and non-supported behavior. +- Support matrix distinguishes MongoDB 7.0 and 8.0 plus Standalone/RS/Sharded/Atlas topologies. +- Runbooks cover primary failover, unknown commit, history lost, index drift, migration failure and credential rotation. +- Release script executes unit, contract, compatibility, migration, security, failure and performance gates. +- Advanced modules are not promoted or transitively included by this gate. + +- [ ] **Step 1: Write the failing test** + +```java +class MongoStableReleaseGateTest { + @org.junit.jupiter.api.Test + void stableReleaseRequiresEveryEvidenceCategory() { + MongoStableReleaseEvidence evidence = MongoStableReleaseEvidence.load(); + org.assertj.core.api.Assertions.assertThat(evidence.categories()) + .contains("mapping", "transaction", "migration", "change-stream", + "security", "failover", "performance", "compatibility"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +bash scripts/verify-mongodb-platform.sh +``` + +Expected: FAIL because the production type, policy, wiring, or external behavior defined by this task does not exist yet. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class MongoStableReleaseGate { + public void verify(MongoStableReleaseEvidence evidence) { + evidence.require("mapping"); + evidence.require("transaction"); + evidence.require("migration"); + evidence.require("change-stream"); + evidence.require("security"); + evidence.require("failover"); + evidence.require("performance"); + evidence.require("compatibility"); + } +} +``` + +Implement every file and invariant listed under **Implementation requirements**. The snippet fixes the public names and central behavior; it does not replace the listed requirements. + +- [ ] **Step 4: Run the focused test and the module suite** + +Run: + +```bash +bash scripts/verify-mongodb-platform.sh +./gradlew :modules:mongodb:mongodb-spring-boot-starter:test +``` + +Expected: PASS with the focused assertion and the module suite green. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'docs/mongodb/support-matrix.md' 'docs/mongodb/document-modeling-guide.md' 'docs/mongodb/bson-mapping-guide.md' 'docs/mongodb/consistency-transaction-guide.md' 'docs/mongodb/query-aggregation-guide.md' 'docs/mongodb/schema-index-migration-guide.md' 'docs/mongodb/change-stream-guide.md' 'docs/mongodb/security-observability.md' 'docs/mongodb/runbooks/failover.md' 'docs/mongodb/runbooks/unknown-commit.md' 'docs/mongodb/runbooks/history-lost.md' 'docs/adr/ADR-MONGO-001-platform-boundary.md' 'docs/adr/ADR-MONGO-002-bson-representation.md' 'docs/adr/ADR-MONGO-003-transaction-retry.md' 'docs/adr/ADR-MONGO-004-index-schema-admin-plane.md' 'modules/mongodb/mongodb-spring-boot-starter/src/test/java/io/backend/skeleton/mongodb/autoconfigure/MongoStableReleaseGateTest.java' 'scripts/verify-mongodb-platform.sh' +git commit -m "docs: publish mongodb platform release contract" +``` diff --git a/mongodb-superpowers-package/docs/superpowers/specs/2026-08-11-mongodb-document-persistence-platform-design.md b/mongodb-superpowers-package/docs/superpowers/specs/2026-08-11-mongodb-document-persistence-platform-design.md new file mode 100644 index 00000000..e79ff9f3 --- /dev/null +++ b/mongodb-superpowers-package/docs/superpowers/specs/2026-08-11-mongodb-document-persistence-platform-design.md @@ -0,0 +1,1303 @@ +# MongoDB 문서 영속성 플랫폼 설계서 + +- 문서 상태: 구현 기준 설계 +- 기준일: 2026-08-11 +- 대상 프로젝트: Java 21 / Spring Boot 4.1 기반 Backend Skeleton +- 모듈 루트: `modules/mongodb` +- 루트 패키지: `io.backend.skeleton.mongodb` +- 요구사항 원본: `MongoDB 문서 영속성 플랫폼 심층 리서치` + +## 1. 설계 목적 + +이 설계의 목적은 Spring Data MongoDB를 다시 감싼 공통 CRUD 라이브러리를 만드는 것이 아니다. 도메인 모듈이 MongoDB의 Document, Repository, Query 의미와 Index 요구를 직접 소유하면서도, 다음 기술 결정은 모든 서비스가 같은 기준으로 재사용하도록 만드는 것이다. + +```text +Document Modeling +→ BSON Mapping +→ Atomic Write +→ Consistency Profile +→ Session / Transaction +→ Retry / Ambiguous Outcome +→ Query / Aggregation Guardrail +→ Schema / Index Manifest +→ Pagination / Bulk +→ Change Stream +→ Security / Observability +→ Topology / Release Gate +``` + +플랫폼은 MongoDB를 관계형 저장소처럼 평탄화하지 않는다. 반대로 Native Driver와 `runCommand()`를 일반 애플리케이션에 무제한 노출하지도 않는다. MongoDB의 단일 문서 원자성, BSON 표현, Aggregation, Replica Set, Change Stream, Sharding, Encryption 의미론을 유지하면서 잘못된 사용을 사전에 차단한다. + +## 2. 최상위 설계 결정 + +| 번호 | 결정 | 구현 결과 | +|---:|---|---| +| D-01 | 도메인이 `@Document`, Repository, Query, Index Requirement, Schema Version을 소유한다. | 플랫폼에는 범용 `CommonMongoRepository`를 만들지 않는다. | +| D-02 | Local 기본 토폴로지는 Single-node Replica Set이다. | Transaction, Retryable Write, Change Stream을 Local에서 동일하게 검증한다. | +| D-03 | Standalone은 smoke test만 지원한다. | 운영 Profile이나 Stable Release Gate로 인정하지 않는다. | +| D-04 | D1/D2 Runtime Client는 Stable API V1과 `apiStrict=true`를 기본으로 한다. | 일반 CRUD·Query·Transaction·Change Stream 경로의 호환성을 제한한다. | +| D-05 | D3 Capability Client와 D4 Admin Client를 분리한다. | Stable API 밖 기능과 관리 명령을 일반 Runtime에서 우회하지 못한다. | +| D-06 | UUID·Decimal·시간·type metadata 표현을 Manifest로 고정한다. | 배포나 라이브러리 변경으로 BSON 표현이 암묵적으로 바뀌지 않는다. | +| D-07 | 부분 변경은 Update Operator를 우선한다. | `save()` 기반 전체 Document 교체로 인한 Lost Update를 줄인다. | +| D-08 | 전체 Document 교체에는 optimistic revision을 요구한다. | `@Version` 또는 expected revision predicate를 사용한다. | +| D-09 | Transaction보다 단일 Document 원자 연산을 우선한다. | Multi-document invariant에만 Transaction을 사용한다. | +| D-10 | Transaction 본문 Retry와 Commit Retry를 분리한다. | `TransientTransactionError`는 전체 본문, `UnknownTransactionCommitResult`는 Commit만 재시도한다. | +| D-11 | Query·Aggregation·Index는 등록된 operation/manifest를 요구한다. | 자유형 JSON Query와 무제한 Pipeline을 차단한다. | +| D-12 | Change Stream은 at-least-once projector다. | physical change event를 업무 Integration Event로 직접 공개하지 않는다. | +| D-13 | TTL은 물리 cleanup이다. | 정확한 업무 Scheduler 또는 접근 차단의 유일한 근거로 사용하지 않는다. | +| D-14 | GridFS는 compatibility adapter다. | 신규 파일 Source of Truth는 기존 Fileserver/Object Storage를 사용한다. | +| D-15 | Sharding·Time Series·Encryption·Search·Vector·Multi-tenancy는 선택 모듈이다. | Stable Starter의 기본 dependency와 권한에 포함하지 않는다. | + +## 3. 범위와 비범위 + +### 3.1 Stable 범위 + +```text +Mapping Manifest +Repository / MongoTemplate 통합 +Imperative / Reactive 실행 경로 +단일 Document 원자 Update +Optimistic Lock +Replica Set Transaction +Consistency Profile +Retry / Error Translation +Query Guardrail +Aggregation Guardrail +Schema / Index Manifest +Keyset Pagination +Bulk Partial Result +Change Stream +TTL Cleanup Contract +GeoJSON / 2dsphere +Security +Observability +Replica Set Testkit +``` + +### 3.2 Advanced 범위 + +```text +Sharding-aware Query +Time Series +CSFLE +Queryable Encryption Equality / Range +Change Stream → Messaging Bridge +Shared Collection Multi-tenancy +``` + +### 3.3 Experimental 범위 + +```text +MongoDB Search +Vector Search +Hybrid Search +Database-per-tenant +Collection-per-tenant +Reshard orchestration +Atlas/provider 고유 기능 +``` + +### 3.4 명시적 비지원 + +```text +CommonMongoRepository +Runtime arbitrary runCommand +운영 auto-index creation +Standalone 운영 계약 +TTL 기반 정확한 Scheduler +Change Stream 원본의 외부 업무 이벤트 공개 +신규 GridFS 파일 플랫폼 +Java FQCN을 장수 BSON schema로 강제 +무제한 skip pagination +무제한 aggregation / regex / result +``` + +## 4. 지원 기준 + +| 구성 | Stable 기준 | 정책 | +|---|---|---| +| Java | 21 | 프로젝트 Runtime 기준 | +| Spring Boot | 4.1.x BOM | 개별 Driver 버전 override 금지 | +| Spring Data MongoDB | 5.1.x | Repository·Template 통합 | +| MongoDB Java Driver | Boot BOM 관리 | 직접 버전 고정 금지 | +| MongoDB Server | 8.0 최신 패치 | Primary Certification Lane | +| MongoDB 7.0 | 최신 7.0 패치 | Compatibility Lane | +| Stable API | V1 | D1/D2 strict 기본 | +| Local | Single-node Replica Set | 기본 개발 환경 | +| 운영 Stable Gate | 3-node Replica Set | Failover 검증 필수 | +| Sharding | 실제 Sharded Cluster | Advanced Gate | +| Search/Vector | Atlas Local + 실제 목표 배포 | 기능별 Gate | +| Encryption | 실제 KMS·Key Vault 환경 | 기능별 Gate | + +MongoDB 8.0/7.0 Stable 계약에서는 `validationAction=errorAndLog`를 사용하지 않는다. Stable Validation Action은 `error`와 `warn`이다. + +## 5. 공개 계층 + +```text +D1 Standard Document Persistence +├─ Spring Data Repository +├─ Typed Query / Projection +├─ Mapping Manifest +├─ Atomic Update Primitive +└─ Optimistic Revision + +D2 Advanced Document Operations +├─ MongoTemplate +├─ Transaction / Session +├─ Bulk +├─ Aggregation +├─ Keyset / Cursor +└─ Change Stream + +D3 Explicit Mongo Capability +├─ Native BSON +├─ Time Series +├─ Search / Vector +├─ CSFLE / QE +└─ Sharding-aware Operations + +D4 Admin Plane +├─ Collection +├─ Validator +├─ Index +├─ Migration +├─ Shard / Refine / Reshard +└─ Repair / Verification +``` + +D3도 raw client escape가 아니다. 모든 D3 호출은 다음 순서를 통과한다. + +```text +Capability 등록 확인 +→ Database Profile 확인 +→ Collection allowlist +→ Operation Name 필수 +→ Timeout / maxTimeMS +→ Consistency Profile +→ Result / Batch Limit +→ Trace +→ Log Redaction +→ Command Category 검증 +→ D4 Command 차단 +→ 실행 +``` + +## 6. 모듈 구조 + +### 6.1 Stable 모듈 + +```text +modules/mongodb/ +├── mongodb-core-api +├── mongodb-spring-data +├── mongodb-imperative +├── mongodb-reactive +├── mongodb-query +├── mongodb-aggregation +├── mongodb-transaction +├── mongodb-index-schema +├── mongodb-change-stream +├── mongodb-geospatial +├── mongodb-migration-core +├── mongodb-migration-flamingock +├── mongodb-observability +├── mongodb-security +├── mongodb-spring-boot-starter +├── mongodb-testkit-core +├── mongodb-testkit-replicaset +├── mongodb-testkit-failover +└── mongodb-testkit-migration +``` + +### 6.2 Advanced·Experimental 모듈 + +```text +modules/mongodb-advanced/ +├── mongodb-sharding +├── mongodb-timeseries +├── mongodb-csfle +├── mongodb-queryable-encryption +├── mongodb-search +├── mongodb-vector-search +├── mongodb-tenancy-shared +├── mongodb-tenancy-database +├── mongodb-change-stream-messaging-bridge +├── mongodb-gridfs-compat +├── mongodb-testkit-sharded +└── mongodb-testkit-atlas +``` + +### 6.3 Dependency 규칙 + +```text +mongodb-core-api + → Java 표준 라이브러리만 + +mongodb-spring-data + → core-api + +mongodb-imperative / mongodb-reactive + → core-api + → spring-data + +mongodb-query + → core-api + → spring-data + +mongodb-aggregation + → core-api + → query + +mongodb-transaction + → core-api + → spring-data + +mongodb-index-schema + → core-api + → spring-data + +mongodb-change-stream + → core-api + → reactive + +mongodb-geospatial + → core-api + → spring-data + +mongodb-migration-flamingock + → migration-core + → index-schema + +mongodb-spring-boot-starter + → Stable 모듈만 + +Advanced 모듈 + → Starter에 자동 포함하지 않음 +``` + +## 7. 핵심 타입 + +### 7.1 Operation·Profile + +```java +public record MongoOperationName(String value) { + public MongoOperationName { + if (value == null || !value.matches("[a-z][a-z0-9.-]{2,95}")) { + throw new IllegalArgumentException("invalid MongoDB operation name"); + } + } +} + +public record DatabaseProfileName(String value) {} +public record CollectionProfileName(String value) {} + +public record MongoOperationContext( + MongoOperationName operationName, + DatabaseProfileName databaseProfile, + CollectionProfileName collectionProfile, + MongoConsistencyProfile consistency, + Duration timeout) { +} +``` + +Operation name은 metric·trace·policy key로 사용하므로 사용자 ID나 동적 값이 들어갈 수 없다. + +### 7.2 Consistency Profile + +```java +public enum MongoConsistencyProfile { + PRIMARY_LOCAL, + PRIMARY_MAJORITY, + CAUSAL_MAJORITY, + STALE_READ_ALLOWED, + SNAPSHOT_TRANSACTION, + MONGO_SHORT_WRITE +} +``` + +| Profile | Read Preference | Read Concern | Write Concern | 용도 | +|---|---|---|---|---| +| PRIMARY_LOCAL | primary | local | acknowledged | 일반 저지연 작업 | +| PRIMARY_MAJORITY | primary | majority | majority | rollback 저항·내구성 | +| CAUSAL_MAJORITY | primary 기본 | majority | majority | read-your-writes | +| STALE_READ_ALLOWED | secondaryPreferred | local/majority | 없음 | 명시적 stale read | +| SNAPSHOT_TRANSACTION | primary | snapshot | majority | multi-document snapshot | +| MONGO_SHORT_WRITE | primary | profile 값 | profile 값 | 짧은 write transaction | + +`STALE_READ_ALLOWED`는 이름으로 위험을 드러낸다. `readOnly=true`를 자동으로 secondary routing 조건으로 사용하지 않는다. + +### 7.3 실행 결과 + +```java +public enum MongoExecutionOutcome { + NOT_SENT, + NO_WRITE_PERFORMED, + WRITE_CONFIRMED, + PARTIAL_BULK_WRITE, + WRITE_RESULT_UNKNOWN, + TRANSACTION_COMMIT_UNKNOWN +} +``` + +`WRITE_RESULT_UNKNOWN`과 `TRANSACTION_COMMIT_UNKNOWN`은 명백한 실패가 아니다. 동일 업무 본문을 무조건 재실행하지 않고 version, unique key, idempotency record, transaction record를 조회해 복구한다. + +### 7.4 Capability + +```java +public enum MongoSupportLevel { + STABLE, + ADVANCED, + EXPERIMENTAL, + UNSUPPORTED +} + +public record MongoCapabilitySupport( + String capability, + MongoSupportLevel level, + Map constraints) { +} +``` + +Capability는 `boolean`만 반환하지 않는다. Time Series, Sharding, Encryption, Search처럼 제약이 큰 기능은 토폴로지·버전·권한·비지원 조합을 함께 반환한다. + +## 8. Client와 권한 경계 + +```text +Runtime Strict Client +├─ Stable API V1 +├─ apiStrict=true +├─ App read/write credential +└─ D1/D2 + +Capability Client +├─ capability allowlist +├─ Stable API strict 여부 기능별 결정 +├─ capability credential +└─ D3 + +Admin Client +├─ Runtime Starter에서 기본 미등록 +├─ migration/deployment job 전용 +├─ admin credential +└─ D4 +``` + +일반 Runtime에서 `MongoClient`, `MongoDatabase`, `MongoCollection` Bean을 이름 없이 전역 공개하지 않는다. Spring Data가 내부적으로 사용하는 Bean은 존재하지만 애플리케이션이 직접 주입하지 않도록 ArchUnit·Bean visibility·문서 규칙을 적용한다. + +## 9. Document 모델링 계약 + +### 9.1 Embed와 Reference + +```text +같이 읽음 +AND 같이 변경함 +AND 크기가 bounded +→ Embed + +독립 lifecycle +OR unbounded growth +OR 여러 Aggregate에서 공유 +OR 독립 sharding 필요 +→ Manual Reference +``` + +| 특성 | Embed | Manual Reference | +|---|---:|---:| +| 항상 함께 조회 | 권장 | 가능 | +| 같은 명령에서 변경 | 권장 | 신중 | +| bounded | 필수 | 불필요 | +| 무한 배열 | 금지 | 권장 | +| 공유 데이터 | 신중 | 권장 | +| 독립 보안·보존 | 신중 | 권장 | +| hot write | 신중 | 권장 | + +### 9.2 Document Modeling Manifest + +도메인별로 다음 Manifest를 제공한다. + +```yaml +collections: + orders: + document-type: order + schema-version: 3 + max-estimated-document-bytes: 2097152 + embedded-collections: + lineItems: + max-elements: 200 + stateHistory: + max-elements: 100 + references: + customerId: + target-collection: customers + required: true +``` + +플랫폼은 16 MiB 한계에 가까운 설정을 허용하지 않고 안전 여유가 포함된 프로젝트 상한을 요구한다. 실제 serialized BSON 크기를 contract test로 측정한다. + +### 9.3 GridFS 경계 + +```text +신규 파일 저장 +→ Fileserver Application +→ Filesystem / Object Storage Adapter + +MongoDB Document +→ FileId / ContentKey Reference + +GridFS +→ Legacy read / migration compatibility only +``` + +## 10. BSON 표현 Manifest + +```yaml +mongodb: + mapping: + uuid-representation: STANDARD + big-decimal-representation: DECIMAL128 + big-integer-representation: STRING + instant-representation: BSON_DATE + local-date-time: REJECT_UNLESS_CONVERTER_REGISTERED + enum-representation: STRING + type-metadata: ALIAS_FOR_LONG_LIVED_COLLECTIONS +``` + +| Java/Domain 타입 | BSON | 정책 | +|---|---|---| +| ObjectId | ObjectId | Mongo 전용 내부 ID에 허용 | +| UUID/UUIDv7 | Binary UUID | STANDARD 고정 | +| String Domain ID | String | ObjectId 자동 변환 차단 | +| BigDecimal | Decimal128 | 정밀도·범위 검사 | +| BigInteger | 명시적 | 컬렉션별 고정 | +| Instant | BSON Date | Stable | +| LocalDateTime | 암묵 저장 금지 | 명시 Converter 요구 | +| Enum | String | rename은 migration | +| Money | Document | amount/currency 고정 | +| Encrypted field | BinData | Encryption 모듈 소유 | + +다음 차이는 모두 schema 차이다. + +```text +absent != null +[] != absent +"1" != 1 +String decimal != Decimal128 +String object id != ObjectId +``` + +### 10.1 Golden BSON Gate + +모든 지원 타입은 다음 흐름을 통과한다. + +```text +Java object +→ MappingMongoConverter +→ BSON snapshot +→ 실제 Replica Set 저장 +→ raw BSON read-back +→ Java object round-trip +``` + +## 11. Type Metadata + +| Collection | 정책 | +|---|---| +| 내부 단기 | 기본 `_class` 허용 가능 | +| 장수·다중 버전 | `@TypeAlias` 필수 | +| 외부 공유 | 명시적 `documentType` | +| package refactoring 가능 | FQCN 금지 | + +`_class` 제거는 모든 다형성 Mapping에 동일하게 적용하지 않는다. Collection별 `TypeMetadataPolicy`를 사용한다. + +## 12. Schema Validation·Versioning·Migration + +### 12.1 Validation 계층 + +```text +Bean Validation +→ 입력·Java 객체 조기 실패 + +MongoDB JSON Schema +→ 저장 BSON type/required/range 최종 방어 + +Domain Invariant +→ 업무 규칙 +``` + +| 단계 | validationLevel | validationAction | +|---|---|---| +| 신규 Collection | strict | error | +| Legacy 정비 시작 | moderate | warn | +| Backfill | migration profile | warn 또는 error | +| 정비 완료 | strict | error | + +### 12.2 Schema Version + +```java +public record DocumentSchemaVersion(int value) { + public DocumentSchemaVersion { + if (value < 0) throw new IllegalArgumentException("negative schema version"); + } +} +``` + +```text +schemaVersion 없음 +→ Legacy V0 + +신규 Write +→ Current Version만 + +지원 범위 밖 Version +→ MongoDataSchemaUnsupportedException +``` + +### 12.3 변경 절차 + +```text +Dual Reader +→ New Writer +→ Rate-limited Backfill +→ Version 잔존량 검증 +→ Legacy Index 제거 +→ Legacy Reader 제거 +``` + +### 12.4 Migration SPI + +```java +public interface MongoMigration { + MigrationId id(); + MigrationChecksum checksum(); + MigrationPrecondition precondition(); + MigrationResult execute(MigrationContext context); + MigrationPostcondition postcondition(); +} +``` + +필수 기능: + +```text +Ledger +Distributed Lock +Dry Run +Batch +Checkpoint +Resume +Rate Limit +maxTimeMS +Precondition +Postcondition +Forward Fix +Operator Metadata +``` + +신규 기본 Adapter는 `mongodb-migration-flamingock`으로 두되, 플랫폼 공개 계약은 Flamingock 타입에 의존하지 않는다. Mongock 신규 채택은 금지한다. Liquibase MongoDB는 후속 D4 Adapter로 추가할 수 있다. + +## 13. 쓰기 모델 + +| 상황 | 기본 연산 | +|---|---| +| 신규 Document | insert | +| 전체 재계산 | versioned save/replace | +| 일부 필드 | updateOne + operator | +| Counter | `$inc` | +| 상태 전이 | expected state + `$set` | +| bounded Set | `$addToSet` | +| 배열 원소 | positional / arrayFilters | +| 변경 결과 반환 | findAndModify | +| 경쟁 생성 | unique index + upsert | +| 독립 다건 | bulk | +| 하나의 multi-document invariant | transaction | + +### 13.1 Atomic Update API + +```java +public interface MongoAtomicOperations { + AtomicUpdateResult updateOne( + MongoOperationContext context, + Class documentType, + AtomicFilter filter, + AtomicUpdate update, + ReturnDocumentMode returnMode); +} +``` + +자유형 BSON filter/update를 받지 않고 등록된 field descriptor와 operator allowlist를 사용한다. + +### 13.2 Optimistic Revision + +전체 Document 교체와 custom update는 다음 predicate를 사용한다. + +```text +filter: + _id = id + version = expected + +update: + business fields + version = version + 1 +``` + +Conflict Retry는 stale object 재저장이 아니다. + +```text +최신 Document 재조회 +→ 전체 Use Case 재계산 +→ 외부 side effect 부재 확인 +→ 제한 재시도 +``` + +## 14. Session·Transaction + +### 14.1 선택 기준 + +```text +단일 Document invariant +→ atomic update + +여러 Document invariant +→ transaction + +MongoDB + HTTP +→ transaction 밖 + +MongoDB + Object Storage +→ saga/state machine + +MongoDB + Messaging +→ outbox 또는 change-stream bridge +``` + +### 14.2 Transaction API + +```java +public interface MongoTransactionExecutor { + T execute(MongoTransactionProfile profile, Supplier work); +} + +public interface ReactiveMongoTransactionExecutor { + Publisher execute( + MongoTransactionProfile profile, + Supplier> work); +} +``` + +### 14.3 Retry 분리 + +```text +TransientTransactionError +→ 새 Session +→ Transaction 본문 전체 재실행 + +UnknownTransactionCommitResult +→ 본문 재실행 금지 +→ Commit만 재시도 +→ 최종 불명 시 reconciliation +``` + +모든 Retry는 max attempts, max elapsed time, jitter, deadline을 가진다. + +## 15. 오류 모델 + +```text +MongoPersistenceException +├─ MongoDuplicateKeyException +├─ MongoSchemaValidationException +├─ MongoOptimisticConflictException +├─ MongoWriteConflictException +├─ MongoTransactionTransientException +├─ MongoTransactionCommitUnknownException +├─ MongoWriteConcernException +├─ MongoReadConcernException +├─ MongoServerSelectionException +├─ MongoConnectionException +├─ MongoTimeoutException +├─ MongoCursorException +├─ MongoDocumentTooLargeException +├─ MongoBulkPartialFailureException +├─ MongoShardRoutingException +├─ MongoResumeException +├─ MongoEncryptionException +└─ MongoOperationRejectedException +``` + +오류에는 다음만 보존한다. + +```text +operationName +databaseProfile +collectionProfile +operationType +consistencyProfile +retryable +ambiguous +errorLabels +serverCode +attempt +elapsed +traceId +``` + +Document, Query parameter, credential, plaintext encrypted field, resume token, shard key 실제 값은 저장하지 않는다. + +## 16. Query Guardrail + +### 16.1 Query 등급 + +| 등급 | API | 사용 위치 | +|---|---|---| +| Q1 | Derived Query, Projection | 일반 Domain | +| Q2 | Criteria, Custom Repository, Querydsl | 동적 Query | +| Q3 | Aggregation, Search/Vector, 제한 Native BSON | Advanced | +| Q4 | Collection/Index/Admin command | D4 | + +### 16.2 정책 + +```text +Sort field allowlist +Projection field allowlist +Field path allowlist +Operator allowlist +Collation profile +Hint allowlist +maxTimeMS +Result limit +Regex 길이·문자 클래스 제한 +Dynamic collection 차단 +``` + +다음 API는 제공하지 않는다. + +```java +Document executeUserBson(String json); +Document runCommand(Map input); +``` + +## 17. Aggregation Guardrail + +| 등급 | Stage | 정책 | +|---|---|---| +| A1 | match/project/set/unset/bounded limit | Stable | +| A2 | sort/group/unwind/lookup/bucket | Resource Budget 필수 | +| A3 | facet/graphLookup/large window/union | Advanced Review | +| A4 | out/merge/admin write stage | D4 | + +기본 설정: + +```text +strictMapping=true +maxTimeMS=profile-defined +resultLimit=bounded +allowDiskUse=explicit resource profile +writeStage=false +lookupCollection=allowlist +operationName=required +``` + +## 18. Schema·Index Manifest + +```yaml +collections: + orders: + owner: order-domain + validator: classpath:/mongodb/orders-schema-v3.json + validation-level: strict + validation-action: error + indexes: + - name: uq_order_number + keys: + orderNumber: 1 + unique: true + expected-usage: order.find-by-number + - name: ix_customer_created_id + keys: + customerId: 1 + createdAt: -1 + _id: -1 + expected-usage: order.find-recent +``` + +Index Manifest 필드: + +```text +name +key order +unique +partialFilter +sparse +collation +expireAfterSeconds +hidden +wildcardProjection +geo options +shardKeySupport +expectedUsage +owner +metadataOwnership +``` + +`metadataOwnership` 값: + +```text +APPLICATION +MONGODB_MANAGED +ENCRYPTION_MANAGED +SEARCH_MANAGED +``` + +운영에서는 자동 Index 생성이 아니라 diff·approval·D4 apply를 사용한다. + +```text +Local/Test: apply 허용 +Dev: apply + diff +Staging: diff + 승인 apply +Prod: D4만 생성·삭제·collMod +``` + +Index 삭제: + +```text +deprecated 표시 +→ usage 확인 +→ hidden +→ 관측 +→ explain regression +→ 승인 drop +``` + +## 19. Pagination·Cursor·Bulk + +### 19.1 Pagination + +```text +작은 Admin 목록 → Page +일반 목록 → Slice +대규모 목록 → Keyset +Batch 처리 → Cursor + batchSize +무한 변경 구독 → Change Stream +``` + +Keyset은 항상 유일한 tie-breaker를 포함한다. + +```text +sort: createdAt DESC, _id DESC +cursor: createdAt + _id + sortVersion +``` + +### 19.2 Bulk + +```java +public record MongoBulkResult( + int requested, + int inserted, + int modified, + int deleted, + int upserted, + List failures, + boolean partial) { +} +``` + +Ordered·Unordered Bulk 모두 부분 성공을 보존한다. 성공 항목을 다시 실행하지 않는다. + +## 20. Change Stream + +### 20.1 상태 + +```text +STARTING +RUNNING +RESUMING +HISTORY_LOST +FAILED +STOPPED +``` + +### 20.2 Checkpoint 계약 + +```text +Event 처리 +→ idempotent projector 완료 +→ checkpoint 저장 +``` + +이 순서는 duplicate 가능성을 허용하지만 event loss를 방지한다. Projector는 stable event identity와 idempotency key를 사용한다. + +### 20.3 Resume + +```text +resumeAfter +→ 일반 resume token + +startAfter +→ invalidate 이후 재개 + +oplog history 부족 +→ HISTORY_LOST +→ 자동으로 최신부터 시작하지 않음 +→ 운영 복구 정책 필요 +``` + +### 20.4 Messaging 경계 + +```text +MongoDB Physical Change +→ Internal Projector +→ Stable Integration Event +→ Messaging Platform +``` + +DB 변경과 Event가 반드시 같은 commit에 있어야 하면 Outbox를 사용한다. + +## 21. TTL·Geo·특수 Collection 경계 + +### 21.1 TTL + +```text +Query 접근 조건: expiresAt > applicationNow +TTL Index: physical cleanup +``` + +TTL 삭제 지연을 정상으로 취급한다. 정확한 업무 상태 전이는 별도 Scheduler가 담당한다. + +### 21.2 Geospatial + +Stable 기본: + +```text +GeoJSON +2dsphere +longitude, latitude 순서 +거리 단위 명시 +bounded near/within query +``` + +### 21.3 Time Series + +Time Series는 일반 Collection 계약을 상속하지 않는다. + +| 기능 | 일반 | Time Series | +|---|---:|---:| +| Validator | O | X | +| Change Stream | O | X | +| CSFLE | O | X | +| Transaction Write | O | X | +| Search | 조건부 | X | +| TTL | O | O | +| Document limit | 16 MiB | 별도 제한 | + +## 22. Sharding 경계 + +Application Plane: + +```text +ShardKeyDescriptor +ShardAwareQueryValidator +CollectionRoutingProfile +Targeted/Scatter Classification +Telemetry +``` + +Admin Plane: + +```text +shardCollection +analyzeShardKey +refineCollectionShardKey +reshardCollection +zone +balancer +shard add/remove +``` + +모든 shard-aware write는 shard key 또는 routing evidence를 요구한다. `@Sharded` 하나로 충분하다고 간주하지 않는다. + +## 23. Encryption 경계 + +```text +mongodb-csfle +mongodb-queryable-encryption +``` + +| 요구 | Capability | +|---|---| +| Query 불필요 PII | randomized 또는 unindexed | +| equality | deterministic 또는 QE equality | +| range | QE range | +| prefix/suffix/substring | MongoDB 8.0 Stable 비지원 | +| tenant key | explicit resolver | +| collection setup | D4 | +| rotation | D4 + runbook | + +CSFLE와 QE를 같은 Collection에 동시에 적용하지 않는다. QE 내부 metadata collection과 `__safeContent__`는 ENCRYPTION_MANAGED ownership으로 보호한다. + +## 24. Search·Vector 경계 + +```text +GeoJSON / 2dsphere → Stable +Legacy $text → Compatibility only +MongoDB Search → Advanced +Vector Search → Advanced +Hybrid Search → Advanced +Search Index Admin → D4 +``` + +Index 생성 완료와 `READY` 상태를 구분한다. Atlas Local은 빠른 CI, 실제 목표 배포는 Release Gate다. + +## 25. Multi-tenancy 경계 + +| 모델 | 등급 | +|---|---| +| Single DB | Stable | +| Shared Collection + tenantId | Advanced | +| Database per Tenant | Experimental | +| Collection per Tenant | 제한 Experimental | +| Cluster per Tenant | Infra Profile | + +Shared Collection은 모든 find/update/delete, aggregation, change stream에 tenant predicate를 적용하고 unique index·shard key에 tenant 의미를 검증한다. raw tenant ID는 log·metric에 남기지 않는다. + +## 26. Security + +Principal 분리: + +```text +mongo-app-read +mongo-app-write +mongo-change-stream +mongo-migration +mongo-search-admin +mongo-shard-admin +mongo-encryption-admin +mongo-dba +``` + +Production 기본 금지: + +```text +auth disabled +TLS disabled +admin credential 공유 +connection string credential source 저장 +raw user BSON 실행 +arbitrary runCommand +Document 전체 logging +plaintext encrypted field logging +``` + +Query 입력은 allowlist로 변환하고 Regex는 길이·패턴·timeout을 제한한다. + +## 27. Observability + +Driver native ObservabilitySettings를 기준으로 한다. + +| 영역 | Signal | +|---|---| +| Pool | size, checked-out, wait, failure | +| SDAM | topology, primary, heartbeat | +| Operation | count, latency, result, timeout | +| Write | inserted, modified, deleted, upsert, concern failure | +| Retry | driver/app retry, labels | +| Transaction | duration, abort, body retry, commit retry, unknown | +| Aggregation | duration, disk use, result count | +| Query Efficiency | examined/returned, index, spill | +| Sharding | targeted/scatter | +| Change Stream | lag, resume, history lost | +| TTL | cleanup lag | +| Migration | progress, checkpoint | +| Search | index readiness | + +허용 Tag: + +```text +mongoProfile +databaseProfile +collectionProfile +operationName +operationType +result +failureCategory +consistencyProfile +``` + +금지 Tag: + +```text +documentId +rawTenantId +dynamicCollectionName +queryParameter +fullBson +resumeToken +shardKeyValue +plaintextPII +credential +``` + +## 28. Spring Boot 설정 + +```yaml +backend: + mongodb: + profiles: + default: + uri-secret: secret://mongodb/default-uri + topology: REPLICA_SET + stable-api: + version: V1 + strict: true + deprecation-errors: true + consistency-default: PRIMARY_MAJORITY + timeout: + server-selection: 3s + connect: 2s + socket-read: 5s + operation: 3s + pool: + min-size: 2 + max-size: 40 + max-wait-time: 500ms + mapping: + uuid-representation: STANDARD + big-decimal-representation: DECIMAL128 + index: + runtime-auto-create: false + security: + tls-required: true + authentication-required: true +``` + +Startup 실패 조건: + +```text +Production에서 Standalone +Production에서 auth/TLS 비활성 +Runtime auto-index 활성 +UUID/Decimal 표현 미설정 +Stable D1/D2에서 apiStrict 미설정 +Required Replica Set인데 topology 불일치 +Transaction 활성인데 Replica Set 아님 +Change Stream 활성인데 watch capability 없음 +Admin Client가 일반 Runtime credential과 동일 +Unsupported schema version +Dynamic collection profile +``` + +## 29. 테스트 아키텍처 + +```text +Unit +→ Manifest / Policy / Classifier + +Repository Slice +→ Converter / Repository / Query + +Single-node Replica Set +→ Mapping / Atomic / Transaction / Change Stream + +3-node Replica Set +→ Failover / Retry / Commit Unknown / Resume + +Sharded Cluster +→ Routing / Chunk Migration / Cross-shard / Reshard + +Atlas Local +→ Search / Vector quick CI + +Actual Target Deployment +→ Search / Vector / Encryption Release Gate +``` + +필수 장애 테스트: + +```text +Primary kill +Network partition +Server selection timeout +Write response loss +TransientTransactionError +UnknownTransactionCommitResult +Bulk partial failure +Change Stream process kill +Resume token loss +Oplog history loss +Index drift +Schema validation mismatch +Credential rotation +``` + +## 30. Release Gate + +Stable 릴리스 조건: + +```text +Java 21 / Boot 4.1 BOM 확인 +MongoDB 7.0 compatibility +MongoDB 8.0 primary certification +Single-node RS contract +3-node RS failover +Golden BSON snapshot +Atomic update race +Optimistic conflict +Transaction body/commit retry 분리 +Index/Schema manifest drift +Keyset pagination +Bulk partial result +Change Stream resume/history loss +TLS/RBAC/redaction +Driver native observability +``` + +Advanced 기능은 해당 모듈과 실제 환경 Gate를 통과하기 전 Starter에 포함하지 않는다. + +## 31. 구현 단계 + +| 단계 | 범위 | 완료 조건 | +|---|---|---| +| 1 Foundation | BOM, modules, profiles, topology, stable API | Context startup·topology gate | +| 2 Document Core | BSON manifest, converter, type metadata, modeling | Golden BSON·size guard | +| 3 Write·Consistency | atomic update, revision, profile, transaction, retry | race·failover·commit ambiguity | +| 4 Schema·Query | validator, migration, query, aggregation, index | strictMapping·diff·explain | +| 5 Scale·Stream | keyset, cursor, bulk, TTL, change stream | partial result·resume·kill | +| 6 Operations | security, observability, starter, runbook | least privilege·redaction | +| 7 Advanced | sharding, time series, encryption, search/vector, tenancy | capability-specific gate | + +## 32. 완료 정의 + +다음 질문에 모두 구현과 테스트로 답할 수 있어야 한다. + +```text +Document가 bounded라는 증거가 있는가? +BSON 타입 표현이 배포 간 고정되는가? +부분 Update가 전체 Document를 덮지 않는가? +Read/Write Concern의 실제 보장을 호출자가 아는가? +Transaction body retry와 commit retry가 분리되는가? +Bulk 일부 성공이 보존되는가? +Query와 Index가 Release 전에 연결되는가? +Change Stream checkpoint 경계가 명확한가? +TTL을 Scheduler로 오해하지 않는가? +Shard Key 없는 Query를 감지하는가? +암호화 metadata를 drift cleanup이 삭제하지 않는가? +실제 Replica Set·Sharded·Atlas 환경에서 검증되는가? +``` + +## 33. 요구사항 추적표 + +| 리서치 영역 | 설계 절 | 구현 계획 영역 | +|---|---|---| +| 기준선·Topology | 4, 8, 28 | Task 1~5, 45~48 | +| D1~D4 경계 | 5, 6 | Task 1, 38, 39 | +| Document Modeling | 9 | Task 10 | +| BSON 표현 | 10, 11 | Task 6~9 | +| Schema·Migration | 12 | Task 11, 18~22, 46 | +| Atomic Write·Optimistic | 13 | Task 13~14 | +| Consistency·Transaction | 14 | Task 23~28 | +| 오류·Retry | 15 | Task 4~5, 27~28 | +| Query·Aggregation | 16~17 | Task 15~17 | +| Index | 18 | Task 18~20 | +| Pagination·Bulk | 19 | Task 29~31 | +| Change Stream | 20 | Task 33~35 | +| TTL·Geo | 21 | Task 32, 36 | +| Security·Observability | 26~27 | Task 37~40, 47 | +| Starter·Gate | 28~30 | Task 41~50 | +| Advanced | 22~25 | 별도 Advanced 계획 | diff --git a/mongodb-superpowers-package/validate_mongodb_docs.py b/mongodb-superpowers-package/validate_mongodb_docs.py new file mode 100644 index 00000000..a5a01ba8 --- /dev/null +++ b/mongodb-superpowers-package/validate_mongodb_docs.py @@ -0,0 +1,73 @@ +from pathlib import Path +import re +import sys + +ROOT = Path(__file__).resolve().parent +DESIGN = ROOT / 'docs/superpowers/specs/2026-08-11-mongodb-document-persistence-platform-design.md' +PLAN = ROOT / 'docs/superpowers/plans/2026-08-11-mongodb-document-persistence-platform-implementation-plan.md' +ADV = ROOT / 'docs/superpowers/plans/2026-08-11-mongodb-advanced-capabilities-expansion-plan.md' + +checks = [] + +def check(name, condition, detail=''): + checks.append((name, bool(condition), detail)) + +texts = {p.name: p.read_text(encoding='utf-8') for p in (DESIGN, PLAN, ADV)} +design = texts[DESIGN.name] +plan = texts[PLAN.name] +adv = texts[ADV.name] + +check('design exists', DESIGN.exists()) +check('stable plan exists', PLAN.exists()) +check('advanced plan exists', ADV.exists()) +check('design purpose', 'MongoDB 문서 영속성 플랫폼 설계서' in design) +check('domain ownership', '도메인이 `@Document`, Repository' in design) +check('no generic repository design', '범용 `CommonMongoRepository`를 만들지 않는다' in design) +check('stable api strict', 'Stable API V1' in design and 'apiStrict=true' in design) +check('local replica set', 'Single-node Replica Set' in design) +check('standalone smoke only', 'Standalone은 smoke test' in design) +check('bson manifest', 'BSON 표현 Manifest' in design) +check('transaction retry separation', 'Transaction 본문 Retry와 Commit Retry를 분리' in design) +check('change stream at least once', 'at-least-once projector' in design) +check('ttl cleanup only', 'TTL은 물리 cleanup' in design) +check('gridfs compatibility only', 'GridFS는 compatibility adapter' in design) +check('driver native observability', 'Driver native ObservabilitySettings' in design) + +for label, text, expected in [('stable', plan, 50), ('advanced', adv, 15)]: + nums = [int(x) for x in re.findall(r'^### Task (\d+):', text, re.M)] + check(f'{label} task count', len(nums) == expected, f'{len(nums)}') + check(f'{label} task sequence', nums == list(range(1, expected + 1)), str(nums[:3]) + '...' + str(nums[-3:])) + sections = re.split(r'(?=^### Task \d+:)', text, flags=re.M)[1:] + for idx, section in enumerate(sections, 1): + for marker in ['**Files:**', '**Interfaces:**', '**Implementation requirements:**', + '**Step 1:', '**Step 2:', '**Step 3:', '**Step 4:', '**Step 5:']: + check(f'{label} task {idx} has {marker}', marker in section) + check(f'{label} task {idx} has commit', 'git commit -m' in section) + check(f'{label} task {idx} has expected result', 'Expected:' in section) + +for name, text in texts.items(): + check(f'{name} code fences balanced', text.count('```') % 2 == 0, str(text.count('```'))) + check(f'{name} no TODO markers', not re.search(r'\b(TODO|TBD|FIXME)\b', text)) + check(f'{name} no wildcard create paths', not re.search(r'- Create: `[^`]*[\*?][^`]*`', text)) + +for label, text in [('stable', plan), ('advanced', adv)]: + created = re.findall(r'- Create: `([^`]+)`', text) + duplicates = sorted({p for p in created if created.count(p) > 1}) + check(f'{label} no duplicate create paths', not duplicates, ', '.join(duplicates)) + +stable_created = set(re.findall(r'- Create: `([^`]+)`', plan)) +advanced_created = set(re.findall(r'- Create: `([^`]+)`', adv)) +check('stable and advanced create paths do not collide', not (stable_created & advanced_created), + ', '.join(sorted(stable_created & advanced_created))) +check('no real generic repository declaration', not re.search(r'public\s+interface\s+(Common|Generic)MongoRepository', design + plan + adv)) +check('no public arbitrary run command', not re.search(r'public\s+[^\n]+\s+runCommand\s*\(', design + plan + adv)) +check('stable starter excludes advanced', 'no advanced module' in plan.lower() and 'Stable Starter' in adv) +check('unknown commit body retry forbidden', 'UnknownTransactionCommitResult' in plan and '업무 본문을 재실행하지 않는다' in plan) +check('mongo seven and eight matrix', 'MongoDB 7.0' in plan and 'MongoDB 8.0' in plan) +check('advanced actual topology gate', 'actual topology' in adv.lower() or '실제 topology' in adv) + +failed = [c for c in checks if not c[1]] +for name, ok, detail in checks: + print(('PASS' if ok else 'FAIL') + ' | ' + name + ((' | ' + detail) if detail else '')) +print(f'SUMMARY | total={len(checks)} pass={len(checks)-len(failed)} fail={len(failed)}') +sys.exit(1 if failed else 0) diff --git a/notification-superpowers-package/MANIFEST.sha256 b/notification-superpowers-package/MANIFEST.sha256 new file mode 100644 index 00000000..66b296fd --- /dev/null +++ b/notification-superpowers-package/MANIFEST.sha256 @@ -0,0 +1,5 @@ +20a39913d84b179fb407811af4ee23bbcad8eebadff893add530cab97fa90878 ./README.md +b84784d8321ea826af7c9788693008fccd4f2bf437816d2d47a515caac098f04 ./VALIDATION.md +176b0c86e11e6d4d8440285dd723e5555616ff9a69a7a9419250c2ce2b67f565 ./docs/superpowers/plans/2026-08-10-notification-platform-implementation-plan.md +fe7b57c6573ef7305033fe9402333d3c10f7a03ccf421dce5f353e2616ac8677 ./docs/superpowers/specs/2026-08-10-notification-platform-design.md +2109766403adf001bb1dcbfe8679d362557f71433532336ff72b90976f3cfe10 ./validate_notification_docs.py diff --git a/notification-superpowers-package/README.md b/notification-superpowers-package/README.md new file mode 100644 index 00000000..3ebf71f0 --- /dev/null +++ b/notification-superpowers-package/README.md @@ -0,0 +1,60 @@ +# Notification Delivery Platform Superpowers Package + +이 패키지는 Backend Skeleton의 `notification` 기술 모듈을 구현하기 위한 설계서와 TDD 구현 계획서다. + +## 파일 + +```text +docs/superpowers/specs/2026-08-10-notification-platform-design.md +docs/superpowers/plans/2026-08-10-notification-platform-implementation-plan.md +VALIDATION.md +validate_notification_docs.py +MANIFEST.sha256 +``` + +## 핵심 계약 + +```text +NotificationRequest +→ RecipientDelivery +→ DeliveryAttempt +→ ProviderEvent append-only ledger +→ channel-specific projection +→ SubmissionOutcome / DeliveryOutcome / EvidenceLevel +``` + +- `submit()` 성공은 durable acceptance만 뜻한다. +- Provider acceptance와 delivery/read evidence를 분리한다. +- 결과를 확정할 수 없으면 `AMBIGUOUS`로 보존한다. +- ambiguous attempt의 자동 retry와 cross-channel fallback은 기본 차단한다. +- FCM은 FID를 primary target으로, registration token을 legacy compatibility로 둔다. +- Callback은 signature 검증 후 원장에 append하고 중복·역순·누락을 처리한다. +- Contact Point는 AES-256-GCM 암호화와 HMAC-SHA-256 equality fingerprint를 사용한다. + +## 구현 기준 가정 + +실제 Backend Skeleton 저장소가 입력되지 않았기 때문에 다음 가정을 문서에 명시했다. + +```text +Java 21 +Gradle Kotlin DSL +Spring Framework 6.2 common line + Spring 7.0 compatibility job +PostgreSQL 16 +JPA + Flyway +root package: io.backend.skeleton.notification +module root: modules/notification +``` + +실제 저장소에서는 dependency catalog와 package/file path를 기존 규칙에 맞게 매핑하되 공개 계약과 신뢰성 의미론은 유지한다. + +## 실행 + +1. 설계서를 검토한다. +2. 계획서 Task 1부터 순서대로 실행한다. +3. 각 Task에서 failing test → red 확인 → 최소 구현 → green 확인 → commit 순서를 지킨다. +4. Task마다 specification review와 code-quality review를 분리한다. +5. 마지막 release gate에서 contract, chaos, security, performance, compatibility job을 모두 실행한다. + +## 검증 범위 + +`VALIDATION.md`는 문서 구조, Task 연속성, 파일 경로 중복, 미확정 표현, 핵심 계약, ZIP CRC를 검증한 결과다. 실제 Gradle compile과 Provider integration은 Backend Skeleton 저장소에서 계획서의 명령을 실행해야 한다. diff --git a/notification-superpowers-package/VALIDATION.md b/notification-superpowers-package/VALIDATION.md new file mode 100644 index 00000000..c063263f --- /dev/null +++ b/notification-superpowers-package/VALIDATION.md @@ -0,0 +1,590 @@ +# Notification Superpowers 문서 정적 검증 + +- 결과: **PASS** +- 실행 검사: **567개** +- 통과: **567개** +- 실패: **0개** +- 설계서: **4,770행** +- 구현 계획서: **5,325행** +- 구현 Task: **50개** +- Create 경로: **322개** +- 설계 SHA-256: `fe7b57c6573ef7305033fe9402333d3c10f7a03ccf421dce5f353e2616ac8677` +- 계획 SHA-256: `176b0c86e11e6d4d8440285dd723e5555616ff9a69a7a9419250c2ce2b67f565` + +## 검사 결과 + +| 검사 | 결과 | 상세 | +|---|---:|---| +| 설계서 존재 | PASS | /mnt/data/notification-platform-design.md | +| 구현 계획서 존재 | PASS | /mnt/data/notification-platform-implementation-plan.md | +| 설계서 최소 상세도 | PASS | 4,770 lines | +| 계획서 최소 상세도 | PASS | 5,325 lines | +| 설계서 코드 fence 균형 | PASS | 482 | +| 계획서 코드 fence 균형 | PASS | 308 | +| 설계 핵심 계약: NotificationRequest | PASS | | +| 설계 핵심 계약: RecipientDelivery | PASS | | +| 설계 핵심 계약: DeliveryAttempt | PASS | | +| 설계 핵심 계약: ProviderEvent | PASS | | +| 설계 핵심 계약: EvidenceLevel | PASS | | +| 설계 핵심 계약: SubmissionOutcome | PASS | | +| 설계 핵심 계약: DeliveryOutcome | PASS | | +| 설계 핵심 계약: AMBIGUOUS | PASS | | +| 설계 핵심 계약: append-only | PASS | | +| 설계 핵심 계약: FCM_FID | PASS | | +| 설계 핵심 계약: FCM_REGISTRATION_TOKEN_LEGACY | PASS | | +| 설계 핵심 계약: AES-256-GCM | PASS | | +| 설계 핵심 계약: HMAC-SHA-256 | PASS | | +| 설계 핵심 계약: FOR UPDATE SKIP LOCKED | PASS | | +| 설계 핵심 계약: SMTP Adapter | PASS | | +| 설계 핵심 계약: Amazon SES Adapter | PASS | | +| 설계 핵심 계약: Twilio Adapter | PASS | | +| 설계 핵심 계약: FCM Adapter | PASS | | +| 설계 핵심 계약: APNs Adapter | PASS | | +| 설계 핵심 계약: Web Push Adapter | PASS | | +| 설계 핵심 계약: In-App Inbox | PASS | | +| 설계 핵심 계약: Reconciliation | PASS | | +| 설계 핵심 계약: N4 Admin Plane | PASS | | +| 설계 핵심 계약: 비지원 범위 | PASS | | +| 설계 핵심 계약: 완료 정의 | PASS | | +| exactlyOnce 단순 옵션 금지 명시 | PASS | | +| 금지 보장 부재: guaranteedDelivery=true | PASS | | +| 금지 보장 부재: APNs HTTP 200 = DELIVERED | PASS | | +| 금지 보장 부재: FCM send success = DEVICE_DELIVERED | PASS | | +| Task 1~50 연속성 | PASS | [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50] | +| Task chunk 수 | PASS | 50 | +| Task 1 필수 항목: **Files:** | PASS | | +| Task 1 필수 항목: **Interfaces:** | PASS | | +| Task 1 필수 항목: **Implementation requirements:** | PASS | | +| Task 1 필수 항목: **Step 1: | PASS | | +| Task 1 필수 항목: **Step 2: | PASS | | +| Task 1 필수 항목: **Step 3: | PASS | | +| Task 1 필수 항목: **Step 4: | PASS | | +| Task 1 필수 항목: **Step 5: | PASS | | +| Task 1 필수 항목: Expected: | PASS | | +| Task 1 필수 항목: git commit -m | PASS | | +| Task 2 필수 항목: **Files:** | PASS | | +| Task 2 필수 항목: **Interfaces:** | PASS | | +| Task 2 필수 항목: **Implementation requirements:** | PASS | | +| Task 2 필수 항목: **Step 1: | PASS | | +| Task 2 필수 항목: **Step 2: | PASS | | +| Task 2 필수 항목: **Step 3: | PASS | | +| Task 2 필수 항목: **Step 4: | PASS | | +| Task 2 필수 항목: **Step 5: | PASS | | +| Task 2 필수 항목: Expected: | PASS | | +| Task 2 필수 항목: git commit -m | PASS | | +| Task 3 필수 항목: **Files:** | PASS | | +| Task 3 필수 항목: **Interfaces:** | PASS | | +| Task 3 필수 항목: **Implementation requirements:** | PASS | | +| Task 3 필수 항목: **Step 1: | PASS | | +| Task 3 필수 항목: **Step 2: | PASS | | +| Task 3 필수 항목: **Step 3: | PASS | | +| Task 3 필수 항목: **Step 4: | PASS | | +| Task 3 필수 항목: **Step 5: | PASS | | +| Task 3 필수 항목: Expected: | PASS | | +| Task 3 필수 항목: git commit -m | PASS | | +| Task 4 필수 항목: **Files:** | PASS | | +| Task 4 필수 항목: **Interfaces:** | PASS | | +| Task 4 필수 항목: **Implementation requirements:** | PASS | | +| Task 4 필수 항목: **Step 1: | PASS | | +| Task 4 필수 항목: **Step 2: | PASS | | +| Task 4 필수 항목: **Step 3: | PASS | | +| Task 4 필수 항목: **Step 4: | PASS | | +| Task 4 필수 항목: **Step 5: | PASS | | +| Task 4 필수 항목: Expected: | PASS | | +| Task 4 필수 항목: git commit -m | PASS | | +| Task 5 필수 항목: **Files:** | PASS | | +| Task 5 필수 항목: **Interfaces:** | PASS | | +| Task 5 필수 항목: **Implementation requirements:** | PASS | | +| Task 5 필수 항목: **Step 1: | PASS | | +| Task 5 필수 항목: **Step 2: | PASS | | +| Task 5 필수 항목: **Step 3: | PASS | | +| Task 5 필수 항목: **Step 4: | PASS | | +| Task 5 필수 항목: **Step 5: | PASS | | +| Task 5 필수 항목: Expected: | PASS | | +| Task 5 필수 항목: git commit -m | PASS | | +| Task 6 필수 항목: **Files:** | PASS | | +| Task 6 필수 항목: **Interfaces:** | PASS | | +| Task 6 필수 항목: **Implementation requirements:** | PASS | | +| Task 6 필수 항목: **Step 1: | PASS | | +| Task 6 필수 항목: **Step 2: | PASS | | +| Task 6 필수 항목: **Step 3: | PASS | | +| Task 6 필수 항목: **Step 4: | PASS | | +| Task 6 필수 항목: **Step 5: | PASS | | +| Task 6 필수 항목: Expected: | PASS | | +| Task 6 필수 항목: git commit -m | PASS | | +| Task 7 필수 항목: **Files:** | PASS | | +| Task 7 필수 항목: **Interfaces:** | PASS | | +| Task 7 필수 항목: **Implementation requirements:** | PASS | | +| Task 7 필수 항목: **Step 1: | PASS | | +| Task 7 필수 항목: **Step 2: | PASS | | +| Task 7 필수 항목: **Step 3: | PASS | | +| Task 7 필수 항목: **Step 4: | PASS | | +| Task 7 필수 항목: **Step 5: | PASS | | +| Task 7 필수 항목: Expected: | PASS | | +| Task 7 필수 항목: git commit -m | PASS | | +| Task 8 필수 항목: **Files:** | PASS | | +| Task 8 필수 항목: **Interfaces:** | PASS | | +| Task 8 필수 항목: **Implementation requirements:** | PASS | | +| Task 8 필수 항목: **Step 1: | PASS | | +| Task 8 필수 항목: **Step 2: | PASS | | +| Task 8 필수 항목: **Step 3: | PASS | | +| Task 8 필수 항목: **Step 4: | PASS | | +| Task 8 필수 항목: **Step 5: | PASS | | +| Task 8 필수 항목: Expected: | PASS | | +| Task 8 필수 항목: git commit -m | PASS | | +| Task 9 필수 항목: **Files:** | PASS | | +| Task 9 필수 항목: **Interfaces:** | PASS | | +| Task 9 필수 항목: **Implementation requirements:** | PASS | | +| Task 9 필수 항목: **Step 1: | PASS | | +| Task 9 필수 항목: **Step 2: | PASS | | +| Task 9 필수 항목: **Step 3: | PASS | | +| Task 9 필수 항목: **Step 4: | PASS | | +| Task 9 필수 항목: **Step 5: | PASS | | +| Task 9 필수 항목: Expected: | PASS | | +| Task 9 필수 항목: git commit -m | PASS | | +| Task 10 필수 항목: **Files:** | PASS | | +| Task 10 필수 항목: **Interfaces:** | PASS | | +| Task 10 필수 항목: **Implementation requirements:** | PASS | | +| Task 10 필수 항목: **Step 1: | PASS | | +| Task 10 필수 항목: **Step 2: | PASS | | +| Task 10 필수 항목: **Step 3: | PASS | | +| Task 10 필수 항목: **Step 4: | PASS | | +| Task 10 필수 항목: **Step 5: | PASS | | +| Task 10 필수 항목: Expected: | PASS | | +| Task 10 필수 항목: git commit -m | PASS | | +| Task 11 필수 항목: **Files:** | PASS | | +| Task 11 필수 항목: **Interfaces:** | PASS | | +| Task 11 필수 항목: **Implementation requirements:** | PASS | | +| Task 11 필수 항목: **Step 1: | PASS | | +| Task 11 필수 항목: **Step 2: | PASS | | +| Task 11 필수 항목: **Step 3: | PASS | | +| Task 11 필수 항목: **Step 4: | PASS | | +| Task 11 필수 항목: **Step 5: | PASS | | +| Task 11 필수 항목: Expected: | PASS | | +| Task 11 필수 항목: git commit -m | PASS | | +| Task 12 필수 항목: **Files:** | PASS | | +| Task 12 필수 항목: **Interfaces:** | PASS | | +| Task 12 필수 항목: **Implementation requirements:** | PASS | | +| Task 12 필수 항목: **Step 1: | PASS | | +| Task 12 필수 항목: **Step 2: | PASS | | +| Task 12 필수 항목: **Step 3: | PASS | | +| Task 12 필수 항목: **Step 4: | PASS | | +| Task 12 필수 항목: **Step 5: | PASS | | +| Task 12 필수 항목: Expected: | PASS | | +| Task 12 필수 항목: git commit -m | PASS | | +| Task 13 필수 항목: **Files:** | PASS | | +| Task 13 필수 항목: **Interfaces:** | PASS | | +| Task 13 필수 항목: **Implementation requirements:** | PASS | | +| Task 13 필수 항목: **Step 1: | PASS | | +| Task 13 필수 항목: **Step 2: | PASS | | +| Task 13 필수 항목: **Step 3: | PASS | | +| Task 13 필수 항목: **Step 4: | PASS | | +| Task 13 필수 항목: **Step 5: | PASS | | +| Task 13 필수 항목: Expected: | PASS | | +| Task 13 필수 항목: git commit -m | PASS | | +| Task 14 필수 항목: **Files:** | PASS | | +| Task 14 필수 항목: **Interfaces:** | PASS | | +| Task 14 필수 항목: **Implementation requirements:** | PASS | | +| Task 14 필수 항목: **Step 1: | PASS | | +| Task 14 필수 항목: **Step 2: | PASS | | +| Task 14 필수 항목: **Step 3: | PASS | | +| Task 14 필수 항목: **Step 4: | PASS | | +| Task 14 필수 항목: **Step 5: | PASS | | +| Task 14 필수 항목: Expected: | PASS | | +| Task 14 필수 항목: git commit -m | PASS | | +| Task 15 필수 항목: **Files:** | PASS | | +| Task 15 필수 항목: **Interfaces:** | PASS | | +| Task 15 필수 항목: **Implementation requirements:** | PASS | | +| Task 15 필수 항목: **Step 1: | PASS | | +| Task 15 필수 항목: **Step 2: | PASS | | +| Task 15 필수 항목: **Step 3: | PASS | | +| Task 15 필수 항목: **Step 4: | PASS | | +| Task 15 필수 항목: **Step 5: | PASS | | +| Task 15 필수 항목: Expected: | PASS | | +| Task 15 필수 항목: git commit -m | PASS | | +| Task 16 필수 항목: **Files:** | PASS | | +| Task 16 필수 항목: **Interfaces:** | PASS | | +| Task 16 필수 항목: **Implementation requirements:** | PASS | | +| Task 16 필수 항목: **Step 1: | PASS | | +| Task 16 필수 항목: **Step 2: | PASS | | +| Task 16 필수 항목: **Step 3: | PASS | | +| Task 16 필수 항목: **Step 4: | PASS | | +| Task 16 필수 항목: **Step 5: | PASS | | +| Task 16 필수 항목: Expected: | PASS | | +| Task 16 필수 항목: git commit -m | PASS | | +| Task 17 필수 항목: **Files:** | PASS | | +| Task 17 필수 항목: **Interfaces:** | PASS | | +| Task 17 필수 항목: **Implementation requirements:** | PASS | | +| Task 17 필수 항목: **Step 1: | PASS | | +| Task 17 필수 항목: **Step 2: | PASS | | +| Task 17 필수 항목: **Step 3: | PASS | | +| Task 17 필수 항목: **Step 4: | PASS | | +| Task 17 필수 항목: **Step 5: | PASS | | +| Task 17 필수 항목: Expected: | PASS | | +| Task 17 필수 항목: git commit -m | PASS | | +| Task 18 필수 항목: **Files:** | PASS | | +| Task 18 필수 항목: **Interfaces:** | PASS | | +| Task 18 필수 항목: **Implementation requirements:** | PASS | | +| Task 18 필수 항목: **Step 1: | PASS | | +| Task 18 필수 항목: **Step 2: | PASS | | +| Task 18 필수 항목: **Step 3: | PASS | | +| Task 18 필수 항목: **Step 4: | PASS | | +| Task 18 필수 항목: **Step 5: | PASS | | +| Task 18 필수 항목: Expected: | PASS | | +| Task 18 필수 항목: git commit -m | PASS | | +| Task 19 필수 항목: **Files:** | PASS | | +| Task 19 필수 항목: **Interfaces:** | PASS | | +| Task 19 필수 항목: **Implementation requirements:** | PASS | | +| Task 19 필수 항목: **Step 1: | PASS | | +| Task 19 필수 항목: **Step 2: | PASS | | +| Task 19 필수 항목: **Step 3: | PASS | | +| Task 19 필수 항목: **Step 4: | PASS | | +| Task 19 필수 항목: **Step 5: | PASS | | +| Task 19 필수 항목: Expected: | PASS | | +| Task 19 필수 항목: git commit -m | PASS | | +| Task 20 필수 항목: **Files:** | PASS | | +| Task 20 필수 항목: **Interfaces:** | PASS | | +| Task 20 필수 항목: **Implementation requirements:** | PASS | | +| Task 20 필수 항목: **Step 1: | PASS | | +| Task 20 필수 항목: **Step 2: | PASS | | +| Task 20 필수 항목: **Step 3: | PASS | | +| Task 20 필수 항목: **Step 4: | PASS | | +| Task 20 필수 항목: **Step 5: | PASS | | +| Task 20 필수 항목: Expected: | PASS | | +| Task 20 필수 항목: git commit -m | PASS | | +| Task 21 필수 항목: **Files:** | PASS | | +| Task 21 필수 항목: **Interfaces:** | PASS | | +| Task 21 필수 항목: **Implementation requirements:** | PASS | | +| Task 21 필수 항목: **Step 1: | PASS | | +| Task 21 필수 항목: **Step 2: | PASS | | +| Task 21 필수 항목: **Step 3: | PASS | | +| Task 21 필수 항목: **Step 4: | PASS | | +| Task 21 필수 항목: **Step 5: | PASS | | +| Task 21 필수 항목: Expected: | PASS | | +| Task 21 필수 항목: git commit -m | PASS | | +| Task 22 필수 항목: **Files:** | PASS | | +| Task 22 필수 항목: **Interfaces:** | PASS | | +| Task 22 필수 항목: **Implementation requirements:** | PASS | | +| Task 22 필수 항목: **Step 1: | PASS | | +| Task 22 필수 항목: **Step 2: | PASS | | +| Task 22 필수 항목: **Step 3: | PASS | | +| Task 22 필수 항목: **Step 4: | PASS | | +| Task 22 필수 항목: **Step 5: | PASS | | +| Task 22 필수 항목: Expected: | PASS | | +| Task 22 필수 항목: git commit -m | PASS | | +| Task 23 필수 항목: **Files:** | PASS | | +| Task 23 필수 항목: **Interfaces:** | PASS | | +| Task 23 필수 항목: **Implementation requirements:** | PASS | | +| Task 23 필수 항목: **Step 1: | PASS | | +| Task 23 필수 항목: **Step 2: | PASS | | +| Task 23 필수 항목: **Step 3: | PASS | | +| Task 23 필수 항목: **Step 4: | PASS | | +| Task 23 필수 항목: **Step 5: | PASS | | +| Task 23 필수 항목: Expected: | PASS | | +| Task 23 필수 항목: git commit -m | PASS | | +| Task 24 필수 항목: **Files:** | PASS | | +| Task 24 필수 항목: **Interfaces:** | PASS | | +| Task 24 필수 항목: **Implementation requirements:** | PASS | | +| Task 24 필수 항목: **Step 1: | PASS | | +| Task 24 필수 항목: **Step 2: | PASS | | +| Task 24 필수 항목: **Step 3: | PASS | | +| Task 24 필수 항목: **Step 4: | PASS | | +| Task 24 필수 항목: **Step 5: | PASS | | +| Task 24 필수 항목: Expected: | PASS | | +| Task 24 필수 항목: git commit -m | PASS | | +| Task 25 필수 항목: **Files:** | PASS | | +| Task 25 필수 항목: **Interfaces:** | PASS | | +| Task 25 필수 항목: **Implementation requirements:** | PASS | | +| Task 25 필수 항목: **Step 1: | PASS | | +| Task 25 필수 항목: **Step 2: | PASS | | +| Task 25 필수 항목: **Step 3: | PASS | | +| Task 25 필수 항목: **Step 4: | PASS | | +| Task 25 필수 항목: **Step 5: | PASS | | +| Task 25 필수 항목: Expected: | PASS | | +| Task 25 필수 항목: git commit -m | PASS | | +| Task 26 필수 항목: **Files:** | PASS | | +| Task 26 필수 항목: **Interfaces:** | PASS | | +| Task 26 필수 항목: **Implementation requirements:** | PASS | | +| Task 26 필수 항목: **Step 1: | PASS | | +| Task 26 필수 항목: **Step 2: | PASS | | +| Task 26 필수 항목: **Step 3: | PASS | | +| Task 26 필수 항목: **Step 4: | PASS | | +| Task 26 필수 항목: **Step 5: | PASS | | +| Task 26 필수 항목: Expected: | PASS | | +| Task 26 필수 항목: git commit -m | PASS | | +| Task 27 필수 항목: **Files:** | PASS | | +| Task 27 필수 항목: **Interfaces:** | PASS | | +| Task 27 필수 항목: **Implementation requirements:** | PASS | | +| Task 27 필수 항목: **Step 1: | PASS | | +| Task 27 필수 항목: **Step 2: | PASS | | +| Task 27 필수 항목: **Step 3: | PASS | | +| Task 27 필수 항목: **Step 4: | PASS | | +| Task 27 필수 항목: **Step 5: | PASS | | +| Task 27 필수 항목: Expected: | PASS | | +| Task 27 필수 항목: git commit -m | PASS | | +| Task 28 필수 항목: **Files:** | PASS | | +| Task 28 필수 항목: **Interfaces:** | PASS | | +| Task 28 필수 항목: **Implementation requirements:** | PASS | | +| Task 28 필수 항목: **Step 1: | PASS | | +| Task 28 필수 항목: **Step 2: | PASS | | +| Task 28 필수 항목: **Step 3: | PASS | | +| Task 28 필수 항목: **Step 4: | PASS | | +| Task 28 필수 항목: **Step 5: | PASS | | +| Task 28 필수 항목: Expected: | PASS | | +| Task 28 필수 항목: git commit -m | PASS | | +| Task 29 필수 항목: **Files:** | PASS | | +| Task 29 필수 항목: **Interfaces:** | PASS | | +| Task 29 필수 항목: **Implementation requirements:** | PASS | | +| Task 29 필수 항목: **Step 1: | PASS | | +| Task 29 필수 항목: **Step 2: | PASS | | +| Task 29 필수 항목: **Step 3: | PASS | | +| Task 29 필수 항목: **Step 4: | PASS | | +| Task 29 필수 항목: **Step 5: | PASS | | +| Task 29 필수 항목: Expected: | PASS | | +| Task 29 필수 항목: git commit -m | PASS | | +| Task 30 필수 항목: **Files:** | PASS | | +| Task 30 필수 항목: **Interfaces:** | PASS | | +| Task 30 필수 항목: **Implementation requirements:** | PASS | | +| Task 30 필수 항목: **Step 1: | PASS | | +| Task 30 필수 항목: **Step 2: | PASS | | +| Task 30 필수 항목: **Step 3: | PASS | | +| Task 30 필수 항목: **Step 4: | PASS | | +| Task 30 필수 항목: **Step 5: | PASS | | +| Task 30 필수 항목: Expected: | PASS | | +| Task 30 필수 항목: git commit -m | PASS | | +| Task 31 필수 항목: **Files:** | PASS | | +| Task 31 필수 항목: **Interfaces:** | PASS | | +| Task 31 필수 항목: **Implementation requirements:** | PASS | | +| Task 31 필수 항목: **Step 1: | PASS | | +| Task 31 필수 항목: **Step 2: | PASS | | +| Task 31 필수 항목: **Step 3: | PASS | | +| Task 31 필수 항목: **Step 4: | PASS | | +| Task 31 필수 항목: **Step 5: | PASS | | +| Task 31 필수 항목: Expected: | PASS | | +| Task 31 필수 항목: git commit -m | PASS | | +| Task 32 필수 항목: **Files:** | PASS | | +| Task 32 필수 항목: **Interfaces:** | PASS | | +| Task 32 필수 항목: **Implementation requirements:** | PASS | | +| Task 32 필수 항목: **Step 1: | PASS | | +| Task 32 필수 항목: **Step 2: | PASS | | +| Task 32 필수 항목: **Step 3: | PASS | | +| Task 32 필수 항목: **Step 4: | PASS | | +| Task 32 필수 항목: **Step 5: | PASS | | +| Task 32 필수 항목: Expected: | PASS | | +| Task 32 필수 항목: git commit -m | PASS | | +| Task 33 필수 항목: **Files:** | PASS | | +| Task 33 필수 항목: **Interfaces:** | PASS | | +| Task 33 필수 항목: **Implementation requirements:** | PASS | | +| Task 33 필수 항목: **Step 1: | PASS | | +| Task 33 필수 항목: **Step 2: | PASS | | +| Task 33 필수 항목: **Step 3: | PASS | | +| Task 33 필수 항목: **Step 4: | PASS | | +| Task 33 필수 항목: **Step 5: | PASS | | +| Task 33 필수 항목: Expected: | PASS | | +| Task 33 필수 항목: git commit -m | PASS | | +| Task 34 필수 항목: **Files:** | PASS | | +| Task 34 필수 항목: **Interfaces:** | PASS | | +| Task 34 필수 항목: **Implementation requirements:** | PASS | | +| Task 34 필수 항목: **Step 1: | PASS | | +| Task 34 필수 항목: **Step 2: | PASS | | +| Task 34 필수 항목: **Step 3: | PASS | | +| Task 34 필수 항목: **Step 4: | PASS | | +| Task 34 필수 항목: **Step 5: | PASS | | +| Task 34 필수 항목: Expected: | PASS | | +| Task 34 필수 항목: git commit -m | PASS | | +| Task 35 필수 항목: **Files:** | PASS | | +| Task 35 필수 항목: **Interfaces:** | PASS | | +| Task 35 필수 항목: **Implementation requirements:** | PASS | | +| Task 35 필수 항목: **Step 1: | PASS | | +| Task 35 필수 항목: **Step 2: | PASS | | +| Task 35 필수 항목: **Step 3: | PASS | | +| Task 35 필수 항목: **Step 4: | PASS | | +| Task 35 필수 항목: **Step 5: | PASS | | +| Task 35 필수 항목: Expected: | PASS | | +| Task 35 필수 항목: git commit -m | PASS | | +| Task 36 필수 항목: **Files:** | PASS | | +| Task 36 필수 항목: **Interfaces:** | PASS | | +| Task 36 필수 항목: **Implementation requirements:** | PASS | | +| Task 36 필수 항목: **Step 1: | PASS | | +| Task 36 필수 항목: **Step 2: | PASS | | +| Task 36 필수 항목: **Step 3: | PASS | | +| Task 36 필수 항목: **Step 4: | PASS | | +| Task 36 필수 항목: **Step 5: | PASS | | +| Task 36 필수 항목: Expected: | PASS | | +| Task 36 필수 항목: git commit -m | PASS | | +| Task 37 필수 항목: **Files:** | PASS | | +| Task 37 필수 항목: **Interfaces:** | PASS | | +| Task 37 필수 항목: **Implementation requirements:** | PASS | | +| Task 37 필수 항목: **Step 1: | PASS | | +| Task 37 필수 항목: **Step 2: | PASS | | +| Task 37 필수 항목: **Step 3: | PASS | | +| Task 37 필수 항목: **Step 4: | PASS | | +| Task 37 필수 항목: **Step 5: | PASS | | +| Task 37 필수 항목: Expected: | PASS | | +| Task 37 필수 항목: git commit -m | PASS | | +| Task 38 필수 항목: **Files:** | PASS | | +| Task 38 필수 항목: **Interfaces:** | PASS | | +| Task 38 필수 항목: **Implementation requirements:** | PASS | | +| Task 38 필수 항목: **Step 1: | PASS | | +| Task 38 필수 항목: **Step 2: | PASS | | +| Task 38 필수 항목: **Step 3: | PASS | | +| Task 38 필수 항목: **Step 4: | PASS | | +| Task 38 필수 항목: **Step 5: | PASS | | +| Task 38 필수 항목: Expected: | PASS | | +| Task 38 필수 항목: git commit -m | PASS | | +| Task 39 필수 항목: **Files:** | PASS | | +| Task 39 필수 항목: **Interfaces:** | PASS | | +| Task 39 필수 항목: **Implementation requirements:** | PASS | | +| Task 39 필수 항목: **Step 1: | PASS | | +| Task 39 필수 항목: **Step 2: | PASS | | +| Task 39 필수 항목: **Step 3: | PASS | | +| Task 39 필수 항목: **Step 4: | PASS | | +| Task 39 필수 항목: **Step 5: | PASS | | +| Task 39 필수 항목: Expected: | PASS | | +| Task 39 필수 항목: git commit -m | PASS | | +| Task 40 필수 항목: **Files:** | PASS | | +| Task 40 필수 항목: **Interfaces:** | PASS | | +| Task 40 필수 항목: **Implementation requirements:** | PASS | | +| Task 40 필수 항목: **Step 1: | PASS | | +| Task 40 필수 항목: **Step 2: | PASS | | +| Task 40 필수 항목: **Step 3: | PASS | | +| Task 40 필수 항목: **Step 4: | PASS | | +| Task 40 필수 항목: **Step 5: | PASS | | +| Task 40 필수 항목: Expected: | PASS | | +| Task 40 필수 항목: git commit -m | PASS | | +| Task 41 필수 항목: **Files:** | PASS | | +| Task 41 필수 항목: **Interfaces:** | PASS | | +| Task 41 필수 항목: **Implementation requirements:** | PASS | | +| Task 41 필수 항목: **Step 1: | PASS | | +| Task 41 필수 항목: **Step 2: | PASS | | +| Task 41 필수 항목: **Step 3: | PASS | | +| Task 41 필수 항목: **Step 4: | PASS | | +| Task 41 필수 항목: **Step 5: | PASS | | +| Task 41 필수 항목: Expected: | PASS | | +| Task 41 필수 항목: git commit -m | PASS | | +| Task 42 필수 항목: **Files:** | PASS | | +| Task 42 필수 항목: **Interfaces:** | PASS | | +| Task 42 필수 항목: **Implementation requirements:** | PASS | | +| Task 42 필수 항목: **Step 1: | PASS | | +| Task 42 필수 항목: **Step 2: | PASS | | +| Task 42 필수 항목: **Step 3: | PASS | | +| Task 42 필수 항목: **Step 4: | PASS | | +| Task 42 필수 항목: **Step 5: | PASS | | +| Task 42 필수 항목: Expected: | PASS | | +| Task 42 필수 항목: git commit -m | PASS | | +| Task 43 필수 항목: **Files:** | PASS | | +| Task 43 필수 항목: **Interfaces:** | PASS | | +| Task 43 필수 항목: **Implementation requirements:** | PASS | | +| Task 43 필수 항목: **Step 1: | PASS | | +| Task 43 필수 항목: **Step 2: | PASS | | +| Task 43 필수 항목: **Step 3: | PASS | | +| Task 43 필수 항목: **Step 4: | PASS | | +| Task 43 필수 항목: **Step 5: | PASS | | +| Task 43 필수 항목: Expected: | PASS | | +| Task 43 필수 항목: git commit -m | PASS | | +| Task 44 필수 항목: **Files:** | PASS | | +| Task 44 필수 항목: **Interfaces:** | PASS | | +| Task 44 필수 항목: **Implementation requirements:** | PASS | | +| Task 44 필수 항목: **Step 1: | PASS | | +| Task 44 필수 항목: **Step 2: | PASS | | +| Task 44 필수 항목: **Step 3: | PASS | | +| Task 44 필수 항목: **Step 4: | PASS | | +| Task 44 필수 항목: **Step 5: | PASS | | +| Task 44 필수 항목: Expected: | PASS | | +| Task 44 필수 항목: git commit -m | PASS | | +| Task 45 필수 항목: **Files:** | PASS | | +| Task 45 필수 항목: **Interfaces:** | PASS | | +| Task 45 필수 항목: **Implementation requirements:** | PASS | | +| Task 45 필수 항목: **Step 1: | PASS | | +| Task 45 필수 항목: **Step 2: | PASS | | +| Task 45 필수 항목: **Step 3: | PASS | | +| Task 45 필수 항목: **Step 4: | PASS | | +| Task 45 필수 항목: **Step 5: | PASS | | +| Task 45 필수 항목: Expected: | PASS | | +| Task 45 필수 항목: git commit -m | PASS | | +| Task 46 필수 항목: **Files:** | PASS | | +| Task 46 필수 항목: **Interfaces:** | PASS | | +| Task 46 필수 항목: **Implementation requirements:** | PASS | | +| Task 46 필수 항목: **Step 1: | PASS | | +| Task 46 필수 항목: **Step 2: | PASS | | +| Task 46 필수 항목: **Step 3: | PASS | | +| Task 46 필수 항목: **Step 4: | PASS | | +| Task 46 필수 항목: **Step 5: | PASS | | +| Task 46 필수 항목: Expected: | PASS | | +| Task 46 필수 항목: git commit -m | PASS | | +| Task 47 필수 항목: **Files:** | PASS | | +| Task 47 필수 항목: **Interfaces:** | PASS | | +| Task 47 필수 항목: **Implementation requirements:** | PASS | | +| Task 47 필수 항목: **Step 1: | PASS | | +| Task 47 필수 항목: **Step 2: | PASS | | +| Task 47 필수 항목: **Step 3: | PASS | | +| Task 47 필수 항목: **Step 4: | PASS | | +| Task 47 필수 항목: **Step 5: | PASS | | +| Task 47 필수 항목: Expected: | PASS | | +| Task 47 필수 항목: git commit -m | PASS | | +| Task 48 필수 항목: **Files:** | PASS | | +| Task 48 필수 항목: **Interfaces:** | PASS | | +| Task 48 필수 항목: **Implementation requirements:** | PASS | | +| Task 48 필수 항목: **Step 1: | PASS | | +| Task 48 필수 항목: **Step 2: | PASS | | +| Task 48 필수 항목: **Step 3: | PASS | | +| Task 48 필수 항목: **Step 4: | PASS | | +| Task 48 필수 항목: **Step 5: | PASS | | +| Task 48 필수 항목: Expected: | PASS | | +| Task 48 필수 항목: git commit -m | PASS | | +| Task 49 필수 항목: **Files:** | PASS | | +| Task 49 필수 항목: **Interfaces:** | PASS | | +| Task 49 필수 항목: **Implementation requirements:** | PASS | | +| Task 49 필수 항목: **Step 1: | PASS | | +| Task 49 필수 항목: **Step 2: | PASS | | +| Task 49 필수 항목: **Step 3: | PASS | | +| Task 49 필수 항목: **Step 4: | PASS | | +| Task 49 필수 항목: **Step 5: | PASS | | +| Task 49 필수 항목: Expected: | PASS | | +| Task 49 필수 항목: git commit -m | PASS | | +| Task 50 필수 항목: **Files:** | PASS | | +| Task 50 필수 항목: **Interfaces:** | PASS | | +| Task 50 필수 항목: **Implementation requirements:** | PASS | | +| Task 50 필수 항목: **Step 1: | PASS | | +| Task 50 필수 항목: **Step 2: | PASS | | +| Task 50 필수 항목: **Step 3: | PASS | | +| Task 50 필수 항목: **Step 4: | PASS | | +| Task 50 필수 항목: **Step 5: | PASS | | +| Task 50 필수 항목: Expected: | PASS | | +| Task 50 필수 항목: git commit -m | PASS | | +| Create 경로 중복 없음 | PASS | | +| Create 경로 충분성 | PASS | 322 | +| 미확정 표현 없음: TODO | PASS | 0 | +| 미확정 표현 없음: TBD | PASS | 0 | +| 미확정 표현 없음: FIXME | PASS | 0 | +| 미확정 표현 없음: fill in details | PASS | 0 | +| 미확정 표현 없음: implement later | PASS | 0 | +| 미확정 표현 없음: concrete assertion below | PASS | 0 | +| 미확정 표현 없음: 유사 작업 참조 | PASS | 0 | +| 계획 핵심 회귀 테스트: providerAcceptanceIsNotDelivery | PASS | | +| 계획 핵심 회귀 테스트: concurrentSameRequestReturnsOneNotificationId | PASS | | +| 계획 핵심 회귀 테스트: providerAcceptsThenResponseIsLostRecordsAmbiguousAndBlocksFallback | PASS | | +| 계획 핵심 회귀 테스트: deliveredBeforeSentNeverDowngrades | PASS | | +| 계획 핵심 회귀 테스트: fcmInstallationAndLegacyTokenAreDifferentTypes | PASS | | +| 계획 핵심 회귀 테스트: http200IsProviderAcceptedNotDelivered | PASS | | +| 계획 핵심 회귀 테스트: ttlHeaderIsRequiredAndAcceptanceIsNotDelivery | PASS | | +| 계획 핵심 회귀 테스트: websocketFailureDoesNotRollbackInboxItem | PASS | | +| 계획 핵심 회귀 테스트: metricTagsNeverContainHighCardinalityIdentifiers | PASS | | +| 계획 핵심 회귀 테스트: acceptedThenResponseLossIsAmbiguousForEveryApplicableAdapter | PASS | | +| 계획 핵심 회귀 테스트: notificationPerformanceTest | PASS | | +| 설계·계획 날짜 일치 | PASS | | +| Java 21 가정 명시 | PASS | | +| 실제 저장소 부재 가정 명시 | PASS | | +| Provider SDK 공개 금지 | PASS | | +| Core async CompletionStage | PASS | | +| FCM FID 우선 | PASS | | +| Ambiguous fallback 금지 | PASS | | +| ProviderEvent 원장 | PASS | | +| ZIP CRC 무결성 | PASS | None | +| ZIP 필수 파일 | PASS | [] | + +## 검증 범위 + +- 이 검증은 Markdown 설계서와 구현 계획서의 구조·정합성·필수 계약·경로 중복·미확정 표현·패키지 CRC를 검사한다. +- 실제 Backend Skeleton 저장소가 입력되지 않았으므로 Gradle compile, Provider sandbox, PostgreSQL integration, chaos, performance test 실행 결과는 포함하지 않는다. +- 구현 시에는 계획의 각 Task가 지정한 red-green TDD 명령을 실제 저장소에서 실행해야 한다. diff --git a/notification-superpowers-package/docs/superpowers/plans/2026-08-10-notification-platform-implementation-plan.md b/notification-superpowers-package/docs/superpowers/plans/2026-08-10-notification-platform-implementation-plan.md new file mode 100644 index 00000000..d79ae00a --- /dev/null +++ b/notification-superpowers-package/docs/superpowers/plans/2026-08-10-notification-platform-implementation-plan.md @@ -0,0 +1,5324 @@ +# Notification Delivery Platform Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Java/Spring Backend Skeleton에 Request·Recipient·Attempt 수명주기, evidence 기반 Provider 결과, durable scheduling, callback ledger, SMTP·SES·Twilio·FCM·APNs·Web Push Stable Adapter, In-App Inbox, 보안·관측성·운영 검증을 갖춘 Notification Delivery Platform을 구현한다. + +**Architecture:** `notification-core-api`가 N1 Typed API와 N2 Orchestration 계약을 소유하고, `notification-provider-spi`를 채널별 Adapter가 구현한다. 논리 요청은 `NotificationRequest → RecipientDelivery → DeliveryAttempt`로 분해하고 Provider callback은 append-only `ProviderEvent` 원장에 저장한 뒤 channel-specific projector가 `SubmissionOutcome`, `DeliveryOutcome`, `EvidenceLevel`, engagement·suppression fact를 갱신한다. `submit()` 성공은 durable acceptance만 뜻하며 `AMBIGUOUS` attempt의 자동 retry·fallback은 기본 차단한다. + +**Tech Stack:** Java 21, Gradle Kotlin DSL, Spring Framework 6.2 common compatibility line with Spring 7.0 compatibility jobs, Spring Boot dependency management, PostgreSQL 16, JPA, Flyway, Spring JavaMail, existing HTTP Client Platform, Thymeleaf reference renderer, JSON Schema 2020-12, AES-256-GCM, HMAC-SHA-256, Micrometer, OpenTelemetry, JUnit 5, AssertJ, ArchUnit, Testcontainers, WireMock, Toxiproxy. + +## Global Constraints + +- Root package는 `io.backend.skeleton.notification`이다. +- 모듈 루트는 `modules/notification`이다. +- Core 공개 비동기 타입은 `CompletionStage`이며 Reactor 타입은 `notification-reactor`에서만 제공한다. +- `notification-core-api`, `notification-content-api`, `notification-contact-api`는 JPA, MVC, WebFlux, Provider SDK에 의존하지 않는다. +- 일반 애플리케이션은 N1 Typed API를 기본으로 사용한다. +- N2는 scheduling, cancel, ordered fallback, multi-recipient, opt-in dedup/collapse만 제공한다. +- N3는 typed provider capability만 노출하며 raw SDK client를 반환하지 않는다. +- N4 Admin Plane은 별도 authority, operation ID, reason, audit를 요구한다. +- `submit()` 성공은 DB에 논리 요청과 recipient delivery가 commit됐음을 의미하며 최종 전달을 뜻하지 않는다. +- SES MessageId, Twilio accepted/queued, FCM send success, APNs 2xx, Web Push 201을 `DELIVERED`로 매핑하지 않는다. +- Provider 결과를 확정할 수 없으면 `AMBIGUOUS`로 저장한다. +- `AMBIGUOUS` attempt가 있는 recipient에는 자동 retry와 cross-channel fallback을 기본 금지한다. +- Callback은 signature 검증 후 append-only ledger에 저장하고 projector를 실행한다. +- Callback 중복·역순·누락을 정상 failure mode로 처리한다. +- FCM primary target은 FID이며 registration token은 legacy compatibility type이다. +- Template ID, version, locale, normalized variables, rendered digest를 submit 시점에 고정한다. +- Idempotency, deduplication, collapse는 별도 기능이다. +- Stable scheduler는 PostgreSQL durable queue와 `FOR UPDATE SKIP LOCKED`를 사용한다. +- Provider 호출은 DB transaction 밖에서 수행하고 호출 전에 Attempt row를 commit한다. +- Contact Point 원문은 AES-256-GCM으로 암호화하고 equality lookup은 HMAC-SHA-256 fingerprint를 사용한다. +- HMAC key와 encryption key를 분리한다. +- metric label과 일반 로그에 recipient, 주소, token, notification ID, attempt ID, provider request ID, body를 기록하지 않는다. +- Web Push endpoint, p256dh, auth secret은 secret 수준으로 보호한다. +- Provider 인증 실패는 개별 메시지 retry가 아니라 Provider runtime health failure로 처리한다. +- In-App Inbox의 PostgreSQL row가 source of truth이며 WebSocket은 commit 이후 신호만 보낸다. +- Webhook extension은 기존 HTTP Client Platform을 재사용한다. +- 각 Task는 실패 테스트 작성 → 실패 확인 → 최소 구현 → 통과 확인 → 커밋 순서로 수행한다. +- 각 Task는 독립적으로 검토 가능한 하나의 커밋으로 종료한다. +- 테스트에 필요한 작은 fixture는 해당 test file 하단의 package-private type으로 작성한다. 공용 fixture만 `notification-testkit`으로 승격한다. + +--- + +## 1. 확정 파일 구조 + +```text +backend-skeleton/ +├── settings.gradle.kts +├── build-logic/src/main/kotlin/notification-library-conventions.gradle.kts +├── modules/notification/ +│ ├── notification-core-api/ +│ ├── notification-content-api/ +│ ├── notification-template-api/ +│ ├── notification-template-thymeleaf/ +│ ├── notification-contact-api/ +│ ├── notification-policy/ +│ ├── notification-provider-spi/ +│ ├── notification-persistence-jpa/ +│ ├── notification-dispatch-runtime/ +│ ├── notification-callback-api/ +│ ├── notification-callback-mvc/ +│ ├── notification-callback-webflux/ +│ ├── notification-email-api/ +│ ├── notification-email-smtp/ +│ ├── notification-email-ses/ +│ ├── notification-sms-api/ +│ ├── notification-sms-twilio/ +│ ├── notification-push-api/ +│ ├── notification-push-fcm/ +│ ├── notification-push-apns/ +│ ├── notification-webpush/ +│ ├── notification-inbox-api/ +│ ├── notification-inbox-jpa/ +│ ├── notification-webhook-extension/ +│ ├── notification-observability/ +│ ├── notification-security/ +│ ├── notification-admin-api/ +│ ├── notification-admin-runtime/ +│ ├── notification-reactor/ +│ ├── notification-spring-boot-starter/ +│ └── notification-testkit/ +├── infra/notification/ +│ ├── postgres/ +│ ├── smtp/ +│ ├── wiremock/ +│ ├── toxiproxy/ +│ └── tls/ +├── docs/notification/ +│ ├── support-matrix.md +│ ├── configuration-reference.md +│ ├── delivery-evidence.md +│ ├── callback-reconciliation.md +│ ├── provider-runbooks.md +│ ├── security-privacy.md +│ ├── operations.md +│ └── migration-guide.md +└── docs/superpowers/specs/2026-08-10-notification-platform-design.md +``` + +## 2. 핵심 패키지 + +```text +io.backend.skeleton.notification.api +io.backend.skeleton.notification.api.content +io.backend.skeleton.notification.api.delivery +io.backend.skeleton.notification.api.error +io.backend.skeleton.notification.api.routing +io.backend.skeleton.notification.contact +io.backend.skeleton.notification.template +io.backend.skeleton.notification.policy +io.backend.skeleton.notification.provider +io.backend.skeleton.notification.persistence +io.backend.skeleton.notification.dispatch +io.backend.skeleton.notification.callback +io.backend.skeleton.notification.email +io.backend.skeleton.notification.sms +io.backend.skeleton.notification.push +io.backend.skeleton.notification.webpush +io.backend.skeleton.notification.inbox +io.backend.skeleton.notification.webhook +io.backend.skeleton.notification.observation +io.backend.skeleton.notification.security +io.backend.skeleton.notification.admin +io.backend.skeleton.notification.autoconfigure +io.backend.skeleton.notification.testkit +``` + + +## 3. Module dependency map + +Each module build file created in Task 1 must use the following direct project dependencies. Provider SDK and Spring web dependencies are added only in the owning adapter module. + +```text +notification-core-api + → no project dependency + +notification-content-api + → notification-core-api + +notification-contact-api + → notification-core-api + +notification-template-api + → notification-core-api + → notification-content-api + +notification-template-thymeleaf + → notification-template-api + → notification-content-api + +notification-provider-spi + → notification-core-api + → notification-content-api + → notification-contact-api + → notification-template-api + +notification-policy + → notification-core-api + → notification-contact-api + → notification-provider-spi + +notification-callback-api + → notification-core-api + → notification-provider-spi + +notification-security + → notification-core-api + → notification-contact-api + +notification-observability + → notification-core-api + → notification-provider-spi + +notification-persistence-jpa + → notification-core-api + → notification-contact-api + → notification-template-api + → notification-callback-api + +notification-dispatch-runtime + → notification-core-api + → notification-content-api + → notification-contact-api + → notification-template-api + → notification-policy + → notification-provider-spi + → notification-callback-api + → notification-persistence-jpa + → notification-security + → notification-observability + +notification-callback-mvc / notification-callback-webflux + → notification-callback-api + → notification-security + +notification-email-api / notification-sms-api / notification-push-api + → notification-core-api + → notification-content-api + → notification-contact-api + +notification-email-smtp + → notification-email-api + → notification-provider-spi + → notification-security + +notification-email-ses + → notification-email-api + → notification-provider-spi + → httpclient platform + +notification-sms-twilio + → notification-sms-api + → notification-provider-spi + → httpclient platform + +notification-push-fcm / notification-push-apns + → notification-push-api + → notification-provider-spi + → notification-security + → httpclient platform + +notification-webpush + → notification-core-api + → notification-content-api + → notification-contact-api + → notification-provider-spi + → notification-security + → httpclient platform + +notification-inbox-api + → notification-core-api + → notification-content-api + +notification-inbox-jpa + → notification-inbox-api + → notification-persistence-jpa + → optional messaging outbox integration + +notification-webhook-extension + → notification-provider-spi + → httpclient platform + +notification-admin-api + → notification-core-api + +notification-admin-runtime + → notification-admin-api + → notification-dispatch-runtime + → notification-persistence-jpa + → notification-observability + +notification-reactor + → notification-core-api + → notification-dispatch-runtime + +notification-spring-boot-starter + → notification-dispatch-runtime + → optional callback/provider/admin modules + +notification-testkit + → test fixtures from every Stable adapter +``` + +No reverse dependency from an API module to a concrete adapter is allowed. + +--- + +### Task 1: Gradle 멀티모듈과 공통 품질 규칙 구성 + +**Files:** +- Modify: `settings.gradle.kts` +- Create: `build-logic/src/main/kotlin/notification-library-conventions.gradle.kts` +- Create: `modules/notification/notification-core-api/build.gradle.kts` +- Create: `modules/notification/notification-content-api/build.gradle.kts` +- Create: `modules/notification/notification-template-api/build.gradle.kts` +- Create: `modules/notification/notification-template-thymeleaf/build.gradle.kts` +- Create: `modules/notification/notification-contact-api/build.gradle.kts` +- Create: `modules/notification/notification-policy/build.gradle.kts` +- Create: `modules/notification/notification-provider-spi/build.gradle.kts` +- Create: `modules/notification/notification-persistence-jpa/build.gradle.kts` +- Create: `modules/notification/notification-dispatch-runtime/build.gradle.kts` +- Create: `modules/notification/notification-callback-api/build.gradle.kts` +- Create: `modules/notification/notification-callback-mvc/build.gradle.kts` +- Create: `modules/notification/notification-callback-webflux/build.gradle.kts` +- Create: `modules/notification/notification-email-api/build.gradle.kts` +- Create: `modules/notification/notification-email-smtp/build.gradle.kts` +- Create: `modules/notification/notification-email-ses/build.gradle.kts` +- Create: `modules/notification/notification-sms-api/build.gradle.kts` +- Create: `modules/notification/notification-sms-twilio/build.gradle.kts` +- Create: `modules/notification/notification-push-api/build.gradle.kts` +- Create: `modules/notification/notification-push-fcm/build.gradle.kts` +- Create: `modules/notification/notification-push-apns/build.gradle.kts` +- Create: `modules/notification/notification-webpush/build.gradle.kts` +- Create: `modules/notification/notification-inbox-api/build.gradle.kts` +- Create: `modules/notification/notification-inbox-jpa/build.gradle.kts` +- Create: `modules/notification/notification-webhook-extension/build.gradle.kts` +- Create: `modules/notification/notification-observability/build.gradle.kts` +- Create: `modules/notification/notification-security/build.gradle.kts` +- Create: `modules/notification/notification-admin-api/build.gradle.kts` +- Create: `modules/notification/notification-admin-runtime/build.gradle.kts` +- Create: `modules/notification/notification-reactor/build.gradle.kts` +- Create: `modules/notification/notification-spring-boot-starter/build.gradle.kts` +- Create: `modules/notification/notification-testkit/build.gradle.kts` +- Test: `modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/ModuleSmokeTest.java` + +**Interfaces:** +- Consumes: 없음. +- Produces: 후속 Task가 사용하는 31개 Gradle project path, Java 21 toolchain, JUnit Platform, dependency boundary. + +**Implementation requirements:** +- 모든 module은 공통 conventions plugin을 적용한다. +- core/content/contact API build file에는 Spring, JPA, Provider SDK dependency를 추가하지 않는다. +- Experimental 기능은 Stable module classpath에 자동 포함하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.api; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + +class ModuleSmokeTest { + @Test + void coreModuleRunsOnJava21() { + assertThat(Runtime.version().feature()).isEqualTo(21); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-core-api:test --tests "*.ModuleSmokeTest"` + +Expected: FAIL: project path `:modules:notification:notification-core-api`가 존재하지 않는다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `settings.gradle.kts` +- `build-logic/src/main/kotlin/notification-library-conventions.gradle.kts` +- `modules/notification/notification-core-api/build.gradle.kts` +- `modules/notification/notification-content-api/build.gradle.kts` +- `modules/notification/notification-template-api/build.gradle.kts` +- `modules/notification/notification-template-thymeleaf/build.gradle.kts` +- `modules/notification/notification-contact-api/build.gradle.kts` +- `modules/notification/notification-policy/build.gradle.kts` +- `modules/notification/notification-provider-spi/build.gradle.kts` +- `modules/notification/notification-persistence-jpa/build.gradle.kts` +- `modules/notification/notification-dispatch-runtime/build.gradle.kts` +- `modules/notification/notification-callback-api/build.gradle.kts` +- `modules/notification/notification-callback-mvc/build.gradle.kts` +- `modules/notification/notification-callback-webflux/build.gradle.kts` +- `modules/notification/notification-email-api/build.gradle.kts` +- `modules/notification/notification-email-smtp/build.gradle.kts` +- `modules/notification/notification-email-ses/build.gradle.kts` +- `modules/notification/notification-sms-api/build.gradle.kts` +- `modules/notification/notification-sms-twilio/build.gradle.kts` +- `modules/notification/notification-push-api/build.gradle.kts` +- `modules/notification/notification-push-fcm/build.gradle.kts` +- `modules/notification/notification-push-apns/build.gradle.kts` +- `modules/notification/notification-webpush/build.gradle.kts` +- `modules/notification/notification-inbox-api/build.gradle.kts` +- `modules/notification/notification-inbox-jpa/build.gradle.kts` +- `modules/notification/notification-webhook-extension/build.gradle.kts` +- `modules/notification/notification-observability/build.gradle.kts` +- `modules/notification/notification-security/build.gradle.kts` +- `modules/notification/notification-admin-api/build.gradle.kts` +- `modules/notification/notification-admin-runtime/build.gradle.kts` +- `modules/notification/notification-reactor/build.gradle.kts` +- `modules/notification/notification-spring-boot-starter/build.gradle.kts` +- `modules/notification/notification-testkit/build.gradle.kts` + +```java +// settings.gradle.kts +val notificationModules = listOf( + "core-api", "content-api", "template-api", "template-thymeleaf", + "contact-api", "policy", "provider-spi", "persistence-jpa", + "dispatch-runtime", "callback-api", "callback-mvc", "callback-webflux", + "email-api", "email-smtp", "email-ses", "sms-api", "sms-twilio", + "push-api", "push-fcm", "push-apns", "webpush", "inbox-api", + "inbox-jpa", "webhook-extension", "observability", "security", + "admin-api", "admin-runtime", "reactor", "spring-boot-starter", "testkit" +) +notificationModules.forEach { + include(":modules:notification:notification-$it") +} + +// notification-library-conventions.gradle.kts +plugins { + `java-library` + id("java-test-fixtures") +} +java { toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } } +tasks.withType().configureEach { useJUnitPlatform() } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-core-api:test` + +Expected: PASS: ModuleSmokeTest 1개 통과, Java 21 toolchain 사용. + +- [ ] **Step 5: Commit** + +```bash +git add 'settings.gradle.kts' 'build-logic/src/main/kotlin/notification-library-conventions.gradle.kts' 'modules/notification/notification-core-api/build.gradle.kts' 'modules/notification/notification-content-api/build.gradle.kts' 'modules/notification/notification-template-api/build.gradle.kts' 'modules/notification/notification-template-thymeleaf/build.gradle.kts' 'modules/notification/notification-contact-api/build.gradle.kts' 'modules/notification/notification-policy/build.gradle.kts' 'modules/notification/notification-provider-spi/build.gradle.kts' 'modules/notification/notification-persistence-jpa/build.gradle.kts' 'modules/notification/notification-dispatch-runtime/build.gradle.kts' 'modules/notification/notification-callback-api/build.gradle.kts' 'modules/notification/notification-callback-mvc/build.gradle.kts' 'modules/notification/notification-callback-webflux/build.gradle.kts' 'modules/notification/notification-email-api/build.gradle.kts' 'modules/notification/notification-email-smtp/build.gradle.kts' 'modules/notification/notification-email-ses/build.gradle.kts' 'modules/notification/notification-sms-api/build.gradle.kts' 'modules/notification/notification-sms-twilio/build.gradle.kts' 'modules/notification/notification-push-api/build.gradle.kts' 'modules/notification/notification-push-fcm/build.gradle.kts' 'modules/notification/notification-push-apns/build.gradle.kts' 'modules/notification/notification-webpush/build.gradle.kts' 'modules/notification/notification-inbox-api/build.gradle.kts' 'modules/notification/notification-inbox-jpa/build.gradle.kts' 'modules/notification/notification-webhook-extension/build.gradle.kts' 'modules/notification/notification-observability/build.gradle.kts' 'modules/notification/notification-security/build.gradle.kts' 'modules/notification/notification-admin-api/build.gradle.kts' 'modules/notification/notification-admin-runtime/build.gradle.kts' 'modules/notification/notification-reactor/build.gradle.kts' 'modules/notification/notification-spring-boot-starter/build.gradle.kts' 'modules/notification/notification-testkit/build.gradle.kts' 'modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/ModuleSmokeTest.java' +git commit -m "build(notification): create delivery platform modules" +``` + +--- + +### Task 2: 핵심 식별자와 증거 Enum 구현 + +**Files:** +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationId.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/RecipientDeliveryId.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/DeliveryAttemptId.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ProviderEventId.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ContactPointId.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ProviderProfileId.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ProviderId.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/TenantId.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CorrelationId.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/IdempotencyKey.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/RequestStatus.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/SubmissionOutcome.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/DeliveryOutcome.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/AttemptConfirmation.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/EvidenceLevel.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/RecipientDeliveryState.java` +- Test: `modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/delivery/EvidenceModelTest.java` + +**Interfaces:** +- Consumes: Task 1의 core-api module. +- Produces: `NotificationId`, `RecipientDeliveryId`, `DeliveryAttemptId`, `SubmissionOutcome`, `DeliveryOutcome`, `AttemptConfirmation`, `EvidenceLevel`, `RecipientDeliveryState`. + +**Implementation requirements:** +- EvidenceLevel ordinal로 Provider event merge를 구현하지 않는다. +- Notification ID는 UUIDv7 generator를 후속 runtime Task에서 주입한다. +- `DELIVERED=true`, `EXACTLY_ONCE` 같은 boolean 보장 타입을 만들지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.api.delivery; + +import org.junit.jupiter.api.Test; +import java.util.UUID; +import io.backend.skeleton.notification.api.NotificationId; +import static org.assertj.core.api.Assertions.*; + +class EvidenceModelTest { + @Test + void providerAcceptanceIsNotDelivery() { + assertThat(EvidenceLevel.PROVIDER_ACCEPTED) + .isNotEqualTo(EvidenceLevel.DEVICE_DELIVERED); + assertThat(SubmissionOutcome.CONFIRMED_ACCEPTED.name()) + .doesNotContain("DELIVERED"); + } + + @Test + void idsRejectNull() { + assertThatThrownBy(() -> new NotificationId(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void ambiguousIsFirstClassOutcome() { + assertThat(SubmissionOutcome.values()) + .contains(SubmissionOutcome.AMBIGUOUS); + assertThat(AttemptConfirmation.values()) + .contains(AttemptConfirmation.AMBIGUOUS); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-core-api:test --tests "*.EvidenceModelTest"` + +Expected: FAIL: NotificationId와 evidence enum이 정의되지 않았다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `NotificationId.java` +- `RecipientDeliveryId.java` +- `DeliveryAttemptId.java` +- `ProviderEventId.java` +- `ContactPointId.java` +- `ProviderProfileId.java` +- `ProviderId.java` +- `TenantId.java` +- `CorrelationId.java` +- `IdempotencyKey.java` +- `RequestStatus.java` +- `SubmissionOutcome.java` +- `DeliveryOutcome.java` +- `AttemptConfirmation.java` +- `EvidenceLevel.java` +- `RecipientDeliveryState.java` + +```java +public record NotificationId(UUID value) { + public NotificationId { + java.util.Objects.requireNonNull(value, "value"); + } +} + +public record TenantId(String value) { + public TenantId { + if (value == null || value.isBlank()) throw new IllegalArgumentException("value"); + } +} + +public enum RequestStatus { + CREATED, VALIDATED, SCHEDULED, PROCESSING, + PARTIALLY_COMPLETED, COMPLETED, CANCELED, EXPIRED, FAILED +} + +public enum SubmissionOutcome { + NOT_SUBMITTED, CONFIRMED_ACCEPTED, CONFIRMED_REJECTED, AMBIGUOUS +} + +public enum DeliveryOutcome { + UNKNOWN, SENT, DELIVERED, UNDELIVERED, BOUNCED, EXPIRED +} + +public enum AttemptConfirmation { + CONFIRMED, REJECTED, AMBIGUOUS +} + +public enum EvidenceLevel { + NONE, + PLATFORM_QUEUED, + PROVIDER_ACCEPTED, + NETWORK_OR_CARRIER_ACCEPTED, + DEVICE_DELIVERED, + USER_AGENT_DISPLAYED, + USER_READ +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-core-api:test` + +Expected: PASS: evidence 단계가 분리되고 null ID가 거부된다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationId.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/RecipientDeliveryId.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/DeliveryAttemptId.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ProviderEventId.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ContactPointId.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ProviderProfileId.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ProviderId.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/TenantId.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CorrelationId.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/IdempotencyKey.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/RequestStatus.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/SubmissionOutcome.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/DeliveryOutcome.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/AttemptConfirmation.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/EvidenceLevel.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/RecipientDeliveryState.java' 'modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/delivery/EvidenceModelTest.java' +git commit -m "feat(notification): add identity and evidence model" +``` + +--- + +### Task 3: 채널별 NotificationContent sealed hierarchy 구현 + +**Files:** +- Create: `modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/NotificationContent.java` +- Create: `modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/EmailContent.java` +- Create: `modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/SmsContent.java` +- Create: `modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/MobilePushContent.java` +- Create: `modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/WebPushContent.java` +- Create: `modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/InAppContent.java` +- Create: `modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/AttachmentRef.java` +- Create: `modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/EmailOptions.java` +- Create: `modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/SmsOptions.java` +- Create: `modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/PushPresentation.java` +- Create: `modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/WebPushOptions.java` +- Create: `modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/InAppAction.java` +- Create: `modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/AttachmentDisposition.java` +- Test: `modules/notification/notification-content-api/src/test/java/io/backend/skeleton/notification/api/content/NotificationContentContractTest.java` + +**Interfaces:** +- Consumes: Task 1 module structure. +- Produces: Provider SDK와 raw credential이 없는 typed content records와 attachment reference. + +**Implementation requirements:** +- Email, SMS, Push 고유 필드를 하나의 거대 record로 합치지 않는다. +- Map은 push data처럼 문자열 key/value가 표준인 위치에만 제한 사용한다. +- attachment는 fileserver/objectstorage reference만 보유한다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.api.content; + +import org.junit.jupiter.api.Test; +import java.net.URI; +import java.util.Map; +import static org.assertj.core.api.Assertions.*; + +class NotificationContentContractTest { + @Test + void pushDataRejectsNullKeyAndValue() { + assertThatThrownBy(() -> new MobilePushContent( + "title", "body", URI.create("https://app.example/item/1"), + Map.of("key", (String) null), PushPresentation.DEFAULT)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void attachmentCarriesReferenceNotBytes() { + var ref = new AttachmentRef("file:01", "report.pdf", + "application/pdf", 1024, "sha256:abc", AttachmentDisposition.ATTACHMENT); + assertThat(ref.contentReference()).isEqualTo("file:01"); + assertThat(ref.getClass().getRecordComponents()) + .noneMatch(c -> c.getType().equals(byte[].class)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-content-api:test --tests "*.NotificationContentContractTest"` + +Expected: FAIL: content records와 AttachmentRef가 존재하지 않는다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `NotificationContent.java` +- `EmailContent.java` +- `SmsContent.java` +- `MobilePushContent.java` +- `WebPushContent.java` +- `InAppContent.java` +- `AttachmentRef.java` +- `EmailOptions.java` +- `SmsOptions.java` +- `PushPresentation.java` +- `WebPushOptions.java` +- `InAppAction.java` +- `AttachmentDisposition.java` + +```java +public sealed interface NotificationContent + permits EmailContent, SmsContent, MobilePushContent, WebPushContent, InAppContent {} + +public record PushPresentation( + java.util.Optional sound, + java.util.Optional badge +) { + public static final PushPresentation DEFAULT = + new PushPresentation(java.util.Optional.empty(), java.util.Optional.empty()); +} + +public record SmsContent(String text, SmsOptions options) + implements NotificationContent { + public SmsContent { + if (text == null || text.isBlank()) throw new IllegalArgumentException("text"); + java.util.Objects.requireNonNull(options, "options"); + } +} + +public record AttachmentRef( + String contentReference, + String displayName, + String contentType, + long expectedSize, + String expectedDigest, + AttachmentDisposition disposition +) { + public AttachmentRef { + if (expectedSize < 0) throw new IllegalArgumentException("expectedSize"); + java.util.Objects.requireNonNull(contentReference, "contentReference"); + java.util.Objects.requireNonNull(expectedDigest, "expectedDigest"); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-content-api:test` + +Expected: PASS: 각 채널 content가 typed record이고 attachment bytes가 공개 모델에 없다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/NotificationContent.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/EmailContent.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/SmsContent.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/MobilePushContent.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/WebPushContent.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/InAppContent.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/AttachmentRef.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/EmailOptions.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/SmsOptions.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/PushPresentation.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/WebPushOptions.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/InAppAction.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/AttachmentDisposition.java' 'modules/notification/notification-content-api/src/test/java/io/backend/skeleton/notification/api/content/NotificationContentContractTest.java' +git commit -m "feat(notification): add typed channel content model" +``` + +--- + +### Task 4: NotificationPlan과 Routing Strategy 구현 + +**Files:** +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationPlan.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/RecipientSpec.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/TemplateSelection.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/DeduplicationSpec.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/DeduplicationAction.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CollapseSpec.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CollapseScope.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ContactPointSelector.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ChannelPreferenceOverride.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/routing/DeliveryStrategy.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/routing/ExplicitChannel.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/routing/OrderedFallback.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/routing/Channel.java` +- Test: `modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/NotificationPlanTest.java` + +**Interfaces:** +- Consumes: Task 2 IDs와 Task 3 content API. +- Produces: `NotificationPlan`, `RecipientSpec`, `DeliveryStrategy`, Stable `ExplicitChannel`·`OrderedFallback`. + +**Implementation requirements:** +- Parallel first-success는 이 Task의 public sealed hierarchy에 넣지 않는다. +- metadata key/value hard limit validation을 NotificationPlan constructor에 적용한다. +- recipient 목록은 immutable copy로 보존한다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.api; + +import org.junit.jupiter.api.Test; +import java.time.Instant; +import java.util.List; +import io.backend.skeleton.notification.api.routing.*; +import static org.assertj.core.api.Assertions.*; + +class NotificationPlanTest { + @Test + void orderedFallbackRequiresDistinctChannels() { + assertThatThrownBy(() -> new OrderedFallback( + List.of(Channel.PUSH, Channel.PUSH))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void expiryMustBeAfterNotBefore() { + var now = Instant.parse("2026-08-10T00:00:00Z"); + assertThatThrownBy(() -> NotificationPlanFixture.plan(now.plusSeconds(60), now)) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-core-api:test --tests "*.NotificationPlanTest"` + +Expected: FAIL: NotificationPlan과 routing types가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `NotificationPlan.java` +- `RecipientSpec.java` +- `TemplateSelection.java` +- `DeduplicationSpec.java` +- `DeduplicationAction.java` +- `CollapseSpec.java` +- `CollapseScope.java` +- `ContactPointSelector.java` +- `ChannelPreferenceOverride.java` +- `DeliveryStrategy.java` +- `ExplicitChannel.java` +- `OrderedFallback.java` +- `Channel.java` + +```java +public record TemplateSelection(String templateId, long version, java.util.Locale locale) { + public TemplateSelection { + if (templateId == null || templateId.isBlank()) throw new IllegalArgumentException("templateId"); + if (version <= 0) throw new IllegalArgumentException("version"); + java.util.Objects.requireNonNull(locale, "locale"); + } +} + +public record DeduplicationSpec( + String dedupKey, + java.time.Duration window, + DeduplicationAction action +) {} + +public record CollapseSpec(String key, CollapseScope scope) {} + +public sealed interface DeliveryStrategy permits ExplicitChannel, OrderedFallback {} + +public record ExplicitChannel(Channel channel) implements DeliveryStrategy { + public ExplicitChannel { java.util.Objects.requireNonNull(channel, "channel"); } +} + +public record OrderedFallback(java.util.List channels) + implements DeliveryStrategy { + public OrderedFallback { + channels = java.util.List.copyOf(channels); + if (channels.isEmpty() || new java.util.HashSet<>(channels).size() != channels.size()) { + throw new IllegalArgumentException("channels must be non-empty and distinct"); + } + } +} + +// NotificationPlan compact constructor +if (expiresAt.isPresent() && notBefore.isPresent() + && !expiresAt.get().isAfter(notBefore.get())) { + throw new IllegalArgumentException("expiresAt must be after notBefore"); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-core-api:test` + +Expected: PASS: Stable routing strategy와 시간 불변 조건이 검증된다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationPlan.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/RecipientSpec.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/TemplateSelection.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/DeduplicationSpec.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/DeduplicationAction.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CollapseSpec.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CollapseScope.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ContactPointSelector.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ChannelPreferenceOverride.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/routing/DeliveryStrategy.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/routing/ExplicitChannel.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/routing/OrderedFallback.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/routing/Channel.java' 'modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/NotificationPlanTest.java' +git commit -m "feat(notification): add notification plan and routing strategies" +``` + +--- + +### Task 5: N1 Typed Facade와 N2 Orchestrator 계약 구현 + +**Files:** +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationReceipt.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationOrchestrator.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CancelResult.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CancelCommand.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationSnapshot.java` +- Create: `modules/notification/notification-email-api/src/main/java/io/backend/skeleton/notification/email/EmailNotification.java` +- Create: `modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsNotification.java` +- Create: `modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsEstimate.java` +- Create: `modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsEncoding.java` +- Create: `modules/notification/notification-push-api/src/main/java/io/backend/skeleton/notification/push/MobilePushNotification.java` +- Create: `modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushNotification.java` +- Create: `modules/notification/notification-email-api/src/main/java/io/backend/skeleton/notification/email/EmailNotifier.java` +- Create: `modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsNotifier.java` +- Create: `modules/notification/notification-push-api/src/main/java/io/backend/skeleton/notification/push/MobilePushNotifier.java` +- Create: `modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushNotifier.java` +- Test: `modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/PublicApiBoundaryTest.java` + +**Interfaces:** +- Consumes: Task 2 IDs, Task 4 NotificationPlan. +- Produces: N1 channel facade, N2 `submit/schedule/cancel/get`, durable acceptance만 표현하는 `NotificationReceipt`. + +**Implementation requirements:** +- N1 facade 구현은 후속 starter/runtime가 제공한다. +- N1 메서드는 provider response를 대기하지 않는다. +- public API에서 Provider SDK와 raw map request를 사용하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.api; + +import org.junit.jupiter.api.Test; +import java.lang.reflect.Method; +import static org.assertj.core.api.Assertions.*; + +class PublicApiBoundaryTest { + @Test + void receiptContainsNoDeliveryBoolean() { + assertThat(NotificationReceipt.class.getRecordComponents()) + .extracting(c -> c.getName()) + .doesNotContain("delivered", "sent", "read"); + } + + @Test + void orchestratorReturnsDurableReceipt() throws Exception { + Method method = NotificationOrchestrator.class + .getMethod("submit", NotificationPlan.class); + assertThat(method.getReturnType()).isEqualTo(NotificationReceipt.class); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-core-api:test --tests "*.PublicApiBoundaryTest"` + +Expected: FAIL: public facade와 receipt가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `NotificationReceipt.java` +- `NotificationOrchestrator.java` +- `CancelResult.java` +- `CancelCommand.java` +- `NotificationSnapshot.java` +- `EmailNotification.java` +- `SmsNotification.java` +- `SmsEstimate.java` +- `SmsEncoding.java` +- `MobilePushNotification.java` +- `WebPushNotification.java` +- `EmailNotifier.java` +- `SmsNotifier.java` +- `MobilePushNotifier.java` +- `WebPushNotifier.java` + +```java +public record NotificationReceipt( + NotificationId notificationId, + RequestStatus status, + java.time.Instant acceptedAt +) { + public NotificationReceipt { + java.util.Objects.requireNonNull(notificationId, "notificationId"); + java.util.Objects.requireNonNull(status, "status"); + java.util.Objects.requireNonNull(acceptedAt, "acceptedAt"); + } +} + +public record SmsEstimate( + SmsEncoding encoding, + int segmentCount, + int encodedLength, + boolean exceedsRecommendedLimit +) {} + +public interface NotificationOrchestrator { + NotificationReceipt submit(NotificationPlan plan); + NotificationReceipt schedule(NotificationPlan plan, java.time.Instant scheduleAt); + CancelResult cancel(NotificationId notificationId, CancelCommand command); + NotificationSnapshot get(NotificationId notificationId); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-core-api:test` + +Expected: PASS: receipt가 final delivery를 표현하지 않고 public API가 typed contract다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationReceipt.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationOrchestrator.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CancelResult.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CancelCommand.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationSnapshot.java' 'modules/notification/notification-email-api/src/main/java/io/backend/skeleton/notification/email/EmailNotification.java' 'modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsNotification.java' 'modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsEstimate.java' 'modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsEncoding.java' 'modules/notification/notification-push-api/src/main/java/io/backend/skeleton/notification/push/MobilePushNotification.java' 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushNotification.java' 'modules/notification/notification-email-api/src/main/java/io/backend/skeleton/notification/email/EmailNotifier.java' 'modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsNotifier.java' 'modules/notification/notification-push-api/src/main/java/io/backend/skeleton/notification/push/MobilePushNotifier.java' 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushNotifier.java' 'modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/PublicApiBoundaryTest.java' +git commit -m "feat(notification): add typed public notification APIs" +``` + +--- + +### Task 6: Contact Point 타입과 FCM FID 우선 모델 구현 + +**Files:** +- Create: `modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/ContactPointValue.java` +- Create: `modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/EmailAddress.java` +- Create: `modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/PhoneNumber.java` +- Create: `modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/MobilePushTarget.java` +- Create: `modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/FcmInstallationId.java` +- Create: `modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/LegacyFcmRegistrationToken.java` +- Create: `modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/ApnsDeviceToken.java` +- Create: `modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/ApnsEnvironment.java` +- Create: `modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/ContactPointStatus.java` +- Test: `modules/notification/notification-contact-api/src/test/java/io/backend/skeleton/notification/contact/ContactPointTypeTest.java` + +**Interfaces:** +- Consumes: Task 1 contact-api module. +- Produces: Typed contact values, FID primary와 legacy token의 명시적 분리, APNs environment. + +**Implementation requirements:** +- Email local-part case 정책은 보존하고 domain만 IDNA/lowercase normalize한다. +- PhoneNumber E.164 구체 검증은 Task 30에서 강화한다. +- Contact Point type에 provider credential을 넣지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.contact; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class ContactPointTypeTest { + @Test + void fcmInstallationAndLegacyTokenAreDifferentTypes() { + assertThat(FcmInstallationId.class) + .isNotEqualTo(LegacyFcmRegistrationToken.class); + } + + @Test + void apnsTokenRequiresEnvironment() { + assertThatThrownBy(() -> new ApnsDeviceToken("token", null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void emailIsNormalizedWithoutChangingOriginalDisplay() { + var address = EmailAddress.parse("User@Example.COM"); + assertThat(address.normalized()).isEqualTo("User@example.com"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-contact-api:test --tests "*.ContactPointTypeTest"` + +Expected: FAIL: typed Contact Point가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `ContactPointValue.java` +- `EmailAddress.java` +- `PhoneNumber.java` +- `MobilePushTarget.java` +- `FcmInstallationId.java` +- `LegacyFcmRegistrationToken.java` +- `ApnsDeviceToken.java` +- `ApnsEnvironment.java` +- `ContactPointStatus.java` + +```java +public sealed interface MobilePushTarget extends ContactPointValue + permits FcmInstallationId, LegacyFcmRegistrationToken, ApnsDeviceToken {} + +public record FcmInstallationId(String value) implements MobilePushTarget { + public FcmInstallationId { + if (value == null || value.isBlank()) throw new IllegalArgumentException("value"); + } +} + +public record LegacyFcmRegistrationToken(String value) implements MobilePushTarget {} + +public record ApnsDeviceToken(String value, ApnsEnvironment environment) + implements MobilePushTarget { + public ApnsDeviceToken { + java.util.Objects.requireNonNull(environment, "environment"); + if (value == null || value.isBlank()) throw new IllegalArgumentException("value"); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-contact-api:test` + +Expected: PASS: FID·legacy token·APNs token이 독립 타입이고 생명주기 enum이 존재한다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/ContactPointValue.java' 'modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/EmailAddress.java' 'modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/PhoneNumber.java' 'modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/MobilePushTarget.java' 'modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/FcmInstallationId.java' 'modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/LegacyFcmRegistrationToken.java' 'modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/ApnsDeviceToken.java' 'modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/ApnsEnvironment.java' 'modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/ContactPointStatus.java' 'modules/notification/notification-contact-api/src/test/java/io/backend/skeleton/notification/contact/ContactPointTypeTest.java' +git commit -m "feat(notification): add protected contact point value types" +``` + +--- + +### Task 7: Contact Point AES-GCM 보호와 HMAC lookup 구현 + +**Files:** +- Create: `modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SecretMaterialProvider.java` +- Create: `modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/ContactPointProtector.java` +- Create: `modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/AesGcmContactPointProtector.java` +- Create: `modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/ProtectedContactPoint.java` +- Create: `modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SecretKeyMaterial.java` +- Create: `modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SecretPurpose.java` +- Create: `modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/AccessContext.java` +- Test: `modules/notification/notification-security/src/test/java/io/backend/skeleton/notification/security/AesGcmContactPointProtectorTest.java` + +**Interfaces:** +- Consumes: Task 6 ContactPointValue. +- Produces: `SecretMaterialProvider`, AES-256-GCM encrypted value, separate HMAC-SHA-256 fingerprint. + +**Implementation requirements:** +- AES key는 정확히 256-bit인지 startup에서 검증한다. +- GCM nonce 재사용을 금지하고 SecureRandom으로 매번 생성한다. +- exception message에 plaintext를 포함하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.security; + +import org.junit.jupiter.api.Test; +import io.backend.skeleton.notification.contact.EmailAddress; +import static org.assertj.core.api.Assertions.*; + +class AesGcmContactPointProtectorTest { + @Test + void encryptsRoundTripAndProducesStableLookupFingerprint() { + var protector = SecurityFixture.protector(); + var value = EmailAddress.parse("user@example.com"); + var first = protector.protect(value); + var second = protector.protect(value); + + assertThat(first.ciphertext()).isNotEqualTo(second.ciphertext()); + assertThat(first.lookupHmac()).isEqualTo(second.lookupHmac()); + assertThat(protector.reveal(first, SecurityFixture.access())).isEqualTo(value); + } + + @Test + void encryptionAndHmacKeysAreDifferent() { + assertThatThrownBy(SecurityFixture::protectorWithSameKeys) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-security:test --tests "*.AesGcmContactPointProtectorTest"` + +Expected: FAIL: security protector classes가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `SecretMaterialProvider.java` +- `ContactPointProtector.java` +- `AesGcmContactPointProtector.java` +- `ProtectedContactPoint.java` +- `SecretKeyMaterial.java` +- `SecretPurpose.java` +- `AccessContext.java` + +```java +public record ProtectedContactPoint( + String type, + String keyId, + byte[] nonce, + byte[] ciphertext, + String lookupHmac +) { + public ProtectedContactPoint { + nonce = nonce.clone(); + ciphertext = ciphertext.clone(); + } +} + +public final class AesGcmContactPointProtector implements ContactPointProtector { + private static final String CIPHER = "AES/GCM/NoPadding"; + private static final String HMAC = "HmacSHA256"; + + public ProtectedContactPoint protect(ContactPointValue value) { + var encryption = keys.activeKey(SecretPurpose.CONTACT_ENCRYPTION); + var lookup = keys.activeKey(SecretPurpose.CONTACT_LOOKUP_HMAC); + if (encryption.keyId().equals(lookup.keyId())) { + throw new IllegalArgumentException("encryption and HMAC keys must differ"); + } + // SecureRandom 96-bit nonce, canonical type+value as AAD, 128-bit GCM tag. + return encryptAndFingerprint(value, encryption, lookup); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-security:test` + +Expected: PASS: 동일 값은 같은 HMAC lookup을 가지지만 nonce로 암호문이 달라지고 복호화된다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SecretMaterialProvider.java' 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/ContactPointProtector.java' 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/AesGcmContactPointProtector.java' 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/ProtectedContactPoint.java' 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SecretKeyMaterial.java' 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SecretPurpose.java' 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/AccessContext.java' 'modules/notification/notification-security/src/test/java/io/backend/skeleton/notification/security/AesGcmContactPointProtectorTest.java' +git commit -m "feat(notification): protect contact points at rest" +``` + +--- + +### Task 8: Template Registry와 immutable version 구현 + +**Files:** +- Create: `modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/NotificationTemplateVersion.java` +- Create: `modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateRegistry.java` +- Create: `modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateStatus.java` +- Create: `modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/VariableSchema.java` +- Create: `modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateContentDefinition.java` +- Create: `modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateVersionConflictException.java` +- Test: `modules/notification/notification-template-api/src/test/java/io/backend/skeleton/notification/template/TemplateRegistryContractTest.java` + +**Interfaces:** +- Consumes: Task 4 Channel과 TemplateSelection. +- Produces: Immutable `NotificationTemplateVersion`, explicit `TemplateSelection`, publish/disable registry contract. + +**Implementation requirements:** +- Template ID/version/locale 조합을 unique하게 관리한다. +- disable은 기존 Notification의 retry/redrive용 조회를 삭제하지 않는다. +- Java class name을 template ID로 사용하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.template; + +import org.junit.jupiter.api.Test; +import java.util.Locale; +import static org.assertj.core.api.Assertions.*; + +class TemplateRegistryContractTest { + @Test + void publishedVersionCannotBeOverwritten() { + var registry = new InMemoryTemplateRegistry(); + var template = TemplateFixture.version(1, Locale.KOREAN); + registry.publish(template); + assertThatThrownBy(() -> registry.publish(template.withChangedContent("changed"))) + .isInstanceOf(TemplateVersionConflictException.class); + } + + @Test + void selectionPinsExactVersion() { + var selection = new TemplateSelection("password-reset", 3, Locale.KOREAN); + assertThat(selection.version()).isEqualTo(3); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-template-api:test --tests "*.TemplateRegistryContractTest"` + +Expected: FAIL: template registry contract가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `NotificationTemplateVersion.java` +- `TemplateRegistry.java` +- `TemplateStatus.java` +- `VariableSchema.java` +- `TemplateContentDefinition.java` +- `TemplateVersionConflictException.java` + +```java +public interface TemplateRegistry { + NotificationTemplateVersion get(TemplateSelection selection); + void publish(NotificationTemplateVersion version); + void disable(String templateId, long version); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-template-api:test` + +Expected: PASS: publish된 버전은 덮어쓰지 못하고 selection이 version을 고정한다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/NotificationTemplateVersion.java' 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateRegistry.java' 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateStatus.java' 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/VariableSchema.java' 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateContentDefinition.java' 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateVersionConflictException.java' 'modules/notification/notification-template-api/src/test/java/io/backend/skeleton/notification/template/TemplateRegistryContractTest.java' +git commit -m "feat(notification): add immutable template registry contract" +``` + +--- + +### Task 9: JSON Schema 변수 검증과 Thymeleaf Reference Renderer 구현 + +**Files:** +- Create: `modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/NotificationTemplateRenderer.java` +- Create: `modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/RenderedNotificationContent.java` +- Create: `modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/RenderCommand.java` +- Create: `modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateVariableValidationException.java` +- Create: `modules/notification/notification-template-thymeleaf/src/main/java/io/backend/skeleton/notification/template/thymeleaf/JsonSchemaVariableValidator.java` +- Create: `modules/notification/notification-template-thymeleaf/src/main/java/io/backend/skeleton/notification/template/thymeleaf/ThymeleafNotificationRenderer.java` +- Test: `modules/notification/notification-template-thymeleaf/src/test/java/io/backend/skeleton/notification/template/thymeleaf/ThymeleafNotificationRendererTest.java` + +**Interfaces:** +- Consumes: Task 3 content records, Task 8 template registry. +- Produces: `NotificationTemplateRenderer`, JSON Schema 2020-12 validation, deterministic rendered digest, locale fallback. + +**Implementation requirements:** +- Renderer error message에 secret-classified variable value를 포함하지 않는다. +- Template resolver가 최신 version을 자동 선택하지 않고 exact selection을 사용한다. +- Thymeleaf type은 template-api에 노출하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.template.thymeleaf; + +import org.junit.jupiter.api.Test; +import java.util.Locale; +import static org.assertj.core.api.Assertions.*; + +class ThymeleafNotificationRendererTest { + @Test + void rejectsMissingRequiredVariableBeforeProviderCall() { + var renderer = RendererFixture.rendererWithRequiredVariable("code"); + assertThatThrownBy(() -> renderer.render(RendererFixture.command(java.util.Map.of()))) + .isInstanceOf(TemplateVariableValidationException.class); + } + + @Test + void sameVersionAndVariablesProduceSameDigest() { + var renderer = RendererFixture.renderer(); + var command = RendererFixture.command(java.util.Map.of("name", "동현")); + assertThat(renderer.render(command).contentDigest()) + .isEqualTo(renderer.render(command).contentDigest()); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-template-thymeleaf:test --tests "*.ThymeleafNotificationRendererTest"` + +Expected: FAIL: renderer와 schema validator가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `NotificationTemplateRenderer.java` +- `RenderedNotificationContent.java` +- `RenderCommand.java` +- `TemplateVariableValidationException.java` +- `JsonSchemaVariableValidator.java` +- `ThymeleafNotificationRenderer.java` + +```java +public interface NotificationTemplateRenderer { + Channel channel(); + RenderedNotificationContent render(RenderCommand command); +} + +public record RenderedNotificationContent( + NotificationContent content, + String contentDigest, + TemplateSelection templateSelection, + java.util.Locale resolvedLocale +) {} + +// Renderer algorithm: +// 1. JSON Schema 2020-12 validation. +// 2. exact locale → language locale → template fallback → platform default. +// 3. deterministic canonical UTF-8 rendering. +// 4. SHA-256 digest over channel, template ID/version, locale and rendered fields. +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-template-thymeleaf:test` + +Expected: PASS: invalid variables are rejected and rendering digest is deterministic. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/NotificationTemplateRenderer.java' 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/RenderedNotificationContent.java' 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/RenderCommand.java' 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateVariableValidationException.java' 'modules/notification/notification-template-thymeleaf/src/main/java/io/backend/skeleton/notification/template/thymeleaf/JsonSchemaVariableValidator.java' 'modules/notification/notification-template-thymeleaf/src/main/java/io/backend/skeleton/notification/template/thymeleaf/ThymeleafNotificationRenderer.java' 'modules/notification/notification-template-thymeleaf/src/test/java/io/backend/skeleton/notification/template/thymeleaf/ThymeleafNotificationRendererTest.java' +git commit -m "feat(notification): add template validation and rendering" +``` + +--- + +### Task 10: Provider SPI, Capability, 실행 증거 모델 구현 + +**Files:** +- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/NotificationProviderAdapter.java` +- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderSubmission.java` +- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderSubmissionResult.java` +- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderCapabilities.java` +- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderExecutionEvidence.java` +- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/EvidenceCertainty.java` +- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderProfileSnapshot.java` +- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderFailure.java` +- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/TraceContext.java` +- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/EvidenceFact.java` +- Test: `modules/notification/notification-provider-spi/src/test/java/io/backend/skeleton/notification/provider/ProviderSpiContractTest.java` + +**Interfaces:** +- Consumes: Tasks 2, 3, 6, 9의 ID·content·contact·rendered content. +- Produces: `NotificationProviderAdapter.submit`, capability model, `CONFIRMED/REJECTED/AMBIGUOUS` result와 execution evidence. + +**Implementation requirements:** +- Provider native SDK object를 ProviderSubmission에 넣지 않는다. +- Adapter가 모르는 실행 사실은 `UNKNOWN` certainty로 기록한다. +- capability가 false인 기능을 runtime이 요청하면 startup 또는 dispatch 전에 거부한다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.provider; + +import org.junit.jupiter.api.Test; +import java.lang.reflect.Method; +import java.util.concurrent.CompletionStage; +import static org.assertj.core.api.Assertions.*; + +class ProviderSpiContractTest { + @Test + void submitIsAsyncAndReturnsEvidenceResult() throws Exception { + Method method = NotificationProviderAdapter.class + .getMethod("submit", ProviderSubmission.class); + assertThat(method.getReturnType()).isEqualTo(CompletionStage.class); + } + + @Test + void ambiguousResultDoesNotClaimAcceptance() { + var result = ProviderResultFixture.ambiguous(); + assertThat(result.confirmation()).isEqualTo(AttemptConfirmation.AMBIGUOUS); + assertThat(result.evidenceLevel()).isNotEqualTo(EvidenceLevel.PROVIDER_ACCEPTED); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-provider-spi:test --tests "*.ProviderSpiContractTest"` + +Expected: FAIL: provider SPI가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `NotificationProviderAdapter.java` +- `ProviderSubmission.java` +- `ProviderSubmissionResult.java` +- `ProviderCapabilities.java` +- `ProviderExecutionEvidence.java` +- `EvidenceCertainty.java` +- `ProviderProfileSnapshot.java` +- `ProviderFailure.java` +- `TraceContext.java` +- `EvidenceFact.java` + +```java +public interface NotificationProviderAdapter { + ProviderId providerId(); + java.util.Set channels(); + ProviderCapabilities capabilities(); + java.util.concurrent.CompletionStage submit( + ProviderSubmission submission); +} + +public record ProviderExecutionEvidence( + EvidenceFact requestStarted, + EvidenceFact requestBodyCommitted, + EvidenceFact responseReceived, + EvidenceFact providerAcceptance +) {} + +public record EvidenceFact(boolean value, EvidenceCertainty certainty) {} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-provider-spi:test` + +Expected: PASS: provider SPI가 CompletionStage와 명시적 evidence를 사용한다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/NotificationProviderAdapter.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderSubmission.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderSubmissionResult.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderCapabilities.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderExecutionEvidence.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/EvidenceCertainty.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderProfileSnapshot.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderFailure.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/TraceContext.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/EvidenceFact.java' 'modules/notification/notification-provider-spi/src/test/java/io/backend/skeleton/notification/provider/ProviderSpiContractTest.java' +git commit -m "feat(notification): add provider adapter SPI and evidence contract" +``` + +--- + +### Task 11: 안정 오류 계층과 FailureCategory 구현 + +**Files:** +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/NotificationException.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/NotificationFailureDescriptor.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/FailureCategory.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/AmbiguousSubmissionException.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/IdempotencyConflictException.java` +- Create: `modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/NotificationSuppressedException.java` +- Test: `modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/error/NotificationExceptionTest.java` + +**Interfaces:** +- Consumes: Task 2 evidence enum과 Task 4 Channel. +- Produces: Driver/SDK exception을 숨기는 안정 예외, low-cardinality failure descriptor. + +**Implementation requirements:** +- Provider SDK exception을 public cause type으로 계약하지 않는다. +- cause는 내부 diagnostic에 보존할 수 있지만 message/body/address를 exception message에 합치지 않는다. +- failure code는 bounded registry로 관리한다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.api.error; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class NotificationExceptionTest { + @Test + void ambiguousDescriptorIsNonRetryableByDefault() { + var descriptor = NotificationFailureDescriptor.ambiguous( + "PROVIDER_RESPONSE_LOST", Channel.EMAIL, new ProviderId("ses"), 1); + assertThat(descriptor.ambiguous()).isTrue(); + assertThat(descriptor.retryable()).isFalse(); + } + + @Test + void exceptionMessageDoesNotContainRecipient() { + var exception = new NotificationSuppressedException( + NotificationFailureDescriptor.suppressed("USER_OPT_OUT", Channel.SMS)); + assertThat(exception.getMessage()).doesNotContain("+821012345678"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-core-api:test --tests "*.NotificationExceptionTest"` + +Expected: FAIL: error hierarchy가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `NotificationException.java` +- `NotificationFailureDescriptor.java` +- `FailureCategory.java` +- `AmbiguousSubmissionException.java` +- `IdempotencyConflictException.java` +- `NotificationSuppressedException.java` + +```java +public enum FailureCategory { + TRANSIENT_PROVIDER, THROTTLED, AUTHENTICATION, AUTHORIZATION, + INVALID_RECIPIENT, INVALID_PAYLOAD, TEMPLATE_FAILURE, + PERMANENT_PROVIDER, AMBIGUOUS_SUBMISSION, + CALLBACK_VALIDATION_FAILURE, CAPACITY_REJECTED, EXPIRED +} + +public record NotificationFailureDescriptor( + String code, + FailureCategory category, + boolean retryable, + boolean ambiguous, + Channel channel, + ProviderId providerId, + int attemptNumber, + java.time.Duration elapsed +) {} + +public abstract class NotificationException extends RuntimeException { + private final NotificationFailureDescriptor descriptor; + protected NotificationException(NotificationFailureDescriptor descriptor) { + super(descriptor.code()); + this.descriptor = descriptor; + } + public NotificationFailureDescriptor descriptor() { return descriptor; } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-core-api:test` + +Expected: PASS: ambiguity와 retryability가 descriptor에 분리되고 exception message가 sanitized된다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/NotificationException.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/NotificationFailureDescriptor.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/FailureCategory.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/AmbiguousSubmissionException.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/IdempotencyConflictException.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/NotificationSuppressedException.java' 'modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/error/NotificationExceptionTest.java' +git commit -m "feat(notification): add stable failure taxonomy" +``` + +--- + +### Task 12: Flyway Notification 영속 스키마 구현 + +**Files:** +- Create: `modules/notification/notification-persistence-jpa/src/main/resources/db/migration/notification/V1__notification_core.sql` +- Create: `modules/notification/notification-persistence-jpa/src/main/resources/db/migration/notification/V2__notification_contact_template_policy.sql` +- Create: `modules/notification/notification-persistence-jpa/src/main/resources/db/migration/notification/V3__notification_inbox_admin.sql` +- Test: `modules/notification/notification-persistence-jpa/src/test/java/io/backend/skeleton/notification/persistence/NotificationFlywayMigrationTest.java` + +**Interfaces:** +- Consumes: Tasks 2, 8, 11의 type names. +- Produces: PostgreSQL tables, unique constraints, SKIP LOCKED indexes, ProviderEvent dedup indexes. + +**Implementation requirements:** +- enum은 PostgreSQL native enum이 아니라 varchar + application validation으로 시작한다. +- Contact Point ciphertext는 bytea, lookup HMAC은 fixed char로 저장한다. +- ProviderEvent raw payload에는 row-level size guard를 application과 DB check에 함께 둔다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.persistence; + +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import static org.assertj.core.api.Assertions.*; + +class NotificationFlywayMigrationTest extends PostgreSqlNotificationTest { + @Test + void createsIdempotencyAndProviderEventUniqueness() { + JdbcTemplate jdbc = jdbc(); + assertThat(indexNames(jdbc, "notification_request")) + .contains("uk_notification_request_idempotency"); + assertThat(indexNames(jdbc, "notification_provider_event")) + .contains("uk_notification_provider_event_id", "uk_notification_provider_event_fingerprint"); + } + + @Test + void createsDispatchPartialIndex() { + assertThat(indexDefinition(jdbc(), "ix_notification_recipient_dispatch")) + .contains("next_dispatch_at") + .contains("READY_TO_DISPATCH") + .contains("RETRY_WAITING"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-persistence-jpa:test --tests "*.NotificationFlywayMigrationTest"` + +Expected: FAIL: Flyway migration이 없어서 notification table과 index가 존재하지 않는다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `V1__notification_core.sql` +- `V2__notification_contact_template_policy.sql` +- `V3__notification_inbox_admin.sql` + +```java +CREATE TABLE notification_request ( + id uuid PRIMARY KEY, + tenant_id varchar(100) NOT NULL, + idempotency_key varchar(200) NOT NULL, + request_fingerprint char(64) NOT NULL, + category varchar(120) NOT NULL, + template_id varchar(160) NOT NULL, + template_version bigint NOT NULL, + strategy_type varchar(40) NOT NULL, + schedule_at timestamptz, + not_before timestamptz, + expires_at timestamptz, + request_status varchar(40) NOT NULL, + correlation_id varchar(160), + metadata_json jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + version bigint NOT NULL DEFAULT 0, + CONSTRAINT uk_notification_request_idempotency + UNIQUE (tenant_id, idempotency_key) +); + +CREATE TABLE notification_recipient_delivery ( + id uuid PRIMARY KEY, + notification_id uuid NOT NULL REFERENCES notification_request(id), + recipient_ref varchar(200) NOT NULL, + routing_plan_json jsonb NOT NULL, + route_cursor integer NOT NULL DEFAULT 0, + delivery_state varchar(40) NOT NULL, + submission_outcome varchar(40) NOT NULL, + delivery_outcome varchar(40) NOT NULL, + evidence_level varchar(50) NOT NULL, + ambiguous_attempt_exists boolean NOT NULL DEFAULT false, + duplicate_risk boolean NOT NULL DEFAULT false, + next_dispatch_at timestamptz, + lease_owner varchar(120), + lease_until timestamptz, + attempt_count integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + version bigint NOT NULL DEFAULT 0 +); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-persistence-jpa:test` + +Expected: PASS: PostgreSQL 16에서 모든 migration과 index assertion이 통과한다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-persistence-jpa/src/main/resources/db/migration/notification/V1__notification_core.sql' 'modules/notification/notification-persistence-jpa/src/main/resources/db/migration/notification/V2__notification_contact_template_policy.sql' 'modules/notification/notification-persistence-jpa/src/main/resources/db/migration/notification/V3__notification_inbox_admin.sql' 'modules/notification/notification-persistence-jpa/src/test/java/io/backend/skeleton/notification/persistence/NotificationFlywayMigrationTest.java' +git commit -m "feat(notification): add durable notification schema" +``` + +--- + +### Task 13: JPA Entity, Repository, Tenant Guard 구현 + +**Files:** +- Create: `modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/NotificationRequestEntity.java` +- Create: `modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/RecipientDeliveryEntity.java` +- Create: `modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/DeliveryAttemptEntity.java` +- Create: `modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/ProviderEventEntity.java` +- Create: `modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/NotificationRequestRepository.java` +- Create: `modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/RecipientDeliveryRepository.java` +- Create: `modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/TenantBoundRepositoryGuard.java` +- Test: `modules/notification/notification-persistence-jpa/src/test/java/io/backend/skeleton/notification/persistence/NotificationRepositoryTest.java` + +**Interfaces:** +- Consumes: Task 12 schema, Tasks 2·13 state names. +- Produces: Optimistic-lock JPA entities and tenant-scoped repositories. + +**Implementation requirements:** +- Entity setter를 public으로 열지 않고 domain transition method를 사용한다. +- repository가 tenant 없는 contact/request lookup을 일반 API로 제공하지 않는다. +- JPA entity를 Core public API에서 반환하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.persistence; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class NotificationRepositoryTest extends PostgreSqlNotificationTest { + @Test + void requestLookupRequiresTenant() { + var saved = fixture().request("tenant-a", "idem-1"); + repository().save(saved); + assertThat(repository().findByTenantAndIdempotencyKey("tenant-b", "idem-1")) + .isEmpty(); + } + + @Test + void optimisticVersionRejectsConcurrentProjectionUpdate() { + var delivery = fixture().recipient(); + recipientRepository().saveAndFlush(delivery); + var first = recipientRepository().findById(delivery.id()).orElseThrow(); + var second = recipientRepository().findById(delivery.id()).orElseThrow(); + first.markSuppressed(); + recipientRepository().saveAndFlush(first); + second.markDispatching(); + assertThatThrownBy(() -> recipientRepository().saveAndFlush(second)) + .isInstanceOf(org.springframework.orm.ObjectOptimisticLockingFailureException.class); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-persistence-jpa:test --tests "*.NotificationRepositoryTest"` + +Expected: FAIL: JPA entity와 repository가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `NotificationRequestEntity.java` +- `RecipientDeliveryEntity.java` +- `DeliveryAttemptEntity.java` +- `ProviderEventEntity.java` +- `NotificationRequestRepository.java` +- `RecipientDeliveryRepository.java` +- `TenantBoundRepositoryGuard.java` + +```java +@Entity +@Table(name = "notification_request") +public class NotificationRequestEntity { + @Id private UUID id; + @Column(name = "tenant_id", nullable = false) private String tenantId; + @Column(name = "idempotency_key", nullable = false) private String idempotencyKey; + @Column(name = "request_fingerprint", nullable = false) private String requestFingerprint; + @Version private long version; + // package-private no-arg constructor; static factory enforces invariants. +} + +public interface NotificationRequestRepository + extends JpaRepository { + Optional findByTenantIdAndIdempotencyKey( + String tenantId, String idempotencyKey); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-persistence-jpa:test` + +Expected: PASS: tenant-scoped lookup과 optimistic lock 테스트가 통과한다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/NotificationRequestEntity.java' 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/RecipientDeliveryEntity.java' 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/DeliveryAttemptEntity.java' 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/ProviderEventEntity.java' 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/NotificationRequestRepository.java' 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/RecipientDeliveryRepository.java' 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/TenantBoundRepositoryGuard.java' 'modules/notification/notification-persistence-jpa/src/test/java/io/backend/skeleton/notification/persistence/NotificationRepositoryTest.java' +git commit -m "feat(notification): add notification JPA repositories" +``` + +--- + +### Task 14: ProviderEvent 원장과 Channel Projector 기반 구현 + +**Files:** +- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderEventRecord.java` +- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderEventProjector.java` +- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/DeliveryProjection.java` +- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProjectionResult.java` +- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/DeliveryAttemptSnapshot.java` +- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/EngagementFacts.java` +- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/SuppressionFacts.java` +- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderEventLedger.java` +- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/VerifiedProviderEvent.java` +- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/AppendEventResult.java` +- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderEventSource.java` +- Create: `modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/JpaProviderEventLedger.java` +- Test: `modules/notification/notification-callback-api/src/test/java/io/backend/skeleton/notification/callback/ProjectionMergeContractTest.java` + +**Interfaces:** +- Consumes: Tasks 2 evidence, Task 13 persistence repositories. +- Produces: Append-only provider event ledger, idempotent projector, out-of-order merge contract. + +**Implementation requirements:** +- single numeric status priority를 사용하지 않는다. +- event raw payload를 수정하지 않는다. +- projector 재실행은 동일 projection을 생성해야 한다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.callback; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class ProjectionMergeContractTest { + @Test + void sentAfterDeliveredDoesNotDowngrade() { + var projector = CallbackFixture.twilioProjector(); + var delivered = projector.project( + CallbackFixture.attempt(), CallbackFixture.event("delivered"), + DeliveryProjection.empty()); + var lateSent = projector.project( + CallbackFixture.attempt(), CallbackFixture.event("sent"), + delivered.projection()); + assertThat(lateSent.projection().deliveryOutcome()) + .isEqualTo(DeliveryOutcome.DELIVERED); + } + + @Test + void complaintAddsFactWithoutRemovingDelivery() { + var result = CallbackFixture.emailProjector().project( + CallbackFixture.attempt(), CallbackFixture.event("complaint"), + CallbackFixture.deliveredProjection()); + assertThat(result.projection().deliveryOutcome()).isEqualTo(DeliveryOutcome.DELIVERED); + assertThat(result.projection().suppressionFacts().complained()).isTrue(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-callback-api:test --tests "*.ProjectionMergeContractTest"` + +Expected: FAIL: event ledger와 projector contract가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `ProviderEventRecord.java` +- `ProviderEventProjector.java` +- `DeliveryProjection.java` +- `ProjectionResult.java` +- `DeliveryAttemptSnapshot.java` +- `EngagementFacts.java` +- `SuppressionFacts.java` +- `ProviderEventLedger.java` +- `VerifiedProviderEvent.java` +- `AppendEventResult.java` +- `ProviderEventSource.java` +- `JpaProviderEventLedger.java` + +```java +public interface ProviderEventProjector { + ProviderId providerId(); + ProjectionResult project( + DeliveryAttemptSnapshot attempt, + ProviderEventRecord event, + DeliveryProjection current); +} + +public record DeliveryProjection( + SubmissionOutcome submissionOutcome, + DeliveryOutcome deliveryOutcome, + EvidenceLevel evidenceLevel, + EngagementFacts engagementFacts, + SuppressionFacts suppressionFacts +) {} + +public interface ProviderEventLedger { + AppendEventResult append(VerifiedProviderEvent event); + java.util.List pendingProjection(int limit); + void markApplied(ProviderEventId eventId, ProjectionResult result); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-callback-api:test :modules:notification:notification-persistence-jpa:test` + +Expected: PASS: 역순 event가 downgrade되지 않고 event append가 idempotent하다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderEventRecord.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderEventProjector.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/DeliveryProjection.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProjectionResult.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/DeliveryAttemptSnapshot.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/EngagementFacts.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/SuppressionFacts.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderEventLedger.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/VerifiedProviderEvent.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/AppendEventResult.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderEventSource.java' 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/JpaProviderEventLedger.java' 'modules/notification/notification-callback-api/src/test/java/io/backend/skeleton/notification/callback/ProjectionMergeContractTest.java' +git commit -m "feat(notification): add provider event ledger and projectors" +``` + +--- + +### Task 15: Idempotent Submit Application Service 구현 + +**Files:** +- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/NotificationSubmissionService.java` +- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/RequestFingerprint.java` +- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/CanonicalNotificationPlanWriter.java` +- Test: `modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/NotificationSubmissionServiceTest.java` + +**Interfaces:** +- Consumes: Tasks 4·5 plan/API, Task 8 exact template, Task 13 repositories. +- Produces: Transactional submit, canonical fingerprint, unique conflict convergence, durable receipt. + +**Implementation requirements:** +- fingerprint에 recipient, template version, variables digest, schedule/expiry, routing을 포함한다. +- Provider 호출을 submit transaction에 포함하지 않는다. +- Receipt acceptedAt은 DB에 저장한 createdAt과 동일하다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.dispatch; + +import org.junit.jupiter.api.Test; +import java.util.concurrent.*; +import static org.assertj.core.api.Assertions.*; + +class NotificationSubmissionServiceTest extends PostgreSqlNotificationTest { + @Test + void concurrentSameRequestReturnsOneNotificationId() throws Exception { + var service = service(); + var plan = fixture().plan("idem-1"); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var a = executor.submit(() -> service.submit(plan)); + var b = executor.submit(() -> service.submit(plan)); + assertThat(a.get().notificationId()).isEqualTo(b.get().notificationId()); + } + assertThat(requestCount()).isEqualTo(1); + } + + @Test + void sameKeyDifferentFingerprintFails() { + service().submit(fixture().plan("idem-2")); + assertThatThrownBy(() -> service().submit(fixture().changedPlan("idem-2"))) + .isInstanceOf(IdempotencyConflictException.class); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-dispatch-runtime:test --tests "*.NotificationSubmissionServiceTest"` + +Expected: FAIL: submission service가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `NotificationSubmissionService.java` +- `RequestFingerprint.java` +- `CanonicalNotificationPlanWriter.java` + +```java +@Transactional +public NotificationReceipt submit(NotificationPlan plan) { + var fingerprint = fingerprint.of(plan); + var existing = requests.findByTenantIdAndIdempotencyKey( + plan.tenantId().value(), plan.idempotencyKey().value()); + if (existing.isPresent()) return compareAndReturn(existing.get(), fingerprint); + + try { + var aggregate = factory.create(plan, fingerprint, clock.instant()); + requests.saveAndFlush(aggregate.request()); + recipients.saveAll(aggregate.recipients()); + return aggregate.receipt(); + } catch (DataIntegrityViolationException duplicate) { + var winner = requests.findByTenantIdAndIdempotencyKey( + plan.tenantId().value(), plan.idempotencyKey().value()).orElseThrow(); + return compareAndReturn(winner, fingerprint); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-dispatch-runtime:test` + +Expected: PASS: 동시 submit이 하나로 수렴하고 fingerprint conflict가 거부된다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/NotificationSubmissionService.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/RequestFingerprint.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/CanonicalNotificationPlanWriter.java' 'modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/NotificationSubmissionServiceTest.java' +git commit -m "feat(notification): add idempotent durable submission" +``` + +--- + +### Task 16: PostgreSQL Durable Scheduler와 Lease Claim 구현 + +**Files:** +- Create: `modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/RecipientLeaseRepository.java` +- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/NotificationScheduler.java` +- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/RecipientLease.java` +- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/LeaseRecoveryService.java` +- Test: `modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/NotificationSchedulerConcurrencyTest.java` + +**Interfaces:** +- Consumes: Task 12 dispatch index, Task 13 repositories, Task 15 recipient rows. +- Produces: SKIP LOCKED claim, lease expiry, restart recovery, bounded batch scheduler. + +**Implementation requirements:** +- lease duration, batch size, poll interval은 bounded validated property다. +- scheduler thread가 Provider 호출을 직접 수행하지 않고 dispatcher queue에 전달한다. +- shutdown 시작 후 새 lease를 획득하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.dispatch; + +import org.junit.jupiter.api.Test; +import java.time.Duration; +import java.util.concurrent.*; +import static org.assertj.core.api.Assertions.*; + +class NotificationSchedulerConcurrencyTest extends PostgreSqlNotificationTest { + @Test + void twoWorkersNeverClaimSameRecipient() throws Exception { + insertReadyRecipients(100); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var first = executor.submit(() -> scheduler("worker-a").claim(60)); + var second = executor.submit(() -> scheduler("worker-b").claim(60)); + var all = new java.util.HashSet<>(first.get()); + assertThat(all.addAll(second.get())).isTrue(); + assertThat(all).hasSize(100); + } + } + + @Test + void expiredLeaseIsRecovered() { + var id = insertExpiredLeasedRecipient(); + assertThat(scheduler("worker-b").claim(1)).containsExactly(id); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-dispatch-runtime:test --tests "*.NotificationSchedulerConcurrencyTest"` + +Expected: FAIL: scheduler와 lease SQL이 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `RecipientLeaseRepository.java` +- `NotificationScheduler.java` +- `RecipientLease.java` +- `LeaseRecoveryService.java` + +```java +// RecipientLeaseRepository native query +SELECT id + FROM notification_recipient_delivery + WHERE next_dispatch_at <= :now + AND delivery_state IN ('READY_TO_DISPATCH', 'RETRY_WAITING') + AND (lease_until IS NULL OR lease_until < :now) + ORDER BY next_dispatch_at, id + FOR UPDATE SKIP LOCKED + LIMIT :limit + +@Transactional +public java.util.List claim(String workerId, int limit) { + var ids = repository.selectClaimable(clock.instant(), limit); + repository.markLeased(ids, workerId, clock.instant().plus(leaseDuration)); + return ids.stream().map(RecipientDeliveryId::new).toList(); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-dispatch-runtime:test` + +Expected: PASS: worker 간 중복 claim이 없고 만료 lease가 복구된다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/RecipientLeaseRepository.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/NotificationScheduler.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/RecipientLease.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/LeaseRecoveryService.java' 'modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/NotificationSchedulerConcurrencyTest.java' +git commit -m "feat(notification): add durable scheduler and lease recovery" +``` + +--- + +### Task 17: Suppression·Preference·Consent Primitive 구현 + +**Files:** +- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionEntry.java` +- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionReason.java` +- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/NotificationEligibilityPolicy.java` +- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/PreferenceRecord.java` +- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/ConsentRecord.java` +- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionId.java` +- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionScope.java` +- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionSource.java` +- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/EligibilityResult.java` +- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/NotificationContext.java` +- Create: `modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/JpaSuppressionRepository.java` +- Test: `modules/notification/notification-policy/src/test/java/io/backend/skeleton/notification/policy/NotificationEligibilityTest.java` + +**Interfaces:** +- Consumes: Task 6 Contact Point, Task 12 policy tables. +- Produces: Distinct suppression/preference/consent models and final dispatch eligibility evaluation. + +**Implementation requirements:** +- 법률·광고 분류를 Core enum으로 만들지 않는다. +- submit와 dispatch 직전 모두 평가하되 dispatch 결과를 최종으로 사용한다. +- suppression 변경은 audit 대상이다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.policy; + +import org.junit.jupiter.api.Test; +import java.time.Instant; +import static org.assertj.core.api.Assertions.*; + +class NotificationEligibilityTest { + @Test + void mandatorySuppressionOverridesPreference() { + var engine = PolicyFixture.engine( + PolicyFixture.preference(Channel.EMAIL), + PolicyFixture.suppression(SuppressionReason.COMPLAINT)); + assertThat(engine.evaluate(PolicyFixture.context()).allowed()).isFalse(); + } + + @Test + void expiredTemporarySuppressionDoesNotBlock() { + var suppression = PolicyFixture.temporarySuppression( + Instant.parse("2026-08-09T00:00:00Z")); + assertThat(PolicyFixture.engine(suppression) + .evaluate(PolicyFixture.contextAt("2026-08-10T00:00:00Z")).allowed()).isTrue(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-policy:test --tests "*.NotificationEligibilityTest"` + +Expected: FAIL: policy primitive가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `SuppressionEntry.java` +- `SuppressionReason.java` +- `NotificationEligibilityPolicy.java` +- `PreferenceRecord.java` +- `ConsentRecord.java` +- `SuppressionId.java` +- `SuppressionScope.java` +- `SuppressionSource.java` +- `EligibilityResult.java` +- `NotificationContext.java` +- `JpaSuppressionRepository.java` + +```java +public interface NotificationEligibilityPolicy { + EligibilityResult evaluate(NotificationContext context); +} + +public enum SuppressionReason { + USER_OPT_OUT, HARD_BOUNCE, COMPLAINT, INVALID_TOKEN, INVALID_PHONE, + ADMIN_BLOCK, PROVIDER_BLOCK, TEMPORARY_SUPPRESSION +} + +public record EligibilityResult( + boolean allowed, + java.util.List reasonCodes +) {} + +// Composite order: mandatory internal suppression → provider suppression +// → injected application eligibility. Preference selects channel only after eligibility. +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-policy:test :modules:notification:notification-persistence-jpa:test` + +Expected: PASS: suppression이 preference보다 우선하고 expiry가 적용된다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionEntry.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionReason.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/NotificationEligibilityPolicy.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/PreferenceRecord.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/ConsentRecord.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionId.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionScope.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionSource.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/EligibilityResult.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/NotificationContext.java' 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/JpaSuppressionRepository.java' 'modules/notification/notification-policy/src/test/java/io/backend/skeleton/notification/policy/NotificationEligibilityTest.java' +git commit -m "feat(notification): add suppression preference and consent primitives" +``` + +--- + +### Task 18: Routing·Ordered Fallback Decision Engine 구현 + +**Files:** +- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RoutingDecisionEngine.java` +- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RoutingDecision.java` +- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RouteCandidate.java` +- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RoutingContext.java` +- Test: `modules/notification/notification-policy/src/test/java/io/backend/skeleton/notification/policy/RoutingDecisionEngineTest.java` + +**Interfaces:** +- Consumes: Task 4 strategies, Task 11 failure category, Task 17 eligibility. +- Produces: Explicit/ordered routing, invalid-recipient fallback, ambiguous fallback hard block. + +**Implementation requirements:** +- Provider accepted 이후 fallback은 기본 금지한다. +- route candidate는 active Contact Point와 enabled Provider Profile을 모두 요구한다. +- Parallel first-success를 Stable engine에 넣지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.policy; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class RoutingDecisionEngineTest { + @Test + void ambiguousAttemptBlocksAutomaticFallback() { + var decision = engine().next(PolicyFixture.ambiguousPushThenSms()); + assertThat(decision.fallbackAllowed()).isFalse(); + assertThat(decision.reconciliationRequired()).isTrue(); + assertThat(decision.duplicateRisk()).isTrue(); + } + + @Test + void invalidPushTargetFallsBackToSms() { + var decision = engine().next(PolicyFixture.invalidPushThenSms()); + assertThat(decision.selected().orElseThrow().channel()).isEqualTo(Channel.SMS); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-policy:test --tests "*.RoutingDecisionEngineTest"` + +Expected: FAIL: routing decision engine이 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `RoutingDecisionEngine.java` +- `RoutingDecision.java` +- `RouteCandidate.java` +- `RoutingContext.java` + +```java +public RoutingDecision next(RoutingContext context) { + if (context.ambiguousAttemptExists()) { + return RoutingDecision.reconcile("AMBIGUOUS_ATTEMPT", true); + } + if (context.lastFailure() == FailureCategory.INVALID_RECIPIENT) { + return selectNextEligibleRoute(context); + } + if (context.lastSubmission() == SubmissionOutcome.CONFIRMED_ACCEPTED) { + return RoutingDecision.stop("PROVIDER_ALREADY_ACCEPTED"); + } + return selectCurrentOrNextRoute(context); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-policy:test` + +Expected: PASS: ambiguous fallback이 차단되고 invalid recipient만 안전하게 다음 채널로 이동한다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RoutingDecisionEngine.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RoutingDecision.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RouteCandidate.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RoutingContext.java' 'modules/notification/notification-policy/src/test/java/io/backend/skeleton/notification/policy/RoutingDecisionEngineTest.java' +git commit -m "feat(notification): add safe routing and fallback engine" +``` + +--- + +### Task 19: Retry Decision Engine과 Expiry·Budget Guard 구현 + +**Files:** +- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RetryContext.java` +- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RetryDecision.java` +- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/NotificationRetryPolicy.java` +- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RetryBudget.java` +- Test: `modules/notification/notification-policy/src/test/java/io/backend/skeleton/notification/policy/NotificationRetryPolicyTest.java` + +**Interfaces:** +- Consumes: Tasks 10·11 provider evidence/error, Task 18 routing. +- Produces: RetryAfter/Reconcile/Fallback/Stop decision, jittered backoff, expiry and budget enforcement. + +**Implementation requirements:** +- Retry budget은 provider profile별 원 요청 대비 추가 시도를 제한한다. +- Retry-After는 maxBackoff와 expiresAt을 초과하지 않는 범위에서 존중한다. +- jitter가 deterministic test Clock/RandomSource로 주입 가능해야 한다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.policy; + +import org.junit.jupiter.api.Test; +import java.time.*; +import static org.assertj.core.api.Assertions.*; + +class NotificationRetryPolicyTest { + @Test + void authenticationFailureStopsAndOpensProvider() { + var decision = policy().decide(PolicyFixture.authFailure()); + assertThat(decision).isInstanceOf(RetryDecision.Stop.class); + } + + @Test + void ambiguousWithoutReconcileOrProviderIdempotencyStops() { + var decision = policy().decide(PolicyFixture.ambiguousUnsafe()); + assertThat(decision).isInstanceOf(RetryDecision.Stop.class); + } + + @Test + void nextBackoffBeyondExpiryExpiresDelivery() { + var decision = policy().decide(PolicyFixture.transientWithExpiry( + Instant.parse("2026-08-10T00:00:01Z"))); + assertThat(((RetryDecision.Stop) decision).reasonCode()).isEqualTo("EXPIRED"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-policy:test --tests "*.NotificationRetryPolicyTest"` + +Expected: FAIL: retry policy가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `RetryContext.java` +- `RetryDecision.java` +- `NotificationRetryPolicy.java` +- `RetryBudget.java` + +```java +public sealed interface RetryDecision { + record RetryAfter(java.time.Duration delay) implements RetryDecision {} + record Reconcile(java.time.Instant at) implements RetryDecision {} + record Fallback(String reasonCode) implements RetryDecision {} + record Stop(String reasonCode) implements RetryDecision {} +} + +public RetryDecision decide(RetryContext context) { + if (context.failureCategory() == FailureCategory.AUTHENTICATION + || context.failureCategory() == FailureCategory.AUTHORIZATION) { + return new RetryDecision.Stop("PROVIDER_CONFIGURATION_FAILURE"); + } + if (context.confirmation() == AttemptConfirmation.AMBIGUOUS) { + if (context.statusQuerySupported()) return new RetryDecision.Reconcile(context.nextReconcileAt()); + if (!context.providerIdempotency()) return new RetryDecision.Stop("AMBIGUOUS_UNSAFE_TO_RETRY"); + } + var delay = backoff.delay(context.attemptNumber()); + if (!context.budget().canConsume() || !context.canFinishBeforeExpiry(delay)) { + return new RetryDecision.Stop("EXPIRED_OR_BUDGET_EXHAUSTED"); + } + return new RetryDecision.RetryAfter(delay); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-policy:test` + +Expected: PASS: auth/ambiguity/expiry/budget 규칙이 정확히 판정된다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RetryContext.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RetryDecision.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/NotificationRetryPolicy.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RetryBudget.java' 'modules/notification/notification-policy/src/test/java/io/backend/skeleton/notification/policy/NotificationRetryPolicyTest.java' +git commit -m "feat(notification): add evidence-aware retry policy" +``` + +--- + +### Task 20: Provider Runtime Registry, Health, Rate·Concurrency Guard 구현 + +**Files:** +- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeRegistry.java` +- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderRuntime.java` +- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeState.java` +- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderAttemptLimiter.java` +- Test: `modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeRegistryTest.java` + +**Interfaces:** +- Consumes: Task 10 adapter capability, Task 19 retry policy. +- Produces: Immutable generation runtime, provider health gate, per-attempt rate and concurrency permits. + +**Implementation requirements:** +- Rate limiter는 실제 provider attempt마다 token을 소비한다. +- backoff 중 concurrency permit을 점유하지 않는다. +- credential generation을 DeliveryAttempt에 기록한다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.dispatch; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class ProviderRuntimeRegistryTest { + @Test + void authenticationFailureRejectsNewAttemptsWithoutConsumingPermit() { + var runtime = RuntimeFixture.runtime(); + runtime.markAuthenticationFailed("INVALID_CREDENTIAL"); + assertThatThrownBy(runtime::acquireAttempt) + .isInstanceOf(ProviderUnavailableException.class); + assertThat(runtime.activeAttempts()).isZero(); + } + + @Test + void replacementKeepsOldRuntimeDraining() { + var registry = RuntimeFixture.registryWithGeneration(1); + var old = registry.current("ses-primary"); + registry.replace(RuntimeFixture.runtime(2)); + assertThat(registry.current("ses-primary").generation()).isEqualTo(2); + assertThat(old.state()).isEqualTo(ProviderRuntimeState.DRAINING); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-dispatch-runtime:test --tests "*.ProviderRuntimeRegistryTest"` + +Expected: FAIL: runtime registry와 limiter가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `ProviderRuntimeRegistry.java` +- `ProviderRuntime.java` +- `ProviderRuntimeState.java` +- `ProviderAttemptLimiter.java` + +```java +public enum ProviderRuntimeState { + HEALTHY, DEGRADED, THROTTLED, AUTHENTICATION_FAILED, DISABLED, DRAINING +} + +public final class ProviderRuntime { + private final long generation; + private final NotificationProviderAdapter adapter; + private final ProviderAttemptLimiter limiter; + private final java.util.concurrent.atomic.AtomicReference state; + + public AttemptPermit acquireAttempt() { + var current = state.get(); + if (current == ProviderRuntimeState.AUTHENTICATION_FAILED + || current == ProviderRuntimeState.DISABLED + || current == ProviderRuntimeState.DRAINING) { + throw new ProviderUnavailableException(current.name()); + } + return limiter.acquire(); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-dispatch-runtime:test` + +Expected: PASS: auth failure가 fail-fast하고 runtime replacement가 generation drain을 수행한다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeRegistry.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderRuntime.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeState.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderAttemptLimiter.java' 'modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeRegistryTest.java' +git commit -m "feat(notification): add provider runtime isolation and limits" +``` + +--- + +### Task 21: Dispatch Orchestrator와 Ambiguous Completion 기록 구현 + +**Files:** +- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/NotificationDispatcher.java` +- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/DeliveryAttemptFactory.java` +- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/DispatchOutcomeRecorder.java` +- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/DispatchPipeline.java` +- Test: `modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/NotificationDispatcherAmbiguityTest.java` + +**Interfaces:** +- Consumes: Tasks 16 scheduler, 17 eligibility, 18 routing, 19 retry, 20 runtime registry. +- Produces: Attempt-before-call transaction, provider call outside transaction, confirmed/rejected/ambiguous outcome recording. + +**Implementation requirements:** +- Provider call을 @Transactional method 내부에서 실행하지 않는다. +- process crash 후 DISPATCHING attempt는 lease recovery/reconciliation 대상이다. +- Outcome record와 next action은 idempotent하게 재실행 가능해야 한다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.dispatch; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class NotificationDispatcherAmbiguityTest extends PostgreSqlNotificationTest { + @Test + void providerAcceptsThenResponseIsLostRecordsAmbiguousAndBlocksFallback() { + var provider = ProviderFixture.acceptThenResetConnection(); + dispatcher(provider).dispatch(fixture().leasedRecipient()); + + var attempt = lastAttempt(); + var recipient = loadRecipient(attempt.recipientDeliveryId()); + assertThat(attempt.submissionOutcome()).isEqualTo(SubmissionOutcome.AMBIGUOUS); + assertThat(attempt.confirmation()).isEqualTo(AttemptConfirmation.AMBIGUOUS); + assertThat(recipient.ambiguousAttemptExists()).isTrue(); + assertThat(recipient.deliveryState()).isEqualTo(RecipientDeliveryState.RECONCILIATION_REQUIRED); + assertThat(provider.calls()).isEqualTo(1); + } + + @Test + void attemptRowExistsBeforeProviderInvocation() { + dispatcher(ProviderFixture.assertAttemptExists(repository())) + .dispatch(fixture().leasedRecipient()); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-dispatch-runtime:test --tests "*.NotificationDispatcherAmbiguityTest"` + +Expected: FAIL: dispatcher와 outcome recorder가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `NotificationDispatcher.java` +- `DeliveryAttemptFactory.java` +- `DispatchOutcomeRecorder.java` +- `DispatchPipeline.java` + +```java +public void dispatch(RecipientLease lease) { + var snapshot = loader.load(lease.recipientDeliveryId()); + guards.verifyNotExpiredAndEligible(snapshot); + var route = routing.next(snapshot.routingContext()); + var attempt = attempts.createAndCommit(snapshot, route.selected().orElseThrow()); + + ProviderSubmissionResult result; + try (var permit = runtimes.current(attempt.providerProfileId()).acquireAttempt()) { + result = runtimes.current(attempt.providerProfileId()) + .adapter().submit(submissionFactory.from(attempt)).toCompletableFuture().join(); + } catch (ProviderTransportException error) { + result = evidenceClassifier.classify(error); + } + recorder.record(attempt.id(), result); + nextAction.apply(attempt.id(), retryPolicy.decide(contextFactory.from(attempt.id()))); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-dispatch-runtime:test` + +Expected: PASS: response loss가 AMBIGUOUS로 저장되고 provider call 전에 Attempt row가 존재한다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/NotificationDispatcher.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/DeliveryAttemptFactory.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/DispatchOutcomeRecorder.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/DispatchPipeline.java' 'modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/NotificationDispatcherAmbiguityTest.java' +git commit -m "feat(notification): implement evidence-aware dispatch pipeline" +``` + +--- + +### Task 22: Callback Verification·Normalization·Ingestion Core 구현 + +**Files:** +- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderCallbackAdapter.java` +- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/CallbackRequest.java` +- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/CallbackVerificationResult.java` +- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/NormalizedProviderEvent.java` +- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/VerifiedCallback.java` +- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/CallbackIngestionResult.java` +- Create: `modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderCallbackIngestionService.java` +- Test: `modules/notification/notification-callback-api/src/test/java/io/backend/skeleton/notification/callback/ProviderCallbackIngestionServiceTest.java` + +**Interfaces:** +- Consumes: Task 14 event ledger/projector, Task 11 callback error category. +- Produces: Signature-first callback service, bounded raw append, duplicate no-op, unknown field tolerance. + +**Implementation requirements:** +- Signature 검증에 필요한 raw bytes와 external URL을 decoding 전에 보존한다. +- unknown JSON field로 parsing을 실패시키지 않는다. +- Callback raw body hard limit을 Provider profile보다 크게 설정하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.callback; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class ProviderCallbackIngestionServiceTest { + @Test + void invalidSignatureNeverChangesProjection() { + var service = CallbackFixture.serviceWithInvalidSignature(); + assertThatThrownBy(() -> service.ingest(CallbackFixture.request())) + .isInstanceOf(CallbackValidationException.class); + assertThat(CallbackFixture.ledger().count()).isZero(); + assertThat(CallbackFixture.projectionWrites()).isZero(); + } + + @Test + void duplicateEventIsAcknowledgedWithoutSecondProjection() { + var service = CallbackFixture.service(); + var request = CallbackFixture.request("event-1"); + service.ingest(request); + var duplicate = service.ingest(request); + assertThat(duplicate.duplicate()).isTrue(); + assertThat(CallbackFixture.projectionWrites()).isEqualTo(1); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-callback-api:test --tests "*.ProviderCallbackIngestionServiceTest"` + +Expected: FAIL: callback ingestion core가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `ProviderCallbackAdapter.java` +- `CallbackRequest.java` +- `CallbackVerificationResult.java` +- `NormalizedProviderEvent.java` +- `VerifiedCallback.java` +- `CallbackIngestionResult.java` +- `ProviderCallbackIngestionService.java` + +```java +public CallbackIngestionResult ingest(CallbackRequest request) { + callbackLimits.validate(request.contentType(), request.body().length); + var adapter = adapters.require(request.providerProfileId()); + var verification = adapter.verify(request); + if (!verification.valid()) { + securityAudit.signatureRejected(request.providerProfileId(), verification.reasonCode()); + throw new CallbackValidationException(verification.reasonCode()); + } + var normalized = adapter.normalize(verification.verifiedCallback()); + var append = ledger.appendAll(normalized); + append.newEvents().forEach(projectorService::project); + return new CallbackIngestionResult(append.newEvents().size(), append.duplicates().size()); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-callback-api:test` + +Expected: PASS: invalid signature는 상태를 바꾸지 않고 duplicate callback은 한 번만 projection된다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderCallbackAdapter.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/CallbackRequest.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/CallbackVerificationResult.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/NormalizedProviderEvent.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/VerifiedCallback.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/CallbackIngestionResult.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderCallbackIngestionService.java' 'modules/notification/notification-callback-api/src/test/java/io/backend/skeleton/notification/callback/ProviderCallbackIngestionServiceTest.java' +git commit -m "feat(notification): add secure callback ingestion core" +``` + +--- + +### Task 23: Spring MVC Provider Callback Endpoint 구현 + +**Files:** +- Create: `modules/notification/notification-callback-mvc/src/main/java/io/backend/skeleton/notification/callback/mvc/NotificationCallbackMvcController.java` +- Create: `modules/notification/notification-callback-mvc/src/main/java/io/backend/skeleton/notification/callback/mvc/ExternalRequestUrlResolver.java` +- Create: `modules/notification/notification-callback-mvc/src/main/java/io/backend/skeleton/notification/callback/mvc/CallbackMvcSecurityConfiguration.java` +- Test: `modules/notification/notification-callback-mvc/src/test/java/io/backend/skeleton/notification/callback/mvc/NotificationCallbackMvcControllerTest.java` + +**Interfaces:** +- Consumes: Task 22 CallbackIngestionService. +- Produces: Bounded MVC endpoint, raw body preservation, profile path binding, fast 2xx response. + +**Implementation requirements:** +- Forwarded header를 무조건 신뢰하지 않고 trusted proxy 설정과 결합한다. +- callback endpoint는 일반 user session security chain과 분리한다. +- raw body를 일반 access log에 기록하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.callback.mvc; + +import org.junit.jupiter.api.Test; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class NotificationCallbackMvcControllerTest extends CallbackMvcTestBase { + @Test + void rejectsOversizedBodyBeforeIngestion() throws Exception { + mockMvc.perform(post("/internal/notification/callbacks/twilio/twilio-primary") + .contentType("application/x-www-form-urlencoded") + .content(new byte[65537])) + .andExpect(status().isPayloadTooLarge()); + verifyNoIngestion(); + } + + @Test + void passesExternallyVisibleUrlForSignatureVerification() throws Exception { + mockMvc.perform(post("/internal/notification/callbacks/twilio/twilio-primary") + .header("Forwarded", "proto=https;host=callback.example.com") + .contentType("application/x-www-form-urlencoded") + .content("MessageSid=SM1&MessageStatus=delivered")) + .andExpect(status().isNoContent()); + verifyExternalUrl("https://callback.example.com/internal/notification/callbacks/twilio/twilio-primary"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-callback-mvc:test --tests "*.NotificationCallbackMvcControllerTest"` + +Expected: FAIL: MVC callback controller가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `NotificationCallbackMvcController.java` +- `ExternalRequestUrlResolver.java` +- `CallbackMvcSecurityConfiguration.java` + +```java +@RestController +@RequestMapping("/internal/notification/callbacks") +final class NotificationCallbackMvcController { + @PostMapping(path = "/{provider}/{profile}") + ResponseEntity callback( + @PathVariable String provider, + @PathVariable String profile, + HttpServletRequest request, + @RequestBody byte[] body) { + var callback = requestFactory.create(provider, profile, request, body); + ingestion.ingest(callback); + return ResponseEntity.noContent().build(); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-callback-mvc:test` + +Expected: PASS: body limit, external URL, profile binding, 204 response가 검증된다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-callback-mvc/src/main/java/io/backend/skeleton/notification/callback/mvc/NotificationCallbackMvcController.java' 'modules/notification/notification-callback-mvc/src/main/java/io/backend/skeleton/notification/callback/mvc/ExternalRequestUrlResolver.java' 'modules/notification/notification-callback-mvc/src/main/java/io/backend/skeleton/notification/callback/mvc/CallbackMvcSecurityConfiguration.java' 'modules/notification/notification-callback-mvc/src/test/java/io/backend/skeleton/notification/callback/mvc/NotificationCallbackMvcControllerTest.java' +git commit -m "feat(notification): add MVC callback endpoints" +``` + +--- + +### Task 24: Spring WebFlux Provider Callback Endpoint 구현 + +**Files:** +- Create: `modules/notification/notification-callback-webflux/src/main/java/io/backend/skeleton/notification/callback/webflux/NotificationCallbackWebFluxHandler.java` +- Create: `modules/notification/notification-callback-webflux/src/main/java/io/backend/skeleton/notification/callback/webflux/CallbackWebFluxRouter.java` +- Create: `modules/notification/notification-callback-webflux/src/main/java/io/backend/skeleton/notification/callback/webflux/BoundedCallbackBodyReader.java` +- Test: `modules/notification/notification-callback-webflux/src/test/java/io/backend/skeleton/notification/callback/webflux/NotificationCallbackWebFluxHandlerTest.java` + +**Interfaces:** +- Consumes: Task 22 CallbackIngestionService. +- Produces: WebFlux raw bytes reader with buffer release, bounded size, boundedElastic ingestion bridge. + +**Implementation requirements:** +- blocking JPA ingestion을 Netty event-loop에서 실행하지 않는다. +- MVC와 WebFlux가 같은 CallbackRequest canonicalization을 사용한다. +- body-to-string 변환 전 signature verification용 bytes를 유지한다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.callback.webflux; + +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; +import static org.assertj.core.api.Assertions.*; + +class NotificationCallbackWebFluxHandlerTest extends CallbackWebFluxTestBase { + @Test + void releasesBuffersWhenBodyLimitIsExceeded() { + StepVerifier.create(client().post() + .uri("/internal/notification/callbacks/ses/ses-primary") + .bodyValue(new byte[65537]) + .exchangeToMono(response -> response.releaseBody().thenReturn(response.statusCode()))) + .expectNextMatches(status -> status.value() == 413) + .verifyComplete(); + assertThat(leakDetector().activeBuffers()).isZero(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-callback-webflux:test --tests "*.NotificationCallbackWebFluxHandlerTest"` + +Expected: FAIL: WebFlux callback router와 bounded reader가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `NotificationCallbackWebFluxHandler.java` +- `CallbackWebFluxRouter.java` +- `BoundedCallbackBodyReader.java` + +```java +public Mono handle(ServerRequest request) { + return bodyReader.read(request.exchange().getRequest()) + .publishOn(Schedulers.boundedElastic()) + .map(bytes -> callbackFactory.create(request, bytes)) + .doOnNext(ingestion::ingest) + .then(ServerResponse.noContent().build()); +} + +// BoundedCallbackBodyReader accumulates at most configured bytes and +// releases every DataBuffer on success, error and cancellation. +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-callback-webflux:test` + +Expected: PASS: body limit 초과와 cancellation 경로에서 DataBuffer leak가 없다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-callback-webflux/src/main/java/io/backend/skeleton/notification/callback/webflux/NotificationCallbackWebFluxHandler.java' 'modules/notification/notification-callback-webflux/src/main/java/io/backend/skeleton/notification/callback/webflux/CallbackWebFluxRouter.java' 'modules/notification/notification-callback-webflux/src/main/java/io/backend/skeleton/notification/callback/webflux/BoundedCallbackBodyReader.java' 'modules/notification/notification-callback-webflux/src/test/java/io/backend/skeleton/notification/callback/webflux/NotificationCallbackWebFluxHandlerTest.java' +git commit -m "feat(notification): add WebFlux callback endpoints" +``` + +--- + +### Task 25: Provider Reconciliation Framework 구현 + +**Files:** +- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ReconciliationCapability.java` +- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ReconciliationResult.java` +- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ReconciliationScheduler.java` +- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ReconciliationService.java` +- Test: `modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/ReconciliationServiceTest.java` + +**Interfaces:** +- Consumes: Task 14 ledger/projector, Task 20 runtime registry, Task 21 attempt persistence. +- Produces: Capability-aware reconcile, synthetic event append, bounded retries and correction audit. + +**Implementation requirements:** +- Reconciliation query도 provider rate/concurrency limit을 사용한다. +- synthetic event가 기존 강한 evidence를 downgrade하지 않는다. +- max reconciliation age와 attempts를 bounded property로 둔다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.dispatch; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class ReconciliationServiceTest extends PostgreSqlNotificationTest { + @Test + void confirmedQueryResultIsAppendedAsSyntheticEvent() { + var attempt = insertAmbiguousAttempt(); + service(ProviderFixture.reconcileDelivered()).reconcile(attempt.id()); + var event = lastProviderEvent(); + assertThat(event.source()).isEqualTo(ProviderEventSource.RECONCILIATION); + assertThat(loadRecipient(attempt.recipientDeliveryId()).deliveryOutcome()) + .isEqualTo(DeliveryOutcome.DELIVERED); + } + + @Test + void unsupportedProviderLeavesAttemptUnknown() { + var attempt = insertAmbiguousAttempt(); + service(ProviderFixture.noReconciliation()).reconcile(attempt.id()); + assertThat(loadAttempt(attempt.id()).submissionOutcome()) + .isEqualTo(SubmissionOutcome.AMBIGUOUS); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-dispatch-runtime:test --tests "*.ReconciliationServiceTest"` + +Expected: FAIL: reconciliation capability와 service가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `ReconciliationCapability.java` +- `ReconciliationResult.java` +- `ReconciliationScheduler.java` +- `ReconciliationService.java` + +```java +public interface ReconciliationCapability { + boolean supports(ProviderProfileSnapshot profile); + CompletionStage reconcile(DeliveryAttemptSnapshot attempt); +} + +public void reconcile(DeliveryAttemptId attemptId) { + var attempt = attempts.load(attemptId); + var runtime = runtimes.current(attempt.providerProfileId()); + var capability = runtime.reconciliationCapability(); + var result = capability.reconcile(attempt).toCompletableFuture().join(); + switch (result) { + case ReconciliationResult.Confirmed confirmed -> + ledger.append(confirmed.asSyntheticEvent(ProviderEventSource.RECONCILIATION)); + case ReconciliationResult.StillUnknown unknown -> schedule(attemptId, unknown.nextCheckAt()); + case ReconciliationResult.Unsupported ignored -> markUnsupported(attemptId); + case ReconciliationResult.Failed failed -> handleFailure(attemptId, failed); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-dispatch-runtime:test` + +Expected: PASS: confirmed result는 ledger를 통하고 unsupported result는 추정하지 않는다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ReconciliationCapability.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ReconciliationResult.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ReconciliationScheduler.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ReconciliationService.java' 'modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/ReconciliationServiceTest.java' +git commit -m "feat(notification): add provider reconciliation framework" +``` + +--- + +### Task 26: Attachment Reference Resolver와 Integrity Guard 구현 + +**Files:** +- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/AttachmentResolver.java` +- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ResolvedAttachment.java` +- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/AttachmentAccessContext.java` +- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/AttachmentIntegrityGuard.java` +- Test: `modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/AttachmentIntegrityGuardTest.java` + +**Interfaces:** +- Consumes: Task 3 AttachmentRef, existing fileserver/objectstorage contracts. +- Produces: READY/authorization/size/digest checked immutable attachment stream, no byte persistence. + +**Implementation requirements:** +- attachment stream은 try-with-resources로 닫는다. +- signed URL을 persistence/log에 보존하지 않는다. +- Provider retry마다 immutable source를 다시 열 수 있어야 한다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.dispatch; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class AttachmentIntegrityGuardTest { + @Test + void rejectsFileThatIsNotReadyBeforeProviderCall() { + var resolver = AttachmentFixture.notReadyResolver(); + assertThatThrownBy(() -> guard(resolver).resolve(AttachmentFixture.ref())) + .isInstanceOf(AttachmentUnavailableException.class); + assertThat(AttachmentFixture.providerCalls()).isZero(); + } + + @Test + void rejectsDigestMismatch() { + assertThatThrownBy(() -> guard(AttachmentFixture.digestMismatchResolver()) + .resolve(AttachmentFixture.ref())) + .isInstanceOf(AttachmentIntegrityException.class); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-dispatch-runtime:test --tests "*.AttachmentIntegrityGuardTest"` + +Expected: FAIL: attachment resolver와 guard가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `AttachmentResolver.java` +- `ResolvedAttachment.java` +- `AttachmentAccessContext.java` +- `AttachmentIntegrityGuard.java` + +```java +public interface AttachmentResolver { + ResolvedAttachment resolve(AttachmentRef reference, AttachmentAccessContext context); +} + +public record ResolvedAttachment( + java.io.InputStream content, + long size, + String digest, + String contentType, + String displayName +) implements AutoCloseable { + @Override public void close() throws java.io.IOException { content.close(); } +} + +public ResolvedAttachment resolve(AttachmentRef ref) { + var attachment = resolver.resolve(ref, accessContext); + if (attachment.size() != ref.expectedSize()) throw new AttachmentIntegrityException("SIZE_MISMATCH"); + if (!constantTimeEquals(attachment.digest(), ref.expectedDigest())) + throw new AttachmentIntegrityException("DIGEST_MISMATCH"); + return attachment; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-dispatch-runtime:test` + +Expected: PASS: READY·authorization·size·digest가 provider call 전에 검증된다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/AttachmentResolver.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ResolvedAttachment.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/AttachmentAccessContext.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/AttachmentIntegrityGuard.java' 'modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/AttachmentIntegrityGuardTest.java' +git commit -m "feat(notification): add safe attachment reference resolution" +``` + +--- + +### Task 27: SMTP Provider Adapter와 MIME 구현 + +**Files:** +- Create: `modules/notification/notification-email-smtp/src/main/java/io/backend/skeleton/notification/email/smtp/SmtpNotificationProviderAdapter.java` +- Create: `modules/notification/notification-email-smtp/src/main/java/io/backend/skeleton/notification/email/smtp/SmtpMimeMessageFactory.java` +- Create: `modules/notification/notification-email-smtp/src/main/java/io/backend/skeleton/notification/email/smtp/SmtpFailureClassifier.java` +- Create: `modules/notification/notification-email-smtp/src/main/java/io/backend/skeleton/notification/email/smtp/SmtpProviderProperties.java` +- Test: `modules/notification/notification-email-smtp/src/test/java/io/backend/skeleton/notification/email/smtp/SmtpNotificationProviderAdapterTest.java` + +**Interfaces:** +- Consumes: Task 10 provider SPI, Task 26 attachment resolver, Task 30 Email content contract. +- Produces: SMTP submit, multipart MIME, bounded timeouts, 4yz/5yz and ambiguous final-response classification. + +**Implementation requirements:** +- SMTP connection/read/write timeout을 모두 유한값으로 강제한다. +- header CRLF injection을 MIME 생성 전 거부한다. +- JavaMail exception 원문에 recipient가 포함되면 logging sanitizer로 제거한다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.email.smtp; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class SmtpNotificationProviderAdapterTest extends SmtpTestServerBase { + @Test + void final250MeansProviderAcceptedNotDelivered() { + smtpServer().respondAfterData(250, "queued"); + var result = adapter().submit(fixture().emailSubmission()).toCompletableFuture().join(); + assertThat(result.submissionOutcome()).isEqualTo(SubmissionOutcome.CONFIRMED_ACCEPTED); + assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED); + assertThat(result.deliveryOutcome()).isEqualTo(DeliveryOutcome.UNKNOWN); + } + + @Test + void connectionLossAfterDataIsAmbiguous() { + smtpServer().acceptDataThenCloseWithoutResponse(); + var result = adapter().submit(fixture().emailSubmission()).toCompletableFuture().join(); + assertThat(result.confirmation()).isEqualTo(AttemptConfirmation.AMBIGUOUS); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-email-smtp:test --tests "*.SmtpNotificationProviderAdapterTest"` + +Expected: FAIL: SMTP adapter와 MIME factory가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `SmtpNotificationProviderAdapter.java` +- `SmtpMimeMessageFactory.java` +- `SmtpFailureClassifier.java` +- `SmtpProviderProperties.java` + +```java +public CompletionStage submit(ProviderSubmission submission) { + return CompletableFuture.supplyAsync(() -> { + try (var attachments = attachmentScope.open(submission)) { + var message = mimeFactory.create(submission, attachments); + javaMailSender.send(message); + return results.accepted(EvidenceLevel.PROVIDER_ACCEPTED); + } catch (MailSendException error) { + return classifier.classify(error); + } + }, smtpExecutor); +} + +// SmtpFailureClassifier maps 4yz to TRANSIENT_PROVIDER, 5yz to permanent or +// invalid recipient, and loss after DATA commitment to AMBIGUOUS_SUBMISSION. +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-email-smtp:test` + +Expected: PASS: SMTP acceptance, MIME, transient/permanent, ambiguous response가 분리된다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-email-smtp/src/main/java/io/backend/skeleton/notification/email/smtp/SmtpNotificationProviderAdapter.java' 'modules/notification/notification-email-smtp/src/main/java/io/backend/skeleton/notification/email/smtp/SmtpMimeMessageFactory.java' 'modules/notification/notification-email-smtp/src/main/java/io/backend/skeleton/notification/email/smtp/SmtpFailureClassifier.java' 'modules/notification/notification-email-smtp/src/main/java/io/backend/skeleton/notification/email/smtp/SmtpProviderProperties.java' 'modules/notification/notification-email-smtp/src/test/java/io/backend/skeleton/notification/email/smtp/SmtpNotificationProviderAdapterTest.java' +git commit -m "feat(notification): add stable SMTP email adapter" +``` + +--- + +### Task 28: Amazon SES Submit Adapter 구현 + +**Files:** +- Create: `modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesNotificationProviderAdapter.java` +- Create: `modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesRequestMapper.java` +- Create: `modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesFailureClassifier.java` +- Create: `modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesProviderProperties.java` +- Test: `modules/notification/notification-email-ses/src/test/java/io/backend/skeleton/notification/email/ses/SesNotificationProviderAdapterTest.java` + +**Interfaces:** +- Consumes: Task 10 provider SPI, Task 20 runtime, existing httpclient platform. +- Produces: SES MessageId mapping, acceptance-only evidence, HTTP ambiguity and error classification. + +**Implementation requirements:** +- SES retry owner는 notification policy이며 HTTP client profile의 blind retry를 비활성화한다. +- configuration set은 approved N3 option으로만 전달한다. +- SES MessageId를 NotificationId로 사용하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.email.ses; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class SesNotificationProviderAdapterTest extends SesWireMockTestBase { + @Test + void messageIdIsAcceptedEvidenceOnly() { + stubSesSuccess("ses-message-1"); + var result = adapter().submit(fixture().submission()).toCompletableFuture().join(); + assertThat(result.providerRequestId()).contains("ses-message-1"); + assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED); + assertThat(result.deliveryOutcome()).isEqualTo(DeliveryOutcome.UNKNOWN); + } + + @Test + void responseLossAfterServerAcceptsIsAmbiguous() { + stubAcceptThenReset(); + var result = adapter().submit(fixture().submission()).toCompletableFuture().join(); + assertThat(result.confirmation()).isEqualTo(AttemptConfirmation.AMBIGUOUS); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-email-ses:test --tests "*.SesNotificationProviderAdapterTest"` + +Expected: FAIL: SES adapter가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `SesNotificationProviderAdapter.java` +- `SesRequestMapper.java` +- `SesFailureClassifier.java` +- `SesProviderProperties.java` + +```java +public CompletionStage submit(ProviderSubmission submission) { + var request = mapper.map(submission); + return httpClient.send(request).handle((response, error) -> { + if (error != null) return classifier.fromTransport(error); + if (response.statusCode().is2xxSuccessful()) { + var messageId = response.body().messageId(); + return ProviderSubmissionResult.accepted( + messageId, EvidenceLevel.PROVIDER_ACCEPTED, + response.executionEvidence()); + } + return classifier.fromResponse(response); + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-email-ses:test` + +Expected: PASS: SES synchronous success와 response-loss ambiguity가 정확히 매핑된다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesNotificationProviderAdapter.java' 'modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesRequestMapper.java' 'modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesFailureClassifier.java' 'modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesProviderProperties.java' 'modules/notification/notification-email-ses/src/test/java/io/backend/skeleton/notification/email/ses/SesNotificationProviderAdapterTest.java' +git commit -m "feat(notification): add stable SES submission adapter" +``` + +--- + +### Task 29: SES Event Ingestion·Projection·Suppression 구현 + +**Files:** +- Create: `modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesCallbackAdapter.java` +- Create: `modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesEventNormalizer.java` +- Create: `modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesDeliveryProjector.java` +- Create: `modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesSuppressionUpdater.java` +- Test: `modules/notification/notification-email-ses/src/test/java/io/backend/skeleton/notification/email/ses/SesEventProjectionTest.java` + +**Interfaces:** +- Consumes: Tasks 14·22 ledger/callback core, Task 17 suppression. +- Produces: SES delivery, delay, bounce, complaint, reject, rendering failure event mapping. + +**Implementation requirements:** +- Open/click event는 reliability delivery state와 분리한다. +- duplicate SES event는 ledger unique constraint로 no-op이다. +- soft bounce와 hard bounce를 같은 suppression reason으로 처리하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.email.ses; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class SesEventProjectionTest { + @Test + void deliveryThenComplaintPreservesDeliveryAndAddsSuppression() { + ingest(fixture().deliveryEvent("m-1")); + ingest(fixture().complaintEvent("m-1")); + var projection = projection("m-1"); + assertThat(projection.deliveryOutcome()).isEqualTo(DeliveryOutcome.DELIVERED); + assertThat(projection.suppressionFacts().complained()).isTrue(); + assertThat(suppressionReason("m-1")).isEqualTo(SuppressionReason.COMPLAINT); + } + + @Test + void hardBounceInvalidatesEmailAndSuppresses() { + ingest(fixture().hardBounce("m-2")); + assertThat(contactStatus("m-2")).isEqualTo(ContactPointStatus.INVALID); + assertThat(suppressionReason("m-2")).isEqualTo(SuppressionReason.HARD_BOUNCE); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-email-ses:test --tests "*.SesEventProjectionTest"` + +Expected: FAIL: SES event normalizer/projector가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `SesCallbackAdapter.java` +- `SesEventNormalizer.java` +- `SesDeliveryProjector.java` +- `SesSuppressionUpdater.java` + +```java +public NormalizedProviderEvent normalize(SesEvent event) { + return switch (event.type()) { + case "Delivery" -> normalized("DELIVERY_CONFIRMED", DeliveryOutcome.DELIVERED, + EvidenceLevel.NETWORK_OR_CARRIER_ACCEPTED); + case "Bounce" -> normalizeBounce(event); + case "Complaint" -> normalizedFact("COMPLAINT"); + case "DeliveryDelay" -> normalizedFact("DELIVERY_DELAYED"); + case "Reject" -> normalizedFailure(FailureCategory.PERMANENT_PROVIDER); + case "RenderingFailure" -> normalizedFailure(FailureCategory.TEMPLATE_FAILURE); + default -> normalizedUnknown(event.type()); + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-email-ses:test` + +Expected: PASS: SES delivery와 complaint/bounce facts가 독립적으로 반영된다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesCallbackAdapter.java' 'modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesEventNormalizer.java' 'modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesDeliveryProjector.java' 'modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesSuppressionUpdater.java' 'modules/notification/notification-email-ses/src/test/java/io/backend/skeleton/notification/email/ses/SesEventProjectionTest.java' +git commit -m "feat(notification): process SES delivery evidence and suppression" +``` + +--- + +### Task 30: E.164 PhoneNumber와 GSM-7/UCS-2 Segment Estimator 구현 + +**Files:** +- Create: `modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/E164PhoneNumberParser.java` +- Create: `modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/GsmAlphabet.java` +- Create: `modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsSegmentEstimator.java` +- Test: `modules/notification/notification-sms-api/src/test/java/io/backend/skeleton/notification/sms/SmsSegmentEstimatorTest.java` + +**Interfaces:** +- Consumes: Task 5 SmsEstimate·SmsEncoding and Task 6 PhoneNumber base type. +- Produces: Strict E.164 parser, GSM-7 extension-aware length, 160/153 and 70/67 segment calculation. + +**Implementation requirements:** +- Java String length만으로 segment를 계산하지 않는다. +- GSM extension table 문자는 2 septet으로 계산한다. +- estimate는 비용 단가를 하드코딩하지 않고 segment count만 제공한다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.sms; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class SmsSegmentEstimatorTest { + @Test + void gsm7BoundaryUsesOneThenTwoSegments() { + assertThat(estimator().estimate("a".repeat(160)).segmentCount()).isEqualTo(1); + assertThat(estimator().estimate("a".repeat(161)).segmentCount()).isEqualTo(2); + } + + @Test + void unicodeBoundaryUsesUcs2() { + assertThat(estimator().estimate("가".repeat(70)).segmentCount()).isEqualTo(1); + assertThat(estimator().estimate("가".repeat(71)).segmentCount()).isEqualTo(2); + assertThat(estimator().estimate("hello🙂").encoding()).isEqualTo(SmsEncoding.UCS_2); + } + + @Test + void parsesE164AndRejectsLocalNumber() { + assertThat(parser().parse("+821012345678").e164()).isEqualTo("+821012345678"); + assertThatThrownBy(() -> parser().parse("01012345678")) + .isInstanceOf(InvalidPhoneNumberException.class); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-sms-api:test --tests "*.SmsSegmentEstimatorTest"` + +Expected: FAIL: SMS parser와 estimator가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `E164PhoneNumberParser.java` +- `GsmAlphabet.java` +- `SmsSegmentEstimator.java` + +```java +public SmsEstimate estimate(String text) { + var gsmUnits = GsmAlphabet.encodedSeptets(text); + if (gsmUnits.isPresent()) { + int units = gsmUnits.getAsInt(); + int segments = units <= 160 ? 1 : divideCeiling(units, 153); + return new SmsEstimate(SmsEncoding.GSM_7, segments, units, segments > 3); + } + int units = text.codePoints().map(cp -> Character.charCount(cp)).sum(); + int segments = units <= 70 ? 1 : divideCeiling(units, 67); + return new SmsEstimate(SmsEncoding.UCS_2, segments, units, segments > 3); +} + +public PhoneNumber parse(String value) { + if (!value.matches("\+[1-9][0-9]{1,14}")) { + throw new InvalidPhoneNumberException("INVALID_E164"); + } + return new PhoneNumber(value); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-sms-api:test` + +Expected: PASS: E.164와 SMS segment 경계가 정확히 계산된다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/E164PhoneNumberParser.java' 'modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/GsmAlphabet.java' 'modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsSegmentEstimator.java' 'modules/notification/notification-sms-api/src/test/java/io/backend/skeleton/notification/sms/SmsSegmentEstimatorTest.java' +git commit -m "feat(notification): add E164 and SMS segment estimation" +``` + +--- + +### Task 31: Twilio SMS Submit Adapter 구현 + +**Files:** +- Create: `modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioSmsProviderAdapter.java` +- Create: `modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioRequestMapper.java` +- Create: `modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioFailureClassifier.java` +- Create: `modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioProviderProperties.java` +- Test: `modules/notification/notification-sms-twilio/src/test/java/io/backend/skeleton/notification/sms/twilio/TwilioSmsProviderAdapterTest.java` + +**Interfaces:** +- Consumes: Task 10 provider SPI, Task 20 runtime, Task 30 E.164 and estimate, existing httpclient. +- Produces: Twilio API submit, accepted/queued evidence, invalid number, 429 and response-loss classification. + +**Implementation requirements:** +- Twilio status callback URL은 profile에서 고정한다. +- phone number와 message body를 HTTP client log에 남기지 않는다. +- httpclient 자동 retry는 끄고 Notification retry policy가 소유한다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.sms.twilio; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class TwilioSmsProviderAdapterTest extends TwilioWireMockTestBase { + @Test + void acceptedStatusIsProviderAcceptedOnly() { + stubCreateMessage("SM1", "accepted"); + var result = adapter().submit(fixture().submission()).toCompletableFuture().join(); + assertThat(result.providerRequestId()).contains("SM1"); + assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED); + assertThat(result.deliveryOutcome()).isEqualTo(DeliveryOutcome.UNKNOWN); + } + + @Test + void provider429IsThrottled() { + stubRateLimited(10); + var result = adapter().submit(fixture().submission()).toCompletableFuture().join(); + assertThat(result.failure().orElseThrow().category()) + .isEqualTo(FailureCategory.THROTTLED); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-sms-twilio:test --tests "*.TwilioSmsProviderAdapterTest"` + +Expected: FAIL: Twilio SMS adapter가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `TwilioSmsProviderAdapter.java` +- `TwilioRequestMapper.java` +- `TwilioFailureClassifier.java` +- `TwilioProviderProperties.java` + +```java +public CompletionStage submit(ProviderSubmission submission) { + var request = mapper.map(submission); + return twilioClient.createMessage(request).handle((response, error) -> { + if (error != null) return classifier.transport(error); + return switch (response.status()) { + case "accepted", "queued", "sending" -> + results.accepted(response.sid(), EvidenceLevel.PROVIDER_ACCEPTED, response.status()); + case "failed" -> classifier.failed(response.errorCode()); + default -> classifier.unexpected(response.status()); + }; + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-sms-twilio:test` + +Expected: PASS: Twilio accepted와 throttle/error가 stable result로 변환된다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioSmsProviderAdapter.java' 'modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioRequestMapper.java' 'modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioFailureClassifier.java' 'modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioProviderProperties.java' 'modules/notification/notification-sms-twilio/src/test/java/io/backend/skeleton/notification/sms/twilio/TwilioSmsProviderAdapterTest.java' +git commit -m "feat(notification): add stable Twilio SMS submission adapter" +``` + +--- + +### Task 32: Twilio Callback·역순 Projection·Reconciliation 구현 + +**Files:** +- Create: `modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioCallbackAdapter.java` +- Create: `modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioStatusNormalizer.java` +- Create: `modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioDeliveryProjector.java` +- Create: `modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioReconciliationCapability.java` +- Test: `modules/notification/notification-sms-twilio/src/test/java/io/backend/skeleton/notification/sms/twilio/TwilioCallbackAndReconciliationTest.java` + +**Interfaces:** +- Consumes: Tasks 22 callback core, 25 reconciliation, 31 Twilio provider profile. +- Produces: X-Twilio-Signature validation, status normalization, reverse-order merge, status polling. + +**Implementation requirements:** +- callback receivedAt 순서가 아니라 event semantics를 사용한다. +- query polling은 provider QPS와 max reconciliation age를 준수한다. +- opt-out provider event를 internal suppression으로 연결한다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.sms.twilio; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class TwilioCallbackAndReconciliationTest extends TwilioCallbackTestBase { + @Test + void deliveredBeforeSentNeverDowngrades() { + ingest(callback("SM1", "delivered")); + ingest(callback("SM1", "sent")); + assertThat(projection("SM1").deliveryOutcome()).isEqualTo(DeliveryOutcome.DELIVERED); + } + + @Test + void missingCallbackIsCorrectedByPolling() { + insertAcceptedAttempt("SM2"); + stubStatusQuery("SM2", "undelivered"); + reconcile("SM2"); + assertThat(projection("SM2").deliveryOutcome()).isEqualTo(DeliveryOutcome.UNDELIVERED); + assertThat(lastEventSource()).isEqualTo(ProviderEventSource.RECONCILIATION); + } + + @Test + void invalidSignatureIsRejected() { + assertThatThrownBy(() -> ingest(callbackWithInvalidSignature())) + .isInstanceOf(CallbackValidationException.class); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-sms-twilio:test --tests "*.TwilioCallbackAndReconciliationTest"` + +Expected: FAIL: Twilio callback adapter/projector/reconcile가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `TwilioCallbackAdapter.java` +- `TwilioStatusNormalizer.java` +- `TwilioDeliveryProjector.java` +- `TwilioReconciliationCapability.java` + +```java +public NormalizedProviderEvent normalize(TwilioStatusCallback callback) { + return switch (callback.messageStatus()) { + case "accepted", "queued", "sending" -> accepted(callback); + case "sent" -> sent(callback, EvidenceLevel.NETWORK_OR_CARRIER_ACCEPTED); + case "delivered" -> delivered(callback, EvidenceLevel.DEVICE_DELIVERED); + case "undelivered" -> undelivered(callback); + case "failed" -> failed(callback); + default -> unknown(callback); + }; +} + +// Projector transition table ignores sent after delivered and retains terminal facts. +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-sms-twilio:test` + +Expected: PASS: signature, reverse-order callback, missing callback reconciliation이 통과한다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioCallbackAdapter.java' 'modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioStatusNormalizer.java' 'modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioDeliveryProjector.java' 'modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioReconciliationCapability.java' 'modules/notification/notification-sms-twilio/src/test/java/io/backend/skeleton/notification/sms/twilio/TwilioCallbackAndReconciliationTest.java' +git commit -m "feat(notification): add Twilio status callback and reconciliation" +``` + +--- + +### Task 33: Mobile Push 공통 API와 Application Receipt 구현 + +**Files:** +- Create: `modules/notification/notification-push-api/src/main/java/io/backend/skeleton/notification/push/ApplicationReceipt.java` +- Create: `modules/notification/notification-push-api/src/main/java/io/backend/skeleton/notification/push/ApplicationReceiptService.java` +- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ApplicationReceiptServiceImpl.java` +- Test: `modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/ApplicationReceiptServiceTest.java` + +**Interfaces:** +- Consumes: Task 3 PushPresentation, Task 5 MobilePushNotification, Task 6 push targets, Task 14 event ledger. +- Produces: Typed push API, authenticated displayed/read receipt, receipt dedup and evidence promotion. + +**Implementation requirements:** +- Provider acceptance 없이 앱 receipt만으로 임의 Attempt를 생성하지 않는다. +- receipt ID, attempt ID, authenticated user/app binding을 검증한다. +- client clock은 참고값이고 server receivedAt을 감사 기준으로 보존한다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.dispatch; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class ApplicationReceiptServiceTest extends PostgreSqlNotificationTest { + @Test + void readReceiptPromotesEvidenceAndIsIdempotent() { + var receipt = fixture().readReceipt("receipt-1"); + service().record(receipt, fixture().authenticatedApp()); + service().record(receipt, fixture().authenticatedApp()); + assertThat(projection(receipt.attemptId()).evidenceLevel()).isEqualTo(EvidenceLevel.USER_READ); + assertThat(receiptEventCount("receipt-1")).isEqualTo(1); + } + + @Test + void receiptForAnotherUserIsRejected() { + assertThatThrownBy(() -> service().record( + fixture().readReceiptForUser("user-b"), fixture().authenticatedUser("user-a"))) + .isInstanceOf(ReceiptAuthorizationException.class); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-dispatch-runtime:test --tests "*.ApplicationReceiptServiceTest"` + +Expected: FAIL: app receipt API와 implementation이 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `ApplicationReceipt.java` +- `ApplicationReceiptService.java` +- `ApplicationReceiptServiceImpl.java` + +```java +public interface ApplicationReceiptService { + ReceiptResult record(ApplicationReceipt receipt, ApplicationIdentity identity); +} + +@Transactional +public ReceiptResult record(ApplicationReceipt receipt, ApplicationIdentity identity) { + authorization.verify(identity, receipt); + var append = ledger.append(receiptEventFactory.from(receipt)); + if (append.created()) projector.project(append.eventId()); + return new ReceiptResult(append.created(), receipt.attemptId()); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-dispatch-runtime:test` + +Expected: PASS: authenticated app receipt가 idempotent하게 evidence를 승격한다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-push-api/src/main/java/io/backend/skeleton/notification/push/ApplicationReceipt.java' 'modules/notification/notification-push-api/src/main/java/io/backend/skeleton/notification/push/ApplicationReceiptService.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ApplicationReceiptServiceImpl.java' 'modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/ApplicationReceiptServiceTest.java' +git commit -m "feat(notification): add mobile push and app receipt contracts" +``` + +--- + +### Task 34: FCM FID·Legacy Target Mapper와 Recipient별 Batch Adapter 구현 + +**Files:** +- Create: `modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmNotificationProviderAdapter.java` +- Create: `modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmTargetMapper.java` +- Create: `modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmBatchCoordinator.java` +- Create: `modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmProviderProperties.java` +- Test: `modules/notification/notification-push-fcm/src/test/java/io/backend/skeleton/notification/push/fcm/FcmBatchAdapterTest.java` + +**Interfaces:** +- Consumes: Tasks 6 FID/legacy types, 10 provider SPI, 33 push API. +- Produces: FID-primary mapping, legacy compatibility, max-500 batch, input-index partial result mapping. + +**Implementation requirements:** +- Batch transport 호출 하나를 하나의 RecipientDelivery로 축소하지 않는다. +- FCM success evidence는 PROVIDER_ACCEPTED까지만 설정한다. +- topic/condition은 N3 experimental interface에 분리한다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.push.fcm; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class FcmBatchAdapterTest { + @Test + void mapsPartialBatchResultToEachRecipientAttempt() { + var submissions = fixture().fiveSubmissions(); + var gateway = fixture().gatewayWithResults(true, false, true, false, true); + var results = coordinator(gateway).submit(submissions).toCompletableFuture().join(); + assertThat(results).hasSize(5); + assertThat(results.get(0).confirmation()).isEqualTo(AttemptConfirmation.CONFIRMED); + assertThat(results.get(1).confirmation()).isEqualTo(AttemptConfirmation.REJECTED); + } + + @Test + void rejectsBatchAbove500() { + assertThatThrownBy(() -> coordinator(fixture().gateway()).submit(fixture().submissions(501))) + .isInstanceOf(ProviderPayloadLimitException.class); + } + + @Test + void fidAndLegacyTokenUseDistinctWireTargetKinds() { + assertThat(mapper().map(new FcmInstallationId("fid-1")).kind()).isEqualTo("FID"); + assertThat(mapper().map(new LegacyFcmRegistrationToken("token-1")).kind()).isEqualTo("LEGACY_TOKEN"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-push-fcm:test --tests "*.FcmBatchAdapterTest"` + +Expected: FAIL: FCM adapter/batch coordinator가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `FcmNotificationProviderAdapter.java` +- `FcmTargetMapper.java` +- `FcmBatchCoordinator.java` +- `FcmProviderProperties.java` + +```java +public CompletionStage> submitBatch( + java.util.List submissions) { + if (submissions.size() > properties.maxBatchSize()) { + throw new ProviderPayloadLimitException("FCM_BATCH_MAX_500"); + } + var request = mapper.mapBatch(submissions); + return gateway.sendBatch(request).thenApply(response -> + java.util.stream.IntStream.range(0, submissions.size()) + .mapToObj(i -> resultMapper.map(submissions.get(i), response.result(i))) + .toList()); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-push-fcm:test` + +Expected: PASS: FID/legacy target와 recipient별 partial batch result가 검증된다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmNotificationProviderAdapter.java' 'modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmTargetMapper.java' 'modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmBatchCoordinator.java' 'modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmProviderProperties.java' 'modules/notification/notification-push-fcm/src/test/java/io/backend/skeleton/notification/push/fcm/FcmBatchAdapterTest.java' +git commit -m "feat(notification): add FCM FID-first batch adapter" +``` + +--- + +### Task 35: FCM 오류 분류·Target Invalidation·TTL/Collapse 구현 + +**Files:** +- Create: `modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmFailureClassifier.java` +- Create: `modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmContactPointUpdater.java` +- Create: `modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmMessageMapper.java` +- Test: `modules/notification/notification-push-fcm/src/test/java/io/backend/skeleton/notification/push/fcm/FcmFailureAndLifecycleTest.java` + +**Interfaces:** +- Consumes: Task 17 policy, Task 34 FCM adapter. +- Produces: UNREGISTERED invalidation, quota/unavailable retry mapping, provider TTL and collapse mapping. + +**Implementation requirements:** +- UNREGISTERED를 transient로 retry하지 않는다. +- credential failure는 Provider runtime auth failure로 승격한다. +- FCM delivery order를 보장한다고 문서화하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.push.fcm; + +import org.junit.jupiter.api.Test; +import java.time.*; +import static org.assertj.core.api.Assertions.*; + +class FcmFailureAndLifecycleTest extends FcmTestBase { + @Test + void unregisteredInvalidatesContactAndDoesNotRetry() { + var result = classifier().classify(fixture().error("UNREGISTERED")); + updater().apply(fixture().contactId(), result); + assertThat(result.failure().orElseThrow().category()).isEqualTo(FailureCategory.INVALID_RECIPIENT); + assertThat(contactStatus()).isEqualTo(ContactPointStatus.INVALID); + assertThat(retryPolicy().decide(fixture().context(result))) + .isInstanceOf(RetryDecision.Fallback.class); + } + + @Test + void ttlIsCappedByExpiry() { + var now = Instant.parse("2026-08-10T00:00:00Z"); + var message = mapper(now).map(fixture().submissionExpiringAt(now.plusSeconds(90))); + assertThat(message.ttl()).isEqualTo(Duration.ofSeconds(90)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-push-fcm:test --tests "*.FcmFailureAndLifecycleTest"` + +Expected: FAIL: FCM failure classifier와 lifecycle updater가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `FcmFailureClassifier.java` +- `FcmContactPointUpdater.java` +- `FcmMessageMapper.java` + +```java +public ProviderSubmissionResult classify(FcmError error) { + return switch (error.code()) { + case "UNREGISTERED" -> rejected(FailureCategory.INVALID_RECIPIENT, false); + case "QUOTA_EXCEEDED" -> rejected(FailureCategory.THROTTLED, true); + case "UNAVAILABLE" -> rejected(FailureCategory.TRANSIENT_PROVIDER, true); + case "INVALID_ARGUMENT" -> rejected(FailureCategory.INVALID_PAYLOAD, false); + case "THIRD_PARTY_AUTH_ERROR" -> rejected(FailureCategory.AUTHENTICATION, false); + default -> rejected(FailureCategory.PERMANENT_PROVIDER, false); + }; +} + +// Message mapper computes TTL = min(expiresAt-now, provider max TTL) and maps +// collapse key only when CollapseSpec is present. +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-push-fcm:test` + +Expected: PASS: FCM target invalidation, retry classification, TTL/collapse mapping이 통과한다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmFailureClassifier.java' 'modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmContactPointUpdater.java' 'modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmMessageMapper.java' 'modules/notification/notification-push-fcm/src/test/java/io/backend/skeleton/notification/push/fcm/FcmFailureAndLifecycleTest.java' +git commit -m "feat(notification): add FCM lifecycle and failure semantics" +``` + +--- + +### Task 36: APNs HTTP/2 Adapter와 Environment·Topic Guard 구현 + +**Files:** +- Create: `modules/notification/notification-push-apns/src/main/java/io/backend/skeleton/notification/push/apns/ApnsNotificationProviderAdapter.java` +- Create: `modules/notification/notification-push-apns/src/main/java/io/backend/skeleton/notification/push/apns/ApnsRequestMapper.java` +- Create: `modules/notification/notification-push-apns/src/main/java/io/backend/skeleton/notification/push/apns/ApnsFailureClassifier.java` +- Create: `modules/notification/notification-push-apns/src/main/java/io/backend/skeleton/notification/push/apns/ApnsProviderProperties.java` +- Test: `modules/notification/notification-push-apns/src/test/java/io/backend/skeleton/notification/push/apns/ApnsNotificationProviderAdapterTest.java` + +**Interfaces:** +- Consumes: Task 6 APNs token/environment, Task 10 provider SPI, existing httpclient HTTP/2 profile. +- Produces: APNs headers, acceptance-only evidence, environment/topic mismatch, invalid token classification. + +**Implementation requirements:** +- APNs 2xx를 delivered로 매핑하지 않는다. +- push type은 allowlist된 typed option만 허용한다. +- sandbox/production Contact Point namespace를 분리한다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.push.apns; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class ApnsNotificationProviderAdapterTest extends ApnsHttp2TestBase { + @Test + void http200IsProviderAcceptedNotDelivered() { + stubApnsSuccess("apns-request-1"); + var result = adapter().submit(fixture().productionSubmission()).toCompletableFuture().join(); + assertThat(result.providerRequestId()).contains("apns-request-1"); + assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED); + assertThat(result.deliveryOutcome()).isEqualTo(DeliveryOutcome.UNKNOWN); + } + + @Test + void sandboxTokenCannotUseProductionProfile() { + assertThatThrownBy(() -> adapter().submit(fixture().sandboxTokenOnProduction())) + .isInstanceOf(ProviderConfigurationException.class); + } + + @Test + void invalidTokenInvalidatesContactPoint() { + stubApnsError(410, "Unregistered"); + var result = adapter().submit(fixture().productionSubmission()).toCompletableFuture().join(); + assertThat(result.failure().orElseThrow().category()).isEqualTo(FailureCategory.INVALID_RECIPIENT); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-push-apns:test --tests "*.ApnsNotificationProviderAdapterTest"` + +Expected: FAIL: APNs adapter가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `ApnsNotificationProviderAdapter.java` +- `ApnsRequestMapper.java` +- `ApnsFailureClassifier.java` +- `ApnsProviderProperties.java` + +```java +public ApnsRequest map(ProviderSubmission submission) { + var target = requireApnsTarget(submission.contactPoint()); + if (target.environment() != properties.environment()) { + throw new ProviderConfigurationException("APNS_ENVIRONMENT_MISMATCH"); + } + return new ApnsRequest( + target.value(), + properties.topic(), + approvedPushType(submission), + expirationEpoch(submission.expiresAt()), + approvedPriority(submission), + collapseId(submission), + payload(submission)); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-push-apns:test` + +Expected: PASS: APNs 2xx, environment, invalid token, header mapping이 검증된다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-push-apns/src/main/java/io/backend/skeleton/notification/push/apns/ApnsNotificationProviderAdapter.java' 'modules/notification/notification-push-apns/src/main/java/io/backend/skeleton/notification/push/apns/ApnsRequestMapper.java' 'modules/notification/notification-push-apns/src/main/java/io/backend/skeleton/notification/push/apns/ApnsFailureClassifier.java' 'modules/notification/notification-push-apns/src/main/java/io/backend/skeleton/notification/push/apns/ApnsProviderProperties.java' 'modules/notification/notification-push-apns/src/test/java/io/backend/skeleton/notification/push/apns/ApnsNotificationProviderAdapterTest.java' +git commit -m "feat(notification): add stable APNs adapter" +``` + +--- + +### Task 37: Web Push RFC 8291 Encryption과 VAPID 구현 + +**Files:** +- Create: `modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushSubscription.java` +- Create: `modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushPayloadEncryptor.java` +- Create: `modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/Rfc8291Aes128GcmEncryptor.java` +- Create: `modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/VapidJwtSigner.java` +- Create: `modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/VapidKeyRegistry.java` +- Test: `modules/notification/notification-webpush/src/test/java/io/backend/skeleton/notification/webpush/WebPushCryptoTest.java` + +**Interfaces:** +- Consumes: Task 7 secret provider, Task 10 provider SPI. +- Produces: Encrypted subscription, RFC 8291 AES128GCM payload, RFC 8292 ES256 VAPID JWT. + +**Implementation requirements:** +- Web Push endpoint와 key material을 log에 출력하지 않는다. +- VAPID private key는 SecretMaterialProvider에서만 획득한다. +- VAPID key rotation은 subscription migration이 필요하다는 상태를 노출한다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.webpush; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class WebPushCryptoTest { + @Test + void encryptsPayloadUsingSubscriptionKeys() { + var encrypted = encryptor().encrypt( + fixture().subscription(), "hello".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + assertThat(encrypted.contentEncoding()).isEqualTo("aes128gcm"); + assertThat(encrypted.body()).doesNotContainSequence("hello".getBytes()); + assertThat(fixture().decrypt(encrypted)).isEqualTo("hello".getBytes()); + } + + @Test + void vapidAudienceUsesEndpointOrigin() { + var jwt = signer().sign(fixture().subscription(), fixture().key()); + assertThat(fixture().claims(jwt).audience()).isEqualTo("https://push.example.com"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-webpush:test --tests "*.WebPushCryptoTest"` + +Expected: FAIL: Web Push crypto와 VAPID가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `WebPushSubscription.java` +- `WebPushPayloadEncryptor.java` +- `Rfc8291Aes128GcmEncryptor.java` +- `VapidJwtSigner.java` +- `VapidKeyRegistry.java` + +```java +public record WebPushSubscription( + ContactPointId id, + EncryptedValue endpoint, + EncryptedValue p256dh, + EncryptedValue authSecret, + String vapidKeyId +) {} + +public EncryptedWebPushPayload encrypt(WebPushKeyMaterial subscription, byte[] plaintext) { + // RFC 8291: P-256 ECDH, auth secret, HKDF, random salt, aes128gcm record. + var sharedSecret = ecdh.derive(ephemeralKeyPair(), subscription.p256dh()); + var keyAndNonce = hkdf.derive(sharedSecret, subscription.authSecret(), randomSalt()); + return aes128gcm.encrypt(plaintext, keyAndNonce); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-webpush:test` + +Expected: PASS: RFC encryption round-trip과 VAPID audience가 검증된다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushSubscription.java' 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushPayloadEncryptor.java' 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/Rfc8291Aes128GcmEncryptor.java' 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/VapidJwtSigner.java' 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/VapidKeyRegistry.java' 'modules/notification/notification-webpush/src/test/java/io/backend/skeleton/notification/webpush/WebPushCryptoTest.java' +git commit -m "feat(notification): add Web Push encryption and VAPID" +``` + +--- + +### Task 38: Web Push RFC 8030 Transport·TTL·Subscription Invalidation 구현 + +**Files:** +- Create: `modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushNotificationProviderAdapter.java` +- Create: `modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushRequestMapper.java` +- Create: `modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushFailureClassifier.java` +- Create: `modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushReceiptCapability.java` +- Test: `modules/notification/notification-webpush/src/test/java/io/backend/skeleton/notification/webpush/WebPushProviderAdapterTest.java` + +**Interfaces:** +- Consumes: Task 37 crypto/VAPID, existing HTTP Client Dynamic/Trusted policy. +- Produces: TTL-required send, urgency/topic, 201 acceptance, 404/410 invalidation, optional receipt capability. + +**Implementation requirements:** +- Web Push endpoint를 arbitrary dynamic URL로 일반 공개하지 않는다. +- HTTP redirect를 따르지 않는다. +- receipt 지원 여부를 ProviderCapabilities로 확인한다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.webpush; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class WebPushProviderAdapterTest extends WebPushWireMockTestBase { + @Test + void ttlHeaderIsRequiredAndAcceptanceIsNotDelivery() { + stubPushService(201); + var result = adapter().submit(fixture().submissionWithTtl(60)).toCompletableFuture().join(); + verifyHeader("TTL", "60"); + assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED); + assertThat(result.deliveryOutcome()).isEqualTo(DeliveryOutcome.UNKNOWN); + } + + @Test + void expiredSubscriptionIsInvalidated() { + stubPushService(404); + var result = adapter().submit(fixture().submissionWithTtl(60)).toCompletableFuture().join(); + assertThat(result.failure().orElseThrow().category()).isEqualTo(FailureCategory.INVALID_RECIPIENT); + } + + @Test + void missingExpiryCannotCreateWebPushAttempt() { + assertThatThrownBy(() -> adapter().submit(fixture().submissionWithoutExpiry())) + .isInstanceOf(ProviderConfigurationException.class); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-webpush:test --tests "*.WebPushProviderAdapterTest"` + +Expected: FAIL: Web Push transport adapter가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `WebPushNotificationProviderAdapter.java` +- `WebPushRequestMapper.java` +- `WebPushFailureClassifier.java` +- `WebPushReceiptCapability.java` + +```java +public WebPushRequest map(ProviderSubmission submission) { + var ttl = java.time.Duration.between(clock.instant(), submission.expiresAt()); + if (ttl.isNegative() || ttl.isZero()) throw new NotificationExpiredException("WEBPUSH_EXPIRED"); + var encrypted = encryptor.encrypt(subscriptionKeys(submission), payload(submission)); + return new WebPushRequest( + endpoint(submission), ttl.toSeconds(), urgency(submission), topic(submission), + vapidSigner.authorization(endpoint(submission)), encrypted); +} + +// 201 = PROVIDER_ACCEPTED, 404 and provider-documented 410 = INVALID_RECIPIENT. +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-webpush:test` + +Expected: PASS: TTL, acceptance evidence, subscription invalidation이 검증된다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushNotificationProviderAdapter.java' 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushRequestMapper.java' 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushFailureClassifier.java' 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushReceiptCapability.java' 'modules/notification/notification-webpush/src/test/java/io/backend/skeleton/notification/webpush/WebPushProviderAdapterTest.java' +git commit -m "feat(notification): add stable Web Push transport" +``` + +--- + +### Task 39: In-App Inbox API·JPA Persistence·Cursor Pagination 구현 + +**Files:** +- Create: `modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/NotificationInbox.java` +- Create: `modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxItem.java` +- Create: `modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxCursor.java` +- Create: `modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxItemId.java` +- Create: `modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxPrincipal.java` +- Create: `modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxPage.java` +- Create: `modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxQuery.java` +- Create: `modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxMutationResult.java` +- Create: `modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/InboxItemEntity.java` +- Create: `modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/JpaNotificationInbox.java` +- Create: `modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/InboxUnreadCounter.java` +- Test: `modules/notification/notification-inbox-jpa/src/test/java/io/backend/skeleton/notification/inbox/jpa/JpaNotificationInboxTest.java` + +**Interfaces:** +- Consumes: Task 12 inbox schema, Task 3 InAppContent. +- Produces: Tenant/user-bound cursor pagination, idempotent seen/read/archive, consistent unread count. + +**Implementation requirements:** +- WebSocket availability를 Inbox transaction에 포함하지 않는다. +- unread count cache를 source of truth로 사용하지 않는다. +- bulk mark-read는 bounded batch와 cursor cutoff를 사용한다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.inbox.jpa; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class JpaNotificationInboxTest extends PostgreSqlNotificationTest { + @Test + void cursorPaginationIsStableWithSameTimestamp() { + insertInboxItemsWithSameTimestamp(25); + var first = inbox().list(query().limit(10)); + var second = inbox().list(query().after(first.nextCursor()).limit(10)); + assertThat(first.items()).doesNotContainAnyElementsOf(second.items()); + assertThat(first.items()).hasSize(10); + assertThat(second.items()).hasSize(10); + } + + @Test + void concurrentMarkReadIsIdempotentAndUnreadCountStaysCorrect() { + var id = insertUnreadItem(); + runConcurrently(20, () -> inbox().markRead(id, principal())); + assertThat(inbox().unreadCount(principal())).isZero(); + assertThat(readAuditCount(id)).isEqualTo(1); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-inbox-jpa:test --tests "*.JpaNotificationInboxTest"` + +Expected: FAIL: Inbox API/JPA implementation이 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `NotificationInbox.java` +- `InboxItem.java` +- `InboxCursor.java` +- `InboxItemId.java` +- `InboxPrincipal.java` +- `InboxPage.java` +- `InboxQuery.java` +- `InboxMutationResult.java` +- `InboxItemEntity.java` +- `JpaNotificationInbox.java` +- `InboxUnreadCounter.java` + +```java +public interface NotificationInbox { + InboxPage list(InboxQuery query); + InboxMutationResult markSeen(InboxItemId id, InboxPrincipal principal); + InboxMutationResult markRead(InboxItemId id, InboxPrincipal principal); + InboxMutationResult archive(InboxItemId id, InboxPrincipal principal); + long unreadCount(InboxPrincipal principal); +} + +// Query ordering: created_at DESC, id DESC. +// Mark-read SQL updates only rows owned by tenant/user and only when read_at IS NULL. +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-inbox-jpa:test` + +Expected: PASS: cursor pagination, concurrent read, unread count, tenant guard가 통과한다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/NotificationInbox.java' 'modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxItem.java' 'modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxCursor.java' 'modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxItemId.java' 'modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxPrincipal.java' 'modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxPage.java' 'modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxQuery.java' 'modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxMutationResult.java' 'modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/InboxItemEntity.java' 'modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/JpaNotificationInbox.java' 'modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/InboxUnreadCounter.java' 'modules/notification/notification-inbox-jpa/src/test/java/io/backend/skeleton/notification/inbox/jpa/JpaNotificationInboxTest.java' +git commit -m "feat(notification): add durable in-app inbox" +``` + +--- + +### Task 40: Inbox Commit Event와 WebSocket Signal Integration 구현 + +**Files:** +- Create: `modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxItemCreated.java` +- Create: `modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/InboxCommitEventPublisher.java` +- Create: `modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/InboxOutboxRecordFactory.java` +- Test: `modules/notification/notification-inbox-jpa/src/test/java/io/backend/skeleton/notification/inbox/jpa/InboxSignalDurabilityTest.java` + +**Interfaces:** +- Consumes: Task 39 Inbox, existing messaging/outbox and websocket capability. +- Produces: DB commit source-of-truth, post-commit/outbox signal, WebSocket failure isolation. + +**Implementation requirements:** +- messaging Outbox가 존재하면 재사용하고 없으면 local post-commit adapter를 선택한다. +- signal payload에 message body와 contact point를 넣지 않는다. +- WebSocket signal은 unread count의 source of truth가 아니다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.inbox.jpa; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class InboxSignalDurabilityTest extends PostgreSqlNotificationTest { + @Test + void websocketFailureDoesNotRollbackInboxItem() { + var publisher = fixture().failingSignalPublisher(); + var service = inboxService(publisher); + var itemId = service.create(fixture().command()); + assertThat(inboxRepository().findById(itemId.value())).isPresent(); + assertThat(signalRetryQueue()).isNotEmpty(); + } + + @Test + void signalIsNotPublishedBeforeCommit() { + fixture().transactionThatRollsBack(() -> inboxService().create(fixture().command())); + assertThat(publishedSignals()).isEmpty(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-inbox-jpa:test --tests "*.InboxSignalDurabilityTest"` + +Expected: FAIL: commit event publisher가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `InboxItemCreated.java` +- `InboxCommitEventPublisher.java` +- `InboxOutboxRecordFactory.java` + +```java +@Transactional +public InboxItemId create(CreateInboxItem command) { + var entity = repository.save(factory.create(command)); + outboxRepository.save(outboxFactory.inboxCreated(entity)); + return new InboxItemId(entity.id()); +} + +// Relay publishes InboxItemCreated after DB commit. WebSocket adapter consumes +// the event; delivery failure retries the signal without altering inbox data. +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-inbox-jpa:test` + +Expected: PASS: Inbox write와 signal failure가 분리되고 rollback transaction은 signal을 만들지 않는다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxItemCreated.java' 'modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/InboxCommitEventPublisher.java' 'modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/InboxOutboxRecordFactory.java' 'modules/notification/notification-inbox-jpa/src/test/java/io/backend/skeleton/notification/inbox/jpa/InboxSignalDurabilityTest.java' +git commit -m "feat(notification): integrate inbox commit signals" +``` + +--- + +### Task 41: Opt-in Deduplication과 Provider Collapse Mapping 구현 + +**Files:** +- Create: `modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/DeduplicationService.java` +- Create: `modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/CollapseCapability.java` +- Test: `modules/notification/notification-policy/src/test/java/io/backend/skeleton/notification/policy/DeduplicationAndCollapseTest.java` + +**Interfaces:** +- Consumes: Task 4 DeduplicationSpec·CollapseSpec, Task 15 submit, Tasks 34·36·38 provider adapters. +- Produces: Recipient/window-scoped dedup and provider-specific collapse hints without delivery guarantee. + +**Implementation requirements:** +- Deduplication은 명시적 spec이 없으면 실행하지 않는다. +- collapse key는 provider length/character limit을 사전 검증한다. +- collapse가 이미 전달된 알림을 취소한다고 표현하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.policy; + +import org.junit.jupiter.api.Test; +import java.time.Duration; +import static org.assertj.core.api.Assertions.*; + +class DeduplicationAndCollapseTest extends PostgreSqlNotificationTest { + @Test + void dedupSuppressesSecondLogicalNotificationWithinWindow() { + var spec = new DeduplicationSpec("order-1-delay", Duration.ofMinutes(30), + DeduplicationAction.RETURN_EXISTING); + var first = service().evaluate(fixture().recipient("user-1"), spec); + var second = service().evaluate(fixture().recipient("user-1"), spec); + assertThat(second.existingNotificationId()).contains(first.notificationId()); + } + + @Test + void collapseDoesNotReportExactlyOnceOrDelivered() { + var mapped = collapseCapability().map(new CollapseSpec("feed:user-1", CollapseScope.RECIPIENT)); + assertThat(mapped.guaranteesSingleUserNotification()).isFalse(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-policy:test --tests "*.DeduplicationAndCollapseTest"` + +Expected: FAIL: dedup/collapse types와 service가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `DeduplicationService.java` +- `CollapseCapability.java` + +```java +@Transactional +public DeduplicationResult evaluate(RecipientIdentity recipient, DeduplicationSpec spec) { + var bucket = bucketClock.bucket(clock.instant(), spec.window()); + return repository.insertOrFind( + recipient.tenantId(), recipient.recipientRef(), spec.dedupKey(), bucket); +} + +public interface CollapseCapability { + ProviderCollapseMapping map(CollapseSpec spec); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-policy:test` + +Expected: PASS: dedup은 logical notification에 적용되고 collapse는 provider transport hint로만 동작한다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/DeduplicationService.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/CollapseCapability.java' 'modules/notification/notification-policy/src/test/java/io/backend/skeleton/notification/policy/DeduplicationAndCollapseTest.java' +git commit -m "feat(notification): add deduplication and collapse capabilities" +``` + +--- + +### Task 42: Webhook Notification Extension을 HTTP Client Platform 위에 구현 + +**Files:** +- Create: `modules/notification/notification-webhook-extension/src/main/java/io/backend/skeleton/notification/webhook/WebhookNotification.java` +- Create: `modules/notification/notification-webhook-extension/src/main/java/io/backend/skeleton/notification/webhook/WebhookNotificationProviderAdapter.java` +- Create: `modules/notification/notification-webhook-extension/src/main/java/io/backend/skeleton/notification/webhook/WebhookSignatureStrategy.java` +- Create: `modules/notification/notification-webhook-extension/src/main/java/io/backend/skeleton/notification/webhook/WebhookSubscription.java` +- Test: `modules/notification/notification-webhook-extension/src/test/java/io/backend/skeleton/notification/webhook/WebhookNotificationProviderAdapterTest.java` + +**Interfaces:** +- Consumes: Task 10 provider SPI and existing HTTP Client H1/H3 gateways. +- Produces: Webhook extension with trusted/dynamic target separation, request signing and HTTP execution evidence mapping. + +**Implementation requirements:** +- HTTP retry, TLS, SSRF, redirect, timeout을 새로 구현하지 않는다. +- webhook target과 signing secret을 사용자 입력으로 한 요청에서 동시에 받지 않는다. +- response body는 bounded diagnostic metadata만 보존한다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.webhook; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class WebhookNotificationProviderAdapterTest extends HttpClientFixtureBase { + @Test + void dynamicTargetNeverInheritsTrustedCredential() { + adapter().submit(fixture().dynamicSubmission()).toCompletableFuture().join(); + assertThat(recordedRequest().headers()).doesNotContainKeys("Authorization", "Cookie"); + } + + @Test + void sentNoResponseMapsToAmbiguous() { + server().acceptBodyThenReset(); + var result = adapter().submit(fixture().trustedSubmission()).toCompletableFuture().join(); + assertThat(result.confirmation()).isEqualTo(AttemptConfirmation.AMBIGUOUS); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-webhook-extension:test --tests "*.WebhookNotificationProviderAdapterTest"` + +Expected: FAIL: webhook extension이 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `WebhookNotification.java` +- `WebhookNotificationProviderAdapter.java` +- `WebhookSignatureStrategy.java` +- `WebhookSubscription.java` + +```java +public CompletionStage submit(ProviderSubmission submission) { + var webhook = mapper.map(submission); + var call = webhook.subscription().trusted() + ? trustedGateway.exchange(webhook.profileName(), webhook.operation(), RESPONSE) + : dynamicGateway.exchange(webhook.dynamicPolicy(), webhook.target(), webhook.operation(), RESPONSE); + return call.handle((result, error) -> evidenceMapper.map(result, error)); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-webhook-extension:test` + +Expected: PASS: credential isolation과 HTTP ambiguity mapping이 검증된다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-webhook-extension/src/main/java/io/backend/skeleton/notification/webhook/WebhookNotification.java' 'modules/notification/notification-webhook-extension/src/main/java/io/backend/skeleton/notification/webhook/WebhookNotificationProviderAdapter.java' 'modules/notification/notification-webhook-extension/src/main/java/io/backend/skeleton/notification/webhook/WebhookSignatureStrategy.java' 'modules/notification/notification-webhook-extension/src/main/java/io/backend/skeleton/notification/webhook/WebhookSubscription.java' 'modules/notification/notification-webhook-extension/src/test/java/io/backend/skeleton/notification/webhook/WebhookNotificationProviderAdapterTest.java' +git commit -m "feat(notification): add webhook delivery extension" +``` + +--- + +### Task 43: Notification Metrics·Tracing·Audit 구현 + +**Files:** +- Create: `modules/notification/notification-observability/src/main/java/io/backend/skeleton/notification/observation/NotificationObservationConvention.java` +- Create: `modules/notification/notification-observability/src/main/java/io/backend/skeleton/notification/observation/NotificationMetrics.java` +- Create: `modules/notification/notification-observability/src/main/java/io/backend/skeleton/notification/observation/NotificationAuditLogger.java` +- Create: `modules/notification/notification-observability/src/main/java/io/backend/skeleton/notification/observation/CardinalityGuard.java` +- Test: `modules/notification/notification-observability/src/test/java/io/backend/skeleton/notification/observation/NotificationObservabilityTest.java` + +**Interfaces:** +- Consumes: Tasks 15 submit, 21 dispatcher, 22 callback, 25 reconciliation. +- Produces: Logical/recipient/attempt observations, bounded tags, callback trace links and audited admin changes. + +**Implementation requirements:** +- templateId는 registry에 등록된 bounded ID일 때만 tag로 허용한다. +- raw provider status는 normalized bounded status로 변환한다. +- audit record에도 Contact Point 원문을 쓰지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.observation; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class NotificationObservabilityTest { + @Test + void metricTagsNeverContainHighCardinalityIdentifiers() { + var observation = fixture().dispatchObservation(); + assertThat(observation.lowCardinalityTags().keySet()) + .doesNotContain("notificationId", "recipientId", "attemptId", + "providerRequestId", "email", "phone", "token"); + } + + @Test + void callbackUsesTraceLinkInsteadOfLongRunningChildSpan() { + var trace = fixture().callbackTrace(); + assertThat(trace.links()).contains(fixture().originalDispatchContext()); + assertThat(trace.parent()).isEmpty(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-observability:test --tests "*.NotificationObservabilityTest"` + +Expected: FAIL: observation convention과 cardinality guard가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `NotificationObservationConvention.java` +- `NotificationMetrics.java` +- `NotificationAuditLogger.java` +- `CardinalityGuard.java` + +```java +public final class CardinalityGuard { + private static final java.util.Set ALLOWED_TAGS = java.util.Set.of( + "channel", "provider", "templateId", "notificationCategory", + "status", "failureCategory", "attemptBucket", "sizeBucket"); + + public void validate(java.util.Map tags) { + if (!ALLOWED_TAGS.containsAll(tags.keySet())) { + throw new IllegalMetricTagException(tags.keySet()); + } + } +} + +// NotificationMetrics exposes requested, suppressed, render, dispatch, +// accepted, delivery, retry, fallback, ambiguous, callback, reconcile and queue metrics. +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-observability:test` + +Expected: PASS: high-cardinality/PII tag가 차단되고 callback trace link가 검증된다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-observability/src/main/java/io/backend/skeleton/notification/observation/NotificationObservationConvention.java' 'modules/notification/notification-observability/src/main/java/io/backend/skeleton/notification/observation/NotificationMetrics.java' 'modules/notification/notification-observability/src/main/java/io/backend/skeleton/notification/observation/NotificationAuditLogger.java' 'modules/notification/notification-observability/src/main/java/io/backend/skeleton/notification/observation/CardinalityGuard.java' 'modules/notification/notification-observability/src/test/java/io/backend/skeleton/notification/observation/NotificationObservabilityTest.java' +git commit -m "feat(notification): add metrics tracing and audit" +``` + +--- + +### Task 44: PII·Secret Redaction과 구조 경계 ArchUnit 구현 + +**Files:** +- Create: `modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/NotificationRedactor.java` +- Create: `modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SensitiveValueClassifier.java` +- Create: `modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SafeDiagnosticContext.java` +- Test: `modules/notification/notification-security/src/test/java/io/backend/skeleton/notification/security/NotificationRedactionTest.java` +- Test: `modules/notification/notification-security/src/test/java/io/backend/skeleton/notification/security/NotificationArchitectureTest.java` + +**Interfaces:** +- Consumes: Task 7 protector, Task 43 observability. +- Produces: Central redaction, safe diagnostic context, provider SDK dependency boundary and raw secret leak tests. + +**Implementation requirements:** +- exception/logging interceptor 모두 NotificationRedactor를 사용한다. +- 일반 SHA-256 주소 fingerprint를 저장하지 않고 keyed HMAC을 사용한다. +- test output과 assertion failure에도 secret fixture 원문을 출력하지 않도록 custom representation을 사용한다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.security; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class NotificationRedactionTest { + @Test + void redactsAllContactAndCredentialKinds() { + var input = "user@example.com +821012345678 fcm-token webpush-endpoint bearer-secret"; + var redacted = redactor().redact(input, fixture().classifiedValues()); + assertThat(redacted) + .doesNotContain("user@example.com", "+821012345678", "fcm-token", + "webpush-endpoint", "bearer-secret"); + } + + @Test + void safeContextAcceptsOnlyBoundedFields() { + assertThatThrownBy(() -> SafeDiagnosticContext.builder() + .put("providerRequestId", "SM123").build()) + .isInstanceOf(UnsafeDiagnosticFieldException.class); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-security:test --tests "*.NotificationRedactionTest"` + +Expected: FAIL: redactor와 architecture rule이 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `NotificationRedactor.java` +- `SensitiveValueClassifier.java` +- `SafeDiagnosticContext.java` +- `NotificationArchitectureTest.java` + +```java +public final class SafeDiagnosticContext { + private static final java.util.Set ALLOWED = java.util.Set.of( + "channel", "provider", "operation", "result", "failureCategory", + "templateId", "attemptBucket"); + // Builder rejects keys outside ALLOWED and normalizes values to bounded enums/IDs. +} + +// ArchUnit rules: +// core/content/contact packages must not depend on Firebase, Twilio, AWS SDK, +// Jakarta Mail, JPA, MVC or WebFlux packages. +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-security:test` + +Expected: PASS: contact/credential redaction과 architecture dependency guard가 통과한다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/NotificationRedactor.java' 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SensitiveValueClassifier.java' 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SafeDiagnosticContext.java' 'modules/notification/notification-security/src/test/java/io/backend/skeleton/notification/security/NotificationRedactionTest.java' 'modules/notification/notification-security/src/test/java/io/backend/skeleton/notification/security/NotificationArchitectureTest.java' +git commit -m "test(notification): enforce PII and architecture boundaries" +``` + +--- + +### Task 45: N4 Admin Redrive·Reconcile·Suppression·Provider Control 구현 + +**Files:** +- Create: `modules/notification/notification-admin-api/src/main/java/io/backend/skeleton/notification/admin/NotificationAdminService.java` +- Create: `modules/notification/notification-admin-api/src/main/java/io/backend/skeleton/notification/admin/AdminActor.java` +- Create: `modules/notification/notification-admin-api/src/main/java/io/backend/skeleton/notification/admin/RedriveCommand.java` +- Create: `modules/notification/notification-admin-runtime/src/main/java/io/backend/skeleton/notification/admin/NotificationAdminServiceImpl.java` +- Create: `modules/notification/notification-admin-runtime/src/main/java/io/backend/skeleton/notification/admin/AdminAuthorizationGuard.java` +- Test: `modules/notification/notification-admin-runtime/src/test/java/io/backend/skeleton/notification/admin/NotificationAdminServiceTest.java` + +**Interfaces:** +- Consumes: Tasks 17 suppression, 25 reconciliation, 20 runtime, 43 audit. +- Produces: Audited N4 operations, dry-run, bounded batch, duplicate-risk approval, original identity preservation. + +**Implementation requirements:** +- Admin operation ID는 idempotent해야 한다. +- dry-run은 DB/Provider 상태를 변경하지 않는다. +- bulk redrive는 rate limit과 max batch size를 적용한다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.admin; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class NotificationAdminServiceTest extends PostgreSqlNotificationTest { + @Test + void applicationAuthorityCannotRedrive() { + assertThatThrownBy(() -> service().redrive( + fixture().redriveCommand(), fixture().applicationActor())) + .isInstanceOf(AdminAccessDeniedException.class); + } + + @Test + void redrivePreservesLogicalIdsAndCreatesNewAttempt() { + var original = insertFailedAttempt(); + var result = service().redrive(fixture().approvedRedrive(original.id()), fixture().adminActor()); + assertThat(result.notificationId()).isEqualTo(original.notificationId()); + assertThat(result.recipientDeliveryId()).isEqualTo(original.recipientDeliveryId()); + assertThat(result.newAttemptId()).isNotEqualTo(original.id()); + assertThat(auditCount(result.operationId())).isEqualTo(1); + } + + @Test + void ambiguousRedriveRequiresDuplicateRiskApproval() { + var ambiguous = insertAmbiguousAttempt(); + assertThatThrownBy(() -> service().redrive( + fixture().redriveWithoutRiskApproval(ambiguous.id()), fixture().adminActor())) + .isInstanceOf(DuplicateRiskApprovalRequiredException.class); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-admin-runtime:test --tests "*.NotificationAdminServiceTest"` + +Expected: FAIL: admin API/runtime가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `NotificationAdminService.java` +- `AdminActor.java` +- `RedriveCommand.java` +- `NotificationAdminServiceImpl.java` +- `AdminAuthorizationGuard.java` + +```java +public interface NotificationAdminService { + AdminOperationResult redrive(RedriveCommand command, AdminActor actor); + AdminOperationResult reconcile(ReconcileCommand command, AdminActor actor); + AdminOperationResult suppress(SuppressCommand command, AdminActor actor); + AdminOperationResult setProviderState(SetProviderStateCommand command, AdminActor actor); +} + +@Transactional +public AdminOperationResult redrive(RedriveCommand command, AdminActor actor) { + authorization.require(actor, NotificationAdminAuthority.REDRIVE); + var original = attempts.load(command.attemptId()); + duplicateRiskGuard.verify(original, command.approveDuplicateRisk()); + var operation = operations.begin(command.operationId(), actor, command.reason()); + var newAttempt = redriveFactory.create(original, operation.id()); + audit.recordRedrive(operation, original, newAttempt); + return result(newAttempt, operation); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-admin-runtime:test` + +Expected: PASS: 권한, identity 보존, duplicate-risk approval, audit가 검증된다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-admin-api/src/main/java/io/backend/skeleton/notification/admin/NotificationAdminService.java' 'modules/notification/notification-admin-api/src/main/java/io/backend/skeleton/notification/admin/AdminActor.java' 'modules/notification/notification-admin-api/src/main/java/io/backend/skeleton/notification/admin/RedriveCommand.java' 'modules/notification/notification-admin-runtime/src/main/java/io/backend/skeleton/notification/admin/NotificationAdminServiceImpl.java' 'modules/notification/notification-admin-runtime/src/main/java/io/backend/skeleton/notification/admin/AdminAuthorizationGuard.java' 'modules/notification/notification-admin-runtime/src/test/java/io/backend/skeleton/notification/admin/NotificationAdminServiceTest.java' +git commit -m "feat(notification): add audited notification admin plane" +``` + +--- + +### Task 46: Credential·Certificate Rotation과 Runtime Generation Drain 구현 + +**Files:** +- Create: `modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/ProviderCredentialManager.java` +- Create: `modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/CredentialGeneration.java` +- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeRotator.java` +- Create: `modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/RuntimeDrainCoordinator.java` +- Test: `modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeRotationTest.java` + +**Interfaces:** +- Consumes: Task 7 secret provider, Task 20 runtime registry, Task 45 admin control. +- Produces: Immutable runtime generation replacement, in-flight drain, attempt generation audit, rollback. + +**Implementation requirements:** +- Contact Point encryption key rotation은 별도 background re-encryption job이다. +- VAPID key rotation은 restricted subscription migration을 요구하므로 generic rotation으로 처리하지 않는다. +- credential material을 audit에 기록하지 않고 generation/key ID만 기록한다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.dispatch; + +import org.junit.jupiter.api.Test; +import java.time.Duration; +import static org.assertj.core.api.Assertions.*; + +class ProviderRuntimeRotationTest { + @Test + void newAttemptUsesNewGenerationWhileOldAttemptDrains() { + var oldPermit = registry().current("apns-main").acquireAttempt(); + rotator().rotate("apns-main", fixture().credentialGeneration(2)); + var newPermit = registry().current("apns-main").acquireAttempt(); + assertThat(oldPermit.generation()).isEqualTo(1); + assertThat(newPermit.generation()).isEqualTo(2); + oldPermit.close(); + assertThat(registry().drainingGenerations("apns-main")).isEmpty(); + } + + @Test + void failedNewCredentialKeepsOldRuntimeActive() { + assertThatThrownBy(() -> rotator().rotate( + "ses-primary", fixture().invalidCredentialGeneration(2))) + .isInstanceOf(CredentialValidationException.class); + assertThat(registry().current("ses-primary").generation()).isEqualTo(1); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-dispatch-runtime:test --tests "*.ProviderRuntimeRotationTest"` + +Expected: FAIL: credential manager/rotator가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `ProviderCredentialManager.java` +- `CredentialGeneration.java` +- `ProviderRuntimeRotator.java` +- `RuntimeDrainCoordinator.java` + +```java +public void rotate(ProviderProfileId profileId, CredentialGeneration generation) { + var candidate = runtimeFactory.create(profileId, generation); + credentialProbe.validate(candidate); + var previous = registry.swap(profileId, candidate); + previous.markDraining(); + drainCoordinator.drain(previous, properties.drainTimeout()); +} + +// If validation fails before swap, previous runtime remains current. +// If drain timeout expires, cancel/close according to provider-specific safe shutdown contract. +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-dispatch-runtime:test` + +Expected: PASS: new generation cutover, old drain, failed candidate rollback이 검증된다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/ProviderCredentialManager.java' 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/CredentialGeneration.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeRotator.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/RuntimeDrainCoordinator.java' 'modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeRotationTest.java' +git commit -m "feat(notification): add credential rotation and runtime draining" +``` + +--- + +### Task 47: Spring Boot Starter·Properties Validation·Actuator 구현 + +**Files:** +- Create: `modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationProperties.java` +- Create: `modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationCoreAutoConfiguration.java` +- Create: `modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationProviderAutoConfiguration.java` +- Create: `modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationCallbackAutoConfiguration.java` +- Create: `modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationHealthEndpoint.java` +- Create: `modules/notification/notification-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` +- Test: `modules/notification/notification-spring-boot-starter/src/test/java/io/backend/skeleton/notification/autoconfigure/NotificationAutoConfigurationTest.java` + +**Interfaces:** +- Consumes: All Core/Adapter runtime contracts from Tasks 1-46. +- Produces: Conditional beans, startup guardrails, MVC/WebFlux selection, non-sensitive health endpoints. + +**Implementation requirements:** +- Health endpoint는 provider state/generation/queue age만 노출하고 secret/address를 제외한다. +- MVC와 WebFlux callback auto-config가 동시에 endpoint를 등록하지 않는다. +- Production trust-all, unbounded queue, ambiguous fallback enable을 거부한다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.autoconfigure; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import static org.assertj.core.api.Assertions.*; + +class NotificationAutoConfigurationTest { + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(org.springframework.boot.autoconfigure.AutoConfigurations.of( + NotificationCoreAutoConfiguration.class, + NotificationProviderAutoConfiguration.class)); + + @Test + void productionWebPushWithoutVapidKeyFailsStartup() { + runner.withPropertyValues( + "notification.providers.webpush.type=WEB_PUSH", + "notification.providers.webpush.enabled=true", + "notification.providers.webpush.environment=PRODUCTION") + .run(context -> assertThat(context.getStartupFailure()) + .hasMessageContaining("VAPID")); + } + + @Test + void adminBeansAreAbsentByDefault() { + runner.run(context -> assertThat(context) + .doesNotHaveBean(NotificationAdminService.class)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-spring-boot-starter:test --tests "*.NotificationAutoConfigurationTest"` + +Expected: FAIL: starter properties와 auto-configuration이 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `NotificationProperties.java` +- `NotificationCoreAutoConfiguration.java` +- `NotificationProviderAutoConfiguration.java` +- `NotificationCallbackAutoConfiguration.java` +- `NotificationHealthEndpoint.java` +- `AutoConfiguration.imports` + +```java +@ConfigurationProperties("notification") +public record NotificationProperties( + DispatchProperties dispatch, + java.util.Map providers, + CallbackProperties callbacks, + SecurityProperties security +) { + public NotificationProperties { + validateBounded(dispatch.claimBatchSize(), 1, 1000, "claimBatchSize"); + providers.forEach((id, profile) -> profile.validate(id)); + } +} + +@AutoConfiguration +@EnableConfigurationProperties(NotificationProperties.class) +public class NotificationCoreAutoConfiguration {} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-spring-boot-starter:test` + +Expected: PASS: missing secrets/invalid bounds가 startup 실패하고 Admin은 opt-in이다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationProperties.java' 'modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationCoreAutoConfiguration.java' 'modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationProviderAutoConfiguration.java' 'modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationCallbackAutoConfiguration.java' 'modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationHealthEndpoint.java' 'modules/notification/notification-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports' 'modules/notification/notification-spring-boot-starter/src/test/java/io/backend/skeleton/notification/autoconfigure/NotificationAutoConfigurationTest.java' +git commit -m "feat(notification): add Spring Boot starter and health endpoints" +``` + +--- + +### Task 48: Reactor Facade와 Cancellation·Context 계약 구현 + +**Files:** +- Create: `modules/notification/notification-reactor/src/main/java/io/backend/skeleton/notification/reactor/ReactiveNotificationOrchestrator.java` +- Create: `modules/notification/notification-reactor/src/main/java/io/backend/skeleton/notification/reactor/ReactorNotificationOrchestrator.java` +- Create: `modules/notification/notification-reactor/src/main/java/io/backend/skeleton/notification/reactor/ReactorContextBridge.java` +- Test: `modules/notification/notification-reactor/src/test/java/io/backend/skeleton/notification/reactor/ReactorNotificationOrchestratorTest.java` + +**Interfaces:** +- Consumes: Task 5 synchronous durable API, CompletionStage core. +- Produces: Mono facade, context propagation, cancellation semantics without cancelling already committed submit. + +**Implementation requirements:** +- `.block()`을 facade 내부에서 사용하지 않는다. +- JPA blocking operation은 boundedElastic/virtual-thread adapter 경계에서 실행한다. +- submit cancellation은 이미 commit된 논리 요청을 자동 취소하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.reactor; + +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; +import static org.assertj.core.api.Assertions.*; + +class ReactorNotificationOrchestratorTest { + @Test + void propagatesCorrelationContext() { + StepVerifier.create(reactive().submit(fixture().plan()) + .contextWrite(context -> context.put("correlationId", "corr-1"))) + .assertNext(receipt -> assertThat(recordedCorrelationId()).isEqualTo("corr-1")) + .verifyComplete(); + } + + @Test + void cancellationAfterCommitDoesNotDeleteNotification() { + var disposable = reactive().submit(fixture().slowReturnAfterCommit()).subscribe(); + fixture().awaitCommit(); + disposable.dispose(); + assertThat(fixture().requestCount()).isEqualTo(1); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-reactor:test --tests "*.ReactorNotificationOrchestratorTest"` + +Expected: FAIL: Reactor facade가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `ReactiveNotificationOrchestrator.java` +- `ReactorNotificationOrchestrator.java` +- `ReactorContextBridge.java` + +```java +public interface ReactiveNotificationOrchestrator { + reactor.core.publisher.Mono submit(NotificationPlan plan); + reactor.core.publisher.Mono schedule(NotificationPlan plan, java.time.Instant at); + reactor.core.publisher.Mono get(NotificationId id); +} + +public Mono submit(NotificationPlan plan) { + return Mono.deferContextual(context -> Mono.fromCompletionStage( + contextBridge.withContext(context, () -> asyncCore.submit(plan)))); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :modules:notification:notification-reactor:test` + +Expected: PASS: Reactor context와 durable commit cancellation semantics가 검증된다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-reactor/src/main/java/io/backend/skeleton/notification/reactor/ReactiveNotificationOrchestrator.java' 'modules/notification/notification-reactor/src/main/java/io/backend/skeleton/notification/reactor/ReactorNotificationOrchestrator.java' 'modules/notification/notification-reactor/src/main/java/io/backend/skeleton/notification/reactor/ReactorContextBridge.java' 'modules/notification/notification-reactor/src/test/java/io/backend/skeleton/notification/reactor/ReactorNotificationOrchestratorTest.java' +git commit -m "feat(notification): add Reactor notification facade" +``` + +--- + +### Task 49: 공통 Provider Contract Testkit·Chaos·Security Suite 구현 + +**Files:** +- Create: `modules/notification/notification-testkit/src/main/java/io/backend/skeleton/notification/testkit/ProviderAdapterContract.java` +- Create: `modules/notification/notification-testkit/src/main/java/io/backend/skeleton/notification/testkit/ProviderFaultHarness.java` +- Create: `modules/notification/notification-testkit/src/main/java/io/backend/skeleton/notification/testkit/CallbackContract.java` +- Create: `modules/notification/notification-testkit/src/main/java/io/backend/skeleton/notification/testkit/PiiLeakScanner.java` +- Create: `modules/notification/notification-testkit/src/test/java/io/backend/skeleton/notification/testkit/CrossProviderContractSuiteTest.java` +- Create: `infra/notification/toxiproxy/docker-compose.yml` +- Test: `modules/notification/notification-testkit/src/test/java/io/backend/skeleton/notification/testkit/NotificationChaosSecurityTest.java` + +**Interfaces:** +- Consumes: Tasks 21-38 provider adapters and callback core. +- Produces: Reusable confirmed/rejected/ambiguous contract, callback duplicate/order contract, Toxiproxy fault and PII scanner. + +**Implementation requirements:** +- 각 abstract test method 본문은 fixture를 호출하고 실제 assertion을 수행한다. +- 외부 Provider sandbox 없이 PR suite가 결정적으로 실행된다. +- Toxiproxy suite는 nightly/release job에도 연결한다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.testkit; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class NotificationChaosSecurityTest { + @Test + void acceptedThenResponseLossIsAmbiguousForEveryApplicableAdapter() { + for (var fixture : ProviderFixtures.responseLossCapableAdapters()) { + var result = fixture.submitAfterServerAcceptsThenDropsResponse(); + assertThat(result.confirmation()) + .as(fixture.providerId()) + .isEqualTo(AttemptConfirmation.AMBIGUOUS); + } + } + + @Test + void logsMetricsAndTracesContainNoSensitiveFixtureValues() { + ProviderFixtures.runAllFailurePaths(); + assertThat(PiiLeakScanner.scan(capturedTelemetry(), ProviderFixtures.secrets())) + .isEmpty(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-testkit:test --tests "*.NotificationChaosSecurityTest"` + +Expected: FAIL: cross-provider contract testkit과 fault harness가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `ProviderAdapterContract.java` +- `ProviderFaultHarness.java` +- `CallbackContract.java` +- `PiiLeakScanner.java` +- `CrossProviderContractSuiteTest.java` +- `infra/notification/toxiproxy/docker-compose.yml` + +```java +public abstract class ProviderAdapterContract { + protected abstract NotificationProviderAdapter adapter(); + protected abstract ProviderFixture fixture(); + + @Test + void confirmedAcceptanceMapsOnlyToProviderAccepted() { + var result = adapter().submit(fixture().confirmedSubmission()) + .toCompletableFuture().join(); + org.assertj.core.api.Assertions.assertThat(result.confirmation()) + .isEqualTo(AttemptConfirmation.CONFIRMED); + org.assertj.core.api.Assertions.assertThat(result.evidenceLevel()) + .isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED); + org.assertj.core.api.Assertions.assertThat(result.deliveryOutcome()) + .isEqualTo(DeliveryOutcome.UNKNOWN); + } + + @Test + void explicitRejectionIsNotAmbiguous() { + var result = adapter().submit(fixture().rejectedSubmission()) + .toCompletableFuture().join(); + org.assertj.core.api.Assertions.assertThat(result.confirmation()) + .isEqualTo(AttemptConfirmation.REJECTED); + org.assertj.core.api.Assertions.assertThat(result.submissionOutcome()) + .isEqualTo(SubmissionOutcome.CONFIRMED_REJECTED); + } + + @Test + void responseLossAfterCommitIsAmbiguous() { + var result = adapter().submit(fixture().acceptedThenResponseLostSubmission()) + .toCompletableFuture().join(); + org.assertj.core.api.Assertions.assertThat(result.confirmation()) + .isEqualTo(AttemptConfirmation.AMBIGUOUS); + org.assertj.core.api.Assertions.assertThat(result.submissionOutcome()) + .isEqualTo(SubmissionOutcome.AMBIGUOUS); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew notificationContractTest notificationChaosTest notificationSecurityTest` + +Expected: PASS: Stable Adapter 공통 contract, ambiguity fault, PII leak scan이 통과한다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-testkit/src/main/java/io/backend/skeleton/notification/testkit/ProviderAdapterContract.java' 'modules/notification/notification-testkit/src/main/java/io/backend/skeleton/notification/testkit/ProviderFaultHarness.java' 'modules/notification/notification-testkit/src/main/java/io/backend/skeleton/notification/testkit/CallbackContract.java' 'modules/notification/notification-testkit/src/main/java/io/backend/skeleton/notification/testkit/PiiLeakScanner.java' 'modules/notification/notification-testkit/src/test/java/io/backend/skeleton/notification/testkit/CrossProviderContractSuiteTest.java' 'infra/notification/toxiproxy/docker-compose.yml' 'modules/notification/notification-testkit/src/test/java/io/backend/skeleton/notification/testkit/NotificationChaosSecurityTest.java' +git commit -m "test(notification): add cross-provider chaos and security gates" +``` + +--- + +### Task 50: 성능 인증·호환성 Matrix·문서·Release Gate 완성 + +**Files:** +- Create: `modules/notification/notification-testkit/src/jmh/java/io/backend/skeleton/notification/testkit/NotificationFanoutBenchmark.java` +- Create: `modules/notification/notification-testkit/src/performanceTest/java/io/backend/skeleton/notification/testkit/NotificationPerformanceCertificationTest.java` +- Create: `.github/workflows/notification-platform.yml` +- Create: `docs/notification/support-matrix.md` +- Create: `docs/notification/configuration-reference.md` +- Create: `docs/notification/delivery-evidence.md` +- Create: `docs/notification/callback-reconciliation.md` +- Create: `docs/notification/provider-runbooks.md` +- Create: `docs/notification/security-privacy.md` +- Create: `docs/notification/operations.md` +- Create: `docs/notification/migration-guide.md` +- Create: `docs/notification/adr/NOTIF-ADR-001-durable-acceptance.md` +- Create: `docs/notification/adr/NOTIF-ADR-002-event-ledger-projection.md` +- Create: `docs/notification/adr/NOTIF-ADR-003-ambiguous-submission.md` +- Create: `docs/notification/adr/NOTIF-ADR-004-fcm-fid-primary.md` +- Test: `modules/notification/notification-testkit/src/test/java/io/backend/skeleton/notification/testkit/NotificationReleaseGateTest.java` + +**Interfaces:** +- Consumes: Tasks 1-49의 모든 코드·테스트·설정. +- Produces: PR/nightly/release workflow, Spring compatibility, bounded resource certification, support docs and ADRs. + +**Implementation requirements:** +- 성능 threshold는 측정 후 versioned certification profile에 숫자로 고정한다. +- Support Matrix는 channel별 최대 evidence와 비지원 보장을 명시한다. +- Release workflow는 실제 Provider sandbox smoke test를 secret-protected optional gate로 분리한다. + +- [ ] **Step 1: Write the failing test** + +```java +package io.backend.skeleton.notification.testkit; + +import org.junit.jupiter.api.Test; +import java.nio.file.*; +import static org.assertj.core.api.Assertions.*; + +class NotificationReleaseGateTest { + @Test + void requiredDocumentationAndAdrsExist() { + assertThat(Path.of("docs/notification/delivery-evidence.md")).exists(); + assertThat(Path.of("docs/notification/callback-reconciliation.md")).exists(); + assertThat(Path.of("docs/notification/adr/NOTIF-ADR-003-ambiguous-submission.md")).exists(); + } + + @Test + void supportMatrixDoesNotClaimGuaranteedDelivery() throws Exception { + var text = Files.readString(Path.of("docs/notification/support-matrix.md")); + assertThat(text).doesNotContain("exactly once notification", "guaranteed read"); + assertThat(text).contains("PROVIDER_ACCEPTED", "AMBIGUOUS", "FCM_FID"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :modules:notification:notification-testkit:test --tests "*.NotificationReleaseGateTest"` + +Expected: FAIL: performance suite, workflow, docs and ADRs가 없다. + +- [ ] **Step 3: Write the minimal implementation** + +Files to implement: +- `NotificationFanoutBenchmark.java` +- `NotificationPerformanceCertificationTest.java` +- `.github/workflows/notification-platform.yml` +- `docs/notification/support-matrix.md` +- `docs/notification/configuration-reference.md` +- `docs/notification/delivery-evidence.md` +- `docs/notification/callback-reconciliation.md` +- `docs/notification/provider-runbooks.md` +- `docs/notification/security-privacy.md` +- `docs/notification/operations.md` +- `docs/notification/migration-guide.md` +- `docs/notification/adr/NOTIF-ADR-001-durable-acceptance.md` +- `docs/notification/adr/NOTIF-ADR-002-event-ledger-projection.md` +- `docs/notification/adr/NOTIF-ADR-003-ambiguous-submission.md` +- `docs/notification/adr/NOTIF-ADR-004-fcm-fid-primary.md` + +```java +// CI jobs in notification-platform.yml +// pr: compile, unit, ArchUnit, PostgreSQL contract, provider fixtures, PII scan +// compatibility: Spring 6.2 latest patch and Spring 7.0 latest patch +// nightly: Toxiproxy ambiguity, process-kill recovery, callback burst +// release: performance certification, credential rotation, full support matrix + +@Test +void scheduledBurstStaysWithinResourceBounds() { + var result = harness().runScheduledBurst(100_000); + assertThat(result.maxHeapBytes()).isLessThan(properties().heapBudgetBytes()); + assertThat(result.maxDbLockWait()).isLessThan(properties().maxDbLockWait()); + assertThat(result.retryAmplification()).isLessThanOrEqualTo(properties().maxRetryAmplification()); + assertThat(result.maxQueueAge()).isLessThan(properties().maxQueueAge()); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew clean notificationContractTest notificationChaosTest notificationSecurityTest notificationPerformanceTest notificationCompatibilityTest` + +Expected: PASS: 전체 build와 contract/chaos/security/performance/compatibility gate가 0 failure로 종료된다. + +- [ ] **Step 5: Commit** + +```bash +git add 'modules/notification/notification-testkit/src/jmh/java/io/backend/skeleton/notification/testkit/NotificationFanoutBenchmark.java' 'modules/notification/notification-testkit/src/performanceTest/java/io/backend/skeleton/notification/testkit/NotificationPerformanceCertificationTest.java' '.github/workflows/notification-platform.yml' 'docs/notification/support-matrix.md' 'docs/notification/configuration-reference.md' 'docs/notification/delivery-evidence.md' 'docs/notification/callback-reconciliation.md' 'docs/notification/provider-runbooks.md' 'docs/notification/security-privacy.md' 'docs/notification/operations.md' 'docs/notification/migration-guide.md' 'docs/notification/adr/NOTIF-ADR-001-durable-acceptance.md' 'docs/notification/adr/NOTIF-ADR-002-event-ledger-projection.md' 'docs/notification/adr/NOTIF-ADR-003-ambiguous-submission.md' 'docs/notification/adr/NOTIF-ADR-004-fcm-fid-primary.md' 'modules/notification/notification-testkit/src/test/java/io/backend/skeleton/notification/testkit/NotificationReleaseGateTest.java' +git commit -m "docs(notification): complete release gates and runbooks" +``` + +--- + +## 4. Final execution order and review gates + +```text +Tasks 1-10 Core public contracts and provider SPI +Tasks 11-20 Error, persistence, submission, scheduler and policy runtime +Tasks 21-30 Dispatch, callback, reconciliation, attachments, Email and SMS foundation +Tasks 31-40 Twilio, Mobile Push, Web Push and In-App Inbox +Tasks 41-48 Dedup/collapse, Webhook, observability, security, Admin, rotation, starter and Reactor +Tasks 49-50 Cross-provider verification, performance, compatibility and release documentation +``` + +Every task requires two review gates before moving forward: + +1. **Specification review:** public signatures, evidence semantics, persistence and failure behavior match the design. +2. **Quality review:** tests prove red/green behavior, no PII leaks, resource lifecycle and module boundaries are correct. + +Do not weaken an earlier contract while executing subsequent tasks. When an Adapter cannot prove a common evidence level, keep the lower evidence and expose the difference through ProviderCapabilities. diff --git a/notification-superpowers-package/docs/superpowers/specs/2026-08-10-notification-platform-design.md b/notification-superpowers-package/docs/superpowers/specs/2026-08-10-notification-platform-design.md new file mode 100644 index 00000000..4637cab6 --- /dev/null +++ b/notification-superpowers-package/docs/superpowers/specs/2026-08-10-notification-platform-design.md @@ -0,0 +1,4770 @@ +# Notification Delivery Platform 설계서 + +> 상태: 구현 기준 설계(Implementation-ready) +> +> 기준일: 2026-08-10 +> +> 요구사항 원본: `Notification 전달 플랫폼 심층 리서치` +> +> 목표 저장 위치: `docs/superpowers/specs/2026-08-10-notification-platform-design.md` + +--- + +## 1. 문서 목적 + +이 문서는 Backend Skeleton의 `notification` 기술 모듈을 구현하기 위한 최종 설계 계약이다. 구현자는 이 문서와 대응 구현 계획서만으로 다음 내용을 다시 선택하거나 질문하지 않아야 한다. + +- Notification Core의 책임과 비지원 범위 +- 공개 API 계층 N1~N4 +- 논리 알림, 수신자 전달, 물리 시도의 식별자와 수명주기 +- Provider 수락, 전달, 표시, 읽음의 증거 모델 +- Contact Point, Template, Suppression, Preference, Consent 계약 +- Scheduling, Retry, Fallback, Callback, Reconciliation의 실행 순서 +- SMTP, SES, Twilio, FCM, APNs, Web Push, In-App Adapter의 지원 수준 +- 데이터베이스 스키마와 트랜잭션 경계 +- 보안, 개인정보, 관측성, 관리자 기능 +- 계약·장애·보안·성능 테스트와 출시 조건 + +Notification Platform은 단순한 `send()` Wrapper가 아니다. 다음 전체 수명주기를 소유한다. + +```text +알림 의도 접수 +→ 논리 요청의 내구성 있는 저장 +→ 수신자별 전달 작업 분해 +→ 실제 Contact Point와 채널 결정 +→ 템플릿 렌더링 +→ 억제·만료·할당량 재검사 +→ Provider 물리 시도 +→ 수락·거절·모호한 완료 기록 +→ 비동기 Callback·Receipt 수집 +→ 상태·증거 Projection +→ Retry·Fallback·Reconciliation·Redrive +→ 감사·Metric·Trace +``` + +핵심 완료 조건은 “발송 API를 호출할 수 있다”가 아니다. + +> 누구에게 어떤 알림을 어떤 채널과 Provider로 몇 번 시도했는지, 각 시도에서 어디까지 확실히 확인됐는지, 무엇이 아직 불명확한지를 영속적으로 설명할 수 있어야 한다. + +--- + +## 2. 목표와 성공 기준 + +### 2.1 목표 + +1. 애플리케이션에 Email, SMS, Mobile Push, Web Push의 Typed API를 제공한다. +2. 하나의 논리 알림을 수신자와 채널별 전달 작업으로 안전하게 분해한다. +3. Provider의 동기 응답과 비동기 Callback을 동일한 증거 원장으로 결합한다. +4. 중복 API 요청, Provider 응답 유실, Callback 중복·역순·누락을 명시적으로 처리한다. +5. Provider SDK 객체와 상태 문자열을 Core 공개 계약에 노출하지 않는다. +6. Contact Point와 메시지 내용 등 민감정보가 로그·Metric·Trace에 노출되지 않게 한다. +7. 신규 Provider Adapter가 Core 계약을 바꾸지 않고 추가되게 한다. +8. 각 Stable Adapter가 동일한 공통 Contract Suite를 통과하게 한다. + +### 2.2 성공 기준 + +- `submit()` 성공은 최종 전달이 아니라 **논리 요청의 내구성 있는 접수**만 뜻한다. +- SES `MessageId`, Twilio `accepted`, FCM handoff, APNs HTTP 성공, Web Push `201`을 `DELIVERED`로 승격하는 코드가 없다. +- `AMBIGUOUS` 시도에서 기본 자동 재전송·교차 채널 Fallback이 발생하지 않는다. +- 동일 `(tenantId, idempotencyKey)`의 동시 요청이 하나의 `notificationId`로 수렴한다. +- 같은 idempotency key에 다른 fingerprint가 들어오면 명시적 충돌을 반환한다. +- Provider Callback이 중복 또는 역순으로 도착해도 Projection이 downgrade되지 않는다. +- Callback이 누락된 Provider는 capability가 있을 때 Reconciliation으로 보정한다. +- 예약 알림은 프로세스 재시작 후에도 누락되지 않고, 실제 dispatch 직전에 suppression과 expiry를 다시 검사한다. +- Contact Point 원문은 암호화 저장되고 equality lookup은 HMAC fingerprint로 수행한다. +- 모든 Retry는 오류 종류, 제출 증거, 남은 만료 시간, 예산, Contact Point 유효성을 함께 판정한다. +- Provider 인증 실패가 개별 알림 Retry 폭풍으로 증폭되지 않는다. +- In-App Inbox의 DB가 source of truth이며 WebSocket은 갱신 신호일 뿐이다. + +--- + +## 3. 입력 자료의 제약과 구현 가정 + +### 3.1 요구사항 원본이 확정한 사항 + +다음은 조사 결과에서 직접 확정된 설계 요구사항이다. + +- `NotificationRequest → RecipientDelivery → DeliveryAttempt → ProviderEvent → Evidence` 구조 +- 단일 선형 상태 머신 대신 append-only event ledger와 channel-specific projector +- Email SMTP·SES, SMS Twilio, Mobile Push FCM·APNs, 표준 Web Push를 Stable 대상으로 설정 +- In-App Inbox는 선택 Stable 별도 모듈 +- Webhook은 기존 `httpclient`를 사용하는 Extension +- FCM target은 FID 우선, registration token은 legacy compatibility +- N1 Typed, N2 Advanced, N3 Provider Extension, N4 Admin Plane +- Idempotency, Deduplication, Collapse의 분리 +- Stable 기본 Routing은 Explicit Channel과 제한형 Ordered Fallback +- `AMBIGUOUS_SUBMISSION`에서 기본 Retry·Fallback 금지 +- Core-owned durable scheduler +- Callback signature 검증, 중복 제거, 역순 병합, Reconciliation +- Contact Point 암호화·HMAC lookup과 생명주기 +- Provider acceptance와 delivery/read 증거 분리 +- 개인정보와 secret의 로그·Metric 비노출 + +### 3.2 실제 저장소가 제공되지 않아 명시적으로 고정한 구현 가정 + +다음은 연구 원본에 없는 저장소·프레임워크 결정이다. 실제 Backend Skeleton에 적용할 때 패키지 경로와 dependency catalog만 매핑하고 공개 계약과 의미론은 유지한다. + +| 항목 | 고정값 | +|---|---| +| Java | Java 21 | +| Build | Gradle Kotlin DSL | +| Root package | `io.backend.skeleton.notification` | +| Module root | `modules/notification` | +| Spring 공통 컴파일 기준 | Spring Framework 6.2 | +| Spring 호환성 검증 | Spring Framework 7.0 별도 CI job | +| Spring Boot | Host 저장소의 dependency management 사용 | +| Core async type | `CompletionStage` | +| Reactive facade | 별도 `notification-reactor` 모듈 | +| Metadata DB | PostgreSQL 16 | +| Persistence | JPA + Flyway | +| Dispatch queue | PostgreSQL SKIP LOCKED 기반 durable queue | +| 외부 HTTP | 기존 `httpclient` 플랫폼 재사용 | +| SMTP | Spring `JavaMailSender` 기반 Adapter | +| Template engine | Engine-neutral API + Thymeleaf reference implementation | +| Variable validation | JSON Schema 2020-12 | +| Contact Point 보호 | AES-256-GCM encryption + HMAC-SHA-256 lookup fingerprint | +| Secret 공급 | `SecretMaterialProvider` Port | +| 관측성 | Micrometer Observation + OpenTelemetry exporter adapter | +| Test | JUnit 5, AssertJ, ArchUnit, Testcontainers, WireMock, Toxiproxy | + +### 3.3 적용 원칙 + +- 실제 저장소가 이미 사용하는 공통 ID, clock, transaction helper가 있으면 파일 경로만 조정한다. +- 이미 존재하는 `httpclient`, `fileserver`, `objectstorage`, `messaging`, `websocket` 공개 계약을 재구현하지 않는다. +- 상기 가정을 변경하더라도 Notification의 상태·증거·중복·보안 계약은 변경하지 않는다. + +--- + +## 4. 범위 + +### 4.1 Core 포함 범위 + +```text +Typed channel API +Notification orchestration +Request / Recipient / Attempt lifecycle +Contact Point registry +Template / Localization +Idempotency +Opt-in deduplication +Collapse mapping +Durable scheduling +Expiration +Suppression primitive +Preference / Consent record primitive +Provider dispatch +Retry / rate limit / backpressure +Callback / Receipt ingestion +Reconciliation +Event ledger / projection +Metrics / tracing / audit +Admin redrive / reconcile / provider control +``` + +### 4.2 채널별 Stable 범위 + +| 채널 | 기준 구현 | 등급 | Core가 기본적으로 인정하는 최대 증거 | +|---|---|---:|---| +| Email | SMTP + Amazon SES API | Stable | Provider 수락, 제공되는 경우 recipient mail server 전달·bounce·complaint | +| SMS | Twilio Programmable Messaging | Stable | accepted/queued, sent, carrier DLR 기반 delivered/undelivered | +| FCM | FID 우선 + legacy token compatibility | Stable | FCM handoff/acceptance와 명시적 실패 | +| APNs | HTTP/2 Provider API | Stable | APNs acceptance | +| Web Push | RFC 8030/8291/8292 | Stable | Push service acceptance, capability가 있을 때 receipt | +| In-App | 자체 DB | 선택 Stable | Persisted, Seen, Read | +| Webhook | 기존 httpclient 재사용 | Extension | 상대 HTTP 계약에 따름 | + +### 4.3 Experimental 범위 + +```text +Provider failover after ambiguous attempt +Parallel first-success +Provider-native scheduling +FCM topic/condition의 범용 N2 노출 +APNs broadcast/live activity channel +Application-generated push delivery receipt +Web Push provider-specific receipt +Kakao 알림톡, WhatsApp, RCS, MMS, Slack, Teams, Discord Adapter +``` + +### 4.4 명시적 비지원 + +```text +업무 대상자 선정 +캠페인 segmentation +무료·유료 회원별 채널 정책 +국가별 법률 판정 +Guaranteed delivery +Guaranteed read +Exactly-once human notification +Provider SDK raw type의 일반 공개 +파일 내용 저장 +브라우저 WebSocket 연결 자체 +시스템 간 일반 Messaging +Notification Core 자체 HTTP stack +Provider 응답이 없는 상태의 무조건 자동 failover +``` + +--- + +## 5. 핵심 설계 원칙 + +1. **내구성 우선:** Provider 호출 전에 논리 요청과 수신자 전달을 DB에 커밋한다. +2. **증거 우선:** 상태 이름보다 어떤 증거를 확보했는지를 저장한다. +3. **모호성 은폐 금지:** 결과를 알 수 없으면 `AMBIGUOUS`로 기록한다. +4. **채널 차이 보존:** 공통 골격은 통일하지만 Provider 의미를 거짓으로 평탄화하지 않는다. +5. **Callback 원장:** Provider Event는 append-only로 먼저 저장한 뒤 Projection한다. +6. **중복 정상화:** API retry, Provider retry, Callback 중복, 운영 Redrive를 정상 failure mode로 취급한다. +7. **안전한 기본값:** ambiguous retry, ambiguous fallback, provider-native schedule, parallel first-success는 기본 비활성이다. +8. **정책 주입:** 업무·법률 판단은 Port로 주입하고 Core는 결과를 실행·기록한다. +9. **민감정보 최소화:** 주소·token·본문을 식별자·로그·Metric label로 사용하지 않는다. +10. **운영자 분리:** Redrive, 강제 억제 해제, Provider disable, credential rotation은 N4 전용이다. + +--- + +## 6. 전체 아키텍처 + +```text +Application + │ + ├─ N1 Typed API + │ ├─ EmailNotifier + │ ├─ SmsNotifier + │ ├─ MobilePushNotifier + │ └─ WebPushNotifier + │ + └─ N2 NotificationOrchestrator + ├─ submit + ├─ schedule + ├─ cancel + └─ getSnapshot + │ + ▼ +┌─────────────────────────────────────────────┐ +│ Notification Core │ +│ │ +│ NotificationRequest │ +│ └─ RecipientDelivery │ +│ └─ DeliveryAttempt │ +│ │ +│ Template / ContactPoint / Suppression │ +│ Idempotency / Scheduling / Routing │ +│ Retry / RateLimit / Backpressure │ +└─────────────────────┬───────────────────────┘ + │ + ▼ + Provider Adapter SPI + ┌──────────────┼──────────────┐ + ▼ ▼ ▼ + SMTP / SES Twilio FCM / APNs + └──────────────┼──────────────┘ + ▼ + ProviderEvent Ledger + │ + ▼ + Channel Projectors + │ + ▼ + Submission / Delivery / Evidence +``` + +### 6.1 Submit 흐름 + +```text +1. API 입력 검증 +2. tenant + idempotency key 조회 +3. request fingerprint 비교 +4. template version과 recipient/contact reference 고정 +5. NotificationRequest 생성 +6. RecipientDelivery 생성 +7. scheduleAt에 따라 SCHEDULED 또는 READY_TO_DISPATCH +8. 동일 DB transaction commit +9. NotificationReceipt 반환 +``` + +`NotificationReceipt`는 Provider 결과를 포함하지 않는다. + +### 6.2 Dispatch 흐름 + +```text +1. Durable queue lease 획득 +2. expiresAt 검사 +3. Contact Point 상태 재조회 +4. Suppression·Eligibility 재검사 +5. Template version 고정 상태 확인 또는 Rendered snapshot 조회 +6. Routing Plan에서 다음 Route 선택 +7. Provider Profile health / rate / concurrency permit 획득 +8. DeliveryAttempt를 DISPATCHING으로 생성 +9. Provider Adapter 호출 +10. CONFIRMED / REJECTED / AMBIGUOUS 기록 +11. Retry / Fallback / Reconciliation 정책 판정 +12. Recipient와 Notification Projection 갱신 +13. lease 해제 +``` + +### 6.3 Callback 흐름 + +```text +1. Content-Type·크기 검증 +2. Provider signature 검증 +3. 원본 event를 bounded encrypted form으로 append +4. provider event ID 또는 fingerprint로 dedup +5. providerRequestId를 DeliveryAttempt에 매핑 +6. Provider-native status를 stable event로 normalize +7. Channel projector 실행 +8. Contact Point invalidation·suppression side effect 적용 +9. Metric·Audit·내부 event 발행 +10. 빠른 2xx 응답 +``` + +### 6.4 Reconciliation 흐름 + +```text +1. Provider별 reconcile 가능한 오래된 Attempt 조회 +2. Provider Profile별 rate limit 적용 +3. Provider status query +4. synthetic ProviderEvent를 원장에 append +5. 동일 projector 실행 +6. 변경된 projection과 correction audit 기록 +``` + +--- + +## 7. 모듈 구조 + +```text +modules/notification/ +├── notification-core-api +├── notification-content-api +├── notification-template-api +├── notification-template-thymeleaf +├── notification-contact-api +├── notification-policy +├── notification-provider-spi +├── notification-persistence-jpa +├── notification-dispatch-runtime +├── notification-callback-api +├── notification-callback-mvc +├── notification-callback-webflux +├── notification-email-api +├── notification-email-smtp +├── notification-email-ses +├── notification-sms-api +├── notification-sms-twilio +├── notification-push-api +├── notification-push-fcm +├── notification-push-apns +├── notification-webpush +├── notification-inbox-api +├── notification-inbox-jpa +├── notification-webhook-extension +├── notification-observability +├── notification-security +├── notification-admin-api +├── notification-admin-runtime +├── notification-reactor +├── notification-spring-boot-starter +└── notification-testkit +``` + +### 7.1 의존 방향 + +```text +*-api + ↑ +provider-spi / policy + ↑ +persistence-jpa / dispatch-runtime / adapter implementations + ↑ +starter / application +``` + +금지 의존성: + +```text +core-api → Spring MVC / WebFlux / JPA / Provider SDK +content-api → Provider SDK +provider-spi → concrete Provider SDK +email-api → SES SDK +push-api → Firebase/APNs client type +``` + +### 7.2 ArchUnit 규칙 + +- `..core..`, `..content..`, `..contact..`에서 `com.google.firebase`, `com.twilio`, `software.amazon.awssdk`, `jakarta.mail.internet.MimeMessage`를 참조하면 실패한다. +- N1 API package가 `Map`를 공개 request type으로 사용하면 실패한다. +- Adapter 구현이 다른 Adapter 구현에 직접 의존하면 실패한다. +- N4 Admin package를 일반 starter가 기본 공개하지 않는다. + +--- + +## 8. 공개 API 계층 + +### 8.1 N1 Typed API + +```java +public interface EmailNotifier { + NotificationReceipt send(EmailNotification notification); +} + +public interface SmsNotifier { + NotificationReceipt send(SmsNotification notification); + SmsEstimate estimate(SmsNotification notification); +} + +public interface MobilePushNotifier { + NotificationReceipt send(MobilePushNotification notification); +} + +public interface WebPushNotifier { + NotificationReceipt send(WebPushNotification notification); +} +``` + +N1 규칙: + +- 명시적 단일 채널만 사용한다. +- durable accept까지 동기적으로 완료한다. +- Provider 호출 완료를 기다리지 않는다. +- Provider SDK type과 임의 provider option map을 받지 않는다. + +### 8.2 N2 Advanced API + +```java +public interface NotificationOrchestrator { + NotificationReceipt submit(NotificationPlan plan); + NotificationReceipt schedule(NotificationPlan plan, Instant scheduleAt); + CancelResult cancel(NotificationId notificationId, CancelCommand command); + NotificationSnapshot get(NotificationId notificationId); +} +``` + +N2에서 허용하는 기능: + +```text +Ordered fallback +Multi-recipient +Best-effort multi-channel +Batch submission +Scheduling +Cancel +Opt-in deduplication +Collapse hint +``` + +### 8.3 N3 Provider Extension + +```java +public interface ProviderExtension { + ProviderId providerId(); + Optional capability(Class type); +} +``` + +N3 예: + +```text +SES Configuration Set +Twilio Messaging Service SID +FCM Topic / Condition +APNs Push Type / Collapse ID +Web Push Urgency / Topic +``` + +N3도 다음 공통 정책을 우회하지 못한다. + +```text +Contact Point 보호 +Provider Profile 고정 +Credential 관리 +Expiry +Payload 제한 +Observation +Audit +``` + +### 8.4 N4 Admin Plane + +```java +public interface NotificationAdminService { + AdminOperationResult redrive(RedriveCommand command, AdminActor actor); + AdminOperationResult reconcile(ReconcileCommand command, AdminActor actor); + AdminOperationResult suppress(SuppressCommand command, AdminActor actor); + AdminOperationResult setProviderState(SetProviderStateCommand command, AdminActor actor); +} +``` + +모든 N4 operation은 다음을 요구한다. + +```text +별도 authority +사유 코드 +dry-run 지원 가능 여부 +bounded batch +감사 로그 +idempotent operation ID +``` + +--- + +## 9. 핵심 식별자와 공통 타입 + +```java +public record NotificationId(UUID value) {} +public record RecipientDeliveryId(UUID value) {} +public record DeliveryAttemptId(UUID value) {} +public record ProviderEventId(String value) {} +public record ContactPointId(UUID value) {} +public record TemplateId(String value) {} +public record TemplateVersion(long value) {} +public record ProviderProfileId(String value) {} +public record CorrelationId(String value) {} +public record TenantId(String value) {} +public record IdempotencyKey(String value) {} +``` + +규칙: + +- 내부 UUID는 UUIDv7을 사용한다. +- Provider ID를 내부 ID로 사용하지 않는다. +- Contact Point 원문을 ID로 사용하지 않는다. +- `providerRequestId`가 없는 Attempt도 허용한다. +- `ProviderEventId`가 없는 Callback은 deterministic fingerprint로 대체한다. +- Metric label에는 위 고카디널리티 ID를 넣지 않는다. + +--- + +## 10. Content 타입 + +```java +public sealed interface NotificationContent + permits EmailContent, SmsContent, MobilePushContent, + WebPushContent, InAppContent { +} + +public record EmailContent( + String subject, + String textBody, + Optional htmlBody, + List attachments, + EmailOptions options +) implements NotificationContent {} + +public record SmsContent( + String text, + SmsOptions options +) implements NotificationContent {} + +public record MobilePushContent( + String title, + String body, + Optional deepLink, + Map data, + PushPresentation presentation +) implements NotificationContent {} + +public record WebPushContent( + String title, + String body, + Optional deepLink, + Map data, + WebPushOptions options +) implements NotificationContent {} + +public record InAppContent( + String title, + String body, + Optional deepLink, + List actions, + String category +) implements NotificationContent {} +``` + +공통 금지: + +```text +provider SDK object +arbitrary Map options +raw credential +unbounded header +binary attachment bytes +``` + + +--- + +## 11. Notification Plan과 Routing 요청 + +```java +public record NotificationPlan( + TenantId tenantId, + IdempotencyKey idempotencyKey, + String category, + TemplateSelection template, + List recipients, + DeliveryStrategy deliveryStrategy, + Optional notBefore, + Optional expiresAt, + Optional deduplication, + Optional collapse, + CorrelationId correlationId, + Map boundedMetadata +) {} +``` + +### 11.1 RecipientSpec + +```java +public record RecipientSpec( + String recipientRef, + Optional locale, + Optional timeZone, + List contactPoints, + Optional channelOverride +) {} +``` + +`recipientRef`는 애플리케이션의 stable reference지만 Contact Point 원문이 아니다. Notification Platform은 이 reference를 Metric label로 사용하지 않는다. + +### 11.2 DeliveryStrategy + +```java +public sealed interface DeliveryStrategy + permits ExplicitChannel, OrderedFallback, + ParallelMultiChannel, AllRequired, BestEffort { +} + +public record ExplicitChannel(Channel channel) implements DeliveryStrategy {} + +public record OrderedFallback(List channels) + implements DeliveryStrategy {} +``` + +Stable 기본: + +```text +ExplicitChannel +OrderedFallback +``` + +Experimental: + +```text +ParallelMultiChannel +FirstSuccess +Provider failover after ambiguous submission +``` + +### 11.3 Metadata 제한 + +- key는 등록된 allowlist에 포함되어야 한다. +- 최대 key 개수 16개, key 64 bytes, value 256 bytes를 기본 hard limit으로 둔다. +- Email, phone, token, 사용자 이름, credential을 metadata에 넣지 않는다. +- Header·Metric·Trace로 자동 전파하지 않는다. + +--- + +## 12. 영속 데이터 모델 + +### 12.1 NotificationRequest + +| 필드 | 타입 | 제약 | +|---|---|---| +| `id` | UUIDv7 | PK | +| `tenant_id` | varchar(100) | 필수 | +| `idempotency_key` | varchar(200) | tenant와 unique | +| `request_fingerprint` | char(64) | SHA-256 canonical fingerprint | +| `category` | varchar(120) | bounded registry | +| `template_id` | varchar(160) | 필수 | +| `template_version` | bigint | immutable | +| `strategy_type` | varchar(40) | 필수 | +| `schedule_at` | timestamptz | nullable | +| `not_before` | timestamptz | nullable | +| `expires_at` | timestamptz | nullable | +| `request_status` | varchar(40) | projection | +| `correlation_id` | varchar(160) | nullable | +| `metadata_json` | jsonb | bounded, PII 금지 | +| `created_at` | timestamptz | 필수 | +| `updated_at` | timestamptz | 필수 | +| `version` | bigint | optimistic lock | + +Unique: + +```sql +UNIQUE (tenant_id, idempotency_key) +``` + +### 12.2 RecipientDelivery + +| 필드 | 타입 | 제약 | +|---|---|---| +| `id` | UUIDv7 | PK | +| `notification_id` | UUID | FK | +| `recipient_ref` | varchar(200) | 암호화 또는 pseudonymous reference | +| `locale` | varchar(35) | nullable | +| `time_zone` | varchar(80) | nullable | +| `routing_plan_json` | jsonb | immutable snapshot | +| `route_cursor` | integer | 현재 route | +| `delivery_state` | varchar(40) | projection | +| `submission_outcome` | varchar(40) | projection | +| `delivery_outcome` | varchar(40) | projection | +| `evidence_level` | varchar(50) | projection | +| `ambiguous_attempt_exists` | boolean | 필수 | +| `duplicate_risk` | boolean | 필수 | +| `next_dispatch_at` | timestamptz | queue index | +| `lease_owner` | varchar(120) | nullable | +| `lease_until` | timestamptz | nullable | +| `attempt_count` | integer | 필수 | +| `last_failure_category` | varchar(50) | nullable | +| `created_at` | timestamptz | 필수 | +| `updated_at` | timestamptz | 필수 | +| `version` | bigint | optimistic lock | + +Indexes: + +```sql +CREATE INDEX ix_recipient_dispatch +ON notification_recipient_delivery(next_dispatch_at, id) +WHERE delivery_state IN ('PENDING', 'READY_TO_DISPATCH', 'RETRY_WAITING'); + +CREATE INDEX ix_recipient_notification +ON notification_recipient_delivery(notification_id, id); +``` + +### 12.3 DeliveryAttempt + +| 필드 | 타입 | 제약 | +|---|---|---| +| `id` | UUIDv7 | PK | +| `recipient_delivery_id` | UUID | FK | +| `attempt_no` | integer | recipient와 unique | +| `channel` | varchar(40) | 필수 | +| `provider_profile_id` | varchar(120) | 필수 | +| `provider_request_id` | varchar(300) | nullable, encrypted/hash index 정책 | +| `request_started` | boolean | 필수 | +| `request_body_committed` | boolean | 필수 | +| `provider_response_received` | boolean | 필수 | +| `submission_outcome` | varchar(40) | 필수 | +| `delivery_outcome` | varchar(40) | 필수 | +| `confirmation` | varchar(30) | CONFIRMED/REJECTED/AMBIGUOUS | +| `evidence_level` | varchar(50) | 필수 | +| `failure_category` | varchar(50) | nullable | +| `failure_code` | varchar(120) | nullable | +| `native_status` | varchar(160) | nullable | +| `provider_occurred_at` | timestamptz | nullable | +| `started_at` | timestamptz | 필수 | +| `completed_at` | timestamptz | nullable | +| `elapsed_ms` | bigint | nullable | +| `credential_generation` | bigint | 필수 | +| `rendered_content_digest` | char(64) | 필수 | +| `created_at` | timestamptz | 필수 | +| `updated_at` | timestamptz | 필수 | +| `version` | bigint | optimistic lock | + +### 12.4 ProviderEvent + +| 필드 | 타입 | 제약 | +|---|---|---| +| `id` | UUIDv7 | PK | +| `provider_profile_id` | varchar(120) | 필수 | +| `provider_event_id` | varchar(300) | nullable | +| `event_fingerprint` | char(64) | 필수 | +| `provider_request_id_hash` | char(64) | nullable | +| `attempt_id` | UUID | nullable, FK | +| `event_type` | varchar(120) | normalized | +| `provider_native_type` | varchar(160) | 필수 | +| `provider_occurred_at` | timestamptz | nullable | +| `received_at` | timestamptz | 필수 | +| `signature_verified` | boolean | 필수 | +| `raw_payload_ciphertext` | bytea | bounded nullable | +| `raw_payload_digest` | char(64) | 필수 | +| `normalized_payload_json` | jsonb | bounded | +| `projection_status` | varchar(40) | PENDING/APPLIED/IGNORED/FAILED | +| `projection_error_code` | varchar(120) | nullable | + +Unique: + +```sql +UNIQUE (provider_profile_id, provider_event_id) + WHERE provider_event_id IS NOT NULL; + +UNIQUE (provider_profile_id, event_fingerprint); +``` + +### 12.5 ContactPoint + +| 필드 | 타입 | 제약 | +|---|---|---| +| `id` | UUIDv7 | PK | +| `tenant_id` | varchar(100) | 필수 | +| `owner_ref` | varchar(200) | 필수 | +| `type` | varchar(50) | EMAIL/PHONE/FCM_FID/... | +| `provider` | varchar(80) | nullable | +| `application_id` | varchar(120) | nullable | +| `environment` | varchar(40) | 필수 | +| `ciphertext` | bytea | AES-GCM | +| `lookup_hmac` | char(64) | equality lookup | +| `key_id` | varchar(120) | 필수 | +| `status` | varchar(40) | 생명주기 | +| `verified` | boolean | 필수 | +| `last_confirmed_at` | timestamptz | nullable | +| `last_successful_delivery_at` | timestamptz | nullable | +| `invalidated_at` | timestamptz | nullable | +| `invalidation_reason` | varchar(120) | nullable | +| `locale` | varchar(35) | nullable | +| `time_zone` | varchar(80) | nullable | +| `created_at` | timestamptz | 필수 | +| `updated_at` | timestamptz | 필수 | +| `version` | bigint | optimistic lock | + +Unique scope: + +```sql +UNIQUE (tenant_id, type, provider, application_id, environment, lookup_hmac) +``` + +### 12.6 Template·Suppression·Preference·Consent·Inbox + +별도 테이블: + +```text +notification_template +notification_template_version +notification_suppression +notification_preference +notification_consent +notification_inbox_item +notification_inbox_user_state +notification_admin_audit +notification_reconciliation_job +``` + +정확한 Flyway DDL은 구현 계획 Task에서 파일 단위로 고정한다. + +--- + +## 13. 상태·증거 모델 + +### 13.1 RequestStatus + +```java +public enum RequestStatus { + CREATED, + VALIDATED, + SCHEDULED, + PROCESSING, + PARTIALLY_COMPLETED, + COMPLETED, + CANCELED, + EXPIRED, + FAILED +} +``` + +이 enum은 논리 요청 Projection이며 Provider 상태를 직접 표현하지 않는다. + +### 13.2 RecipientDeliveryState + +```java +public enum RecipientDeliveryState { + PENDING, + READY_TO_DISPATCH, + SUPPRESSED, + DISPATCHING, + RETRY_WAITING, + RECONCILIATION_REQUIRED, + COMPLETED, + FAILED, + EXPIRED, + CANCELED +} +``` + +### 13.3 Attempt 결과 + +```java +public enum SubmissionOutcome { + NOT_SUBMITTED, + CONFIRMED_ACCEPTED, + CONFIRMED_REJECTED, + AMBIGUOUS +} + +public enum DeliveryOutcome { + UNKNOWN, + SENT, + DELIVERED, + UNDELIVERED, + BOUNCED, + EXPIRED +} + +public enum AttemptConfirmation { + CONFIRMED, + REJECTED, + AMBIGUOUS +} + +public enum EvidenceLevel { + NONE, + PLATFORM_QUEUED, + PROVIDER_ACCEPTED, + NETWORK_OR_CARRIER_ACCEPTED, + DEVICE_DELIVERED, + USER_AGENT_DISPLAYED, + USER_READ +} +``` + +### 13.4 독립 Fact + +다음은 Delivery Outcome과 독립적으로 저장한다. + +```java +public record EngagementFacts( + boolean opened, + boolean clicked, + boolean read, + Instant lastEngagedAt +) {} + +public record SuppressionFacts( + boolean hardBounced, + boolean complained, + boolean providerSuppressed, + boolean userOptedOut +) {} +``` + +Email이 전달된 뒤 complaint가 발생해도 `DELIVERED` 사실을 지우지 않고 complaint fact를 추가한다. + +### 13.5 Evidence 승격 규칙 + +| Provider 이벤트 | 최대 Evidence | +|---|---| +| 내부 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 | USER_READ | +| 앱 interaction receipt | USER_READ 또는 USER_AGENT_DISPLAYED | + +금지: + +```text +FCM success → DEVICE_DELIVERED +APNs 2xx → DELIVERED +SES MessageId → DELIVERED +SMTP 250 → inbox delivered +``` + +--- + +## 14. ProviderEvent 원장과 Projection + +### 14.1 원장 규칙 + +1. Callback은 Projection 전에 원장에 append한다. +2. 원장 row가 commit되지 않으면 2xx를 반환하지 않는다. +3. 동일 Provider event ID 또는 fingerprint는 두 번째부터 no-op이다. +4. 원본 payload는 제한된 크기만 암호화 저장한다. +5. 알 수 없는 필드는 raw payload에 보존하고 typed parser는 무시한다. +6. Projection 실패는 원장 row를 지우지 않고 `FAILED`로 기록한다. +7. Projector 재실행은 idempotent해야 한다. + +### 14.2 Channel projector + +```java +public interface ProviderEventProjector { + ProviderId providerId(); + ProjectionResult project( + DeliveryAttemptSnapshot attempt, + ProviderEventRecord event, + DeliveryProjection current + ); +} +``` + +### 14.3 Merge 규칙 + +단일 ordinal 비교를 금지한다. + +```text +sent → delivered 허용 +delivered → sent 무시 +delivered → complaint complaint fact 추가 +complaint → delivered complaint 유지 +accepted → bounced 허용 +read → displayed read 유지 +invalid-recipient → accepted invalidation을 자동 철회하지 않음 +``` + +판정 입력: + +```text +normalized event type +provider occurredAt +receivedAt +existing terminal facts +evidence strength +provider-specific transition table +``` + +### 14.4 Attempt를 찾지 못한 이벤트 + +- provider request ID hash로 재검색한다. +- 일정 기간 `UNMATCHED` queue에 보관한다. +- Reconciliation 또는 늦은 Attempt response 등록 후 재매칭한다. +- 보관 기간 만료 시 운영 alert와 audit를 남긴다. + +--- + +## 15. 트랜잭션 경계와 불변 조건 + +### 15.1 Submit transaction + +동일 DB transaction: + +```text +NotificationRequest INSERT +RecipientDelivery INSERT N개 +idempotency unique 획득 +initial dispatch schedule 설정 +``` + +Provider 호출은 포함하지 않는다. + +### 15.2 Dispatch lease transaction + +```sql +SELECT id +FROM notification_recipient_delivery +WHERE next_dispatch_at <= now() + AND delivery_state IN ('READY_TO_DISPATCH', 'RETRY_WAITING') + AND (lease_until IS NULL OR lease_until < now()) +ORDER BY next_dispatch_at, id +FOR UPDATE SKIP LOCKED +LIMIT :batchSize; +``` + +같은 transaction에서 `lease_owner`, `lease_until`, `DISPATCHING`을 기록한다. + +### 15.3 Provider 호출 경계 + +Provider 호출은 DB transaction 밖에서 수행한다. 호출 전 Attempt를 `DISPATCHING`으로 commit하고, 호출 후 별도 transaction에서 outcome을 기록한다. + +Crash 시나리오: + +```text +Attempt row commit 전 crash +→ Provider 호출 없음 + +Attempt row commit 후 Provider 호출 전 crash +→ requestStarted=false +→ safe lease recovery + +Provider accepted 후 process crash +→ Attempt가 DISPATCHING으로 남음 +→ ambiguous/reconciliation recovery + +Outcome commit 후 crash +→ projector와 next action은 idempotent replay +``` + +### 15.4 핵심 invariant + +- NotificationReceipt가 반환된 request는 DB에 존재한다. +- 하나의 RecipientDelivery에는 동시에 하나의 유효 lease만 있다. +- `attempt_no`는 RecipientDelivery 안에서 단조 증가한다. +- Provider 호출 전 Attempt row가 존재한다. +- `AMBIGUOUS` Attempt가 존재하면 자동 cross-channel fallback을 시작하지 않는다. +- Expired RecipientDelivery에는 새 Attempt를 만들지 않는다. +- Suppressed Contact Point에는 새 Attempt를 만들지 않는다. +- Callback event는 삭제·덮어쓰기하지 않는다. +- Projection은 event ledger에서 재생 가능하다. + +--- + +## 16. Contact Point 계약 + +### 16.1 Typed hierarchy + +```java +public sealed interface ContactPointValue + permits EmailAddress, PhoneNumber, MobilePushTarget, + WebPushSubscriptionValue, InAppRecipientRef { +} + +public sealed interface MobilePushTarget + permits FcmInstallationId, LegacyFcmRegistrationToken, + ApnsDeviceToken { +} +``` + +```java +public record FcmInstallationId(String value) implements MobilePushTarget {} +public record LegacyFcmRegistrationToken(String value) implements MobilePushTarget {} +public record ApnsDeviceToken(String value, ApnsEnvironment environment) + implements MobilePushTarget {} +``` + +### 16.2 상태 + +```java +public enum ContactPointStatus { + UNVERIFIED, + ACTIVE, + STALE, + INVALID, + SUPPRESSED, + REVOKED, + DELETED +} +``` + +### 16.3 등록·갱신 규칙 + +- normalized value를 HMAC fingerprint로 조회한다. +- 원문은 AES-GCM으로 암호화한다. +- 같은 token이 다른 owner에 등록되면 소유권 이전 정책을 명시적으로 수행한다. +- APNs sandbox와 production을 별 Contact Point로 본다. +- FCM project/application identity를 Provider Profile에 고정한다. +- invalid target 응답은 Contact Point를 `INVALID`로 전환한다. +- 사용자 재등록은 새 확인 증거로 `ACTIVE` 전환할 수 있다. +- 주소 원문을 API 응답에 반환하지 않고 masked display만 제공한다. + +### 16.4 암호화 계약 + +```java +public interface SecretMaterialProvider { + SecretKeyMaterial activeKey(SecretPurpose purpose); + SecretKeyMaterial keyById(String keyId); +} + +public interface ContactPointProtector { + ProtectedContactPoint protect(ContactPointValue value); + ContactPointValue reveal(ProtectedContactPoint protectedValue, AccessContext context); + String fingerprint(ContactPointValue value); +} +``` + +암호문에는 다음을 포함한다. + +```text +keyId +nonce +ciphertext +authenticationTag +normalizedType +``` + +HMAC key와 encryption key는 분리한다. + +--- + +## 17. Template·Localization 계약 + +### 17.1 Template 모델 + +```java +public record NotificationTemplateVersion( + TemplateId templateId, + TemplateVersion version, + Channel channel, + Locale locale, + Optional fallbackLocale, + VariableSchema variableSchema, + TemplateContentDefinition content, + TemplateStatus status, + String contentDigest +) {} +``` + +### 17.2 발송 시 고정값 + +```text +templateId +templateVersion +locale +normalized variables +renderedContentDigest +``` + +Retry와 Redrive에서 최신 Template을 자동 재선택하지 않는다. + +### 17.3 Locale 해석 순서 + +```text +Recipient exact locale +→ language-only locale +→ template fallback locale +→ platform default locale +→ TEMPLATE_NOT_FOUND +``` + +### 17.4 Variable 검증 + +- JSON Schema 2020-12를 사용한다. +- Provider 호출 전에 검증한다. +- 누락 필수 변수와 잘못된 타입은 non-retryable이다. +- secret/PII classification이 있는 변수는 로그·preview에서 마스킹한다. +- Template Version publish 전에 sample payload snapshot test를 실행한다. + +### 17.5 Renderer SPI + +```java +public interface NotificationTemplateRenderer { + Channel channel(); + RenderedNotificationContent render(RenderCommand command); +} +``` + +Thymeleaf는 reference implementation일 뿐 Core API에 engine type을 노출하지 않는다. + +### 17.6 Client-side push localization + +FCM/APNs client resource key 기반 localization은 N3 capability로 둔다. 감사 가능한 정확한 문구가 필요한 알림은 서버 렌더링을 기본으로 한다. + +--- + +## 18. Attachment 계약 + +```java +public record AttachmentRef( + String contentReference, + String displayName, + String contentType, + long expectedSize, + String expectedDigest, + AttachmentDisposition disposition +) {} +``` + +규칙: + +- bytes를 Notification DB에 저장하지 않는다. +- `fileserver` 또는 `objectstorage` reference만 사용한다. +- Provider dispatch 직전에 권한과 READY 상태를 검증한다. +- 최대 개수, 단일 크기, 총 크기를 Provider Profile로 제한한다. +- Retry 시 같은 immutable content digest를 사용한다. +- signed public URL을 Message body나 audit에 저장하지 않는다. +- attachment 읽기 실패는 Provider 호출 전이면 명확한 reject이며 Provider 호출 후이면 시도 증거에 따라 판정한다. + + +--- + +## 19. Idempotency·Deduplication·Collapse + +### 19.1 Idempotency + +질문: + +```text +같은 API 요청을 다시 보낸 것인가? +``` + +Key scope: + +```text +(tenantId, idempotencyKey) +``` + +Fingerprint 입력: + +```text +category +templateId + version +recipient specification +strategy +scheduleAt / notBefore / expiresAt +canonical variables digest +bounded metadata +``` + +결과: + +| 상황 | 결과 | +|---|---| +| key 없음 | 새 request 생성 | +| 같은 key + 같은 fingerprint | 기존 NotificationReceipt 반환 | +| 같은 key + 다른 fingerprint | `IdempotencyConflictException` | +| 동시 INSERT | unique constraint 승자 조회 후 fingerprint 비교 | + +Provider actual side effect 중복까지 제거한다고 광고하지 않는다. + +### 19.2 Deduplication + +질문: + +```text +서로 다른 API 요청이지만 일정 기간 같은 사용자 알림으로 볼 것인가? +``` + +```java +public record DeduplicationSpec( + String dedupKey, + Duration window, + DeduplicationAction action +) {} +``` + +Scope: + +```text +tenant + recipient + category + dedupKey + time bucket +``` + +업무 의미가 개입되므로 opt-in이다. + +### 19.3 Collapse·Coalescing + +질문: + +```text +Provider에 아직 전달되지 않은 이전 알림을 최신 알림으로 대체할 것인가? +``` + +```java +public record CollapseSpec(String key, CollapseScope scope) {} +``` + +Adapter mapping: + +```text +FCM Android collapse key +APNs apns-collapse-id +Web Push Topic +``` + +Collapse는 이미 전달된 알림을 취소하거나 사용자가 하나만 받는다고 보장하지 않는다. + +--- + +## 20. Scheduling·TTL·Expiration + +### 20.1 Core-owned scheduler + +Stable 기본은 DB 기반 durable scheduler다. + +```text +SCHEDULED +→ scheduleAt 도달 +→ READY_TO_DISPATCH +→ lease claim +→ Provider dispatch +``` + +Provider-native scheduling은 N3 Experimental이다. + +### 20.2 시간 필드 + +```text +scheduleAt +- 플랫폼이 작업을 활성화할 시각 + +notBefore +- 이보다 앞서 Provider에 제출하면 안 되는 시각 + +expiresAt +- 이 시각 이후 새 Attempt·Retry·Fallback을 금지 +``` + +### 20.3 Provider TTL 계산 + +```text +providerTTL = min( + expiresAt - now, + providerMaximumTTL, + channelPolicyMaximumTTL +) +``` + +Web Push는 TTL header 필수다. + +### 20.4 Retry 전 시간 검사 + +```text +now + nextBackoff + estimatedDispatchDuration < expiresAt +``` + +거짓이면 `EXPIRED`로 종료한다. + +### 20.5 Cancel + +| 시점 | 동작 | +|---|---| +| Provider Attempt 생성 전 | 취소 가능 | +| Attempt가 NOT_SUBMITTED | 취소 가능 | +| Provider accepted | 외부 부작용 취소를 보장하지 않음 | +| Ambiguous | 논리 후속 시도만 중단, 기존 부작용은 불명 | +| Provider native schedule | capability가 취소를 증명할 때만 Provider cancel 호출 | + +--- + +## 21. Suppression·Preference·Consent + +세 개를 분리한다. + +```text +Preference +- 사용자의 선호 채널·빈도 + +Consent +- 외부 정책 판단에 필요한 동의 기록 + +Suppression +- 현재 발송을 기술적으로 차단하는 상태 +``` + +### 21.1 Suppression 모델 + +```java +public record SuppressionEntry( + SuppressionId id, + TenantId tenantId, + SuppressionScope scope, + SuppressionReason reason, + String normalizedTargetHmac, + Optional notificationCategory, + Instant effectiveAt, + Optional expiresAt, + SuppressionSource source +) {} +``` + +Reason: + +```text +USER_OPT_OUT +HARD_BOUNCE +COMPLAINT +INVALID_TOKEN +INVALID_PHONE +ADMIN_BLOCK +PROVIDER_BLOCK +TEMPORARY_SUPPRESSION +``` + +### 21.2 Eligibility Port + +```java +public interface NotificationEligibilityPolicy { + EligibilityResult evaluate(NotificationContext context); +} +``` + +Core가 판단하지 않는 항목: + +```text +광고 여부 +국가별 법률 +보안 알림의 opt-out 가능 여부 +야간 제한 +회원 등급별 채널 우선순위 +``` + +### 21.3 판정 순서 + +```text +Internal mandatory suppression +OR Provider suppression +OR injected eligibility=false +→ dispatch 금지 +``` + +Submit 시점과 dispatch 직전에 모두 실행한다. dispatch 직전 판정이 최종이다. + +--- + +## 22. Routing·Fallback + +### 22.1 Stable 전략 + +```text +EXPLICIT_CHANNEL +ORDERED_FALLBACK +``` + +### 22.2 Attempt 결과별 기본 처리 + +| 결과 | 동일 채널 Retry | 다음 채널 Fallback | +|---|---:|---:| +| Provider 호출 전 validation 실패 | X | Contact Point가 있으면 O | +| INVALID_RECIPIENT | X | O | +| THROTTLED | O | 기본 X | +| 명백한 transient, 수락 전 | O | 정책에 따라 O | +| CONFIRMED_REJECTED | 오류 종류에 따라 | O | +| CONFIRMED_ACCEPTED | X | 기본 X | +| AMBIGUOUS | 기본 X | 기본 X | +| DELIVERED | X | X | + +### 22.3 Route 상태 + +```java +public record RoutingDecision( + Optional selected, + boolean retryAllowed, + boolean fallbackAllowed, + boolean reconciliationRequired, + boolean duplicateRisk, + String reasonCode +) {} +``` + +### 22.4 Parallel 전략 + +`PARALLEL_MULTI_CHANNEL`, `FIRST_SUCCESS`는 duplicate-tolerant 알림에서만 Experimental로 허용한다. Provider 제출 후 취소 불가능성을 API 문서와 audit에 표시한다. + +### 22.5 Fallback invariant + +```text +ambiguousAttemptExists = true +→ automatic fallback prohibited +``` + +운영자 강제 Fallback은 N4에서 사유와 duplicate risk 승인 후만 수행한다. + +--- + +## 23. Provider Adapter SPI + +```java +public interface NotificationProviderAdapter { + ProviderId providerId(); + Set channels(); + ProviderCapabilities capabilities(); + CompletionStage submit(ProviderSubmission submission); +} +``` + +```java +public record ProviderSubmission( + DeliveryAttemptId attemptId, + ProviderProfileSnapshot profile, + ProtectedContactPoint contactPoint, + RenderedNotificationContent content, + Instant expiresAt, + Optional providerIdempotencyKey, + Map approvedNativeOptions, + TraceContext traceContext +) {} +``` + +```java +public record ProviderSubmissionResult( + AttemptConfirmation confirmation, + SubmissionOutcome submissionOutcome, + EvidenceLevel evidenceLevel, + Optional providerRequestId, + Optional nativeStatus, + Optional failure, + ProviderExecutionEvidence executionEvidence, + Duration elapsed +) {} +``` + +### 23.1 Execution Evidence + +```java +public record ProviderExecutionEvidence( + boolean requestStarted, + boolean requestBodyCommitted, + boolean responseReceived, + boolean providerAcceptanceProven +) {} +``` + +Adapter가 증명하지 못한 boolean은 임의로 `false` 처리하지 않고 `EvidenceCertainty`를 함께 둔다. + +```java +public enum EvidenceCertainty { + PROVEN, + INFERRED, + UNKNOWN +} +``` + +### 23.2 Callback SPI + +```java +public interface ProviderCallbackAdapter { + ProviderId providerId(); + CallbackVerificationResult verify(CallbackRequest request); + List normalize(VerifiedCallback callback); +} +``` + +### 23.3 Reconciliation SPI + +```java +public interface ReconciliationCapability { + boolean supports(ProviderProfileSnapshot profile); + CompletionStage reconcile(DeliveryAttemptSnapshot attempt); +} +``` + +### 23.4 Provider capability + +```java +public record ProviderCapabilities( + boolean batch, + boolean providerIdempotency, + boolean statusCallback, + boolean statusQuery, + boolean deliveryReceipt, + boolean nativeScheduling, + boolean nativeCancel, + boolean collapse, + int maxBatchSize, + long maxPayloadBytes, + Duration maxTtl +) {} +``` + +--- + +## 24. Provider Profile과 Runtime Generation + +```yaml +notification: + providers: + ses-primary: + type: SES + enabled: true + environment: PRODUCTION + region: ap-northeast-2 + credential-profile: ses-primary + sender-identity: transactional.example.com + timeout: 3s + max-concurrency: 32 + rate-limit-per-second: 50 + retry-policy: provider-transient + callback-profile: ses-events + + twilio-primary: + type: TWILIO + enabled: true + environment: PRODUCTION + credential-profile: twilio-primary + messaging-service-sid-ref: twilio-service + timeout: 3s + max-concurrency: 20 + rate-limit-per-second: 30 + retry-policy: twilio-transient + + fcm-main: + type: FCM + enabled: true + project-id: example-prod + application-id: mobile-main + credential-profile: fcm-main + max-batch-size: 500 + + apns-main: + type: APNS + enabled: true + environment: PRODUCTION + topic: com.example.app + credential-profile: apns-main +``` + +### 24.1 Runtime generation + +Credential·certificate rotation 시 기존 Runtime을 mutate하지 않는다. + +```text +Profile generation N +→ immutable ProviderRuntime N +→ 새 dispatch는 N+1 사용 +→ N의 in-flight 완료 +→ drain timeout 후 close +``` + +Attempt에 `credentialGeneration`을 기록한다. + +### 24.2 Provider health + +```java +public enum ProviderRuntimeState { + HEALTHY, + DEGRADED, + THROTTLED, + AUTHENTICATION_FAILED, + DISABLED, + DRAINING +} +``` + +`AUTHENTICATION_FAILED`는 개별 메시지 Retry가 아니라 Provider route open/disable과 운영 alert를 유발한다. + +--- + +## 25. 공통 오류 모델 + +```text +NotificationException + ├─ NotificationValidationException + ├─ IdempotencyConflictException + ├─ TemplateNotFoundException + ├─ TemplateRenderingException + ├─ InvalidContactPointException + ├─ NotificationExpiredException + ├─ NotificationSuppressedException + ├─ ProviderAuthenticationException + ├─ ProviderAuthorizationException + ├─ ProviderThrottledException + ├─ ProviderTransientException + ├─ ProviderPermanentException + ├─ ProviderRejectedException + ├─ AmbiguousSubmissionException + ├─ CallbackValidationException + ├─ CallbackProjectionException + ├─ ReconciliationException + └─ NotificationCapacityException +``` + +### 25.1 FailureCategory + +```java +public enum FailureCategory { + TRANSIENT_PROVIDER, + THROTTLED, + AUTHENTICATION, + AUTHORIZATION, + INVALID_RECIPIENT, + INVALID_PAYLOAD, + TEMPLATE_FAILURE, + PERMANENT_PROVIDER, + AMBIGUOUS_SUBMISSION, + CALLBACK_VALIDATION_FAILURE, + CAPACITY_REJECTED, + EXPIRED +} +``` + +### 25.2 안정 Metadata + +```java +public record NotificationFailureDescriptor( + String code, + FailureCategory category, + boolean retryable, + boolean ambiguous, + Channel channel, + ProviderId providerId, + int attemptNumber, + Duration elapsed +) {} +``` + +금지 metadata: + +```text +address/token 원문 +Provider credential +message body +Template variable 원문 +Callback raw payload +full Provider exception message +``` + +--- + +## 26. Retry Policy Engine + +```java +public record RetryContext( + FailureCategory failureCategory, + AttemptConfirmation confirmation, + ProviderExecutionEvidence evidence, + boolean providerIdempotency, + boolean contactPointActive, + boolean fallbackCommitted, + boolean ambiguousAttemptExists, + Instant expiresAt, + int attemptNumber, + RetryBudgetSnapshot budget, + ProviderRuntimeState providerState +) {} +``` + +```java +public sealed interface RetryDecision + permits RetryAfter, Reconcile, Fallback, Stop { +} +``` + +### 26.1 기본 규칙 + +```text +TRANSIENT_PROVIDER ++ confirmed not accepted ++ expiry/budget remaining +→ RetryAfter + +THROTTLED +→ Retry-After 또는 exponential backoff + +INVALID_RECIPIENT +→ Contact Point invalidate +→ Fallback 가능 + +AUTHENTICATION / AUTHORIZATION +→ 개별 자동 Retry 금지 +→ Provider runtime unhealthy + +INVALID_PAYLOAD / TEMPLATE_FAILURE +→ 즉시 Stop + +AMBIGUOUS_SUBMISSION +→ Provider query 가능: Reconcile +→ Provider idempotency 증명 가능: 제한 재요청 +→ 둘 다 없음: Stop + manual review +``` + +### 26.2 Backoff + +```text +exponential backoff +full jitter +provider Retry-After 존중 +overall expiresAt 상한 +provider별 retry budget +``` + +### 26.3 Retry identity + +Retry는 새 `DeliveryAttemptId`를 만들지만 동일 `RecipientDeliveryId`, `NotificationId`, logical content digest를 유지한다. + +--- + +## 27. Rate Limit·Backpressure·Capacity + +### 27.1 제한 계층 + +```text +global +→ channel +→ provider account +→ sender identity +→ destination country/class +→ recipient/contact point +``` + +### 27.2 Dispatch pipeline + +```text +Durable queue +→ expiry check +→ suppression re-check +→ provider health gate +→ rate limiter +→ concurrency limiter +→ provider adapter +``` + +### 27.3 설정 + +```yaml +notification: + dispatch: + claim-batch-size: 100 + lease-duration: 30s + max-global-concurrency: 128 + max-queue-age: 24h + max-retry-concurrency: 32 + scheduler-poll-interval: 250ms + callback-worker-concurrency: 16 +``` + +수치는 기본 동작 예시이며 Provider profile과 부하 테스트로 조정한다. 하드 상한과 무제한 금지는 설계 계약이다. + +### 27.4 Overload 동작 + +- API intake가 DB capacity를 넘으면 `NotificationCapacityException`으로 fail-fast한다. +- 메모리 queue로 무제한 축적하지 않는다. +- Provider가 장기 장애이면 dispatch queue가 durable하게 쌓이고 retry amplification은 budget으로 제한한다. +- Provider auth failure이면 해당 route를 open하고 같은 credential로 반복 호출하지 않는다. +- 예약 fan-out은 한 tick에 bounded batch로 materialize한다. + +--- + +## 28. Callback·Receipt 처리 + +### 28.1 HTTP endpoint + +```text +POST /internal/notification/callbacks/ses/{profile} +POST /internal/notification/callbacks/twilio/{profile} +POST /internal/notification/callbacks/app-receipt/{applicationId} +``` + +WebFlux와 MVC 모듈은 동일 callback application service를 호출한다. + +### 28.2 처리 순서 + +```text +body limit +→ content type +→ profile lookup +→ signature verification +→ replay defense +→ raw event append +→ dedup +→ normalize +→ attempt resolve +→ project +→ side effect +→ 2xx +``` + +### 28.3 Signature + +- Twilio는 공식 validation algorithm/SDK adapter를 사용한다. +- Reverse proxy 뒤 원본 URL 복원 규칙을 profile에 고정한다. +- raw body가 필요한 서명은 decoding 전 bytes로 검증한다. +- timestamp·nonce가 제공되면 허용 skew와 replay cache를 적용한다. +- 서명 실패는 원장에 정상 provider event로 기록하지 않고 security audit에 기록한다. + +### 28.4 Unknown fields + +Callback parser는 unknown field를 허용한다. 필수 식별 필드가 없을 때만 normalization reject다. + +### 28.5 빠른 응답 + +Callback endpoint는 원장 append 이후 빠르게 2xx를 반환하고, Projection은 같은 transaction 또는 bounded async worker에서 수행한다. Provider 재전송 규칙을 고려해 응답 SLA를 profile에 기록한다. + +--- + +## 29. Reconciliation + +### 29.1 대상 + +```text +DISPATCHING 상태가 lease보다 오래됨 +AMBIGUOUS submission +Provider accepted 후 callback SLA 초과 +Twilio callback 누락 +unmatched provider event +``` + +### 29.2 결과 + +```java +public sealed interface ReconciliationResult { + record Confirmed(NormalizedProviderEvent event) implements ReconciliationResult {} + record StillUnknown(Instant nextCheckAt) implements ReconciliationResult {} + record Unsupported() implements ReconciliationResult {} + record Failed(String code, boolean retryable) implements ReconciliationResult {} +} +``` + +### 29.3 규칙 + +- synthetic event도 ProviderEvent ledger에 append한다. +- `source=RECONCILIATION`을 기록한다. +- 기존 Callback보다 약한 증거로 downgrade하지 않는다. +- 수정된 Projection은 correction audit를 남긴다. +- Provider query capability가 없으면 자동으로 final status를 추정하지 않는다. + +--- + +## 30. Email 공통 계약 + +```java +public record EmailNotification( + TenantId tenantId, + IdempotencyKey idempotencyKey, + ContactPointId recipient, + TemplateSelection template, + Map variables, + Optional expiresAt, + CorrelationId correlationId +) {} +``` + +### 30.1 기능 + +```text +Text +HTML +multipart/alternative +attachment reference +inline resource reference +CC/BCC/Reply-To +custom approved header +List-Unsubscribe capability +``` + +### 30.2 MIME 규칙 + +- CRLF가 포함된 header value를 거부한다. +- UTF-8을 기본으로 한다. +- text와 html을 multipart/alternative로 구성한다. +- attachment는 immutable reference와 digest를 검증한다. +- bulk recipient를 To/CC에 노출하지 않는다. +- MIME 생성 실패는 Provider 호출 전 non-retryable이다. + +### 30.3 SenderIdentity + +```java +public record SenderIdentity( + String identityId, + String domain, + String fromAddressRef, + Optional replyToAddressRef, + ProviderId providerId, + ReadinessStatus dkimStatus, + ReadinessStatus spfStatus, + ReadinessStatus dmarcStatus, + boolean enabled +) {} +``` + +Production profile은 준비되지 않은 sender identity로 시작하지 않는다. + + +--- + +## 31. SMTP Adapter + +### 31.1 구현 + +```text +Spring JavaMailSender +MimeMessageHelper +Dedicated connection/timeout profile +SMTP response classifier +``` + +### 31.2 결과 매핑 + +| SMTP 결과 | Failure/Outcome | +|---|---| +| final 2xx 수락 | CONFIRMED_ACCEPTED / PROVIDER_ACCEPTED | +| 4yz | TRANSIENT_PROVIDER | +| 5yz | PERMANENT_PROVIDER 또는 INVALID_RECIPIENT | +| DATA 전 명백한 연결 실패 | CONFIRMED_REJECTED 또는 NOT_SUBMITTED | +| DATA 후 final response 유실 | AMBIGUOUS_SUBMISSION | + +SMTP 2xx를 inbox delivery로 보지 않는다. + +### 31.3 Timeout + +```text +connection timeout +read timeout +write timeout +pool acquire timeout +whole attempt deadline +``` + +무한 timeout을 허용하지 않는다. + +### 31.4 SMTP Provider Profile + +```yaml +notification: + providers: + smtp-primary: + type: SMTP + host: smtp.example.com + port: 587 + tls-mode: STARTTLS_REQUIRED + credential-profile: smtp-primary + connect-timeout: 1s + read-timeout: 3s + write-timeout: 3s + max-concurrency: 10 +``` + +--- + +## 32. Amazon SES Adapter + +### 32.1 Submit + +- 기존 `httpclient` 또는 AWS SDK Adapter 내부 구현을 사용하되 Core에 SDK type을 노출하지 않는다. +- `MessageId`를 `providerRequestId`로 저장한다. +- API 성공은 `CONFIRMED_ACCEPTED`, `PROVIDER_ACCEPTED`다. +- API 성공을 `SENT`·`DELIVERED`로 기록하지 않는다. + +### 32.2 Event mapping + +| SES event | Normalized event | +|---|---| +| Send | PROVIDER_ACCEPTED fact 보강 | +| Delivery | DELIVERY_CONFIRMED / NETWORK_OR_CARRIER_ACCEPTED | +| DeliveryDelay | DELIVERY_DELAYED fact | +| Bounce hard | BOUNCED + HARD_BOUNCE suppression | +| Bounce transient | UNDELIVERED + retry/fallback policy input | +| Complaint | COMPLAINT fact + suppression | +| Reject | PROVIDER_REJECTED | +| RenderingFailure | TEMPLATE_FAILURE | + +### 32.3 Duplicate event + +SES event ID가 있으면 해당 ID를 사용하고, 없으면 message ID·event type·timestamp·payload digest로 fingerprint한다. + +### 32.4 List-Unsubscribe + +구독형 Email capability에서만 활성화한다. + +```text +List-Unsubscribe +List-Unsubscribe-Post +DKIM signed header readiness +``` + +Unsubscribe token은 암호학적으로 보호하고 로그에 남기지 않는다. + +--- + +## 33. SMS 공통 계약 + +### 33.1 PhoneNumber + +```java +public record PhoneNumber(String e164) implements ContactPointValue {} +``` + +- E.164 canonical form을 저장한다. +- 형식 검증과 실제 번호 사용 가능성 검증을 분리한다. +- 번호 원문은 암호화하고 HMAC fingerprint를 별도 저장한다. + +### 33.2 Encoding·Segment estimator + +```java +public record SmsEstimate( + SmsEncoding encoding, + int segmentCount, + int encodedLength, + boolean exceedsRecommendedLimit +) {} + +public enum SmsEncoding { + GSM_7, + UCS_2 +} +``` + +계약: + +```text +GSM-7 single: 160 +GSM-7 concatenated segment: 153 +UCS-2 single: 70 +UCS-2 concatenated segment: 67 +``` + +Extension table과 escape character를 실제 인코딩 길이에 반영한다. + +### 33.3 Sender Profile + +```text +number +sender ID +short code +messaging service +country capability +``` + +비즈니스 코드가 sender를 임의 문자열로 지정하지 않는다. + +--- + +## 34. Twilio Adapter + +### 34.1 Submit mapping + +| Twilio 상태 | Submission/Delivery | +|---|---| +| accepted/queued | CONFIRMED_ACCEPTED / PROVIDER_ACCEPTED | +| sending | Delivery UNKNOWN, native status 보존 | +| sent | SENT / NETWORK_OR_CARRIER_ACCEPTED | +| delivered | DELIVERED / DEVICE_DELIVERED | +| undelivered | UNDELIVERED | +| failed | CONFIRMED_REJECTED 또는 permanent failure | + +### 34.2 Callback merge + +Twilio Callback 도착 순서에 의존하지 않는다. + +```text +delivered callback 수신 +→ evidence DEVICE_DELIVERED + +그 뒤 sent callback 수신 +→ native event는 원장에 append +→ projection downgrade 없음 +``` + +### 34.3 Signature + +- `X-Twilio-Signature`를 검증한다. +- Proxy가 원본 scheme/host/path를 변경할 때 canonical external URL 설정을 사용한다. +- 공식 validator를 Adapter 내부에서 사용한다. + +### 34.4 Reconciliation + +- 상태 Callback SLA를 넘은 Attempt는 Message status query 대상으로 등록한다. +- polling rate limit과 최대 age를 설정한다. +- query 결과도 synthetic ProviderEvent로 기록한다. + +### 34.5 Opt-out + +Provider opt-out 상태와 내부 suppression을 동기화한다. 내부 mandatory suppression이 우선한다. + +--- + +## 35. Mobile Push 공통 계약 + +### 35.1 Target + +```text +FCM_FID +FCM_REGISTRATION_TOKEN_LEGACY +APNS_DEVICE_TOKEN +``` + +### 35.2 공통 content 제한 + +- title/body/data payload를 구분한다. +- reserved provider key 충돌을 거부한다. +- deep link scheme/host allowlist를 적용한다. +- payload bytes를 Provider 호출 전 계산한다. +- batch 결과를 RecipientDelivery별로 분해한다. + +### 35.3 Provider success 의미 + +```text +Provider API success +→ PROVIDER_ACCEPTED +→ DEVICE_DELIVERED 아님 +``` + +### 35.4 앱 Receipt + +```java +public interface ApplicationReceiptService { + ReceiptResult displayed(AppReceipt receipt); + ReceiptResult read(AppReceipt receipt); +} +``` + +- 앱 receipt는 cryptographically authenticated application session을 요구한다. +- 동일 receipt ID를 dedup한다. +- Provider delivery 결과와 별도 Evidence source로 기록한다. + +--- + +## 36. FCM Adapter + +### 36.1 Target 우선순위 + +- `FcmInstallationId`를 primary Stable target으로 둔다. +- `LegacyFcmRegistrationToken`은 compatibility capability로 분리한다. +- FID와 legacy token을 하나의 문자열 field로 평탄화하지 않는다. + +### 36.2 Batch + +- Adapter capability의 `maxBatchSize` 기본은 500이다. +- 하나의 Provider batch 요청이라도 Recipient별 `DeliveryAttempt`를 유지한다. +- 부분 성공 결과를 input index로 매핑한다. +- batch transport failure가 모든 item의 같은 outcome을 의미하는지 Adapter evidence로 구분한다. + +### 36.3 오류 매핑 + +| FCM 오류 | 처리 | +|---|---| +| UNREGISTERED | Contact Point INVALID, 자동 retry 금지 | +| INVALID_ARGUMENT payload | INVALID_PAYLOAD | +| QUOTA_EXCEEDED | THROTTLED, backoff | +| UNAVAILABLE | TRANSIENT_PROVIDER, Retry-After/jitter | +| auth credential failure | AUTHENTICATION, Provider runtime unhealthy | +| project/target mismatch | INVALID_RECIPIENT 또는 AUTHORIZATION | + +### 36.4 Collapse·TTL + +FCM collapse는 Provider capability다. `expiresAt`을 FCM TTL로 제한 변환한다. + +### 36.5 Topic·Condition + +N3 Experimental이다. 일반 N1 Contact Point 전송과 동일 delivery evidence를 제공한다고 가정하지 않는다. + +--- + +## 37. APNs Adapter + +### 37.1 Profile + +```text +applicationId +topic +environment SANDBOX/PRODUCTION +credential generation +push type allowlist +``` + +### 37.2 Request mapping + +```text +apns-topic +apns-push-type +apns-expiration +apns-priority +apns-collapse-id +apns-request-id +``` + +### 37.3 결과 + +- HTTP 2xx는 `CONFIRMED_ACCEPTED`, `PROVIDER_ACCEPTED`다. +- APNs success를 `DELIVERED`로 매핑하지 않는다. +- invalid token, wrong environment, wrong topic을 Contact Point lifecycle과 profile error로 구분한다. +- token auth key rotation은 runtime generation으로 수행한다. + +### 37.4 Offline·Ordering + +Core는 APNs가 알림을 저장·교체·폐기할 수 있고 순서를 보장하지 않는다는 제약을 문서화한다. 업무 이벤트 순서 전달 수단으로 사용하지 않는다. + +--- + +## 38. Web Push Adapter + +### 38.1 표준 + +```text +RFC 8030 +RFC 8291 +RFC 8292 +``` + +### 38.2 Subscription + +```java +public record WebPushSubscriptionValue( + URI endpoint, + byte[] p256dh, + byte[] authSecret, + String vapidKeyId +) implements ContactPointValue {} +``` + +모든 필드는 암호화 저장하며 endpoint는 capability URL이므로 secret 수준으로 처리한다. + +### 38.3 Send 계약 + +- TTL header는 필수다. +- `Urgency`, `Topic`을 typed option으로 제공한다. +- `aes128gcm` payload encryption을 사용한다. +- VAPID JWT는 현재 target origin에 맞춰 생성한다. +- Payload가 profile maximum을 넘으면 Provider 호출 전 거부한다. + +### 38.4 Subscription invalidation + +- RFC 계약의 404 expired subscription을 처리한다. +- Provider-specific 410 등은 Adapter mapping으로 추가한다. +- invalid subscription은 Contact Point를 `INVALID`로 만든다. + +### 38.5 VAPID rotation + +VAPID restricted subscription은 signing key 변경 시 새 subscription이 필요할 수 있다. 따라서 일반 credential hot rotation과 별도 migration operation으로 관리한다. + +### 38.6 Receipt capability + +모든 Push Service가 receipt를 제공한다고 가정하지 않는다. capability가 있을 때만 `DEVICE_DELIVERED`로 승격한다. + +--- + +## 39. In-App Inbox + +### 39.1 Source of truth + +DB row가 source of truth다. WebSocket 또는 Push는 새 item 존재를 알리는 신호일 뿐이다. + +### 39.2 API + +```java +public interface NotificationInbox { + InboxPage list(InboxQuery query); + InboxItem get(InboxItemId id, InboxPrincipal principal); + InboxMutationResult markSeen(InboxItemId id, InboxPrincipal principal); + InboxMutationResult markRead(InboxItemId id, InboxPrincipal principal); + InboxMutationResult archive(InboxItemId id, InboxPrincipal principal); + InboxMutationResult markAllRead(MarkAllReadCommand command); + long unreadCount(InboxPrincipal principal); +} +``` + +### 39.3 상태 + +```text +PERSISTED +SEEN +READ +ARCHIVED +EXPIRED +DELETED +``` + +`READ`는 `SEEN`을 포함한다. 역순 요청은 idempotent하다. + +### 39.4 Pagination + +Cursor: + +```text +(createdAt DESC, id DESC) +``` + +Offset pagination을 기본으로 사용하지 않는다. + +### 39.5 Broadcast + +- 개인 알림은 fan-out-on-write. +- 대규모 공지는 broadcast item + per-user state의 별도 capability. +- 동일 테이블에 수백만 복제 row를 무조건 생성하지 않는다. + +### 39.6 WebSocket 연계 + +DB commit 후 내부 event를 `messaging`으로 발행하거나 transaction outbox를 사용한다. WebSocket 실패가 Inbox write rollback을 유발하지 않는다. + +--- + +## 40. Webhook Extension + +`notification-webhook-extension`은 기존 `httpclient`를 사용한다. + +재사용: + +```text +TLS +Timeout +Retry +Circuit Breaker +SSRF +Dynamic Target policy +Request signing +Observation +``` + +Notification이 추가하는 모델: + +```text +WebhookSubscription +WebhookTemplate +DeliveryAttempt +Callback/response evidence +Redrive +``` + +Webhook target이 사용자 입력이면 `DynamicTargetGateway`를 사용하고 Trusted Provider credential을 상속하지 않는다. + +--- + +## 41. Security·Privacy + +### 41.1 보호 대상 + +```text +Email address +Phone number +FCM FID / legacy token +APNs device token +Web Push endpoint/p256dh/auth +VAPID private key +Provider credential +Callback signing secret +Template variables +Rendered message body +Attachment reference +Unsubscribe token +``` + +### 41.2 저장 + +- Contact Point는 field-level encryption. +- equality lookup은 HMAC fingerprint. +- raw Callback payload는 bounded encrypted retention. +- rendered content snapshot은 필요한 category에서만 encrypted 저장. +- secret material은 secret manager Port로 공급. +- encryption key ID를 row에 기록한다. + +### 41.3 로그·Metric 금지 + +```text +Email 원문 +Phone 원문 +FID/token/device token +Web Push endpoint/key +message body +Template variables +Provider credential +unsubscribe token +attachment URL +raw callback payload +provider request ID 원문 +``` + +### 41.4 Tenant 격리 + +모든 repository query는 tenant boundary를 포함한다. Admin operation도 explicit tenant 또는 global role을 요구한다. + +### 41.5 Callback 보안 + +```text +TLS +signature +replay defense +body limit +content-type +profile binding +rate limit +idempotent ingestion +security audit +``` + +--- + +## 42. Credential·환경 격리 + +Namespace: + +```text +tenant +applicationId +provider +environment +credentialProfile +generation +``` + +APNs: + +```text +(APNS, appId, SANDBOX) +(APNS, appId, PRODUCTION) +``` + +FCM: + +```text +(FCM, projectId, applicationId, environment) +``` + +SMTP/SES/Twilio: + +```text +sender identity와 credential profile을 분리 +``` + +Startup validation: + +- Production profile의 plaintext endpoint 금지. +- credential reference 누락 시 startup 실패 또는 해당 provider bean 미생성. +- APNs environment와 token environment 혼용 차단. +- FCM project/application mismatch guard. +- expired certificate/credential health fail. + +--- + +## 43. Observability + +### 43.1 관측 단위 + +```text +Notification +RecipientDelivery +ProviderAttempt +ProviderCallback +Reconciliation +``` + +### 43.2 Metric + +| Metric | Low-cardinality tag | +|---|---| +| `notification.requested` | category, strategy | +| `notification.suppressed` | channel, reason | +| `notification.render` | channel, templateId, result | +| `notification.dispatch` | channel, provider, result | +| `notification.provider.accepted` | channel, provider | +| `notification.delivery` | channel, provider, outcome | +| `notification.retry` | provider, failureCategory, attemptBucket | +| `notification.fallback` | fromChannel, toChannel, reason | +| `notification.ambiguous` | channel, provider | +| `notification.callback` | provider, eventType, result | +| `notification.callback.delay` | provider, eventType | +| `notification.reconciliation` | provider, correction | +| `notification.queue.depth` | channel, provider | +| `notification.queue.age` | channel, provider | +| `notification.schedule.delay` | channel | +| `notification.contact.invalid` | channel, provider | + +### 43.3 금지 tag + +```text +recipientId +Contact Point +notificationId +recipientDeliveryId +attemptId +providerRequestId +full error message +``` + +### 43.4 Trace + +```text +notification.submit +notification.render +notification.dispatch +provider.http 또는 provider.smtp +notification.callback +notification.project +notification.reconcile +``` + +Callback은 원 dispatch span의 장시간 child가 아니라 trace link/correlation으로 연결한다. + +### 43.5 Audit + +```text +template publish/disable +suppression add/remove +consent update +contact invalidation/reactivation +manual redrive/retry/cancel +reconciliation correction +provider enable/disable +credential rotation +admin override +callback signature reject +``` + +--- + +## 44. Admin Plane + +### 44.1 기능 + +```text +Provider runtime 조회·enable·disable +Attempt 조회 +Ambiguous queue 조회 +Reconciliation 실행 +DLQ/failed delivery redrive +Suppression add/remove +Contact Point invalidate/reactivate +Template publish/disable +Credential generation cutover +Projection replay +``` + +### 44.2 Redrive + +- 원 `NotificationId`, `RecipientDeliveryId`를 보존한다. +- 새 `DeliveryAttemptId`를 생성한다. +- `redriveOperationId`, actor, reason, time을 기록한다. +- 원 Template version과 rendered digest를 기본 사용한다. +- 새로운 내용으로 다시 보내려면 새 Notification으로 제출한다. +- ambiguous Attempt의 redrive는 duplicate risk 확인을 요구한다. + +### 44.3 Projection replay + +ProviderEvent ledger를 기준으로 projection을 재생성한다. raw provider status를 public enum으로 직접 저장하지 않기 때문에 projector version migration을 지원한다. + +--- + +## 45. Spring Boot Starter + +### 45.1 Auto-configuration + +```text +NotificationCoreAutoConfiguration +NotificationPersistenceAutoConfiguration +NotificationDispatchAutoConfiguration +NotificationTemplateAutoConfiguration +NotificationProviderAutoConfiguration +NotificationCallbackAutoConfiguration +NotificationObservabilityAutoConfiguration +NotificationAdminAutoConfiguration +``` + +### 45.2 조건 + +- Provider dependency가 classpath에 있고 profile이 enabled일 때만 Adapter bean 생성. +- Admin bean은 별도 property와 authority integration이 있을 때만 생성. +- MVC/WebFlux callback bean 중 활성 web stack에 맞는 것만 생성. +- Reactor facade는 reactor module이 있을 때만 생성. + +### 45.3 Properties validation + +Startup 실패 조건: + +```text +unbounded payload/queue setting +negative timeout +expiresAt 없는 provider TTL-required profile +callback signature secret 누락 +production trust-all +APNs environment/topic 누락 +WebPush VAPID key 누락 +provider profile ID 중복 +route가 disabled provider만 가리킴 +ambiguous fallback 기본 허용 +``` + +### 45.4 Actuator + +```text +notificationProviders +notificationQueue +notificationCallbacks +notificationScheduler +notificationReconciliation +``` + +민감 주소와 Provider credential은 노출하지 않는다. + +--- + +## 46. 테스트 전략 + +### 46.1 공통 Contract Suite + +모든 Provider Adapter가 다음을 통과한다. + +```text +confirmed acceptance +confirmed rejection +ambiguous response loss +invalid recipient +throttle +transient provider failure +permanent failure +credential failure +expiry before submit +payload limit +secret masking +attempt evidence mapping +``` + +### 46.2 Core Suite + +```text +idempotency concurrency +idempotency conflict +schedule restart +lease expiry +suppression at submit and dispatch +ambiguous fallback block +retry budget +expiry during retry +callback duplicate +callback out-of-order +projection replay +unmatched event +reconciliation correction +tenant isolation +``` + +### 46.3 Provider Suite + +Email: + +```text +SMTP 2xx/4xx/5xx +DATA response loss +MIME text/html/attachment +SES accepted/delivery/bounce/complaint/render failure +``` + +SMS: + +```text +E.164 +GSM-7/UCS-2 segment boundary +Twilio callback reverse order +missing callback reconciliation +``` + +Push: + +```text +FCM FID +legacy token +batch partial result +UNREGISTERED/QUOTA/UNAVAILABLE +APNs sandbox/production +wrong topic +token invalid +acceptance not delivery +``` + +Web Push: + +```text +TTL required +AES128GCM +VAPID +404/410 invalidation +payload size +Topic replacement +``` + +Inbox: + +```text +cursor pagination +seen/read idempotency +unread count concurrency +WebSocket outage +``` + +### 46.4 장애 도구 + +```text +WireMock / MockWebServer +GreenMail 또는 SMTP test server +Toxiproxy +PostgreSQL Testcontainers +Provider callback fixtures +process kill harness +clock control +``` + +### 46.5 보안 테스트 + +```text +PII log scanner +Metric cardinality guard +callback signature invalid +callback replay +cross-tenant query +secret rotation +Web Push endpoint leak +header injection +attachment authorization +``` + +### 46.6 성능 테스트 + +```text +100k recipient fan-out +scheduled burst +provider 30m outage +429 sustained +callback burst +slow provider +DB slow +process kill during in-flight +large redrive +credential failure amplification +``` + +완료값은 throughput 숫자 하나가 아니라 memory, DB lock, queue age, retry amplification, thread/connection 상한을 함께 검증한다. + +--- + +## 47. 호환성 인증 매트릭스 + +| 대상 | CI 빈도 | Release gate | +|---|---|---| +| Java 21 | 모든 PR | 필수 | +| Spring 6.2 latest patch | 모든 PR | 필수 | +| Spring 7.0 latest patch | release | 필수 | +| PostgreSQL 16 | 모든 PR | 필수 | +| SMTP test server | 모든 PR | 필수 | +| SES contract fixtures | 모든 PR | 필수 | +| Twilio contract fixtures | 모든 PR | 필수 | +| FCM emulator/mock + fixture | 모든 PR | 필수 | +| APNs HTTP/2 fixture | release | 필수 | +| Web Push RFC vectors | 모든 PR | 필수 | +| Toxiproxy ambiguity suite | nightly/release | 필수 | +| Performance suite | release candidate | 필수 | + +실제 외부 Provider sandbox smoke test는 secret이 있는 전용 CI 환경에서 수행한다. PR 필수 검증을 외부 서비스 가용성에 종속시키지 않는다. + +--- + +## 48. 단계별 출시 + +### Phase 1: Foundation Alpha + +```text +Core IDs +Request/Recipient/Attempt +JPA schema +idempotent submit +durable dispatch queue +provider SPI +error/evidence model +basic audit/metrics +``` + +완료 조건: + +- concurrent idempotency test +- provider accepted response loss → AMBIGUOUS +- process restart recovery +- PII log scan + +### Phase 2: Email Stable Beta + +```text +Template +SMTP +SES +MIME/attachment +SES callback +bounce/complaint suppression +``` + +완료 조건: + +- SMTP 4xx/5xx +- SMTP ambiguous response +- SES acceptance/delivery 분리 +- callback dedup + +### Phase 3: SMS Stable Beta + +```text +E.164 +segment estimator +Twilio submit/callback/reconcile +opt-out suppression +``` + +완료 조건: + +- callback reverse order +- callback missing reconcile +- segment boundary + +### Phase 4: Mobile Push Stable Beta + +```text +FCM FID + legacy +FCM batch +APNs +TTL/collapse/priority +app receipt extension +``` + +완료 조건: + +- partial batch +- invalid target lifecycle +- Provider acceptance not delivery + +### Phase 5: Web Push RC + +```text +RFC 8030/8291/8292 +subscription lifecycle +VAPID +TTL/Urgency/Topic +``` + +완료 조건: + +- RFC vectors +- secret leak test +- expired subscription + +### Phase 6: Advanced Delivery Release + +```text +ordered fallback +retry budget +provider health +reconciliation +admin redrive +credential rotation +``` + +완료 조건: + +- ambiguous fallback block +- provider outage retry amplification guard +- rotation drain + +### Phase 7: Inbox·Preference Release + +```text +Inbox +Preference +Consent +Suppression admin +WebSocket signal integration +``` + +완료 조건: + +- unread consistency +- tenant isolation +- WebSocket outage durability + +--- + +## 49. 구현 결정 원장 + +| ID | 결정 | +|---|---| +| NOTIF-ADR-001 | Core submit 성공은 durable acceptance만 의미한다. | +| NOTIF-ADR-002 | Request, Recipient, Attempt를 별 엔터티로 둔다. | +| NOTIF-ADR-003 | ProviderEvent는 append-only ledger다. | +| NOTIF-ADR-004 | 단일 선형 delivery enum을 사용하지 않는다. | +| NOTIF-ADR-005 | Ambiguous submission은 Core 1급 상태다. | +| NOTIF-ADR-006 | Ambiguous 상태의 자동 retry/fallback을 기본 금지한다. | +| NOTIF-ADR-007 | FCM은 FID 우선, token legacy compatibility다. | +| NOTIF-ADR-008 | Core-owned durable scheduler를 기본으로 한다. | +| NOTIF-ADR-009 | Contact Point는 AES-GCM + HMAC lookup으로 보호한다. | +| NOTIF-ADR-010 | Callback은 signature 검증 후 원장 append, 그 뒤 projection한다. | +| NOTIF-ADR-011 | Template version은 submit 시 고정한다. | +| NOTIF-ADR-012 | Retry, Dedup, Collapse를 별 기능으로 둔다. | +| NOTIF-ADR-013 | Provider SDK type은 N1/N2에 노출하지 않는다. | +| NOTIF-ADR-014 | In-App DB가 source of truth다. | +| NOTIF-ADR-015 | Webhook은 기존 httpclient를 재사용한다. | + +--- + +## 50. 비지원 범위 재확인 + +다음 기능은 구현 계획에 포함하지 않는다. + +```text +캠페인 UI와 세그먼트 엔진 +법률 규정 판정 엔진 +사용자 등급별 발송 정책 +실시간 WebSocket 서버 구현 +Email reputation 자동 최적화 엔진 +Provider 가격 비교·자동 비용 최적화 +무조건적인 multi-provider failover +Provider acceptance 이후 중복 없는 자동 재전송 +모든 채널의 최종 delivery/read 통일 +Provider raw SDK의 일반 공개 +파일 bytes 저장 +``` + +--- + +## 51. 완료 정의 + +### 51.1 공개 계약 + +- N1 Typed API로 네 Stable 채널을 제출할 수 있다. +- N2는 durable schedule, cancel, ordered fallback을 제공한다. +- Provider SDK 객체가 public API에 없다. +- submit 성공과 delivery success가 구분된다. + +### 51.2 상태·신뢰성 + +- Request/Recipient/Attempt/Event가 독립 ID를 가진다. +- Callback 중복·역순이 Projection을 손상시키지 않는다. +- AMBIGUOUS가 명시적으로 저장된다. +- Retry/Fallback이 증거와 expiry를 기반으로 판정된다. +- Reconciliation 결과가 event ledger를 거쳐 반영된다. + +### 51.3 채널 + +- SMTP/SES/Twilio/FCM/APNs/Web Push Contract Suite가 통과한다. +- FCM FID와 legacy token이 분리된다. +- APNs/FCM success를 DELIVERED로 매핑하지 않는다. +- Web Push TTL·encryption·VAPID가 검증된다. + +### 51.4 보안·운영 + +- Contact Point 원문 암호화와 HMAC lookup이 적용된다. +- 로그·Metric·Trace에서 PII/secret 스캔이 통과한다. +- Callback signature/replay defense가 통과한다. +- Provider credential rotation이 runtime generation으로 검증된다. +- N4 작업은 별도 권한과 audit를 요구한다. + +### 51.5 장애·성능 + +- Provider accepted 후 response loss가 AMBIGUOUS로 재현된다. +- process kill 후 durable queue가 복구된다. +- Provider outage에서 retry storm이 발생하지 않는다. +- callback burst, scheduled burst에서 bounded resource를 유지한다. +- external Provider 없이 contract CI가 결정적으로 실행된다. + +--- + +## 부록 A. 요구사항 원본 추적본 + +아래 내용은 설계 결정의 근거와 누락 방지를 위해 첨부된 심층 리서치를 그대로 보존한 추적 부록이다. 본문의 구현 계약이 우선하며, 부록의 연구 문장은 근거와 채널별 제약을 제공한다. + + +## 조사 결론과 지원 경계 + +이번 `notification` 모듈의 가장 중요한 설계 결론은 **`send()` 추상화가 아니라 “알림 의도 → 수신자별 전달 → 공급자 시도 → 비동기 전달 증거 → 실패·재처리·억제”의 전체 수명주기를 소유하는 플랫폼**으로 만들어야 한다는 것입니다. + +이 결론은 채널별 실제 보장이 크게 다르기 때문에 중요합니다. Amazon SES는 API 요청을 성공적으로 받아 `MessageId`를 반환한 뒤에도 바이러스나 잘못된 템플릿 개인화 때문에 실제 발송을 하지 않을 수 있다고 명시합니다. Twilio도 `accepted/queued`, `sent`, `delivered/undelivered`를 서로 다른 단계로 관리합니다. Apple은 APNs가 알림 전달을 시도하지만 전달을 보장하지 않으며, APNs가 요청을 수락한 뒤에도 저장·폐기·후속 전달이 발생할 수 있다고 설명합니다. Web Push 역시 push service의 메시지 수락과 user agent acknowledgement를 별도의 receipt 메커니즘으로 구분합니다. citeturn14search0turn16search6turn13search1turn13search5turn12search0 + +따라서 Core에 `DELIVERED=true`, `exactlyOnce=true` 같은 단순 옵션을 두는 것은 잘못된 추상화입니다. 플랫폼이 반환해야 하는 핵심은 **“현재까지 어떤 증거를 확보했는가”**이며, `PROVIDER_ACCEPTED`, `DELIVERED`, `READ`는 서로 다른 증거입니다. + +**권장 경계는 다음과 같습니다.** + +```text +Domain / Application + │ + │ "이 사용자에게 이런 알림을 보내라" + ▼ +Notification Platform + ├─ Request / Recipient / Attempt 수명주기 + ├─ Contact Point + ├─ Template / Localization + ├─ Idempotency / Suppression + ├─ Scheduling / Expiration + ├─ Routing / Fallback + ├─ Provider Adapter + ├─ Callback / Receipt / Reconciliation + ├─ Retry / Rate Limit / Backpressure + ├─ Audit / Metrics / Trace + │ + ├─ Email ── SMTP / SES / ... + ├─ SMS ── Twilio / ... + ├─ Push ── FCM / APNs + ├─ WebPush + └─ Inbox +``` + +반대로 **누구에게 어떤 업무 알림을 보낼지, 마케팅 세그먼트를 어떻게 만들지, 특정 국가의 법률상 동의가 필요한지, 무료·유료 회원의 우선 채널이 무엇인지는 Core 책임이 아니어야 합니다.** Core는 그런 판단을 주입할 Port와 결과를 기록할 Primitive를 제공합니다. + +### 채널·공급자 지원 매트릭스 + +| 영역 | 기준 구현 | 권장 등급 | Core가 보장할 수 있는 최대 기본 증거 | 비고 | +|---|---|---|---|---| +| Email | SMTP + Amazon SES HTTP API | **Stable** | SMTP/Provider 수락, provider가 제공하면 recipient mail server 전달·bounce·complaint | SES `Delivery`는 recipient mail server까지의 전달이지 inbox/read 보장이 아님. citeturn14search0turn14search8 | +| SMS | Twilio Programmable Messaging | **Stable** | accepted/queued → sent → carrier DLR 기반 delivered/undelivered | callback 순서 보장 없음. citeturn16search0turn16search3 | +| Mobile Push / Android 계열 | FCM | **Stable** | FCM handoff/acceptance + 명시적 오류 | 일반 서버 API 결과를 device delivery로 승격하면 안 됨. Admin SDK는 handoff 실패와 부분 실패를 구분. citeturn15search2turn15search4 | +| Mobile Push / Apple | APNs | **Stable** | APNs acceptance | APNs 수락 후 저장·폐기·전달될 수 있고 전달 자체는 보장되지 않음. citeturn13search1turn13search5 | +| Web Push | RFC 8030/8291/8292 | **Stable** | push service acceptance; receipt 지원 시 user-agent acknowledgment | receipt 실제 제공 여부는 push-service capability로 취급. citeturn12search0turn20search0turn20search1 | +| In-App Inbox | 자체 저장소 | **선택 Stable / 별도 모듈** | persisted/available, 명시적 seen/read | 외부 Provider가 아니라 애플리케이션 데이터 저장 문제 | +| Webhook | 기존 `httpclient` 재사용 | **Extension** | 상대 시스템 HTTP 계약에 따름 | Notification Core가 HTTP stack을 다시 만들지 않음 | +| Kakao 알림톡·WhatsApp·RCS·MMS | Adapter | **Experimental/확장** | 공급자별 | Core 의미론을 바꾸지 않음 | +| Slack·Teams·Discord | Adapter | **Experimental/확장** | 공급자별 | 사람 대상 협업 채널이지만 Core N3 확장으로 적절 | +| Provider failover | SES→다른 Email Provider 등 | **Experimental** | 첫 시도 상태에 따라 다름 | 첫 공급자 결과가 `AMBIGUOUS`이면 중복 발송 위험 | +| “Exactly once notification” | 없음 | **비지원** | 보장 불가능 | Provider acceptance와 사용자 전달 사이에 플랫폼 밖 구간 존재 | + +여기서 특히 **FCM의 지원 모델에는 2026년 기준 변화가 있습니다.** 2026년 8월 현재 Firebase Admin SDK 문서는 Firebase Installation ID(FID)를 권장하고 registration token 기반 multicast API를 deprecated/obsolete 경로로 표시하고 있습니다. Node Admin SDK 14 계열은 2026년 6월에 FID 기반 메시징 타입을 추가하고 기존 token 기반 타입을 deprecated 처리했습니다. 따라서 Core 타입을 지금 `FcmRegistrationToken` 하나로 고정하는 것은 피해야 합니다. citeturn15search0turn15search2turn15search6turn15search11 + +권장 모델은 다음입니다. + +```text +MobilePushTarget +├─ FCM_FID +├─ FCM_REGISTRATION_TOKEN_LEGACY +└─ APNS_DEVICE_TOKEN +``` + +이는 공급자 변경을 숨기기 위한 추상화가 아니라 **Contact Point의 식별 방식 자체가 변할 수 있음을 모델에 반영하기 위한 것**입니다. + +### 공개 계층 최종 권고 + +| 계층 | 대상 | 노출 범위 | 권고 | +|---|---|---|---| +| N1 | 일반 애플리케이션 | Typed Email/SMS/Push/WebPush API | **기본 진입점** | +| N2 | 고급 기능 | Schedule, Fallback, Batch, Multi-channel, Cancel | Stable은 기능별 capability 확인 | +| N3 | 공급자 특수 기능 | FCM topic, APNs push-type, SMS sender pool, SES configuration set | 제한형 Extension | +| N4 | 운영자 | Redrive, Reconcile, Suppression override, Provider disable | Admin Plane | + +일반 코드에 `Firebase Message`, `MimeMessage`, Twilio SDK 객체를 그대로 노출해서는 안 됩니다. Spring의 `JavaMailSender`와 `MimeMessageHelper`는 SMTP Adapter를 구현하기에는 유용하고 attachment·inline resource도 지원하지만, 이것을 공통 Notification API로 노출할 이유는 없습니다. citeturn14search1turn14search9 + +### Stable·Experimental·비지원 범위 + +**Stable**에는 Typed channel API, Request/Recipient/Attempt 분리, event ledger 기반 상태 추적, idempotency, provider callback ingestion, Core-owned durable scheduling, expiration, suppression primitives, Email SMTP/SES, SMS Twilio, FCM/APNs, 표준 Web Push, metric/audit를 두는 것이 적절합니다. + +**Experimental**에는 모호한 시도 이후의 provider failover, Parallel First-success, provider-native scheduling, APNs broadcast channel 같은 고유 기능, application-generated push delivery receipt, Web Push receipt의 공급자별 활용, 신규 Kakao/RCS/WhatsApp Adapter를 두는 편이 안전합니다. Apple의 Broadcast Push Channel은 Live Activity에 특화되어 있고 별도 환경·채널 수명주기를 가지므로 일반 Mobile Push Core에 흡수하기보다 N3 capability가 적절합니다. citeturn19search2 + +**Core 비지원**은 업무 대상자 선정, 캠페인 segmentation, 국가별 법률 판정, guaranteed delivery, guaranteed read, exactly-once human notification, Provider SDK raw type의 일반 노출, 파일 자체 저장, 브라우저 WebSocket 연결, 시스템 간 일반 메시징이어야 합니다. + +## 공개 API와 데이터·상태 계약 + +### Notification Core 공개 API 초안 + +단일 `NotificationService.send(Map)`보다 **채널별 Typed facade + 공통 내부 Core + Advanced Orchestrator**를 권장합니다. + +```java +public interface EmailNotifier { + NotificationReceipt send(EmailNotification notification); +} + +public interface SmsNotifier { + NotificationReceipt send(SmsNotification notification); +} + +public interface MobilePushNotifier { + NotificationReceipt send(MobilePushNotification notification); +} + +public interface WebPushNotifier { + NotificationReceipt send(WebPushNotification notification); +} + +public interface NotificationOrchestrator { + NotificationReceipt submit(NotificationPlan plan); + NotificationReceipt schedule(NotificationPlan plan, Instant scheduleAt); + CancelResult cancel(NotificationId notificationId); + NotificationSnapshot get(NotificationId notificationId); +} +``` + +여기서 `send()`의 반환값은 **최종 전송 결과가 아니라 플랫폼이 durable하게 요청을 받아들였다는 결과**로 정의하는 것이 안전합니다. + +```java +public record NotificationReceipt( + NotificationId notificationId, + RequestStatus status, + Instant acceptedAt +) {} +``` + +즉: + +```text +send() 성공 +≠ SES가 Email을 전달함 +≠ Twilio carrier가 SMS를 전달함 +≠ FCM/APNs가 기기에 표시함 +≠ 사용자가 읽음 +``` + +SES는 API 성공과 실제 발송을 명시적으로 구분하고, APNs도 수락 이후 저장·폐기·전달을 구분하므로 이 차이는 API 계약 자체에 반영되어야 합니다. citeturn14search0turn13search5 + +채널 Content도 sealed hierarchy로 분리하는 것이 적절합니다. + +```java +public sealed interface NotificationContent + permits EmailContent, SmsContent, MobilePushContent, + WebPushContent, InAppContent { +} + +public record EmailContent( + String subject, + String textBody, + String htmlBody, + List attachments, + EmailOptions options +) implements NotificationContent {} + +public record SmsContent( + String text, + SmsOptions options +) implements NotificationContent {} + +public record MobilePushContent( + String title, + String body, + URI deepLink, + Map data, + PushPresentation presentation +) implements NotificationContent {} +``` + +공급자 고유 필드를 여기 넣지 말고 N3에서 확장합니다. + +```java +public interface ProviderExtension { + T capabilities(); +} +``` + +### Notification·Recipient·Attempt 데이터 모델 + +세 엔터티는 반드시 독립 ID와 독립 수명주기를 가져야 합니다. + +| 엔터티 | 의미 | 대표 필드 | +|---|---|---| +| `NotificationRequest` | 애플리케이션이 요청한 논리 알림 | `notificationId`, `tenantId`, `idempotencyKey`, `category`, `templateId`, `templateVersion`, `scheduleAt`, `expiresAt`, `correlationId` | +| `RecipientDelivery` | 한 수신자에게 전달할 논리 작업 | `recipientDeliveryId`, `notificationId`, `recipientRef`, `routingPlan`, `deliveryState`, `evidence` | +| `DeliveryAttempt` | 특정 채널·Provider로 한 번 수행한 물리 시도 | `attemptId`, `recipientDeliveryId`, `channel`, `provider`, `providerRequestId`, `attemptNo`, `submissionOutcome`, `failureCategory` | +| `ProviderEvent` | 비동기 callback/receipt의 원본 이벤트 | `providerEventId`, `providerRequestId`, `providerOccurredAt`, `receivedAt`, `rawDigest`, `verified` | +| `ContactPoint` | 실제 주소/토큰/구독 | `contactPointId`, `ownerId`, `type`, `provider`, `environment`, `status`, `lastConfirmedAt` | +| `RenderedContent` | 특정 Template 버전을 사용해 생성한 결과 | `templateId`, `templateVersion`, `locale`, `contentDigest`, 필요 시 암호화 snapshot | + +`providerRequestId`와 `attemptId`도 분리해야 합니다. 요청을 보냈지만 응답을 잃은 경우 Provider ID를 얻지 못할 수도 있기 때문입니다. 같은 이유로 `notificationId = providerRequestId` 방식도 피해야 합니다. + +### 단일 상태 머신 대신 “상태 + 증거 + 이벤트 원장” + +사용자가 제안한 다음 상태들은 API projection으로는 유용합니다. + +```text +PENDING +SUPPRESSED +QUEUED +DISPATCHING +PROVIDER_ACCEPTED +PROVIDER_REJECTED +SENT +DELIVERED +UNDELIVERED +BOUNCED +COMPLAINT +READ +EXPIRED +CANCELED +UNKNOWN +``` + +다만 이것을 **하나의 선형 enum 상태 머신으로 저장하는 것은 권장하지 않습니다.** + +예를 들어 Email은 `DELIVERY` 후 `COMPLAINT`가 발생할 수 있습니다. Twilio callback은 네트워크 지연 때문에 순서대로 도착한다는 보장이 없습니다. APNs의 경우 수락한 알림이 나중에 저장되거나 폐기될 수 있습니다. 따라서 단순히 `ordinal(new) > ordinal(old)` 규칙으로 상태를 갱신하면 실제 정보를 잃게 됩니다. citeturn14search8turn16search0turn13search5 + +권장 구조는 다음입니다. + +```text +ProviderEvent append-only ledger + │ + ▼ +Channel-specific projector + │ + ├─ SubmissionOutcome + ├─ DeliveryOutcome + ├─ EvidenceLevel + ├─ EngagementFacts + └─ SuppressionFacts +``` + +`DeliveryAttempt`에는 별도로 다음 완료 결과가 필요합니다. + +```text +CONFIRMED +- Provider의 명시적 결과를 받음 + +REJECTED +- Provider가 확실하게 수락하지 않음 + +AMBIGUOUS +- 요청 전송 여부 또는 Provider 수락 여부를 확정할 수 없음 +``` + +그리고 Submission과 Delivery를 분리합니다. + +```java +enum SubmissionOutcome { + NOT_SUBMITTED, + CONFIRMED_ACCEPTED, + CONFIRMED_REJECTED, + AMBIGUOUS +} + +enum DeliveryOutcome { + UNKNOWN, + SENT, + DELIVERED, + UNDELIVERED, + BOUNCED, + EXPIRED +} +``` + +### 전달 증거 매핑표 + +| 채널 | PLATFORM_QUEUED | PROVIDER_ACCEPTED | NETWORK_OR_CARRIER_ACCEPTED | DEVICE_DELIVERED | USER_AGENT_DISPLAYED | USER_READ | +|---|---|---|---|---|---|---| +| Email/SES | 내부 queue | SES `MessageId` | SES `Delivery`: recipient mail server 수락 | 직접 증거 없음 | 직접 증거 없음 | Open은 별도 engagement telemetry로만 취급 | +| SMS/Twilio | 내부 queue | `accepted/queued` | `sent` | `delivered` DLR | 보통 직접 증거 없음 | 일반 SMS는 없음 | +| FCM | 내부 queue | Admin SDK가 FCM에 handoff | 공급자 내부 | 일반 send API로 확정 불가 | 기본 서버 API로 확정 불가 | 앱 callback을 별도로 구현해야 함 | +| APNs | 내부 queue | APNs HTTP 성공 | APNs 내부 | 일반 provider API로 보장 불가 | 앱 측 관측 필요 | 앱 interaction event 필요 | +| Web Push | 내부 queue | Push service `201` | Push service 저장/전달 | RFC receipt 지원 시 UA acknowledgement | 브라우저 앱 instrumentation | 앱 event 필요 | +| In-App | 저장 대기 | 자체 저장 완료 | 해당 없음 | 해당 없음 | `seen` API | `read` API | + +SES의 `Delivery`는 recipient의 mail server까지 전달했다는 의미이며 최종 inbox 표시를 의미하지 않습니다. Twilio는 SMS `sent`, `delivered`, `undelivered`를 별도로 정의하고 delivery callback에는 carrier DLR 정보가 포함될 수 있습니다. citeturn14search8turn16search0turn16search3 + +FCM Admin SDK는 실패를 “FCM으로 전달하기 위한 handoff 과정의 오류”로 표현하고, multicast에서 개별 성공·실패를 별도로 반환합니다. 따라서 FCM send 성공을 `DEVICE_DELIVERED`로 해석해서는 안 됩니다. citeturn15search2turn15search4 + +Apple도 APNs가 알림을 적시에 전달하기 위해 노력하지만 전달 자체는 보장하지 않는다고 설명하며, 최신 Metrics 문서는 APNs acceptance 이후 알림이 delivered, discarded, persistent storage 중 하나로 갈 수 있음을 보여줍니다. citeturn13search1turn13search5 + +Web Push RFC 8030은 application server의 push 요청 수락과 별개로 delivery receipt를 정의하고, user agent가 메시지를 acknowledge하면 receipt stream에 `204`가 전달될 수 있습니다. 그러나 이 기능을 모든 실제 브라우저 push service가 동일하게 노출한다고 Core가 가정해서는 안 되므로 `DeliveryReceiptCapability`로 모델링하는 것이 안전합니다. citeturn12search0 + +### Contact Point 생명주기 + +권장 타입은 다음과 같습니다. + +```text +EmailAddress +PhoneNumber +MobilePushTarget +WebPushSubscription +InAppRecipient +``` + +공통 상태는 단순 `active=true/false`보다 다음이 적합합니다. + +```text +UNVERIFIED +ACTIVE +STALE +INVALID +SUPPRESSED +REVOKED +DELETED +``` + +FCM의 경우 2026년 현재 FID를 중심으로 보는 것이 중요합니다. Firebase는 FID가 uninstall/reinstall, cache 삭제, 장기 미사용 등으로 rotate/delete될 수 있고 현재 문서에서는 장기 inactivity 기준으로 270일을 설명합니다. 따라서 저장소에는 “등록 당시 값”만 두지 말고 `lastConfirmedAt`, `lastSuccessfulDeliveryAt`, `invalidatedAt`, `targetKind`, `providerEnvironment`를 함께 관리해야 합니다. citeturn15search7turn15search0 + +APNs device token도 주기적으로 바뀔 수 있으므로 Apple은 로컬에 영구 캐시하지 말고 등록 결과를 서버에 전달할 것을 권고합니다. 개발·운영 APNs 환경도 논리적으로 분리해야 합니다. citeturn19search9turn19search10 + +Web Push subscription은 push service가 언제든 만료시킬 수 있고, RFC 8030은 expired subscription에 application server가 보내면 `404 Not Found`를 반환하도록 규정합니다. 따라서 흔히 구현체에서 보이는 `410`만 Core 규칙으로 고정해서는 안 됩니다. 표준 Core는 `404 = expired subscription`을 지원하고, `410` 등은 Provider Adapter가 해당 서비스의 문서에 따라 `INVALID`로 추가 매핑하도록 하는 편이 맞습니다. citeturn12search2 + +권장 Contact Point 식별 방식은 다음과 같습니다. + +```text +실제 address/token +→ 암호화 저장 + +normalized address hash +→ equality / dedup / suppression lookup + +contactPointId +→ 애플리케이션·API 노출 +``` + +즉, 이메일·전화번호·device token 자체를 primary key나 외부 API identifier로 사용하지 않습니다. + +## 템플릿·라우팅·중복·스케줄링 정책 + +### Template·Localization 계약 + +공통 Template의 최소 계약은 다음 정도가 적합합니다. + +```java +record NotificationTemplate( + String templateId, + long version, + Channel channel, + Locale locale, + String fallbackLocale, + VariableSchema variables, + ContentDefinition content, + TemplateStatus status +) {} +``` + +발송 시점에는 반드시 다음 조합을 고정해야 합니다. + +```text +templateId ++ templateVersion ++ locale ++ normalized variables ++ renderedContentDigest +``` + +템플릿이 이후 수정되더라도 이미 생성된 Notification의 의미가 바뀌면 안 되기 때문입니다. 이 정책은 retry와 redrive에서도 중요합니다. 기본 redrive는 **현재 최신 Template을 다시 렌더링하는 작업이 아니라 원래 논리 알림을 재실행하는 것**으로 보는 것이 안전합니다. + +Provider Template은 N3로 제한하는 것이 좋습니다. Provider 측 Template을 직접 사용하면 최종 렌더링 내용을 플랫폼이 재현하기 어려울 수 있으므로, provider template ID와 version/revision을 반드시 Attempt에 기록해야 합니다. + +Localization은 다음 순서가 적절합니다. + +```text +Recipient locale +→ 정확한 locale template +→ language-only locale +→ template fallback locale +→ platform default +→ 실패 +``` + +FCM 자체도 `*_loc_key`, `*_loc_args`로 클라이언트 리소스 기반 localization을 지원하지만, 이는 동일 Template 버전의 실제 최종 문자열이 앱 버전에 따라 달라질 수 있습니다. 따라서 **서버가 감사 가능한 정확한 문구를 통제해야 하는 알림은 서버 렌더링을 기본값으로 하고, FCM client-side localization은 N3 capability로 두는 편이 낫습니다.** citeturn15search8 + +채널별 renderer는 별도로 두어야 합니다. + +| 채널 | 발송 전 검증 | +|---|---| +| Email | subject, text/html MIME, character encoding, attachment reference, header injection | +| SMS | GSM-7/UCS-2 판정, segment 수, 길이·비용 estimate | +| Mobile Push | title/body/data size, platform override, deep link, collapse | +| Web Push | encrypted payload 크기, TTL, urgency, topic | +| In-App | UI-independent semantic content, deep link/action schema | + +Spring의 `MimeMessageHelper`는 multipart attachment와 inline resource를 제공하므로 SMTP adapter 구현 재료로는 적합합니다. 단 attachment 자체의 저장 수명주기는 Notification이 아니라 `fileserver`/`objectstorage`가 담당해야 합니다. citeturn14search1turn14search9 + +### Routing·Fallback 정책 + +지원 primitive는 다음 여섯 가지로 충분합니다. + +```text +EXPLICIT_CHANNEL +ORDERED_FALLBACK +PARALLEL_MULTI_CHANNEL +FIRST_SUCCESS +ALL_REQUIRED +BEST_EFFORT +``` + +그러나 **Stable 기본은 `EXPLICIT_CHANNEL`과 제한형 `ORDERED_FALLBACK`**을 권장합니다. + +Fallback 판정은 아래와 같이 해야 합니다. + +| 첫 Attempt 결과 | 같은 채널 Retry | 다른 채널 Fallback | 기본 정책 | +|---|---:|---:|---| +| 요청 전 명백한 validation 실패 | X | 조건부 | 다른 Contact Point가 있으면 가능 | +| `INVALID_RECIPIENT` | X | O | 해당 Contact Point invalidation 후 fallback | +| `THROTTLED` | O | 보통 X | TTL 내 backoff | +| 명확한 Provider 5xx/일시 장애, 수락 전 | O | 정책에 따라 O | 같은 Provider retry 우선 | +| `CONFIRMED_REJECTED` | 실패 유형에 따라 | O | 안전 | +| `PROVIDER_ACCEPTED` | X | 보통 X | 중복 가능 | +| `AMBIGUOUS_SUBMISSION` | **기본 X** | **기본 X** | reconcile 또는 운영 판단 | +| `DELIVERED` | X | X | 완료 | + +핵심은: + +```text +Push Timeout +→ 곧바로 SMS Fallback +``` + +이 기본 동작이 위험하다는 것입니다. Push가 이미 Provider에 접수됐는데 응답만 유실됐다면 SMS와 Push가 모두 사용자에게 도착할 수 있습니다. + +Email 공급자 A의 응답이 유실됐다고 공급자 B로 즉시 재전송하는 것도 같은 문제입니다. 특히 SES는 “API accepted”와 실제 send가 이미 분리되어 있으므로 Provider 경계를 넘는 fallback은 더욱 보수적으로 다뤄야 합니다. citeturn14search0 + +따라서 `RecipientDelivery`에 다음 필드가 실제로 필요합니다. + +```text +fallbackAllowed +fallbackReason +previousAttemptOutcome +ambiguousAttemptExists +duplicateRisk +``` + +`FIRST_SUCCESS`도 이름과 달리 진정한 first-success가 아닐 수 있습니다. 두 Provider에 병렬로 제출한 순간 둘 다 취소 불가능한 상태가 될 수 있기 때문에, 이를 Stable 기본 primitive로 제공하기보다 **duplicate-tolerant notification 전용 Experimental 기능**으로 두는 것이 맞습니다. + +### Idempotency·Deduplication·Collapse + +세 개는 완전히 다른 기능으로 정의해야 합니다. + +| 기능 | 질문 | 권장 구현 | +|---|---|---| +| Idempotency | “같은 API 요청인가?” | `(tenantId, idempotencyKey)` unique + request fingerprint | +| Deduplication | “서로 다른 요청이지만 같은 사용자 알림인가?” | `dedupKey + recipient + window` | +| Collapse/Coalescing | “아직 전달되지 않은 옛 알림을 새 알림으로 대체할 것인가?” | Provider capability | + +Idempotency에서는 같은 key와 같은 fingerprint면 기존 `notificationId`를 반환하고, **같은 key인데 payload/template/recipient가 다르면 충돌 오류**를 반환하는 것이 안전합니다. + +```text +idempotencyKey = abc +request A = "결제 완료 10,000원" + +같은 key +request B = "결제 완료 100,000원" + +→ 기존 성공 반환 X +→ IDENTITY_CONFLICT +``` + +Deduplication은 업무 의미가 개입되므로 Opt-in이어야 합니다. + +```text +dedupKey = "order:123:shipping-delayed" +window = 30m +recipient = user-42 +``` + +Collapse는 Provider transport 최적화입니다. FCM은 미전달 collapsible message를 새 메시지로 교체할 수 있고, 전송 순서 자체는 보장하지 않습니다. FCM 문서에 따르면 Android의 collapse key, APNs의 `apns-collapse-id`, Web Push의 `Topic`을 각각 활용할 수 있습니다. citeturn15search3 + +Web Push RFC 8030도 같은 `Topic`의 outstanding message를 새로운 message resource로 대체하도록 정의합니다. citeturn12search0 + +중요한 규칙은: + +> **Collapse는 Deduplication이 아니다.** + +이전 메시지가 이미 기기에 도달했다면 collapse는 아무 효과가 없습니다. 따라서 `collapseKey`가 있다고 “사용자가 하나만 받는다”고 보장해서는 안 됩니다. + +### Scheduling·TTL·Expiration + +권장 구조는 **플랫폼 자체 Durable Scheduler를 Stable 기본값**으로 두는 것입니다. + +```text +Notification DB + SCHEDULED + │ + │ scheduleAt 도래 + ▼ + READY_TO_DISPATCH + │ + ▼ + Provider +``` + +Provider Native Scheduling은 N3입니다. 예를 들어 Twilio는 `scheduled` 상태를 제공하고 send time 이전 cancel을 지원하지만, FCM의 console scheduling도 실제 fan-out이 시작된 뒤에는 취소할 수 없습니다. Provider마다 “예약했다”와 “취소 가능하다”의 의미가 달라 공통 Core를 Provider schedule에 종속시키면 일관된 수명주기를 만들기 어렵습니다. citeturn16search6turn15search13 + +Core의 세 시각은 분리해야 합니다. + +```text +scheduleAt +→ 플랫폼이 발송을 시작할 수 있는 시각 + +notBefore +→ 이보다 먼저 Provider에 제출하면 안 됨 + +expiresAt +→ 이 시각 이후에는 새 Retry/Fallback/Dispatch 금지 +``` + +그리고 Adapter가 실제 Provider TTL로 변환합니다. + +```text +providerTTL = + min( + expiresAt - now, + providerMaximumTTL, + channelPolicyMaximumTTL + ) +``` + +Web Push에서는 TTL header가 선택이 아니라 프로토콜 필수이며, 누락 시 push service가 `400`을 반환해야 합니다. Push service는 요청보다 더 짧은 실제 TTL을 선택할 수 있고 TTL이 끝난 메시지를 더 이상 전달하려 해서는 안 됩니다. citeturn12search0 + +FCM은 message lifespan과 collapse를 제공하고, APNs도 expiry를 이용해 저장된 알림 수명을 제어합니다. Apple의 현재 Metrics 문서는 persistent storage에서 명시된 expiry가 없을 경우 최대 30일 TTL을 언급하지만, Core에서 이를 공통 기본값으로 하드코딩해서는 안 됩니다. citeturn15search5turn19search8 + +모든 Retry는 다음 검사를 먼저 해야 합니다. + +```text +now + nextBackoff + estimatedDispatchDuration < expiresAt +``` + +거짓이면 `EXPIRED`로 종료합니다. + +## 채널별 기능과 운영 매트릭스 + +### Email 기능·운영 매트릭스 + +| 기능 | SMTP | SES API 기준 | Core 정책 | +|---|---|---|---| +| Text | O | O | Stable | +| HTML | O | O | Stable | +| multipart/alternative | O | O | Stable | +| Attachment | O | O | Ref 기반 Stable | +| Inline Resource | O | O | Ref 기반 Stable | +| CC/BCC/Reply-To | O | O | Typed field | +| Provider Template | 해당 없음 | O | N3 | +| Bulk personalization | 직접 구현 | Provider 지원 | N2/N3 | +| Provider accepted 확인 | SMTP final response | `MessageId` | `PROVIDER_ACCEPTED` | +| Delivery | DSN/bounce 인프라 필요 | Delivery event | 별도 evidence | +| Bounce | DSN/Return Path | event | suppression 연동 | +| Complaint | SMTP 자체로 부족 | event | 즉시 suppression 후보 | +| Open/Click | 외부 기능 | event publishing 가능 | reliability 상태와 분리 | +| Retry | SMTP code 기반 | API 오류 기반 | 공통 RetryPolicy | +| List unsubscribe | MIME header | 가능 | Bulk/subscription mail capability | + +SMTP의 기본 오류 분류는 프로토콜 자체에서 4yz와 5yz로 나뉩니다. RFC 5321은 4yz가 같은 요청을 다시 시도하면 성공할 수 있는 임시 실패이고 client가 retry해야 하는 범주이며, 5yz는 동일한 요청을 그대로 반복해서는 안 되는 영구 실패로 설명합니다. citeturn18search2 + +단, SMTP에서도 네트워크가 **최종 수락 응답 경계에서 끊어지면 실제 Provider가 메시지를 받은 것인지 모호한 상태**가 생길 수 있으므로 `SMTP_SEND_FAILED = safeRetry`로 단순화해서는 안 됩니다. 이는 `AMBIGUOUS_SUBMISSION` 모델이 HTTP Provider에만 필요한 것이 아니라는 뜻입니다. + +SES의 `200 + MessageId`는 accepted 증거지만 최종 전달 증거가 아니며, SES 자체가 accept 이후 virus 또는 invalid template personalization으로 send하지 않을 수 있다고 명시합니다. citeturn14search0 + +SES event publishing은 reject, delivery, bounce, complaint, delivery delay 등을 분리합니다. `Delivery`는 recipient mail server까지 전달됐다는 뜻이고, `Complaint`는 그 후 사용자가 spam으로 신고한 상황이므로 이 역시 앞서 설명한 “단일 선형 상태 enum”이 부적절한 이유입니다. citeturn14search8 + +대량·구독형 Email의 one-click unsubscribe를 지원한다면 RFC 8058 규칙을 별도 capability로 구현해야 합니다. `List-Unsubscribe`의 HTTPS URI와 `List-Unsubscribe-Post`를 사용하고, 해당 헤더들이 유효한 DKIM signature에 포함되어야 합니다. citeturn14search2turn14search4 + +DKIM 자체는 도메인이 메시지에 대한 책임을 cryptographic signature로 선언하고 DNS에서 공개 키를 검색해 검증하는 표준입니다. Notification Core가 DKIM을 업무 API에 노출하기보다 `SenderIdentity`의 운영 준비 상태로 관리하는 것이 적절합니다. citeturn18search0 + +```text +SenderIdentity +├─ domain +├─ fromAddress +├─ replyTo +├─ provider +├─ dkimStatus +├─ spfStatus +├─ dmarcStatus +└─ enabled +``` + +SPF/DKIM/DMARC 정책 자체는 Email delivery infrastructure 영역이고, Core는 “이 Sender가 Production 발송에 적합한가”를 Admin/Health Gate로 확인하는 역할이 적절합니다. + +### SMS 기능·운영 매트릭스 + +| 항목 | Stable 정책 | +|---|---| +| 주소 | E.164 normalized phone number | +| 실제 번호 유효성 | 형식 검증과 별도; Provider lookup/verification capability | +| Sender | number/sender ID/short code 등 adapter profile | +| 본문 | Unicode String | +| Encoding 분석 | GSM-7/UCS-2 사전 계산 | +| Segment 예상 | 필수 | +| 예상 비용 정보 | Provider pricing과 분리하되 segment count 제공 | +| Delivery callback | Stable | +| Callback ordering | 순서 의존 금지 | +| Recipient invalidation | provider error 기반 ContactPoint invalid | +| Opt-out | 내부 suppression + Provider suppression 동기화 | +| MMS/RCS | 별도 Adapter/Extension | + +Twilio는 수신 번호를 E.164 형태로 받으며, 상태를 `accepted`, `queued`, `sending`, `sent`, `failed`, `delivered`, `undelivered` 등으로 구분합니다. citeturn16search3 + +SMS는 문자열 `.length()`로 비용이나 전송 크기를 계산하면 안 됩니다. Twilio의 설명처럼 GSM-7이면 일반 단일 segment가 160문자이며 concatenated segment는 153자, UCS-2는 각각 70/67자가 기준이 됩니다. 하나의 비-GSM 문자가 전체 메시지를 UCS-2로 바꿔 segment 수를 크게 늘릴 수 있습니다. citeturn16search1turn16search2 + +따라서 N1 API에 `estimate()`를 제공할 가치가 있습니다. + +```java +SmsEstimate estimate(SmsNotification notification); +``` + +예: + +```java +record SmsEstimate( + SmsEncoding encoding, + int segmentCount, + int encodedLength, + boolean exceedsRecommendedLimit +) {} +``` + +Twilio callback은 HTTP 네트워크 지연 때문에 전송된 순서대로 도착한다는 보장이 없습니다. 그러므로 `queued → delivered → sent` 순으로 callback이 실제 endpoint에 도착해도 마지막 `sent`가 상태를 downgrade해서는 안 됩니다. citeturn16search0 + +### FCM·APNs 기능 비교 + +| 항목 | FCM | APNs | +|---|---|---| +| 기준 Target | **2026 현재 FID 권장**, token legacy 경로 존재 | device token | +| 단일 Device | O | O | +| Batch/Multicast | Admin SDK 최대 500 destination, 부분 실패 결과 | Core가 개별 request orchestration | +| Topic/Broadcast | Topic 지원 | 일반 push와 별도 Broadcast/Live Activity capability | +| Notification/Data | 둘 다 지원 | `aps` + custom payload | +| Platform Override | Android/APNs/Web push config | APNs native headers/payload | +| TTL/Expiration | O | `apns-expiration` | +| Collapse | Android collapse key, APNs/Webpush override 가능 | `apns-collapse-id`, 저장 시 교체 의미 | +| Priority | O | `apns-priority` | +| Push Type | platform config | `apns-push-type` | +| Provider Request ID | FCM message result | `apns-request-id` | +| Delivery order | 보장하지 않음 | 보장하지 않음 | +| Server-side final delivery receipt | 일반 send API로 없음 | 일반 provider API로 없음 | +| Invalid target 처리 | `UNREGISTERED` 등 | token-related errors | +| 앱 측 권한 | 플랫폼별 | UserNotifications permission | +| 환경 | Firebase project/app | sandbox/production 분리 | + +FCM은 notification message와 data message를 구분하고 payload는 최대 4096 bytes를 지원한다고 현재 문서에서 설명합니다. citeturn15search1 + +현재 FCM Admin SDK에서 multicast는 최대 500개의 FID/target을 다루고, 응답은 각 input과 대응되는 부분 성공·실패를 제공하므로 `Batch Attempt 1건`이 아니라 **Recipient별 Attempt 결과를 반드시 생성해야 합니다.** citeturn15search0turn15search2 + +또한 Firebase 문서는 FCM이 delivery order를 보장하지 않으며 collapsible message는 아직 전달되지 않은 이전 메시지를 대체할 수 있다고 명시합니다. 따라서 Push를 순차적인 업무 이벤트 전달 수단처럼 사용하면 안 됩니다. citeturn15search3 + +APNs는 일반 remote notification의 timely delivery를 보장하지 않습니다. Apple의 현재 운영 문서는 APNs가 수락한 알림을 전달하거나, 저장하거나, 조건에 따라 폐기할 수 있고 persistent storage에서는 같은 앱·기기의 다른 알림에 의해 기존 알림이 덮어써질 수 있으며 ordering guarantee도 없다고 설명합니다. citeturn13search1turn19search8 + +따라서 다음은 금지해야 합니다. + +```text +APNs HTTP 200 + ↓ +RecipientDelivery.status = DELIVERED // 금지 +``` + +대신: + +```text +APNs HTTP 200 + ↓ +SubmissionOutcome = CONFIRMED_ACCEPTED +Evidence = PROVIDER_ACCEPTED +``` + +사용자가 실제로 notification을 열었다는 증거가 필요하면 앱에서 `UNNotificationResponse` 등 사용자 interaction을 application backend로 다시 보고하는 별도 telemetry가 필요합니다. Apple은 사용자가 알림을 열거나 action을 선택했을 때 앱이 이를 처리할 수 있는 callback API를 제공합니다. citeturn19search6turn19search4 + +### Web Push 표준 지원표 + +| 표준 | 역할 | 지원 등급 | +|---|---|---| +| RFC 8030 | Push subscription, delivery, TTL, urgency, topic, optional receipt | Stable | +| RFC 8291 | Payload encryption | Stable, 필수 | +| RFC 8292 | VAPID application server identification/restricted subscription | Stable | +| RFC 8030 Receipt | User agent acknowledgement | Capability/Experimental 활용 | +| Provider-specific 404/410 처리 | subscription invalidation | Adapter capability | + +RFC 8030은 Push Service와 Application Server 간 통신에 TLS를 요구하고, application server가 push 요청에 `TTL` header를 반드시 넣도록 규정합니다. Push service는 요청 TTL보다 짧은 TTL을 선택할 수도 있습니다. citeturn12search0 + +RFC 8291은 Web Push payload에 P-256 ECDH와 authentication secret을 사용하는 `aes128gcm` 암호화를 정의하며, subscription의 public key와 auth secret 자체도 authenticated confidential channel을 통해 application server로 전달해야 합니다. RFC는 push service가 4096 bytes를 초과한 body를 지원할 필요가 없다고 명시합니다. citeturn20search0 + +RFC 8292 VAPID는 ES256-signed JWT를 이용해 application server의 identity를 표현하고, subscription을 특정 application server key에 제한할 수 있게 합니다. 제한된 subscription의 signing key를 교체하면 새로운 subscription이 필요할 수 있으므로 VAPID key rotation은 단순 credential rotation과 다릅니다. citeturn20search1 + +Web Push endpoint는 일반 URL처럼 취급해서는 안 됩니다. RFC 8030은 push URI를 **knowledge 자체가 권한이 되는 capability URL, 사실상의 bearer token**으로 정의합니다. 따라서 endpoint를 로그·metric·일반 설정에 노출해서는 안 됩니다. citeturn12search0 + +권장 `WebPushSubscription`은 다음과 같습니다. + +```text +subscriptionId +ownerId +endpointEncrypted +endpointHash +p256dhEncrypted +authSecretEncrypted +vapidKeyId +createdAt +lastConfirmedAt +expiredAt +status +``` + +### In-App Inbox 설계 범위 + +In-App은 Provider Adapter가 아니라 별도 `notification-inbox`로 두는 것이 좋습니다. + +```text +notification + │ + └── RecipientDelivery + │ + ▼ + notification-inbox + ├─ persisted + ├─ seen + ├─ read + ├─ archived + └─ expired +``` + +지원 범위는 cursor pagination, unread/read, seen, archive, expire, bulk mark-read, unread count, category, deep link/action 정도입니다. + +`WebSocket`은 새 Inbox item의 실시간 신호일 뿐 source of truth가 되어서는 안 됩니다. + +```text +DB commit +→ Inbox item 존재 + +WebSocket 실패 +→ 화면 실시간 갱신만 실패 +→ Inbox 데이터는 보존 +``` + +대규모 broadcast에서는 fan-out-on-write와 fan-out-on-read를 별도 전략으로 둡니다. 일반 개인 알림은 fan-out-on-write가 단순하고, 수백만 사용자에게 동일한 공지를 복제하는 경우는 broadcast item + per-user state 같은 fan-out-on-read/materialization 방식을 별도 capability로 두는 것이 적절합니다. + +## 실패·재시도·Callback·Reconciliation + +### 공통 오류 모델 + +Provider SDK exception을 그대로 밖으로 노출하지 않고 최소 다음 taxonomy가 필요합니다. + +```text +NotificationException + ├─ NotificationValidationException + ├─ TemplateRenderingException + ├─ InvalidContactPointException + ├─ NotificationExpiredException + ├─ ProviderAuthenticationException + ├─ ProviderAuthorizationException + ├─ ProviderThrottledException + ├─ ProviderTransientException + ├─ ProviderPermanentException + ├─ ProviderRejectedException + ├─ AmbiguousSubmissionException + ├─ CallbackValidationException + └─ NotificationSuppressedException +``` + +실제 retry 판정에 사용하는 category는 다음과 같이 단순화할 수 있습니다. + +| Failure Category | 자동 Retry | Contact Point 변경 | Fallback | 운영 대응 | +|---|---:|---:|---:|---| +| `TRANSIENT_PROVIDER` | O | X | 조건부 | backoff | +| `THROTTLED` | O | X | 보통 X | Retry-After/Provider rate | +| `AUTHENTICATION` | 일반 메시지별 Retry X | X | X | Provider route 중단·credential 점검 | +| `AUTHORIZATION` | X | X | X | ACL/config 점검 | +| `INVALID_RECIPIENT` | X | **O** | O | invalidate/suppress | +| `INVALID_PAYLOAD` | X | X | X | 개발 오류 | +| `TEMPLATE_FAILURE` | X | X | X | template 수정 필요 | +| `PERMANENT_PROVIDER` | X | 상황별 | 정책에 따라 | admin | +| `AMBIGUOUS_SUBMISSION` | **기본 X** | X | **기본 X** | reconcile | +| `CALLBACK_VALIDATION_FAILURE` | callback 처리 X | X | X | security alert | + +FCM은 `QUOTA_EXCEEDED`, `UNAVAILABLE`, `UNREGISTERED`, 인증 관련 오류 등을 구분합니다. 현재 공식 문서는 `429 QUOTA_EXCEEDED`에는 exponential backoff를 적용하고, `503 UNAVAILABLE`에서는 `Retry-After`를 존중하고 jitter를 사용하도록 안내합니다. `UNREGISTERED`는 해당 target이 더 이상 유효하지 않음을 의미합니다. citeturn21search1turn21search4 + +따라서 Retry는 단순: + +```text +429 || 5xx → retry +``` + +가 아니라: + +```text +retryableFailure +AND submissionIsSafeToRepeat +AND recipientStillValid +AND !fallbackAlreadyCommitted +AND deadlineRemaining +AND retryBudgetRemaining +``` + +이어야 합니다. + +### 모호한 완료 모델 + +가장 중요한 장애 케이스는 다음입니다. + +```text +Platform + │ + ├──── send ────▶ Provider + │ │ + │ └─ accepted + │ + X connection reset +``` + +이때 플랫폼에는 Provider ID가 없을 수 있지만 실제 알림은 발송될 수 있습니다. + +따라서 Attempt는 다음 metadata를 보존해야 합니다. + +```text +requestStarted +requestBodyCommitted +providerResponseReceived +providerRequestId +submissionOutcome +ambiguousReason +attemptNumber +elapsed +``` + +그리고 다음 규칙을 권장합니다. + +```text +CONFIRMED_REJECTED +→ 재시도 가능 + +CONFIRMED_ACCEPTED +→ 같은 알림 재제출 금지 + +AMBIGUOUS +→ Provider 조회 가능: reconciliation +→ Provider idempotency 지원: 동일 idempotency contract로 재요청 +→ 둘 다 없음: 자동 재발송 기본 금지 +``` + +Provider가 처음부터 per-request idempotency를 지원하지 않는다면 Core의 `idempotencyKey`만으로 **Provider 실제 발송 중복까지 제거할 수 없습니다.** Core idempotency는 “NotificationRequest 중복 생성”을 막을 뿐 이미 Provider에 제출된 Attempt의 부작용을 지울 수 없습니다. + +### Rate Limit·Backpressure + +Global QPS 하나보다 다음 계층을 모두 지원해야 합니다. + +```text +global + ↓ +channel + ↓ +provider account + ↓ +sender identity + ↓ +country / destination class + ↓ +recipient/contact point +``` + +Dispatcher에는 다음 제한을 별도로 둡니다. + +```text +maxConcurrentDispatch +providerQps +providerBurst +maxBatchSize +maxQueueDepth +maxQueueAge +maxRetryConcurrency +maxScheduledFanoutPerTick +callbackWorkerConcurrency +``` + +외부 Provider의 실제 quota는 자주 변할 수 있기 때문에 코드 상수보다 Provider Profile 설정으로 두어야 합니다. FCM에서도 quota 초과의 원인이 project message rate, individual device rate, topic rate로 다를 수 있습니다. citeturn21search4 + +권장 흐름은: + +```text +Durable Dispatch Queue + │ + ▼ +Expiration check + │ + ▼ +Suppression re-check + │ + ▼ +Rate Limiter + │ + ▼ +Concurrency Limiter + │ + ▼ +Provider Adapter +``` + +특히 예약 후 실제 발송까지 시간이 길다면 **submit 시점 한 번만 suppression을 검사해서는 안 됩니다.** 예약 후 사용자가 opt-out하거나 Contact Point가 invalidated될 수 있으므로 실제 dispatch 직전에 다시 검사합니다. + +### Callback·Receipt 처리 계약 + +Callback pipeline은 아래 순서로 고정하는 것을 권장합니다. + +```text +HTTP Callback + │ + ▼ +Payload size/content-type limit + │ + ▼ +Provider signature verification + │ + ▼ +Raw event durable append + │ + ▼ +Event deduplication + │ + ▼ +providerRequestId → attemptId resolve + │ + ▼ +Provider-specific normalization + │ + ▼ +Projection merge + │ + ▼ +Metric / audit / internal event +``` + +Twilio는 webhook 요청을 `X-Twilio-Signature`로 서명하고 공식 SDK의 validator 사용을 권장합니다. 또한 webhook 필드는 채널·이벤트에 따라 달라지고 새 필드가 추가될 수 있다고 명시하므로, DTO parser가 unknown field 때문에 실패해서는 안 됩니다. citeturn17search0turn16search0 + +따라서 callback DTO의 원칙은: + +```text +known fields → typed normalize +unknown fields → preserve/ignore safely +raw payload → bounded encrypted/archive storage +signature → 원본 bytes/원본 parameter set 기준 검증 +``` + +입니다. + +Callback 중복 제거는 가능한 경우: + +```text +UNIQUE(provider, providerEventId) +``` + +를 사용하고 Provider event ID가 없다면: + +```text +provider ++ providerRequestId ++ eventType ++ providerOccurredAt ++ normalizedPayloadDigest +``` + +같은 fingerprint 방식이 필요합니다. + +Twilio는 callback이 순서대로 도착한다는 보장이 없다고 명시하므로 `receivedAt`만 보고 현재 상태를 갱신해서는 안 됩니다. citeturn16search0 + +또 callback은 **누락될 수도 있습니다.** Twilio는 delivery status가 12시간 내 갱신되지 않으면 API polling을 수행하고, 놓친 이벤트 확인을 위해 최소 하루 한 번 reconciliation을 권장합니다. citeturn17search1 + +따라서 Adapter SPI에 다음 capability를 둘 가치가 있습니다. + +```java +interface ReconciliationCapability { + ReconciliationResult reconcile(DeliveryAttempt attempt); +} +``` + +단 모든 Provider가 이를 지원한다고 가정해서는 안 됩니다. + +| Provider | Callback | Query/Reconcile | 최종 상태 복구 전략 | +|---|---|---|---| +| SES | event publishing | Provider 기능에 따라 | event + internal audit | +| Twilio | 상태 callback | Message status polling 가능 | callback + scheduled reconciliation | +| FCM | 일반 delivery callback 없음 | send 오류·target lifecycle 중심 | 앱 receipt 선택 | +| APNs | 일반 transactional delivery callback 없음 | Console/Metrics 운영 정보 | 앱 receipt 선택 | +| Web Push | RFC receipt optional | Push-service dependent | capability | +| In-App | 내부 event | DB 자체 | transactional projection | + +상태 merge에는 단일 numeric priority가 아니라 **상태 종류별 transition rule**을 사용합니다. + +```text +sent → delivered : 허용 +delivered → sent : 무시 + +delivered → complaint : complaint fact 추가 +complaint → delivered : complaint 제거 금지 + +accepted → bounced : 허용 + +expired callback가 늦게 도착 +→ providerOccurredAt와 기존 terminal event 비교 +``` + +그리고 callback에서 새로운 상태를 발견했다고 raw provider 상태 문자열을 public enum에 바로 추가하지 않습니다. + +```text +Provider status +→ Adapter normalization +→ stable common status ++ providerNativeStatus +``` + +이렇게 해야 Provider가 상태를 추가해도 Core API를 깨뜨리지 않습니다. + +## 보안·억제·동의·관측성 + +### Suppression·Preference·Consent Primitive + +Core가 제공할 데이터 구조는 다음처럼 분리하는 것이 좋습니다. + +```text +SuppressionEntry +PreferenceRecord +ConsentRecord +ContactPointStatus +``` + +`SuppressionEntry`의 예시는: + +```java +record SuppressionEntry( + SuppressionId id, + TenantId tenantId, + SuppressionScope scope, + SuppressionReason reason, + String normalizedTargetHash, + String notificationCategory, + Instant effectiveAt, + Instant expiresAt, + SuppressionSource source +) {} +``` + +억제 이유는 사용자가 제안한 형태가 적절합니다. + +```text +USER_OPT_OUT +HARD_BOUNCE +COMPLAINT +INVALID_TOKEN +INVALID_PHONE +ADMIN_BLOCK +PROVIDER_BLOCK +TEMPORARY_SUPPRESSION +``` + +핵심은 `Preference`와 `Consent`를 합치지 않는 것입니다. + +```text +Preference +→ "나는 Push보다 Email을 선호한다" + +Consent +→ "어떤 정책 판단에 필요한 동의 기록" + +Suppression +→ "현재 이 발송을 기술적으로 차단한다" +``` + +Core는: + +```java +interface NotificationEligibilityPolicy { + EligibilityResult evaluate(NotificationContext context); +} +``` + +같은 Hook을 실행할 수 있지만, “한국의 특정 메시지가 광고인지”, “보안 알림을 opt-out할 수 있는지” 같은 법률·업무 판정을 자체 hard-code해서는 안 됩니다. + +Email hard bounce와 complaint, Push invalid target, Web Push expired subscription, SMS opt-out 등은 Adapter에서 suppression primitive로 연결할 수 있습니다. SES는 bounce·complaint event를 별도로 제공하고, Twilio도 opt-out 관련 Provider 기능을 제공하므로 내부 suppression과 Provider suppression을 동기화하되 **어느 쪽이 source of truth인지 명시**해야 합니다. citeturn14search8turn16search3 + +권장 우선순위는: + +```text +Internal mandatory suppression + OR +Provider suppression + OR +Injected business eligibility=false +→ SEND BLOCK +``` + +입니다. + +### Security·Privacy 정책 + +보호 대상에는 최소 다음이 포함됩니다. + +```text +Email address +Phone number +FCM FID / legacy token +APNs device token +Web Push endpoint +p256dh / auth secret +VAPID private key +Provider credentials +Callback signing secret +Template variables +Rendered message body +Attachment reference +``` + +Web Push endpoint는 RFC상 capability URL이므로 secret에 준해 취급해야 합니다. citeturn12search0 + +로그 정책은 특히 엄격해야 합니다. OWASP는 access token, authentication password, encryption key, sensitive PII 등을 원문으로 기록하지 말고 필요하면 제거·마스킹·해시·암호화하도록 권고하며, 전화번호와 이메일 주소도 특별 취급 대상이라고 명시합니다. citeturn21search0 + +따라서 다음은 **로그 및 Metric Label 금지**로 지정하는 것이 맞습니다. + +```text +Email address +phone number +FCM/APNs target +Web Push endpoint +p256dh/auth +전체 message body +template variables +provider credential +unsubscribe token +attachment URL +provider callback raw payload +``` + +구조화 로그는 다음 정도만 남깁니다. + +```text +channel=email +provider=ses +operation=dispatch +attempt=2 +result=throttled +failureCategory=THROTTLED +templateId=password-reset +``` + +민감 Contact Point의 lookup이 필요하면: + +```text +HMAC(key, normalizedAddress) +``` + +같은 keyed fingerprint를 별도로 저장하고 원문은 암호화 저장하는 방식을 권장합니다. 일반 SHA-256만 쓰면 전화번호나 이메일처럼 추측 가능한 값은 dictionary attack이 쉬우므로 lookup hash에도 secret key를 사용하는 편이 안전합니다. + +Provider credential은 code/config repository나 평문 로그가 아니라 secret manager를 통해 전달하고, 최소 권한·rotation·revocation이 가능한 구조로 관리해야 합니다. OWASP도 secrets가 필요한 주체에만 보이고 회전·폐기 가능해야 하며 평문으로 로그에 남아서는 안 된다고 권고합니다. citeturn21search2 + +Callback endpoint에는 다음 방어가 필요합니다. + +```text +TLS +provider signature validation +timestamp / nonce 검증이 제공되면 replay 방어 +body size limit +content-type validation +unknown field tolerant parsing +idempotent ingestion +rate limit +tenant/provider binding +raw body bounded retention +``` + +Twilio의 경우 서명 검증이 URL과 payload를 사용하므로 proxy/rewrite 뒤에서 원래 URL을 잘못 복원하면 검증이 실패할 수 있고, JSON callback은 raw body를 기준으로 확인해야 합니다. 공식 SDK validator를 사용하는 것이 권장됩니다. citeturn17search0 + +### 환경과 credential 격리 + +아래 값들은 Contact Point 또는 Provider Profile에 environment를 포함해야 합니다. + +```text +tenant +applicationId +provider +environment +credentialProfile +``` + +특히 APNs 개발/운영 token을 혼용하지 않도록: + +```text +(APNS, appId, SANDBOX) +(APNS, appId, PRODUCTION) +``` + +을 별 namespace로 보아야 합니다. Apple도 device token과 환경이 맞지 않는 경우를 APNs 오류로 다루며, 최신 troubleshooting 문서 역시 서버가 최신 device token을 유지할 것을 요구합니다. citeturn19search10 + +FCM 역시 project/app identity와 target association을 Adapter profile 안에 고정합니다. + +### Metric·Trace·Audit 계약 + +관측 단위는 반드시 세 레벨입니다. + +```text +Notification +RecipientDelivery +ProviderAttempt +``` + +**권장 Metric** + +| Metric | 주요 Tag | +|---|---| +| notification.requested | channel plan, category | +| notification.suppressed | channel, reason | +| notification.render | channel, templateId, result | +| notification.dispatch | channel, provider, result | +| notification.provider.accepted | channel, provider | +| notification.delivery | channel, provider, outcome | +| notification.retry | provider, failureCategory, attempt bucket | +| notification.fallback | fromChannel, toChannel, reason | +| notification.ambiguous | channel, provider | +| notification.callback | provider, eventType, result | +| notification.callback.delay | provider, eventType | +| notification.reconciliation | provider, correction | +| notification.queue.depth | channel/provider | +| notification.queue.age | channel/provider | +| notification.schedule.delay | channel | +| notification.contact.invalid | channel, provider | + +허용 tag는 bounded vocabulary로 제한합니다. + +```text +channel +provider +templateId +notificationCategory +status +failureCategory +attemptBucket +sizeBucket +``` + +`templateId`도 무제한 사용자 입력이 아니라 등록된 template registry 값일 때만 Metric tag로 허용해야 합니다. + +금지: + +```text +recipientId +email +phone +device/FID/token +notificationId +recipientDeliveryId +attemptId +providerRequestId +full exception message +full URL +``` + +Trace는 다음처럼 나누는 것이 좋습니다. + +```text +notification.submit + │ + ├─ render + ├─ dispatch attempt + │ └─ provider HTTP/SMTP span + │ + └─ enqueue +``` + +비동기 callback은 원래 dispatch span이 이미 끝난 뒤 수시간 후 도착할 수 있으므로 **장시간 child span**으로 유지하는 것보다 원래 trace/attempt와 correlation/link를 두는 방식이 적절합니다. + +Audit는 Metric보다 강하게 보존해야 합니다. + +```text +template publish +template disable +suppression add/remove +consent update +contact invalidation/reactivation +manual redrive +manual retry +manual cancel +reconciliation correction +provider enable/disable +credential profile rotation +admin override +callback signature reject +``` + +Audit에도 PII 원문은 꼭 필요한 경우가 아니면 저장하지 않고 `contactPointId`나 보호된 identifier를 사용합니다. + +## 테스트와 구현 로드맵 + +### 공통 계약·보안·장애·성능 테스트 + +구현 전부터 모든 Adapter가 통과해야 할 **공통 Contract Test Kit**을 만드는 것이 중요합니다. + +| 테스트 영역 | 필수 시나리오 | 합격 조건 | +|---|---|---| +| Request | 단일·다중 recipient | RecipientDelivery 개수가 계약과 일치 | +| Idempotency | 같은 key 재요청 | 같은 logical Notification | +| Idempotency conflict | 같은 key, 다른 payload | 명확한 conflict | +| Template | 변수 누락·타입 오류 | Provider 호출 전 실패 | +| Locale | exact/fallback/default | 고정된 template version 사용 | +| Scheduling | restart 포함 | schedule 누락·중복 없음 | +| Expiration | queue/retry 중 만료 | 만료 후 Provider 제출 없음 | +| Suppression | schedule 후 opt-out | dispatch 직전 다시 차단 | +| Provider accepted | 명시적 성공 | PROVIDER_ACCEPTED만 기록 | +| Provider rejected | 4xx/permanent | 자동 위험 retry 없음 | +| Timeout before send | 명백한 미전송 | safe retry | +| Response loss | Provider accepted 후 응답 단절 | AMBIGUOUS | +| Retry | transient/429 | backoff·budget·expiry 준수 | +| Fallback | confirmed failure | 규칙대로 차순위 채널 | +| Ambiguous fallback | 응답 유실 | 기본 자동 fallback 금지 | +| Callback | 정상 | Attempt와 정확히 매핑 | +| Callback duplicate | 동일 이벤트 N회 | projection 1회 효과 | +| Callback out-of-order | delivered 후 sent 도착 | downgrade 없음 | +| Callback unknown fields | 새 field 추가 | 파싱 성공 | +| Callback security | invalid signature | 상태 변경 없음 | +| Reconciliation | callback 누락 | Provider 상태로 correction | +| Batch | 부분 성공 | recipient별 독립 상태 | +| Shutdown | in-flight dispatch | 유실 또는 무조건 중복 없음; ambiguous 기록 | +| DB crash | commit 전/후 | durable state 계약 유지 | +| Queue overload | burst | heap/thread 폭증 없이 backpressure | +| Credential rotation | old/new 중간 상태 | 안전한 전환 | +| Tenant isolation | 교차 ID 접근 | 차단 | +| Logging | PII/secret 입력 | 원문 미노출 | + +Twilio Adapter에서는 `delivered` callback보다 `sent` callback을 늦게 보내는 테스트를 필수로 넣어야 합니다. 실제 Twilio가 callback 도착 순서를 보장하지 않기 때문입니다. citeturn16search0 + +Twilio reconciliation 테스트에서는 callback을 의도적으로 누락시키고 polling으로 수정되는지도 확인합니다. 공급자 자체도 callback 누락 가능성 때문에 polling과 주기적 reconciliation을 권장합니다. citeturn17search1 + +### Email 테스트 + +```text +SMTP 2xx +SMTP 4xx +SMTP 5xx +DATA 후 connection reset +MIME text/html +multipart/alternative +attachment +inline resource +Unicode subject/body +malformed recipient +SES accepted +SES reject +SES delivery +delivery delay +hard bounce +complaint +rendering failure +duplicate provider event +List-Unsubscribe headers +DKIM-signed unsubscribe headers +``` + +SMTP 4xx/5xx 분류는 RFC 5321 계약과 일치해야 합니다. citeturn18search2 + +### SMS 테스트 + +```text +E.164 normalization +invalid phone +GSM-7 160 +GSM-7 161 +UCS-2 70 +UCS-2 71 +emoji 1개로 encoding 변경 +segment estimator +accepted→queued→sent→delivered +accepted→failed +sent→undelivered +delivered callback before sent callback +duplicate callback +provider 429 +provider timeout +opt-out +provider suppression synchronization +``` + +SMS segment 경계는 실제 GSM-7/UCS-2 규칙으로 검증해야 합니다. citeturn16search1turn16search2 + +### FCM·APNs 테스트 + +```text +FCM FID 정상 +legacy token compatibility +UNREGISTERED +QUOTA_EXCEEDED +UNAVAILABLE +500개 multicast +partial batch failure +collapse +TTL expiry +notification/data combinations +foreground/background semantics + +APNs sandbox +APNs production namespace separation +invalid device token +wrong topic/environment +apns-expiration +apns-priority +apns-collapse-id +apns-push-type +provider accepted ≠ delivered +offline storage / expiry assumptions +app-generated read/open receipt +``` + +FCM 2026 FID migration은 반드시 contract test에 포함해야 합니다. registration-token-only 구현은 현재 Admin SDK의 권장 방향과 어긋납니다. citeturn15search0turn15search6 + +또 Android에서는 background notification message와 notification+data 조합의 앱 callback 동작이 foreground와 다르므로, “Provider가 보냈으니 앱 callback이 반드시 실행된다”는 테스트 가정을 두어서는 안 됩니다. Firebase는 background notification을 system tray가 처리하는 경우를 별도로 설명합니다. citeturn15search10 + +### Web Push 테스트 + +```text +RFC 8030 TTL 누락 → reject +TTL 0 +TTL expiry +Urgency +Topic replacement +expired subscription +VAPID success/failure +wrong VAPID key +VAPID key rotation +p256dh/auth corruption +aes128gcm encryption +oversized payload +receipt capability on/off +endpoint secret logging test +``` + +RFC 8030상 TTL 누락은 `400` 대상이며 expired subscription에는 `404`가 정의되어 있으므로 이 두 계약은 공통 Web Push contract test에 넣을 수 있습니다. citeturn12search0turn12search2 + +RFC 8291 encryption test에는 P-256, auth secret, `aes128gcm` 처리와 잘못된 key/payload 검증을 포함해야 합니다. citeturn20search0 + +### In-App 테스트 + +```text +cursor pagination +read/unread +seen +idempotent mark-read +concurrent mark-read +archive +expire +unread count consistency +bulk mark-read +notification deletion policy +large broadcast +fan-out restart +WebSocket outage +tenant isolation +``` + +실시간 WebSocket이 끊겨도 DB 조회에서는 Inbox notification이 나타나는 것이 핵심 계약입니다. + +### 성능·장애 시나리오 + +최소 다음 부하 시나리오를 별도 성능 suite로 둡니다. + +| 시나리오 | 검증값 | +|---|---| +| 대량 recipient fan-out | DB write rate, queue depth, memory | +| 특정 예약시각 집중 | schedule lag, dispatch burst | +| Provider 30분 장애 | backlog 증가율, retry amplification | +| Provider 429 지속 | rate adaptation, retry storm 여부 | +| Callback burst | callback queue lag | +| Slow Provider | worker/concurrency 고갈 여부 | +| DB slow | queue claim/lock behavior | +| Process kill | in-flight ambiguous attempt 수 | +| Provider accepted 후 response loss | duplicate-risk 처리 | +| 대규모 DLQ/redrive | Provider flood 방지 | +| credential 만료 | 실패가 개별 recipient retry 폭풍으로 증폭되지 않는지 | + +특히 인증 실패는 메시지별 exponential retry를 해서는 안 됩니다. Credential이 만료된 상태에서 수십만 notification이 각각 retry하면 Provider와 내부 queue를 동시에 압박하므로, `AUTHENTICATION`은 Provider Profile을 unhealthy/open 상태로 만들고 운영 alert를 발생시키는 쪽이 낫습니다. + +### 구현 순서와 단계별 완료 조건 + +**기반 단계 — Notification Core** + +구현 범위: + +```text +NotificationRequest +RecipientDelivery +DeliveryAttempt +ContactPoint +ProviderEvent ledger +Typed API +Provider Adapter SPI +Idempotency +Durable dispatch queue +Error taxonomy +Basic metrics/audit +``` + +완료 조건은 동일 idempotency key의 동시 요청에서 logical notification이 하나만 생성되고, `Provider accepted 후 response loss`를 `AMBIGUOUS`로 표현할 수 있으며, process kill 후 미처리 Notification을 복구하고, logs/metrics에서 Contact Point 원문이 검출되지 않는 것입니다. + +**Email 단계** + +구현 범위: + +```text +EmailContent +Template renderer +SMTP Adapter +SES Adapter +MIME +attachment reference +bounce/complaint/delivery +Email suppression +``` + +완료 조건은 SMTP 4xx/5xx 계약, SES acceptance와 delivery 분리, bounce·complaint suppression, MIME·Unicode·attachment 테스트, callback/event idempotency가 모두 통과하는 것입니다. SES acceptance를 `DELIVERED`로 매핑하는 코드가 없어야 합니다. citeturn14search0turn14search8 + +**SMS 단계** + +구현 범위: + +```text +PhoneNumber +Twilio Adapter +SMS encoding/segment estimator +Status callback +Reconciliation +Opt-out/suppression integration +``` + +완료 조건은 GSM-7/UCS-2 segment 계산, 역순 callback, callback 누락 후 reconciliation, invalid number, throttling 테스트가 통과하는 것입니다. citeturn16search0turn17search1 + +**Mobile Push 단계** + +구현 범위: + +```text +MobilePushTarget +FCM FID +legacy registration token compatibility +APNs token +FCM Adapter +APNs Adapter +TTL +priority +collapse +platform override +``` + +완료 조건은 FCM 부분 batch 실패가 Recipient별로 분해되고, FCM `UNREGISTERED`와 APNs invalid token이 Contact Point lifecycle에 반영되며, APNs/FCM Provider success가 `DELIVERED`로 오인되지 않는 것입니다. FCM FID를 현재 기준의 primary target으로 테스트해야 합니다. citeturn15search0turn21search1turn19search9 + +**Web Push 단계** + +구현 범위: + +```text +WebPushSubscription +RFC 8030 +RFC 8291 encryption +RFC 8292 VAPID +TTL/Urgency/Topic +subscription invalidation +``` + +완료 조건은 표준 test vector/interop test, expired subscription cleanup, VAPID 오류, endpoint 비밀값 보호가 통과하는 것입니다. citeturn12search0turn20search0turn20search1 + +**Advanced Delivery 단계** + +구현 범위: + +```text +Durable scheduling +Ordered fallback +Dedup +Collapse mapping +Retry budget +provider rate limiter +backpressure +reconciliation framework +``` + +완료 조건은 예약 중 재시작, 예약 후 suppression, expiry 중 retry, ambiguous submission 상태에서 자동 cross-channel fallback 금지, provider outage 중 retry storm 방지가 검증되는 것입니다. + +**Inbox·Preference 단계** + +구현 범위: + +```text +notification-inbox +Preference +ConsentRecord +SuppressionEntry +ChannelPreferenceResolver +Eligibility Policy Port +``` + +완료 조건은 Inbox pagination/read/unread concurrency, tenant isolation, suppression audit, provider suppression synchronization이 통과하는 것입니다. + +**운영·확장 단계** + +구현 범위: + +```text +N3 provider extensions +N4 admin +redrive +manual reconciliation +provider profile enable/disable +credential rotation +Kakao/WhatsApp/RCS 등 adapter +``` + +완료 조건은 Admin 기능에 별도 권한이 적용되고, manual redrive가 원 Notification과 새 Attempt의 관계를 보존하며, Provider credential rotation 중 유실·중복이 발생하지 않고, 신규 Adapter가 기존 Core contract test를 수정하지 않고 통과하는 것입니다. + +### 최종 권장 아키텍처 + +전체 연구를 종합하면 목표 구조는 다음과 같습니다. + +```text +Application + │ + ▼ +┌──────────────────────────────────────────────┐ +│ Notification API │ +│ Email / SMS / Push / WebPush / Orchestrator │ +└──────────────────────┬───────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ Notification Core │ +│ │ +│ Request ── RecipientDelivery ── Attempt │ +│ │ │ │ │ +│ │ │ ├─ outcome │ +│ │ │ └─ evidence│ +│ │ │ │ +│ Template ContactPoint │ +│ Scheduling Suppression │ +│ Idempotency Routing/Fallback │ +│ Retry RateLimit/Backpressure │ +│ Audit Metrics/Trace │ +└──────────────────────┬───────────────────────┘ + │ + ┌─────────────┼─────────────┐ + ▼ ▼ ▼ + Email SMS Mobile Push + SMTP / SES Twilio FCM / APNs + │ │ │ + └─────────────┼─────────────┘ + │ + ProviderEvent + │ + ▼ + Callback / Receipt / Reconciliation + │ + ▼ + Append-only Event Ledger + │ + ▼ + Delivery State Projection +``` + +가장 중요한 설계 규칙은 하나로 압축할 수 있습니다. + +> **Notification Core는 “보냈다”를 기록하는 시스템이 아니라, “누구에게 어떤 알림을 어떤 채널과 공급자로 몇 번 시도했고, 각 시도에서 어디까지 확실히 확인됐으며, 아직 무엇을 모르는가”를 기록하는 시스템이어야 합니다.** + +SES의 API 수락과 실제 전송이 다르고, Twilio의 callback은 역순으로 올 수 있으며, APNs는 수락 뒤에도 저장·폐기·후속 전달될 수 있고, FCM은 현재 FID 중심으로 target 모델 자체가 전환되고 있으며, Web Push는 수락·TTL·receipt를 프로토콜 수준에서 별도로 정의합니다. 이 차이를 없애려고 공통 enum 하나로 평탄화하기보다 **`Notification → RecipientDelivery → DeliveryAttempt → ProviderEvent → Evidence`를 공통 골격으로 만들고, 채널별 의미를 Adapter capability로 보존하는 설계가 장기적으로 가장 안전합니다.** citeturn14search0turn16search0turn13search5turn15search0turn12search0 \ No newline at end of file diff --git a/notification-superpowers-package/validate_notification_docs.py b/notification-superpowers-package/validate_notification_docs.py new file mode 100755 index 00000000..3e2715d3 --- /dev/null +++ b/notification-superpowers-package/validate_notification_docs.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from collections import Counter +from pathlib import Path +import hashlib +import re +import sys +import zipfile + +ROOT = Path('/mnt/data') +DESIGN = ROOT / 'notification-platform-design.md' +PLAN = ROOT / 'notification-platform-implementation-plan.md' +REPORT = ROOT / 'notification-superpowers-validation.md' +PACKAGE = ROOT / 'notification-superpowers-package.zip' + +checks: list[tuple[str, bool, str]] = [] + +def check(name: str, condition: bool, detail: str = '') -> None: + checks.append((name, bool(condition), detail)) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + digest.update(chunk) + return digest.hexdigest() + + +def main() -> int: + check('설계서 존재', DESIGN.is_file(), str(DESIGN)) + check('구현 계획서 존재', PLAN.is_file(), str(PLAN)) + if not DESIGN.is_file() or not PLAN.is_file(): + return write_report() + + design = DESIGN.read_text(encoding='utf-8') + plan = PLAN.read_text(encoding='utf-8') + design_lines = design.count('\n') + 1 + plan_lines = plan.count('\n') + 1 + + check('설계서 최소 상세도', design_lines >= 3000, f'{design_lines:,} lines') + check('계획서 최소 상세도', plan_lines >= 4000, f'{plan_lines:,} lines') + check('설계서 코드 fence 균형', design.count('```') % 2 == 0, str(design.count('```'))) + check('계획서 코드 fence 균형', plan.count('```') % 2 == 0, str(plan.count('```'))) + + required_design_terms = [ + 'NotificationRequest', 'RecipientDelivery', 'DeliveryAttempt', + 'ProviderEvent', 'EvidenceLevel', 'SubmissionOutcome', 'DeliveryOutcome', + 'AMBIGUOUS', 'append-only', 'FCM_FID', 'FCM_REGISTRATION_TOKEN_LEGACY', + 'AES-256-GCM', 'HMAC-SHA-256', 'FOR UPDATE SKIP LOCKED', + 'SMTP Adapter', 'Amazon SES Adapter', 'Twilio Adapter', 'FCM Adapter', + 'APNs Adapter', 'Web Push Adapter', 'In-App Inbox', 'Reconciliation', + 'N4 Admin Plane', '비지원 범위', '완료 정의' + ] + for term in required_design_terms: + check(f'설계 핵심 계약: {term}', term in design) + + check('exactlyOnce 단순 옵션 금지 명시', + '`exactlyOnce=true` 같은 단순 옵션을 두는 것은 잘못된 추상화' in design) + forbidden_design_claims = [ + 'guaranteedDelivery=true', + 'APNs HTTP 200 = DELIVERED', + 'FCM send success = DEVICE_DELIVERED', + ] + for phrase in forbidden_design_claims: + check(f'금지 보장 부재: {phrase}', phrase not in design) + + tasks = [int(x) for x in re.findall(r'^### Task (\d+):', plan, re.MULTILINE)] + check('Task 1~50 연속성', tasks == list(range(1, 51)), str(tasks)) + + task_chunks = re.split(r'(?=^### Task \d+:)', plan, flags=re.MULTILINE)[1:] + check('Task chunk 수', len(task_chunks) == 50, str(len(task_chunks))) + required_task_sections = [ + '**Files:**', '**Interfaces:**', '**Implementation requirements:**', + '**Step 1:', '**Step 2:', '**Step 3:', '**Step 4:', '**Step 5:', + 'Expected:', 'git commit -m' + ] + for index, chunk in enumerate(task_chunks, 1): + for marker in required_task_sections: + check(f'Task {index} 필수 항목: {marker}', marker in chunk) + + creates = re.findall(r'^- Create: `([^`]+)`', plan, re.MULTILINE) + duplicate_creates = sorted(path for path, count in Counter(creates).items() if count > 1) + check('Create 경로 중복 없음', not duplicate_creates, ', '.join(duplicate_creates)) + check('Create 경로 충분성', len(creates) >= 200, str(len(creates))) + + red_flags = { + 'TODO': r'\bTODO\b', + 'TBD': r'\bTBD\b', + 'FIXME': r'\bFIXME\b', + 'fill in details': r'fill in details', + 'implement later': r'implement later', + 'concrete assertion below': r'concrete assertion below', + '유사 작업 참조': r'Similar to Task', + } + for label, pattern in red_flags.items(): + matches = re.findall(pattern, plan, re.IGNORECASE) + check(f'미확정 표현 없음: {label}', not matches, str(len(matches))) + + key_plan_terms = [ + 'providerAcceptanceIsNotDelivery', + 'concurrentSameRequestReturnsOneNotificationId', + 'providerAcceptsThenResponseIsLostRecordsAmbiguousAndBlocksFallback', + 'deliveredBeforeSentNeverDowngrades', + 'fcmInstallationAndLegacyTokenAreDifferentTypes', + 'http200IsProviderAcceptedNotDelivered', + 'ttlHeaderIsRequiredAndAcceptanceIsNotDelivery', + 'websocketFailureDoesNotRollbackInboxItem', + 'metricTagsNeverContainHighCardinalityIdentifiers', + 'acceptedThenResponseLossIsAmbiguousForEveryApplicableAdapter', + 'notificationPerformanceTest', + ] + for term in key_plan_terms: + check(f'계획 핵심 회귀 테스트: {term}', term in plan) + + check('설계·계획 날짜 일치', '2026-08-10' in design and '2026-08-10' in plan) + check('Java 21 가정 명시', 'Java 21' in design and 'Java 21' in plan) + check('실제 저장소 부재 가정 명시', '실제 저장소가 제공되지 않아' in design) + check('Provider SDK 공개 금지', 'Provider SDK' in design and 'raw SDK client' in plan) + check('Core async CompletionStage', 'CompletionStage' in design and 'CompletionStage' in plan) + check('FCM FID 우선', 'FID 우선' in design and 'FCM primary target은 FID' in plan) + check('Ambiguous fallback 금지', 'ambiguousAttemptExists = true' in design and '`AMBIGUOUS` attempt가 있는 recipient' in plan) + check('ProviderEvent 원장', 'append-only ledger' in plan and 'ProviderEvent 원장' in design) + + if PACKAGE.is_file(): + try: + with zipfile.ZipFile(PACKAGE) as archive: + bad = archive.testzip() + names = set(archive.namelist()) + required = { + 'notification-superpowers-package/docs/superpowers/specs/2026-08-10-notification-platform-design.md', + 'notification-superpowers-package/docs/superpowers/plans/2026-08-10-notification-platform-implementation-plan.md', + 'notification-superpowers-package/README.md', + 'notification-superpowers-package/VALIDATION.md', + 'notification-superpowers-package/validate_notification_docs.py', + 'notification-superpowers-package/MANIFEST.sha256', + } + check('ZIP CRC 무결성', bad is None, str(bad)) + check('ZIP 필수 파일', required.issubset(names), str(sorted(required - names))) + except zipfile.BadZipFile as exc: + check('ZIP 열기', False, str(exc)) + else: + check('ZIP 패키지 존재', False, str(PACKAGE)) + + return write_report(design_lines, plan_lines, len(creates)) + + +def write_report(design_lines: int = 0, plan_lines: int = 0, create_count: int = 0) -> int: + passed = sum(1 for _, ok, _ in checks if ok) + failed = [(name, detail) for name, ok, detail in checks if not ok] + status = 'PASS' if not failed else 'FAIL' + rows = [ + '# Notification Superpowers 문서 정적 검증', '', + f'- 결과: **{status}**', + f'- 실행 검사: **{len(checks)}개**', + f'- 통과: **{passed}개**', + f'- 실패: **{len(failed)}개**', + f'- 설계서: **{design_lines:,}행**', + f'- 구현 계획서: **{plan_lines:,}행**', + f'- 구현 Task: **50개**', + f'- Create 경로: **{create_count:,}개**', + f'- 설계 SHA-256: `{sha256(DESIGN) if DESIGN.exists() else "missing"}`', + f'- 계획 SHA-256: `{sha256(PLAN) if PLAN.exists() else "missing"}`', + '', '## 검사 결과', '', + '| 검사 | 결과 | 상세 |', '|---|---:|---|' + ] + for name, ok, detail in checks: + safe = detail.replace('|', '\\|').replace('\n', ' ')[:500] + rows.append(f'| {name} | {"PASS" if ok else "FAIL"} | {safe} |') + rows.extend(['', '## 검증 범위', '', + '- 이 검증은 Markdown 설계서와 구현 계획서의 구조·정합성·필수 계약·경로 중복·미확정 표현·패키지 CRC를 검사한다.', + '- 실제 Backend Skeleton 저장소가 입력되지 않았으므로 Gradle compile, Provider sandbox, PostgreSQL integration, chaos, performance test 실행 결과는 포함하지 않는다.', + '- 구현 시에는 계획의 각 Task가 지정한 red-green TDD 명령을 실제 저장소에서 실행해야 한다.', + ]) + if failed: + rows.extend(['', '## 실패 항목', '']) + rows.extend(f'- **{name}**: {detail}' for name, detail in failed) + REPORT.write_text('\n'.join(rows) + '\n', encoding='utf-8') + print(f'{status}: {passed}/{len(checks)} checks passed') + if failed: + for name, detail in failed: + print(f'FAIL: {name}: {detail}', file=sys.stderr) + return 1 + return 0 + +if __name__ == '__main__': + raise SystemExit(main())