# 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" ```