diff --git a/src/adapter/inbound/graphql/CLAUDE.md b/src/adapter/inbound/graphql/CLAUDE.md
index 0ee7b725..4f42e93f 100644
--- a/src/adapter/inbound/graphql/CLAUDE.md
+++ b/src/adapter/inbound/graphql/CLAUDE.md
@@ -13,11 +13,30 @@ Package root: `dev.caskeleton.adapter.inbound.graphql`.
코드 주석에서 덜어낸 **설계 결정의 근거**는 [README.md](README.md) 가 모아둔다 (이 문서는 모듈
규칙 SSOT).
+## 플랫폼 모듈 = sub-package (Gradle 모듈 아님)
+
+GraphQL 플랫폼 설계서는 Stable 16개 + Advanced 12개 "모듈"을 말하지만, 이 레포의 leaf 정체성
+SSOT 는 `src/config/architecture/modules.json` 이고 거기에는 **정확히 19개 leaf** 만 존재한다.
+따라서 설계서의 28개 모듈은 이 leaf 안의 **bounded sub-package** 로 매핑한다(선례: httpclient
+leaf). 대신 모듈 경계는 문서가 아니라 기계가 지킨다:
+
+- `build/GraphQlStableModule` · `build/GraphQlAdvancedModule` 이 모듈 정체성과 허용 의존 edge 를
+ 값으로 선언하고, `build/GraphQlBuildModel` 이 실제 소스 트리를 스캔한다.
+- `build/GraphQlModuleBoundaryTest` 가 (a) Stable 패키지의 `...graphql.advanced` import 금지,
+ (b) `graphql-core-api` 계열의 Spring/GraphQL Java/Reactor/persistence import 금지,
+ (c) Stable 의존 edge 가 Advanced 모듈을 가리키지 않을 것을 강제한다.
+
+**새 플랫폼 sub-package 를 추가할 때는 반드시 해당 모듈 레코드에 정체성과 허용 edge 를 먼저
+등록한다.** 등록 없이 추가된 패키지는 경계 테스트가 실패시킨다.
+
## Responsibility
- GraphQL 전송 인프라만: 최소 health 스키마(`skeleton.graphqls`) + `HealthGraphqlController`,
프로토콜 에러 매핑(`GraphqlExceptionResolver`). Spring for GraphQL 이 스키마와 컨트롤러를
자동 합성/바인딩하도록 얹는 얇은 계층이다.
+- 그 위에 **GraphQL API 실행 플랫폼**(`...graphql` 하위 sub-package 군)이 스키마 계약·전송
+ 프로파일·실행 정책·비용 한계·DataLoader·페이지네이션·에러/보안 경계·관측·릴리스 게이트를
+ 소유한다. 플랫폼은 여전히 **feature-agnostic** 이며 정책·계약·검증 기계만 제공한다.
- feature-agnostic: `classpath:graphql/**` 스키마와 모든 `@Controller` `@QueryMapping`/
`@MutationMapping` 을 generic 하게 합성한다. **WorkLog 등 구체 기능을 이름으로 알지 않는다.**
- classpath opt-in: 현재 `app-bootstrap`/`sample-portfolio` production runtime 은 이 leaf 를
@@ -30,6 +49,13 @@ Package root: `dev.caskeleton.adapter.inbound.graphql`.
- `spring-boot-starter-graphql`, `spring-boot-starter-web`, `jackson-datatype-jsr310`
(전부 Spring Boot BOM 관리 — 버전 명시 없음).
- test scope 에 한해 실제 HTTP 인증/CORS qualification 용 `spring-boot-starter-security`.
+- `compileOnly` 로만 `spring-webflux` — REACTIVE_WEBFLUX 전송 프로파일(`http/webflux/`)을
+ 컴파일하기 위한 것이고, 의도적으로 `runtimeClasspath` 에서 제외한다. MVC 배치에 WebFlux 를
+ 끌어들이지 않기 위함이며 `gradle.lockfile` 이 이 스코프 제한을 고정한다
+ (`spring-webflux:...=compileClasspath,testCompileClasspath,testRuntimeClasspath`).
+- `annotationProcessor` 로 `spring-boot-configuration-processor` — `GraphQlPlatformProperties` 가
+ `@ConfigurationProperties` 이므로 레포 전역 `verifyConfigurationPropertiesProcessor` 패리티
+ 게이트가 이 선언을 요구한다.
## Forbidden
@@ -59,15 +85,26 @@ feature 는 `ApiErrorCarrier` 를 구현한 예외(자신의 `ApiErrorCode` 를
현재 sample 에 feature GraphQL schema/controller/resolver 가 있다고 가정하지 않는다. 이 leaf 는
health 스키마만 소유한다.
-## 명시적 미구현 범위(P2)
+## 구현된 플랫폼 범위
-- feature GraphQL schema/resolver
-- query depth/cost 제한
-- persisted operation
-- DataLoader/batching
-- subscription
+query depth/cost 제한(`cost/`), persisted operation(`advanced/persisted/`),
+DataLoader/batching(`dataloader/`), subscription(`advanced/subscription/`, `advanced/websocket/`,
+`advanced/sse/`)은 **더 이상 미구현이 아니다.** 다만 이들은 정책·계약·검증 기계이며, 실제
+composition root 가 채택할 때 정책 값과 인증/인가 빈을 함께 제공해야 한다.
-이 범위는 production GraphQL 표면 채택 시 별도 설계와 qualification 을 요구한다.
+여전히 미구현인 것:
+
+- feature GraphQL schema/resolver — 이 leaf 는 health 표면만 소유한다(변경 없음).
+- 실부하 성능/장애 시나리오 증거 — `release/GraphQlPerformanceScenario`,
+ `GraphQlFaultScenario` 는 시나리오 카탈로그를 정의하고 `GraphQlReleaseGate` 는 그 증거가
+ 없으면 릴리스를 **거부**한다. 증거 자체는 실제 부하 인프라를 요구하므로 이 leaf 밖에서
+ 생성한다(`graphqlPerformanceTest` 레인이 그 자리를 예약해 둔다).
+- 실제 datastore 통합 증거 — `testkit/GraphQlJpaIntegrationFixture` /
+ `GraphQlMongoIntegrationFixture` 가 계약을 정의하고 `GraphQlStorageIntegrationEvidence` 가
+ 증거를 요구한다. 실 datastore 기동은 persistence leaf 의 책임 범위다.
+- Advanced capability 는 전부 **기본 비활성**이다(`advanced/bootstrap/GraphQlAdvancedFeatureFlags`).
+ EXPERIMENTAL 등급(RSocket, incremental delivery, HTTP GET draft)은 명시적 승인 없이는
+ `GraphQlAdvancedModuleGuard` 가 production 활성화를 거부한다.
## Test
@@ -78,3 +115,16 @@ cd src
--tests dev.caskeleton.adapter.inbound.graphql.GraphqlHttpBoundaryQualificationTest \
--console=plain
```
+
+플랫폼 테스트 레인(`gradle/graphql-platform-conventions.gradle` 등록). 기본 `test` 는
+`quarantine`·`graphql-performance` 태그를 제외한다:
+
+```bash
+./gradlew :adapter:inbound:graphql:graphqlStableTest --console=plain # 404 tests
+./gradlew :adapter:inbound:graphql:graphqlContractTest --console=plain # 9 tests
+./gradlew :adapter:inbound:graphql:graphqlAdvancedTest --console=plain # 141 tests
+./gradlew :adapter:inbound:graphql:graphqlPerformanceTest --console=plain # 실부하 인프라 필요
+```
+
+`graphqlPerformanceTest` 는 `@Tag("graphql-performance")` 가 하나도 없으면 **실패한다** — 이는
+버그가 아니라 "성능 증거 없음"을 통과로 위장하지 않기 위한 fail-closed 설계다.
diff --git a/src/adapter/inbound/graphql/README.md b/src/adapter/inbound/graphql/README.md
index ce6966a5..1337763f 100644
--- a/src/adapter/inbound/graphql/README.md
+++ b/src/adapter/inbound/graphql/README.md
@@ -85,8 +85,97 @@ composition root 는 이 leaf 를 채택할 때 인증/인가 및 CORS 정책을
`spring.graphql.graphiql.enabled=false`,
`spring.graphql.schema.introspection.enabled=false` 를 운영 설정으로 명시해야 한다.
-## 아직 구현하지 않은 P2 범위
+---
-이 leaf 와 현재 sample 에는 feature GraphQL schema/resolver, query depth/cost 제한, persisted
-operation, DataLoader/batching, subscription 이 구현되어 있지 않다. 이 항목들은 실제 GraphQL 제품
-표면을 채택할 때 별도 설계·테스트와 함께 추가해야 한다.
+# GraphQL API 실행 플랫폼 — 설계 결정의 근거
+
+위 스켈레톤 머시너리 위에, GraphQL 설계 문서(Stable 48 Task / Advanced 19 Task)의 내용을
+이 leaf 의 sub-package 로 구현했다. 아래는 그 과정에서 내린 **되돌리기 어려운 결정**과 근거다.
+
+## 설계서의 "모듈"을 Gradle 모듈로 만들지 않은 이유
+
+설계서는 Stable 16 + Advanced 12 = 28개 Gradle 모듈을 전제한다. 그러나 이 레포의 leaf 정체성
+SSOT 는 `src/config/architecture/modules.json` 이고 **정확히 19개** 로 고정되어 있다. 28개를
+추가하면 레지스트리·`settings.gradle` fail-closed 검증·의존 게이트가 전부 깨지고, 이는 Prime
+Directive 5번(HARD-STOP)에 정면으로 저촉된다.
+
+그래서 **모듈 = bounded sub-package** 로 매핑했다(선례: httpclient leaf 가 366개 java 파일을 같은
+방식으로 담는다). 대신 "패키지는 경계가 아니다"라는 통상의 약점을 기계 검증으로 메웠다 —
+`build/GraphQlStableModule`·`GraphQlAdvancedModule` 이 모듈 정체성과 허용 edge 를 값으로 선언하고,
+`GraphQlModuleBoundaryTest` 가 **실제 소스 트리를 스캔**해 Stable→Advanced import, core-api 의
+프레임워크 import, Stable edge 의 Advanced 참조를 실패시킨다. Gradle 이 해주던 일을 테스트가
+한다.
+
+## ArchUnit/JPA 없이 아키텍처 규칙을 강제한 방법
+
+`architecture/` 의 규칙(리졸버 경계, 전송 타입, `@Transactional` 금지, Entity/Document 반환 금지)은
+**리플렉션 + 단순명(simple name) 매칭**으로 구현했다. 인바운드 어댑터가 자신이 금지하는 대상
+(`jakarta.persistence`, `spring-tx`)에 의존해야 그걸 검사할 수 있다면 본말전도이기 때문이다.
+`GraphQlControllerTransactionRule` 이 애노테이션 타입이 아니라 단순명 `Transactional` 을 보는 것은
+이 때문이며, 의도된 트레이드오프다. `GraphQlResolverBoundaryRules` 는 스캔 대상 패키지가 비어
+있으면 **실패한다** — 검사할 게 없어서 통과하는 조용한 무력화를 막는다.
+
+## 부분 실패는 200, 요청 실패는 4xx
+
+`http/GraphQlHttpStatusMapper.V1` 은 검증 통과 후 발생한 필드 에러를 HTTP 200 + partial data 로
+매핑한다. GraphQL over HTTP 초안의 status 294 는 **채택하지 않았다**
+(`GraphQlHttpProfile.usesDraftPartialResponseStatus()` 가 `false` 로 못 박고, 초안 프로파일은
+`advanced/get/GraphQlHttpDraftCompatibilityReport` 가 "의도적 미채택"으로 기록한다). 초안 상태
+코드를 프로덕션 와이어 계약에 넣으면 초안이 바뀔 때 클라이언트가 깨진다.
+
+이 때문에 `Map.copyOf` 를 응답 데이터 경로에 쓸 수 없다 — partial data 는 **정당하게 null 값을
+가진다**. `GraphQlExecutionOutcome`·`GraphQlHttpResponse`·`GraphQlContractResponse` 는 null 을
+허용하는 `LinkedHashMap` 복사를 쓴다.
+
+## 요청 단위 DB 트랜잭션을 열지 않는다
+
+GraphQL 한 요청은 여러 root field 를 담을 수 있고, 각 root 는 자기 use case 를 호출한다.
+요청 전체를 하나의 트랜잭션으로 묶으면 커넥션을 요청 수명만큼 점유하고 부분 실패 의미론이
+무너진다. `mutation/GraphQlMutationContractValidator.rejectRequestWideTransaction` 이 이를
+계약으로 강제하고, `GraphQlControllerTransactionRule` 이 컨트롤러의 `@Transactional` 을 막는다.
+
+## 커서는 HMAC 서명된 버전 있는 keyset
+
+`pagination/HmacGraphQlCursorCodec` 은 offset 이 아니라 keyset payload 를 담고, 버전과 서명을
+붙인다. 비교는 `MessageDigest.isEqual` 로 상수 시간이다. 클라이언트가 커서를 조작해 다른
+tenant/정렬 축으로 넘어가는 것을 막기 위함이며, 키 회전은 `GraphQlCursorKeyRing` 이 담당한다.
+
+## DataLoader 는 요청 스코프, 캐시 키는 actor·tenant 지문으로 격리
+
+`dataloader/GraphQlDataLoaderRequestRegistry` 는 요청마다 새 인스턴스를 만든다. 전역 캐시는
+tenant 간 데이터 누출 경로가 된다. `security/GraphQlBatchContext.cacheScope()` 는 raw tenant id 가
+아니라 **sha256 지문**을 캐시 스코프에 쓴다(로그·메트릭에 tenant 원문이 새지 않도록).
+
+## 관측은 저-카디널리티 강제
+
+`observation/GraphQlMetricCardinalityPolicy` 는 operation name·필드 좌표처럼 유한 집합만 태그로
+허용하고, 변수·인자·actor id 는 `GraphQlSensitiveAttributeFilter` 가 걸러낸다. GraphQL 은
+카디널리티 폭발이 쉬운 전송이라 이 게이트가 없으면 메트릭 백엔드가 먼저 죽는다.
+
+## Advanced 는 전부 기본 비활성 + 등급제
+
+`advanced/bootstrap/GraphQlAdvancedFeatureFlags` 는 기본 전부 off 다. EXPERIMENTAL 등급
+(RSocket, incremental delivery, HTTP GET draft)은 명시적 승인 플래그 없이는
+`GraphQlAdvancedModuleGuard` 가 production 활성화를 거부한다. 등급 승격은
+`advanced/release/GraphQlAdvancedPromotionDecision` 이 ADR 번호·승인자·미해결 증거를 요구한다 —
+"조용히 켜짐"을 구조적으로 불가능하게 만든다.
+
+## 릴리스 게이트는 증거가 없으면 거부한다
+
+`release/GraphQlReleaseGate` 와 `advanced/release/GraphQlAdvancedReleaseGate` 는 성능·장애·보안·
+호환성 증거가 없으면 **통과시키지 않는다**. Advanced 는 Stable 기준선 없이는 릴리스 자체가
+불가능하다. 같은 이유로 `graphqlPerformanceTest` 레인은 성능 태그가 하나도 없으면 실패한다 —
+증거 부재를 통과로 위장하지 않기 위한 fail-closed 설계다.
+
+## 설계서의 내부 불일치 처리
+
+Stable 16번째 모듈이 산문에서는 `graphql-testkit-security`, Task 1 파일 목록과 설계 §5 에서는
+`graphql-testkit-integration` 으로 서로 다르게 적혀 있다. **파일 목록 쪽(testkit-integration)을
+채택**하고, 보안 계약 표면은 `testkit/GraphQlSecurityContractSuite` 로 제공했다. 둘 다 실제로
+존재하므로 어느 쪽 독법이든 표면은 비지 않는다.
+
+## 아직 구현하지 않은 범위
+
+feature GraphQL schema/resolver 는 여전히 이 leaf 밖이다(스켈레톤은 health 표면만 소유).
+실부하 성능 증거와 실 datastore 통합 증거도 이 leaf 밖에서 생성해야 한다 — 다만 그 **부재가
+릴리스를 막도록** 게이트가 이미 서 있다.
diff --git a/src/adapter/inbound/graphql/build.gradle b/src/adapter/inbound/graphql/build.gradle
index 2ff848ae..c7e8a9c9 100644
--- a/src/adapter/inbound/graphql/build.gradle
+++ b/src/adapter/inbound/graphql/build.gradle
@@ -8,9 +8,10 @@
// spring-graphql / graphql-java versions are managed by the Spring Boot BOM, so no explicit
// versions or module-scoped platform imports are needed (unlike the grpc adapter, whose io.grpc
// coordinates the BOM does not manage).
-description = 'Inbound adapter: GraphQL API (Spring for GraphQL, skeleton machinery)'
+description = 'Inbound adapter: GraphQL API (Spring for GraphQL, GraphQL execution platform)'
apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle"
+apply from: "${rootProject.projectDir}/gradle/graphql-platform-conventions.gradle"
dependencies {
implementation project(':shared-contract')
@@ -18,6 +19,19 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-graphql'
implementation 'org.springframework.boot:spring-boot-starter-web'
+ // GraphQlPlatformProperties is a @ConfigurationProperties binding, so this leaf owes the
+ // repository-wide processor parity gate (`verifyConfigurationPropertiesProcessor`) a metadata
+ // declaration — an adopter configuring spring.graphql.platform.* gets IDE completion and
+ // validation from the generated metadata rather than from prose.
+ annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
+
+ // REACTIVE_WEBFLUX execution profile (design §10). WebFlux is compileOnly on purpose: the
+ // reactive transport adapter and its event-loop guard compile against Spring's reactive
+ // transport types, but an adopter that runs the BLOCKING_MVC profile must not inherit a WebFlux
+ // runtime. Reactor Core itself arrives with spring-graphql, so the reactive contracts stay
+ // usable in both profiles. spring-webflux is already on the test classpath.
+ compileOnly 'org.springframework:spring-webflux'
+
// GraphQlTester (spring-graphql-test, BOM-managed) — the health test assembles the schema +
// controller through a real AnnotatedControllerConfigurer and drives it with an
// ExecutionGraphQlServiceTester.
@@ -29,6 +43,8 @@ dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-security'
}
+registerGraphQlPlatformTestLanes()
+
registerStrictQualificationTest(
name: 'graphqlTransportQualificationTest',
sourceSet: sourceSets.test,
diff --git a/src/adapter/inbound/graphql/gradle.lockfile b/src/adapter/inbound/graphql/gradle.lockfile
index 717d79a5..9c4de66b 100644
--- a/src/adapter/inbound/graphql/gradle.lockfile
+++ b/src/adapter/inbound/graphql/gradle.lockfile
@@ -117,6 +117,7 @@ org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClass
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
org.springframework.boot:spring-boot-graphql-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
@@ -161,7 +162,7 @@ org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testComp
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
-org.springframework:spring-webflux:7.0.1=testCompileClasspath,testRuntimeClasspath
+org.springframework:spring-webflux:7.0.1=compileClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationAdminAuthorization.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationAdminAuthorization.java
new file mode 100644
index 00000000..18e683cd
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationAdminAuthorization.java
@@ -0,0 +1,50 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.admin;
+
+import java.util.Set;
+
+/**
+ * Who may change the approved-operation registry.
+ *
+ *
A separate authority from the request path. Application credentials are held by every running
+ * instance and reachable from any resolver; if one of them could register or block an operation, a
+ * compromised request path could rewrite what the whole platform is willing to execute.
+ */
+public final class GraphQlPersistedOperationAdminAuthorization {
+
+ private final Set administrators;
+
+ /**
+ * Creates the authorization.
+ *
+ * @param administrators operator references permitted to administer the registry
+ */
+ public GraphQlPersistedOperationAdminAuthorization(Set administrators) {
+ this.administrators = Set.copyOf(administrators);
+ if (this.administrators.isEmpty()) {
+ throw new IllegalArgumentException("at least one registry administrator is required");
+ }
+ }
+
+ /**
+ * Requires the operator to be an administrator.
+ *
+ * @throws GraphQlPersistedOperationAdminDeniedException when they are not
+ */
+ public void requireAdministrator(String operator) {
+ if (operator == null || !administrators.contains(operator)) {
+ throw new GraphQlPersistedOperationAdminDeniedException();
+ }
+ }
+
+ /**
+ * Refuses an application credential outright.
+ *
+ * @throws GraphQlPersistedOperationAdminDeniedException when the caller came from the request
+ * path
+ */
+ public void rejectApplicationCredential(boolean applicationCredential) {
+ if (applicationCredential) {
+ throw new GraphQlPersistedOperationAdminDeniedException();
+ }
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationAdminDeniedException.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationAdminDeniedException.java
new file mode 100644
index 00000000..54d5a4bf
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationAdminDeniedException.java
@@ -0,0 +1,17 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.admin;
+
+/**
+ * Raised when a caller may not administer the approved-operation registry.
+ *
+ *
Carries no operator identity, so a denial cannot be used to enumerate who the administrators
+ * are.
+ */
+public class GraphQlPersistedOperationAdminDeniedException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ /** Creates the failure. */
+ public GraphQlPersistedOperationAdminDeniedException() {
+ super("persisted operation administration is denied");
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationAdminService.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationAdminService.java
new file mode 100644
index 00000000..00203e65
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationAdminService.java
@@ -0,0 +1,132 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.admin;
+
+import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperation;
+import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationId;
+import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationRegistry;
+import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationStatus;
+import java.time.Clock;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * The G4 operations plane for approved operations (Advanced plan Task 4).
+ *
+ *
Every change is authorized against the administrator set and recorded in the audit trail,
+ * because a registry change silently alters what the whole platform will execute. Blocking takes
+ * effect immediately; removal has to pass the usage gate first.
+ */
+public final class GraphQlPersistedOperationAdminService {
+
+ private final GraphQlPersistedOperationRegistry registry;
+ private final GraphQlPersistedOperationAdminAuthorization authorization;
+ private final GraphQlPersistedOperationRemovalGate removalGate;
+ private final Clock clock;
+ private final List auditTrail = new ArrayList<>();
+
+ /**
+ * Creates the service.
+ *
+ * @param registry the approved operation store
+ * @param authorization who may administer it
+ * @param removalGate the usage gate protecting removals
+ * @param clock clock used for audit timestamps and the quiet period
+ */
+ public GraphQlPersistedOperationAdminService(
+ GraphQlPersistedOperationRegistry registry,
+ GraphQlPersistedOperationAdminAuthorization authorization,
+ GraphQlPersistedOperationRemovalGate removalGate,
+ Clock clock) {
+ this.registry = Objects.requireNonNull(registry);
+ this.authorization = Objects.requireNonNull(authorization);
+ this.removalGate = Objects.requireNonNull(removalGate);
+ this.clock = Objects.requireNonNull(clock);
+ }
+
+ /** Registers a new approved operation. */
+ public void register(
+ GraphQlPersistedOperation operation, String operator, String reason, String traceId) {
+ authorization.requireAdministrator(operator);
+ registry.register(operation);
+ audit(operation.id().value(), operator, reason, "ABSENT", operation.status().name(), traceId);
+ }
+
+ /**
+ * Blocks an operation immediately.
+ *
+ *
Takes effect on the next request; a cached parse does not keep it executable.
+ */
+ public void block(GraphQlPersistedOperationBlockCommand command) {
+ authorization.requireAdministrator(command.operator());
+ String before =
+ registry
+ .find(command.operationId())
+ .map(operation -> operation.status().name())
+ .orElse("ABSENT");
+ registry.updateStatus(command.operationId(), GraphQlPersistedOperationStatus.BLOCKED);
+ audit(
+ command.operationId().value(),
+ command.operator(),
+ command.reason(),
+ before,
+ GraphQlPersistedOperationStatus.BLOCKED.name(),
+ command.traceId());
+ }
+
+ /** Marks an operation deprecated, which keeps it executable while clients migrate. */
+ public void deprecate(
+ GraphQlPersistedOperationId operationId, String operator, String reason, String traceId) {
+ authorization.requireAdministrator(operator);
+ String before =
+ registry.find(operationId).map(operation -> operation.status().name()).orElse("ABSENT");
+ registry.updateStatus(operationId, GraphQlPersistedOperationStatus.DEPRECATED);
+ audit(
+ operationId.value(),
+ operator,
+ reason,
+ before,
+ GraphQlPersistedOperationStatus.DEPRECATED.name(),
+ traceId);
+ }
+
+ /**
+ * Removes an operation once usage evidence permits it.
+ *
+ * @throws GraphQlPersistedOperationRemovalRejectedException when it was used within the quiet
+ * period
+ */
+ public void remove(
+ GraphQlPersistedOperationId operationId,
+ GraphQlPersistedOperationUsage usage,
+ String operator,
+ String reason,
+ String traceId) {
+ authorization.requireAdministrator(operator);
+ removalGate.verify(usage, clock.instant());
+ registry.updateStatus(operationId, GraphQlPersistedOperationStatus.BLOCKED);
+ audit(
+ operationId.value(),
+ operator,
+ reason,
+ "REMOVAL_APPROVED",
+ GraphQlPersistedOperationStatus.BLOCKED.name(),
+ traceId);
+ }
+
+ /** The audit trail, in order. */
+ public List auditTrail() {
+ return List.copyOf(auditTrail);
+ }
+
+ private void audit(
+ String operationId,
+ String operator,
+ String reason,
+ String before,
+ String after,
+ String traceId) {
+ auditTrail.add(
+ new GraphQlPersistedOperationAudit(
+ operationId, operator, reason, before, after, clock.instant(), traceId));
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationAudit.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationAudit.java
new file mode 100644
index 00000000..d2be758b
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationAudit.java
@@ -0,0 +1,43 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.admin;
+
+import java.time.Instant;
+
+/**
+ * One audited change to the approved-operation registry.
+ *
+ *
Records operator, reason, before/after state, time and trace — the questions asked after an
+ * incident. It never records variables or credentials, so the audit trail itself stays safe to
+ * retain.
+ *
+ * @param operationId the operation that changed
+ * @param operator who made the change
+ * @param reason why
+ * @param before previous state
+ * @param after new state
+ * @param at when
+ * @param traceId correlation identity
+ */
+public record GraphQlPersistedOperationAudit(
+ String operationId,
+ String operator,
+ String reason,
+ String before,
+ String after,
+ Instant at,
+ String traceId) {
+
+ public GraphQlPersistedOperationAudit {
+ if (operationId == null || operationId.isBlank()) {
+ throw new IllegalArgumentException("audited operation id is required");
+ }
+ if (operator == null || operator.isBlank()) {
+ throw new IllegalArgumentException("audited operator is required");
+ }
+ if (reason == null || reason.isBlank()) {
+ throw new IllegalArgumentException("audited reason is required");
+ }
+ if (at == null) {
+ throw new IllegalArgumentException("audit instant is required");
+ }
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationBlockCommand.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationBlockCommand.java
new file mode 100644
index 00000000..0929217a
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationBlockCommand.java
@@ -0,0 +1,31 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.admin;
+
+import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationId;
+
+/**
+ * An instruction to block one approved operation immediately.
+ *
+ *
The incident-response tool: it stops a single expensive or dangerous operation without a
+ * redeploy and without taking the endpoint down for everyone. Operator and reason are required
+ * because a block is a production change that someone will have to explain and eventually reverse.
+ *
+ * @param operationId the operation to block
+ * @param operator who is blocking it
+ * @param reason why
+ * @param traceId incident correlation identity
+ */
+public record GraphQlPersistedOperationBlockCommand(
+ GraphQlPersistedOperationId operationId, String operator, String reason, String traceId) {
+
+ public GraphQlPersistedOperationBlockCommand {
+ if (operationId == null) {
+ throw new IllegalArgumentException("operation id is required");
+ }
+ if (operator == null || operator.isBlank()) {
+ throw new IllegalArgumentException("blocking operator is required");
+ }
+ if (reason == null || reason.isBlank()) {
+ throw new IllegalArgumentException("blocking reason is required");
+ }
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationRemovalGate.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationRemovalGate.java
new file mode 100644
index 00000000..4cda0934
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationRemovalGate.java
@@ -0,0 +1,45 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.admin;
+
+import java.time.Duration;
+import java.time.Instant;
+
+/**
+ * Refuses to remove an approved operation that is still in use (Advanced plan Task 4).
+ *
+ *
The quiet period exists because "no traffic right now" is not the same as "no client uses
+ * this": a monthly report or a mobile build in slow rollout can be silent for weeks and then send
+ * the operation again.
+ */
+public final class GraphQlPersistedOperationRemovalGate {
+
+ private final Duration quietPeriod;
+
+ /**
+ * Creates the gate.
+ *
+ * @param quietPeriod how long an operation must be unused before it may be removed
+ */
+ public GraphQlPersistedOperationRemovalGate(Duration quietPeriod) {
+ if (quietPeriod == null || quietPeriod.isNegative()) {
+ throw new IllegalArgumentException("quiet period must not be negative");
+ }
+ this.quietPeriod = quietPeriod;
+ }
+
+ /** The configured quiet period. */
+ public Duration quietPeriod() {
+ return quietPeriod;
+ }
+
+ /**
+ * Verifies that removal is safe.
+ *
+ * @throws GraphQlPersistedOperationRemovalRejectedException when the operation was used recently
+ */
+ public void verify(GraphQlPersistedOperationUsage usage, Instant now) {
+ if (usage.executions() > 0 && usage.lastUsedAt().plus(quietPeriod).isAfter(now)) {
+ throw new GraphQlPersistedOperationRemovalRejectedException(
+ "persisted operation used within quiet period");
+ }
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationRemovalRejectedException.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationRemovalRejectedException.java
new file mode 100644
index 00000000..4551f823
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationRemovalRejectedException.java
@@ -0,0 +1,21 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.admin;
+
+/**
+ * Raised when an approved operation may not be removed yet.
+ *
+ *
Removing an operation a deployed client still sends breaks that client with no warning and no
+ * migration path, so the gate refuses until the usage evidence says otherwise.
+ */
+public class GraphQlPersistedOperationRemovalRejectedException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Creates the failure.
+ *
+ * @param reason why removal is blocked
+ */
+ public GraphQlPersistedOperationRemovalRejectedException(String reason) {
+ super(reason);
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationUsage.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationUsage.java
new file mode 100644
index 00000000..d3d48d64
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/admin/GraphQlPersistedOperationUsage.java
@@ -0,0 +1,29 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.admin;
+
+import java.time.Instant;
+
+/**
+ * Observed usage of one approved operation.
+ *
+ *
Counts and a last-used instant, never the callers: usage feeds a removal decision, and the
+ * identity of who ran an operation is not needed to decide whether it is still in use.
+ *
+ * @param lastUsedAt when it was last executed, or {@code null} when never
+ * @param executions executions observed in the window
+ */
+public record GraphQlPersistedOperationUsage(Instant lastUsedAt, long executions) {
+
+ public GraphQlPersistedOperationUsage {
+ if (executions < 0) {
+ throw new IllegalArgumentException("executions cannot be negative");
+ }
+ if (executions > 0 && lastUsedAt == null) {
+ throw new IllegalArgumentException("observed executions require a last-used instant");
+ }
+ }
+
+ /** Measured, and never used. */
+ public static GraphQlPersistedOperationUsage unused() {
+ return new GraphQlPersistedOperationUsage(null, 0);
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedCapability.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedCapability.java
new file mode 100644
index 00000000..d9a78f7f
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedCapability.java
@@ -0,0 +1,62 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap;
+
+import java.util.Locale;
+
+/**
+ * The Advanced and Experimental capabilities, each behind its own flag (Advanced plan Task 1).
+ *
+ *
Enumerated so a capability cannot activate merely by being on the classpath. Every one of
+ * these changes the platform's operational shape — a long-lived connection, an approved-operation
+ * registry, a second schema topology — and each deserves a deliberate decision.
+ */
+public enum GraphQlAdvancedCapability {
+
+ /** Approved operation registry and execution lookup. */
+ PERSISTED_OPERATION(GraphQlAdvancedCapabilityGrade.ADVANCED_STABLE),
+
+ /** {@code graphql-transport-ws} subscriptions. */
+ WEBSOCKET_SUBSCRIPTION(GraphQlAdvancedCapabilityGrade.ADVANCED_STABLE),
+
+ /** Server-sent event subscriptions, one connection per subscription. */
+ SSE_SUBSCRIPTION(GraphQlAdvancedCapabilityGrade.ADVANCED),
+
+ /** Federation subgraph schema and entity resolution. */
+ FEDERATION_SUBGRAPH(GraphQlAdvancedCapabilityGrade.ADVANCED),
+
+ /** Client and transport DTO code generation. */
+ CODE_GENERATION(GraphQlAdvancedCapabilityGrade.ADVANCED),
+
+ /** Allowlisted Spring Data repository exposure. */
+ SPRING_DATA_COMPAT(GraphQlAdvancedCapabilityGrade.ADVANCED),
+
+ /** GraphQL Java 25 chained DataLoader dispatch. */
+ DATALOADER_CHAINING(GraphQlAdvancedCapabilityGrade.ADVANCED),
+
+ /** Messaging-backed subscription replay. */
+ SUBSCRIPTION_REPLAY(GraphQlAdvancedCapabilityGrade.ADVANCED),
+
+ /** RSocket transport. */
+ RSOCKET(GraphQlAdvancedCapabilityGrade.EXPERIMENTAL),
+
+ /** GraphQL over HTTP GET, per the still-moving draft. */
+ HTTP_GET(GraphQlAdvancedCapabilityGrade.EXPERIMENTAL),
+
+ /** Incremental delivery. */
+ INCREMENTAL_DELIVERY(GraphQlAdvancedCapabilityGrade.EXPERIMENTAL);
+
+ private final GraphQlAdvancedCapabilityGrade grade;
+
+ GraphQlAdvancedCapability(GraphQlAdvancedCapabilityGrade grade) {
+ this.grade = grade;
+ }
+
+ /** The capability's support grade. */
+ public GraphQlAdvancedCapabilityGrade grade() {
+ return grade;
+ }
+
+ /** The property that enables it. */
+ public String featureFlag() {
+ return "backend.graphql.advanced." + name().toLowerCase(Locale.ROOT).replace('_', '-');
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedCapabilityDisabledException.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedCapabilityDisabledException.java
new file mode 100644
index 00000000..939917f0
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedCapabilityDisabledException.java
@@ -0,0 +1,21 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap;
+
+/**
+ * Raised when a disabled or unapproved capability is used.
+ *
+ *
Fails loudly rather than degrading: a subscription endpoint that silently does nothing because
+ * its flag is off is far harder to diagnose than one that refuses to start.
+ */
+public class GraphQlAdvancedCapabilityDisabledException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Creates the failure.
+ *
+ * @param capabilityName the capability that is not enabled
+ */
+ public GraphQlAdvancedCapabilityDisabledException(String capabilityName) {
+ super("GraphQL advanced capability is not enabled: " + capabilityName);
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedCapabilityGrade.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedCapabilityGrade.java
new file mode 100644
index 00000000..962d26e8
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedCapabilityGrade.java
@@ -0,0 +1,31 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap;
+
+/**
+ * How far a capability has been proven.
+ *
+ *
The grade decides what enabling it takes. An Advanced capability needs its feature flag; an
+ * Experimental one additionally needs an approval profile in production, because the evidence for
+ * it has not been collected yet and a config change alone should not put it in front of users.
+ */
+public enum GraphQlAdvancedCapabilityGrade {
+
+ /** Proven in production use; enabled by its feature flag. */
+ ADVANCED_STABLE(false),
+
+ /** Supported; enabled by its feature flag. */
+ ADVANCED(false),
+
+ /** Not yet proven; production activation also requires an approval profile. */
+ EXPERIMENTAL(true);
+
+ private final boolean productionApprovalRequired;
+
+ GraphQlAdvancedCapabilityGrade(boolean productionApprovalRequired) {
+ this.productionApprovalRequired = productionApprovalRequired;
+ }
+
+ /** Whether production activation needs approval beyond the feature flag. */
+ public boolean productionApprovalRequired() {
+ return productionApprovalRequired;
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedDependencyRules.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedDependencyRules.java
new file mode 100644
index 00000000..f466befa
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedDependencyRules.java
@@ -0,0 +1,50 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap;
+
+import dev.caskeleton.adapter.inbound.graphql.build.GraphQlBuildModel;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * The Stable/Advanced dependency direction (Advanced plan Task 1).
+ *
+ *
Advanced consumes Stable, never the other way round. If a Stable module depended on an
+ * Advanced one, every Stable deployment would carry the Advanced capability's code and
+ * configuration surface — and the feature flag would be the only thing standing between an ordinary
+ * service and a subscription runtime.
+ */
+public final class GraphQlAdvancedDependencyRules {
+
+ private GraphQlAdvancedDependencyRules() {}
+
+ /**
+ * Verifies that no Stable module declares an Advanced dependency.
+ *
+ * @throws IllegalStateException naming the offending edges
+ */
+ public static void verifyStableDoesNotDependOnAdvanced() {
+ Set advanced = GraphQlBuildModel.advancedModules();
+ List violations = new ArrayList<>();
+ GraphQlBuildModel.stableDependencyEdges()
+ .forEach(
+ (module, dependencies) ->
+ dependencies.stream()
+ .filter(advanced::contains)
+ .forEach(dependency -> violations.add(module + " -> " + dependency)));
+ if (!violations.isEmpty()) {
+ throw new IllegalStateException(
+ "stable graphql modules must not depend on advanced capabilities: " + violations);
+ }
+ }
+
+ /** Whether a package belongs to an Advanced capability. */
+ public static boolean advancedPackage(String packageName) {
+ return packageName != null
+ && packageName.startsWith(GraphQlBuildModel.PACKAGE_ROOT + ".advanced");
+ }
+
+ /** Every capability that must be flagged before it can run. */
+ public static Set flaggedCapabilities() {
+ return Set.of(GraphQlAdvancedCapability.values());
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedFeatureFlags.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedFeatureFlags.java
new file mode 100644
index 00000000..daeb0509
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedFeatureFlags.java
@@ -0,0 +1,40 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap;
+
+import java.util.Set;
+
+/**
+ * Which Advanced capabilities are enabled, and whether experimental approval was granted.
+ *
+ *
Nothing is on by default. An Advanced capability that arrived because a dependency was added
+ * is exactly what the Stable/Advanced split exists to prevent.
+ *
+ * @param enabled capabilities whose feature flag is set
+ * @param experimentalApprovedInProduction whether Experimental capabilities may run in production
+ */
+public record GraphQlAdvancedFeatureFlags(
+ Set enabled, boolean experimentalApprovedInProduction) {
+
+ public GraphQlAdvancedFeatureFlags {
+ enabled = Set.copyOf(enabled);
+ }
+
+ /** Nothing enabled. */
+ public static GraphQlAdvancedFeatureFlags disabled() {
+ return new GraphQlAdvancedFeatureFlags(Set.of(), false);
+ }
+
+ /** The given capabilities enabled, without experimental production approval. */
+ public static GraphQlAdvancedFeatureFlags enabling(GraphQlAdvancedCapability... capabilities) {
+ return new GraphQlAdvancedFeatureFlags(Set.of(capabilities), false);
+ }
+
+ /** Whether a capability's flag is set. */
+ public boolean isEnabled(GraphQlAdvancedCapability capability) {
+ return enabled.contains(capability);
+ }
+
+ /** Returns a copy with experimental capabilities approved for production. */
+ public GraphQlAdvancedFeatureFlags withExperimentalApproval() {
+ return new GraphQlAdvancedFeatureFlags(enabled, true);
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedModuleGuard.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedModuleGuard.java
new file mode 100644
index 00000000..b80a4228
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/bootstrap/GraphQlAdvancedModuleGuard.java
@@ -0,0 +1,63 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap;
+
+import java.util.Objects;
+
+/**
+ * The single gate every Advanced capability passes through.
+ *
+ *
One place to check means a capability cannot be half-enabled: the flag governs whether it
+ * starts at all, and an Experimental capability additionally needs production approval, so neither
+ * can be reached by a code path that forgot to ask.
+ */
+public final class GraphQlAdvancedModuleGuard {
+
+ private final GraphQlAdvancedFeatureFlags flags;
+ private final boolean production;
+
+ /**
+ * Creates a non-production guard.
+ *
+ * @param flags the enabled capabilities
+ */
+ public GraphQlAdvancedModuleGuard(GraphQlAdvancedFeatureFlags flags) {
+ this(flags, false);
+ }
+
+ /**
+ * Creates the guard.
+ *
+ * @param flags the enabled capabilities
+ * @param production whether production rules apply
+ */
+ public GraphQlAdvancedModuleGuard(GraphQlAdvancedFeatureFlags flags, boolean production) {
+ this.flags = Objects.requireNonNull(flags);
+ this.production = production;
+ }
+
+ /**
+ * Requires a capability to be enabled and, in production, approved.
+ *
+ * @throws GraphQlAdvancedCapabilityDisabledException when it is not
+ */
+ public void requireEnabled(GraphQlAdvancedCapability capability) {
+ if (!flags.isEnabled(capability)) {
+ throw new GraphQlAdvancedCapabilityDisabledException(capability.name());
+ }
+ if (production
+ && capability.grade().productionApprovalRequired()
+ && !flags.experimentalApprovedInProduction()) {
+ throw new GraphQlAdvancedCapabilityDisabledException(
+ capability.name() + " is experimental and requires an approval profile in production");
+ }
+ }
+
+ /** Whether a capability may run here. */
+ public boolean enabled(GraphQlAdvancedCapability capability) {
+ try {
+ requireEnabled(capability);
+ return true;
+ } catch (GraphQlAdvancedCapabilityDisabledException ex) {
+ return false;
+ }
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/chaining/GraphQlChainedDataLoaderPolicy.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/chaining/GraphQlChainedDataLoaderPolicy.java
new file mode 100644
index 00000000..50f0036b
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/chaining/GraphQlChainedDataLoaderPolicy.java
@@ -0,0 +1,25 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.chaining;
+
+/**
+ * Whether chained DataLoader dispatch is enabled, and how deep it may chain.
+ *
+ *
Off by default. Chained dispatch changes when loaders fire, which changes query counts, batch
+ * sizes and result ordering — all things existing N+1 regression tests assert on. Enabling it is a
+ * deliberate change with its own regression evidence, not a free improvement.
+ *
+ * @param enabled whether chained dispatch is active
+ * @param maximumDepth deepest dependency chain permitted
+ */
+public record GraphQlChainedDataLoaderPolicy(boolean enabled, int maximumDepth) {
+
+ public GraphQlChainedDataLoaderPolicy {
+ if (maximumDepth < 1) {
+ throw new IllegalArgumentException("chained dispatch depth must be positive");
+ }
+ }
+
+ /** The Stable default: chained dispatch disabled. */
+ public static GraphQlChainedDataLoaderPolicy disabled() {
+ return new GraphQlChainedDataLoaderPolicy(false, 1);
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/chaining/GraphQlChainedDispatchConfigurer.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/chaining/GraphQlChainedDispatchConfigurer.java
new file mode 100644
index 00000000..8cf1521e
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/chaining/GraphQlChainedDispatchConfigurer.java
@@ -0,0 +1,72 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.chaining;
+
+import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapability;
+import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedModuleGuard;
+import java.util.Objects;
+
+/**
+ * Enables chained dispatch, once the flag, the graph and the depth all allow it (Advanced plan Task
+ * 11).
+ *
+ *
Every Stable DataLoader rule still applies: request scope, actor and tenant isolation, and the
+ * loader's maximum batch size. Chaining changes dispatch timing, nothing else.
+ */
+public final class GraphQlChainedDispatchConfigurer {
+
+ private final GraphQlAdvancedModuleGuard guard;
+ private final GraphQlDataLoaderCycleDetector cycleDetector;
+
+ /**
+ * Creates the configurer.
+ *
+ * @param guard the Advanced capability guard
+ * @param cycleDetector the dependency cycle check
+ */
+ public GraphQlChainedDispatchConfigurer(
+ GraphQlAdvancedModuleGuard guard, GraphQlDataLoaderCycleDetector cycleDetector) {
+ this.guard = Objects.requireNonNull(guard);
+ this.cycleDetector = Objects.requireNonNull(cycleDetector);
+ }
+
+ /**
+ * Verifies chained dispatch may be enabled for a graph.
+ *
+ * @throws GraphQlDataLoaderDependencyCycleException when the graph has a cycle
+ * @throws IllegalArgumentException when the graph is deeper than the policy allows
+ */
+ public void configure(
+ GraphQlChainedDataLoaderPolicy policy, GraphQlDataLoaderDependencyGraph graph) {
+ if (!policy.enabled()) {
+ return;
+ }
+ guard.requireEnabled(GraphQlAdvancedCapability.DATALOADER_CHAINING);
+ cycleDetector.verify(graph);
+ int depth = depthOf(graph);
+ if (depth > policy.maximumDepth()) {
+ throw new IllegalArgumentException(
+ "loader dependency depth "
+ + depth
+ + " exceeds the configured maximum of "
+ + policy.maximumDepth());
+ }
+ }
+
+ /** The longest dependency chain in the graph. */
+ public int depthOf(GraphQlDataLoaderDependencyGraph graph) {
+ cycleDetector.verify(graph);
+ int deepest = 0;
+ for (String loader : graph.loaders()) {
+ deepest = Math.max(deepest, depthFrom(graph, loader));
+ }
+ return deepest;
+ }
+
+ private int depthFrom(GraphQlDataLoaderDependencyGraph graph, String loader) {
+ var dependencies = graph.edges().getOrDefault(loader, java.util.Set.of());
+ int deepest = 0;
+ for (String dependency : dependencies) {
+ deepest = Math.max(deepest, 1 + depthFrom(graph, dependency));
+ }
+ return deepest;
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/chaining/GraphQlChainedLoaderMetrics.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/chaining/GraphQlChainedLoaderMetrics.java
new file mode 100644
index 00000000..446bc473
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/chaining/GraphQlChainedLoaderMetrics.java
@@ -0,0 +1,52 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.chaining;
+
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Dispatch counters for chained loading.
+ *
+ *
Query count and batch size are the numbers that prove chaining helped or hurt. Without them,
+ * enabling chained dispatch is a change whose effect nobody can measure — which is exactly why it
+ * defaults to off.
+ */
+public final class GraphQlChainedLoaderMetrics {
+
+ private final AtomicLong dispatches = new AtomicLong();
+ private final AtomicLong keysLoaded = new AtomicLong();
+ private final AtomicLong chainedDispatches = new AtomicLong();
+
+ /**
+ * Records one dispatch round.
+ *
+ * @param keys keys dispatched
+ * @param chained whether it was triggered by another loader's completion
+ */
+ public void recordDispatch(int keys, boolean chained) {
+ dispatches.incrementAndGet();
+ keysLoaded.addAndGet(keys);
+ if (chained) {
+ chainedDispatches.incrementAndGet();
+ }
+ }
+
+ /** Dispatch rounds. */
+ public long dispatches() {
+ return dispatches.get();
+ }
+
+ /** Keys loaded in total. */
+ public long keysLoaded() {
+ return keysLoaded.get();
+ }
+
+ /** Dispatch rounds triggered by chaining. */
+ public long chainedDispatches() {
+ return chainedDispatches.get();
+ }
+
+ /** Average batch size, the number a chaining regression shows up in first. */
+ public double averageBatchSize() {
+ long rounds = dispatches.get();
+ return rounds == 0 ? 0 : (double) keysLoaded.get() / rounds;
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/chaining/GraphQlDataLoaderCycleDetector.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/chaining/GraphQlDataLoaderCycleDetector.java
new file mode 100644
index 00000000..a6709888
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/chaining/GraphQlDataLoaderCycleDetector.java
@@ -0,0 +1,51 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.chaining;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Finds dependency cycles before chained dispatch is enabled.
+ *
+ *
A depth-first walk that reports the cycle it found, not just that one exists — with a dozen
+ * loaders, "there is a cycle somewhere" is not an actionable diagnostic.
+ */
+public final class GraphQlDataLoaderCycleDetector {
+
+ /**
+ * Verifies the graph is acyclic.
+ *
+ * @throws GraphQlDataLoaderDependencyCycleException naming the cycle
+ */
+ public void verify(GraphQlDataLoaderDependencyGraph graph) {
+ Set settled = new LinkedHashSet<>();
+ for (String loader : graph.loaders()) {
+ if (!settled.contains(loader)) {
+ walk(graph, loader, new LinkedHashSet<>(), settled);
+ }
+ }
+ }
+
+ private void walk(
+ GraphQlDataLoaderDependencyGraph graph,
+ String loader,
+ Set path,
+ Set settled) {
+
+ if (path.contains(loader)) {
+ List cycle = new ArrayList<>(path);
+ cycle.add(loader);
+ throw new GraphQlDataLoaderDependencyCycleException(cycle);
+ }
+ if (settled.contains(loader)) {
+ return;
+ }
+ path.add(loader);
+ for (String dependency : graph.edges().getOrDefault(loader, Set.of())) {
+ walk(graph, dependency, path, settled);
+ }
+ path.remove(loader);
+ settled.add(loader);
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/chaining/GraphQlDataLoaderDependencyCycleException.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/chaining/GraphQlDataLoaderDependencyCycleException.java
new file mode 100644
index 00000000..8217234c
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/chaining/GraphQlDataLoaderDependencyCycleException.java
@@ -0,0 +1,23 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.chaining;
+
+import java.util.List;
+
+/**
+ * Raised when loaders depend on each other in a cycle.
+ *
+ *
A cycle has no dispatch order that satisfies it, so chained dispatch would deadlock or never
+ * dispatch — failing at startup is far cheaper than discovering it under load.
+ */
+public class GraphQlDataLoaderDependencyCycleException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Creates the failure.
+ *
+ * @param cycle the loaders on the cycle, in order
+ */
+ public GraphQlDataLoaderDependencyCycleException(List cycle) {
+ super("data loader dependency cycle: " + cycle);
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/chaining/GraphQlDataLoaderDependencyGraph.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/chaining/GraphQlDataLoaderDependencyGraph.java
new file mode 100644
index 00000000..e2300334
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/chaining/GraphQlDataLoaderDependencyGraph.java
@@ -0,0 +1,44 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.chaining;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Declared dependencies between loaders.
+ *
+ *
Declared rather than inferred: chained dispatch changes when each loader fires, and the
+ * platform cannot discover from bytecode that one loader's keys come from another's results.
+ */
+public final class GraphQlDataLoaderDependencyGraph {
+
+ private final Map> edges = new LinkedHashMap<>();
+
+ /**
+ * Declares that one loader depends on another.
+ *
+ * @param loader the dependent loader
+ * @param dependency the loader it needs first
+ */
+ public GraphQlDataLoaderDependencyGraph dependsOn(String loader, String dependency) {
+ if (loader == null || dependency == null) {
+ throw new IllegalArgumentException("loader and dependency names are required");
+ }
+ edges.computeIfAbsent(loader, ignored -> new LinkedHashSet<>()).add(dependency);
+ return this;
+ }
+
+ /** The declared edges. */
+ public Map> edges() {
+ return Collections.unmodifiableMap(edges);
+ }
+
+ /** Loaders that appear in the graph. */
+ public Set loaders() {
+ Set loaders = new LinkedHashSet<>(edges.keySet());
+ edges.values().forEach(loaders::addAll);
+ return Collections.unmodifiableSet(loaders);
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlClientOperationGenerator.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlClientOperationGenerator.java
new file mode 100644
index 00000000..75ac6798
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlClientOperationGenerator.java
@@ -0,0 +1,51 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.codegen;
+
+import dev.caskeleton.adapter.inbound.graphql.compat.GraphQlSchemaComparator;
+import java.util.Objects;
+
+/**
+ * Generates client request and response types, validating operations against the schema first.
+ *
+ *
Compile-time validation is most of the value: an operation that no longer matches the schema
+ * becomes a build failure in the client's repository instead of a runtime error in production.
+ */
+public final class GraphQlClientOperationGenerator {
+
+ private final GraphQlCodegenProfile profile;
+
+ /**
+ * Creates the generator.
+ *
+ * @param profile what to generate and where
+ */
+ public GraphQlClientOperationGenerator(GraphQlCodegenProfile profile) {
+ this.profile = Objects.requireNonNull(profile);
+ }
+
+ /**
+ * Validates that an operation document still matches the schema.
+ *
+ * @param sdl the schema
+ * @param operationDocument the operation to validate
+ * @throws GraphQlCodegenBoundaryException when either is missing
+ */
+ public void validateOperation(String sdl, String operationDocument) {
+ if (sdl == null || sdl.isBlank() || operationDocument == null || operationDocument.isBlank()) {
+ throw new GraphQlCodegenBoundaryException("CLIENT_REQUEST");
+ }
+ // Parsing the schema is what makes generation fail on an invalid schema rather than emitting
+ // sources against one.
+ GraphQlSchemaComparator.compare(sdl, sdl);
+ }
+
+ /** The kinds this generator produces. */
+ public java.util.Set generatedTypes() {
+ profile.generatedTypes().forEach(GraphQlGeneratedSourceBoundary.standard()::requireAllowed);
+ return profile.generatedTypes();
+ }
+
+ /** The package generated client sources are written to. */
+ public String generatedPackage() {
+ return profile.generatedPackage();
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlCodegenBoundaryException.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlCodegenBoundaryException.java
new file mode 100644
index 00000000..3e6296c5
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlCodegenBoundaryException.java
@@ -0,0 +1,20 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.codegen;
+
+/**
+ * Raised when generation is attempted for a kind that must be written by hand.
+ *
+ *
Fails at build time, where a generated domain type is still cheap to delete.
+ */
+public class GraphQlCodegenBoundaryException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Creates the failure.
+ *
+ * @param generatedType the kind that may not be generated
+ */
+ public GraphQlCodegenBoundaryException(String generatedType) {
+ super(generatedType + " must be written by hand, not generated from the GraphQL schema");
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlCodegenProfile.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlCodegenProfile.java
new file mode 100644
index 00000000..e08fa7ec
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlCodegenProfile.java
@@ -0,0 +1,34 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.codegen;
+
+import java.util.List;
+import java.util.Set;
+
+/**
+ * What one generation run produces, and where.
+ *
+ *
Generated sources go to their own directory and package. Mixing them with hand-written code
+ * means the next run either overwrites something a person wrote or silently stops regenerating.
+ *
+ * @param generatedPackage package generated sources are written to
+ * @param generatedSourceDirectory directory they are written to
+ * @param generatedTypes kinds this run produces
+ * @param scalarMappings custom scalar mappings
+ */
+public record GraphQlCodegenProfile(
+ String generatedPackage,
+ String generatedSourceDirectory,
+ Set generatedTypes,
+ List scalarMappings) {
+
+ public GraphQlCodegenProfile {
+ if (generatedPackage == null || generatedPackage.isBlank()) {
+ throw new IllegalArgumentException("generated package is required");
+ }
+ if (generatedSourceDirectory == null || generatedSourceDirectory.isBlank()) {
+ throw new IllegalArgumentException("generated source directory is required");
+ }
+ generatedTypes = Set.copyOf(generatedTypes);
+ scalarMappings = List.copyOf(scalarMappings);
+ generatedTypes.forEach(GraphQlGeneratedSourceBoundary.standard()::requireAllowed);
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlGeneratedCompatibilityGate.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlGeneratedCompatibilityGate.java
new file mode 100644
index 00000000..265c1a59
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlGeneratedCompatibilityGate.java
@@ -0,0 +1,38 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.codegen;
+
+import dev.caskeleton.adapter.inbound.graphql.compat.GraphQlChangeKind;
+import dev.caskeleton.adapter.inbound.graphql.compat.GraphQlCompatibilityReport;
+import dev.caskeleton.adapter.inbound.graphql.compat.GraphQlSchemaChange;
+import java.util.List;
+
+/**
+ * Reports schema changes that are wire-compatible but break generated client source.
+ *
+ *
The case worth naming: adding an enum value or a union member is additive on the wire, and it
+ * breaks a generated client whose {@code switch} is exhaustive. The server sees a successful
+ * release; the client sees a compile error, or worse, a runtime one.
+ */
+public final class GraphQlGeneratedCompatibilityGate {
+
+ private GraphQlGeneratedCompatibilityGate() {}
+
+ /** Changes that need a generated-client rebuild or review, in deterministic order. */
+ public static List generatedClientImpacts(GraphQlCompatibilityReport report) {
+ return report.changes().stream()
+ .filter(GraphQlSchemaChange::reviewRequired)
+ .map(GraphQlSchemaChange::describe)
+ .toList();
+ }
+
+ /** Whether a change is additive on the wire yet risky for an exhaustive generated client. */
+ public static boolean exhaustivenessRisk(GraphQlSchemaChange change) {
+ return change.kind() == GraphQlChangeKind.ENUM_VALUE_ADDED
+ || change.kind() == GraphQlChangeKind.UNION_MEMBER_ADDED
+ || change.kind() == GraphQlChangeKind.INTERFACE_IMPLEMENTATION_ADDED;
+ }
+
+ /** Whether the report can be released without regenerating clients. */
+ public static boolean generatedClientsUnaffected(GraphQlCompatibilityReport report) {
+ return generatedClientImpacts(report).isEmpty();
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlGeneratedSourceBoundary.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlGeneratedSourceBoundary.java
new file mode 100644
index 00000000..f1858982
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlGeneratedSourceBoundary.java
@@ -0,0 +1,45 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.codegen;
+
+import java.util.Set;
+
+/**
+ * What code generation may and may not produce (design §21.2).
+ *
+ *
Client and transport types are good candidates: they mirror the schema exactly and carry no
+ * logic. Domain entities, use cases and repositories are not — they exist to hold behaviour the
+ * schema does not describe, and generating them from the schema inverts the dependency the whole
+ * architecture rests on.
+ */
+public final class GraphQlGeneratedSourceBoundary {
+
+ /** Kinds that may be generated. */
+ public static final Set ALLOWED =
+ Set.of("CLIENT_REQUEST", "CLIENT_RESPONSE", "TRANSPORT_INPUT", "TRANSPORT_OUTPUT");
+
+ /** Kinds that must be written by hand. */
+ public static final Set FORBIDDEN =
+ Set.of("DOMAIN_ENTITY", "APPLICATION_USE_CASE", "REPOSITORY", "PERSISTENCE_MODEL");
+
+ private GraphQlGeneratedSourceBoundary() {}
+
+ /** The standard boundary. */
+ public static GraphQlGeneratedSourceBoundary standard() {
+ return new GraphQlGeneratedSourceBoundary();
+ }
+
+ /** Whether a kind may be generated. */
+ public boolean isAllowed(String generatedType) {
+ return ALLOWED.contains(generatedType);
+ }
+
+ /**
+ * Requires a kind to be generatable.
+ *
+ * @throws GraphQlCodegenBoundaryException when it is not
+ */
+ public void requireAllowed(String generatedType) {
+ if (!isAllowed(generatedType)) {
+ throw new GraphQlCodegenBoundaryException(generatedType);
+ }
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlScalarMapping.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlScalarMapping.java
new file mode 100644
index 00000000..519afe18
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlScalarMapping.java
@@ -0,0 +1,27 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.codegen;
+
+/**
+ * How a custom scalar maps onto a generated Java type.
+ *
+ *
Declared explicitly, because a generator that does not know a scalar falls back to {@code
+ * String} — and a {@code BigDecimal} arriving as a {@code String} in generated client code is
+ * precisely the precision loss the scalar was defined to prevent.
+ *
+ * @param scalarName the GraphQL scalar
+ * @param javaType the generated Java type
+ * @param codecId the codec that converts between them
+ */
+public record GraphQlScalarMapping(String scalarName, String javaType, String codecId) {
+
+ public GraphQlScalarMapping {
+ if (scalarName == null || scalarName.isBlank()) {
+ throw new IllegalArgumentException("scalar name is required");
+ }
+ if (javaType == null || javaType.isBlank()) {
+ throw new IllegalArgumentException("generated Java type is required");
+ }
+ if (codecId == null || codecId.isBlank()) {
+ throw new IllegalArgumentException("codec id is required");
+ }
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlTransportTypeGenerator.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlTransportTypeGenerator.java
new file mode 100644
index 00000000..58f8bdea
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/codegen/GraphQlTransportTypeGenerator.java
@@ -0,0 +1,54 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.codegen;
+
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Generates server-side transport DTOs.
+ *
+ *
Transport types only. A generated type that a resolver maps to and from is fine; a generated
+ * type the Application layer consumes directly would make every schema change a change to business
+ * code.
+ */
+public final class GraphQlTransportTypeGenerator {
+
+ private static final Set TRANSPORT_KINDS = Set.of("TRANSPORT_INPUT", "TRANSPORT_OUTPUT");
+
+ private final GraphQlCodegenProfile profile;
+
+ /**
+ * Creates the generator.
+ *
+ * @param profile what to generate and where
+ */
+ public GraphQlTransportTypeGenerator(GraphQlCodegenProfile profile) {
+ this.profile = Objects.requireNonNull(profile);
+ }
+
+ /**
+ * The transport kinds this profile generates.
+ *
+ * @throws GraphQlCodegenBoundaryException when the profile asks for a non-transport kind
+ */
+ public Set transportTypes() {
+ profile.generatedTypes().stream()
+ .filter(kind -> !TRANSPORT_KINDS.contains(kind) && !kind.startsWith("CLIENT_"))
+ .findFirst()
+ .ifPresent(
+ kind -> {
+ throw new GraphQlCodegenBoundaryException(kind);
+ });
+ return profile.generatedTypes().stream()
+ .filter(TRANSPORT_KINDS::contains)
+ .collect(java.util.stream.Collectors.toUnmodifiableSet());
+ }
+
+ /**
+ * Whether generated sources are separated from hand-written ones.
+ *
+ * @param handWrittenSourceDirectory where hand-written sources live
+ */
+ public boolean separatedFrom(String handWrittenSourceDirectory) {
+ return !profile.generatedSourceDirectory().equals(handWrittenSourceDirectory);
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryAllowlist.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryAllowlist.java
new file mode 100644
index 00000000..efbc1399
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryAllowlist.java
@@ -0,0 +1,39 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
+
+import java.util.Set;
+
+/**
+ * The repositories permitted to back GraphQL fields.
+ *
+ *
Empty by default. Spring Data's automatic exposure is convenient and turns filter, sort and
+ * pagination semantics into public API the moment it is switched on — the allowlist is what makes
+ * each of those a decision.
+ */
+public final class GraphQlRepositoryAllowlist {
+
+ private final Set repositoryNames;
+
+ private GraphQlRepositoryAllowlist(Set repositoryNames) {
+ this.repositoryNames = Set.copyOf(repositoryNames);
+ }
+
+ /** Nothing exposed. */
+ public static GraphQlRepositoryAllowlist empty() {
+ return new GraphQlRepositoryAllowlist(Set.of());
+ }
+
+ /** The named repositories exposed. */
+ public static GraphQlRepositoryAllowlist of(String... repositoryNames) {
+ return new GraphQlRepositoryAllowlist(Set.of(repositoryNames));
+ }
+
+ /** Whether a repository is allowlisted. */
+ public boolean contains(String repositoryName) {
+ return repositoryNames.contains(repositoryName);
+ }
+
+ /** The allowlisted repositories. */
+ public Set repositoryNames() {
+ return repositoryNames;
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryArgumentPolicy.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryArgumentPolicy.java
new file mode 100644
index 00000000..e7242ef8
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryArgumentPolicy.java
@@ -0,0 +1,40 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
+
+import java.util.Set;
+import java.util.TreeSet;
+
+/**
+ * Which filter and sort arguments an exposed repository accepts.
+ *
+ *
Enumerated, because automatic exposure turns GraphQL arguments into Querydsl predicates: an
+ * unlisted argument becomes a query nobody designed, against a column that may have no index and
+ * may not be meant to be filterable at all.
+ *
+ * @param allowedFilterFields fields that may be filtered on
+ * @param allowedSortFields fields that may be sorted by
+ */
+public record GraphQlRepositoryArgumentPolicy(
+ Set allowedFilterFields, Set allowedSortFields) {
+
+ public GraphQlRepositoryArgumentPolicy {
+ allowedFilterFields = Set.copyOf(allowedFilterFields);
+ allowedSortFields = Set.copyOf(allowedSortFields);
+ }
+
+ /**
+ * Verifies the arguments a request supplied.
+ *
+ * @throws GraphQlRepositoryExposureRejectedException naming the unlisted arguments
+ */
+ public void verify(Set filterFields, Set sortFields) {
+ var rejected = new TreeSet();
+ filterFields.stream()
+ .filter(field -> !allowedFilterFields.contains(field))
+ .forEach(rejected::add);
+ sortFields.stream().filter(field -> !allowedSortFields.contains(field)).forEach(rejected::add);
+ if (!rejected.isEmpty()) {
+ throw new GraphQlRepositoryExposureRejectedException(
+ "unlisted filter or sort fields " + rejected);
+ }
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryExposure.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryExposure.java
new file mode 100644
index 00000000..1e37bc19
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryExposure.java
@@ -0,0 +1,19 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
+
+/**
+ * One repository exposed at one schema coordinate.
+ *
+ * @param repositoryName the repository
+ * @param schemaCoordinate the field it backs
+ */
+public record GraphQlRepositoryExposure(String repositoryName, String schemaCoordinate) {
+
+ public GraphQlRepositoryExposure {
+ if (repositoryName == null || repositoryName.isBlank()) {
+ throw new IllegalArgumentException("repository name is required");
+ }
+ if (schemaCoordinate == null || schemaCoordinate.isBlank()) {
+ throw new IllegalArgumentException("schema coordinate is required");
+ }
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryExposureRejectedException.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryExposureRejectedException.java
new file mode 100644
index 00000000..657773f2
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryExposureRejectedException.java
@@ -0,0 +1,21 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
+
+/**
+ * Raised when a repository would be exposed without being allowlisted.
+ *
+ *
Automatic exposure turns a repository into a public API the moment it is annotated, so the
+ * default has to be refusal rather than registration.
+ */
+public class GraphQlRepositoryExposureRejectedException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Creates the failure.
+ *
+ * @param repositoryName the repository that is not allowlisted
+ */
+ public GraphQlRepositoryExposureRejectedException(String repositoryName) {
+ super("repository is not allowlisted for GraphQL exposure: " + repositoryName);
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryExposureValidator.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryExposureValidator.java
new file mode 100644
index 00000000..544acb15
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryExposureValidator.java
@@ -0,0 +1,58 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
+
+import java.util.Objects;
+
+/**
+ * Refuses repository exposure that was not deliberately configured (Advanced plan Task 15).
+ *
+ *
This is a compatibility path, not the mainstream API. The Stable route is a resolver calling
+ * an Application use case; automatic exposure exists for the cases where that is genuinely not
+ * worth writing, and it stays behind an allowlist, an argument policy, an explicit pagination
+ * choice and an approved projection.
+ */
+public final class GraphQlRepositoryExposureValidator {
+
+ private final GraphQlRepositoryAllowlist allowlist;
+
+ /**
+ * Creates the validator.
+ *
+ * @param allowlist repositories permitted to be exposed
+ */
+ public GraphQlRepositoryExposureValidator(GraphQlRepositoryAllowlist allowlist) {
+ this.allowlist = Objects.requireNonNull(allowlist);
+ }
+
+ /**
+ * Verifies a repository may be exposed.
+ *
+ * @throws GraphQlRepositoryExposureRejectedException when it is not allowlisted
+ */
+ public void verify(GraphQlRepositoryExposure exposure) {
+ if (!allowlist.contains(exposure.repositoryName())) {
+ throw new GraphQlRepositoryExposureRejectedException(exposure.repositoryName());
+ }
+ }
+
+ /**
+ * Verifies the full exposure configuration.
+ *
+ * @param exposure the repository and coordinate
+ * @param pagination the pagination policy
+ * @param projection the projection policy
+ * @throws GraphQlRepositoryExposureRejectedException when anything was left to default
+ */
+ public void verifyConfiguration(
+ GraphQlRepositoryExposure exposure,
+ GraphQlRepositoryPaginationPolicy pagination,
+ GraphQlRepositoryProjectionPolicy projection) {
+
+ verify(exposure);
+ if (pagination.implicitSpringDataDefault()) {
+ throw new GraphQlRepositoryExposureRejectedException(
+ exposure.repositoryName()
+ + " relies on the implicit offset pagination default; choose a pagination policy");
+ }
+ Objects.requireNonNull(projection, "an approved projection is required");
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryPaginationPolicy.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryPaginationPolicy.java
new file mode 100644
index 00000000..d8fe606b
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryPaginationPolicy.java
@@ -0,0 +1,35 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
+
+/**
+ * Pagination for an exposed repository, stated rather than inherited.
+ *
+ *
Spring Data's automatic exposure paginates by offset, twenty at a time, unless told otherwise.
+ * Both defaults are decisions: offset pagination skips and repeats rows under concurrent writes,
+ * and a page size that arrived by default is one nobody chose.
+ *
+ * @param keysetPagination whether keyset pagination is used instead of offset
+ * @param defaultPageSize page size when the client asks for none
+ * @param maximumPageSize largest page size the client may ask for
+ */
+public record GraphQlRepositoryPaginationPolicy(
+ boolean keysetPagination, int defaultPageSize, int maximumPageSize) {
+
+ /** The default Spring Data behaviour, which this platform requires to be chosen explicitly. */
+ public static final int SPRING_DATA_DEFAULT_PAGE_SIZE = 20;
+
+ public GraphQlRepositoryPaginationPolicy {
+ if (defaultPageSize < 1 || maximumPageSize < defaultPageSize) {
+ throw new IllegalArgumentException("invalid repository pagination policy");
+ }
+ }
+
+ /** An explicitly chosen keyset policy. */
+ public static GraphQlRepositoryPaginationPolicy keyset(int defaultPageSize, int maximumPageSize) {
+ return new GraphQlRepositoryPaginationPolicy(true, defaultPageSize, maximumPageSize);
+ }
+
+ /** Whether this policy merely restates Spring Data's defaults rather than choosing them. */
+ public boolean implicitSpringDataDefault() {
+ return !keysetPagination && defaultPageSize == SPRING_DATA_DEFAULT_PAGE_SIZE;
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryProjectionPolicy.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryProjectionPolicy.java
new file mode 100644
index 00000000..b2a40147
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/compat/GraphQlRepositoryProjectionPolicy.java
@@ -0,0 +1,39 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
+
+import java.util.Set;
+
+/**
+ * Which projection an exposed repository returns.
+ *
+ *
Never the entity or document itself. Returning one exposes every persistence field as API —
+ * including the ones added later, by someone who had no idea this repository was reachable from
+ * GraphQL.
+ *
+ * @param projectionType the approved projection type name
+ * @param exposedFields fields the projection exposes
+ */
+public record GraphQlRepositoryProjectionPolicy(String projectionType, Set exposedFields) {
+
+ public GraphQlRepositoryProjectionPolicy {
+ if (projectionType == null || projectionType.isBlank()) {
+ throw new IllegalArgumentException("an approved projection type is required");
+ }
+ exposedFields = Set.copyOf(exposedFields);
+ if (exposedFields.isEmpty()) {
+ throw new IllegalArgumentException("a projection must expose at least one field");
+ }
+ }
+
+ /**
+ * Verifies the projection is not a persistence type.
+ *
+ * @param persistenceTypeNames entity and document type names
+ * @throws GraphQlRepositoryExposureRejectedException when the projection is one of them
+ */
+ public void verifyNotPersistenceType(Set persistenceTypeNames) {
+ if (persistenceTypeNames.contains(projectionType)) {
+ throw new GraphQlRepositoryExposureRejectedException(
+ projectionType + " is a persistence type and must not be returned directly");
+ }
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/composition/GraphQlFederationCompositionGate.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/composition/GraphQlFederationCompositionGate.java
new file mode 100644
index 00000000..6b18df6b
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/composition/GraphQlFederationCompositionGate.java
@@ -0,0 +1,44 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.composition;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Blocks a federated release without complete evidence.
+ *
+ *
The gate exists because in a federated topology a mistake is not contained: an entity key
+ * change or an unbudgeted cross-subgraph call degrades queries the owning team never wrote.
+ */
+public final class GraphQlFederationCompositionGate {
+
+ /**
+ * Verifies a federated release.
+ *
+ * @throws GraphQlFederationReleaseRejectedException naming the missing evidence
+ */
+ public void verify(GraphQlFederationReleaseEvidence evidence) {
+ List missing = missing(evidence);
+ if (!missing.isEmpty()) {
+ throw new GraphQlFederationReleaseRejectedException(
+ "federation composition evidence incomplete: " + missing);
+ }
+ }
+
+ /** Which evidence is missing, in a deterministic order. */
+ public List missing(GraphQlFederationReleaseEvidence evidence) {
+ List missing = new ArrayList<>();
+ if (!evidence.compositionPassed()) {
+ missing.add("composition");
+ }
+ if (!evidence.entityContractsPassed()) {
+ missing.add("entityContracts");
+ }
+ if (!evidence.latencyPassed()) {
+ missing.add("latency");
+ }
+ if (!evidence.failureContractsPassed()) {
+ missing.add("failureContracts");
+ }
+ return List.copyOf(missing);
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/composition/GraphQlFederationCompositionResult.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/composition/GraphQlFederationCompositionResult.java
new file mode 100644
index 00000000..98ca41ea
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/composition/GraphQlFederationCompositionResult.java
@@ -0,0 +1,38 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.composition;
+
+import java.util.List;
+
+/**
+ * What composition CI reported.
+ *
+ *
Produced by the router project, consumed here: this repository owns a subgraph and cannot
+ * compose the supergraph itself, so the result is an input to the gate rather than something it
+ * computes.
+ *
+ * @param composed whether the supergraph composed
+ * @param supergraphHash hash of the composed supergraph, or {@code null} when composition failed
+ * @param problems composition problems, empty when it succeeded
+ */
+public record GraphQlFederationCompositionResult(
+ boolean composed, String supergraphHash, List problems) {
+
+ public GraphQlFederationCompositionResult {
+ problems = problems == null ? List.of() : List.copyOf(problems);
+ if (composed && (supergraphHash == null || supergraphHash.isBlank())) {
+ throw new IllegalArgumentException("a successful composition must report a supergraph hash");
+ }
+ if (!composed && problems.isEmpty()) {
+ throw new IllegalArgumentException("a failed composition must report its problems");
+ }
+ }
+
+ /** A successful composition. */
+ public static GraphQlFederationCompositionResult composed(String supergraphHash) {
+ return new GraphQlFederationCompositionResult(true, supergraphHash, List.of());
+ }
+
+ /** A failed composition. */
+ public static GraphQlFederationCompositionResult failed(List problems) {
+ return new GraphQlFederationCompositionResult(false, null, problems);
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/composition/GraphQlFederationDeploymentOrder.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/composition/GraphQlFederationDeploymentOrder.java
new file mode 100644
index 00000000..d0eef52b
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/composition/GraphQlFederationDeploymentOrder.java
@@ -0,0 +1,42 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.composition;
+
+/**
+ * Which must deploy first, the subgraph or the router.
+ *
+ *
Order matters because the two are separate deployments. A subgraph that removes a field before
+ * the router stops asking for it breaks every query in flight; a router that starts asking for a
+ * field the subgraph does not have yet breaks just as loudly. Additive changes go subgraph-first,
+ * removals router-first.
+ */
+public enum GraphQlFederationDeploymentOrder {
+
+ /** Subgraph first: it adds capability the router will start using. */
+ SUBGRAPH_FIRST,
+
+ /** Router first: it must stop using capability the subgraph is removing. */
+ ROUTER_FIRST;
+
+ /**
+ * The required order for a change.
+ *
+ * @param removesCapability whether the change removes a field, key or type
+ */
+ public static GraphQlFederationDeploymentOrder forChange(boolean removesCapability) {
+ return removesCapability ? ROUTER_FIRST : SUBGRAPH_FIRST;
+ }
+
+ /**
+ * Verifies a planned deployment order.
+ *
+ * @param removesCapability whether the change removes capability
+ * @param planned the planned order
+ * @throws GraphQlFederationReleaseRejectedException when the order would break in-flight queries
+ */
+ public static void verify(boolean removesCapability, GraphQlFederationDeploymentOrder planned) {
+ GraphQlFederationDeploymentOrder required = forChange(removesCapability);
+ if (required != planned) {
+ throw new GraphQlFederationReleaseRejectedException(
+ "this change requires " + required + " deployment, not " + planned);
+ }
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/composition/GraphQlFederationLatencyBudget.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/composition/GraphQlFederationLatencyBudget.java
new file mode 100644
index 00000000..36ebced1
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/composition/GraphQlFederationLatencyBudget.java
@@ -0,0 +1,40 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.composition;
+
+import java.time.Duration;
+
+/**
+ * The latency a federated query may spend, and how many subgraph hops it may take.
+ *
+ *
Hops matter as much as time. A query that fans out to four subgraphs pays four network round
+ * trips before any data is read, and a per-entity downstream call turns that into a cross-subgraph
+ * N+1 — invisible in any single subgraph's own metrics.
+ *
+ * @param maximumTotal end-to-end budget for a federated query
+ * @param maximumSubgraphHops subgraph calls one query may make
+ * @param maximumPerEntityCalls per-entity downstream calls permitted
+ */
+public record GraphQlFederationLatencyBudget(
+ Duration maximumTotal, int maximumSubgraphHops, int maximumPerEntityCalls) {
+
+ public GraphQlFederationLatencyBudget {
+ if (maximumTotal == null || maximumTotal.isZero() || maximumTotal.isNegative()) {
+ throw new IllegalArgumentException("federation latency budget must be positive");
+ }
+ if (maximumSubgraphHops < 1 || maximumPerEntityCalls < 0) {
+ throw new IllegalArgumentException("invalid federation call budget");
+ }
+ }
+
+ /**
+ * Whether measured behaviour is within budget.
+ *
+ * @param observedTotal measured end-to-end latency
+ * @param observedHops measured subgraph hops
+ * @param observedPerEntityCalls measured per-entity downstream calls
+ */
+ public boolean within(Duration observedTotal, int observedHops, int observedPerEntityCalls) {
+ return observedTotal.compareTo(maximumTotal) <= 0
+ && observedHops <= maximumSubgraphHops
+ && observedPerEntityCalls <= maximumPerEntityCalls;
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/composition/GraphQlFederationReleaseEvidence.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/composition/GraphQlFederationReleaseEvidence.java
new file mode 100644
index 00000000..dcce0384
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/composition/GraphQlFederationReleaseEvidence.java
@@ -0,0 +1,25 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.composition;
+
+/**
+ * The evidence a federated release requires (Advanced plan Task 13).
+ *
+ *
Composition succeeding is the weakest of the four. It proves the schemas fit together, not
+ * that entity keys are stable, that cross-subgraph latency is within budget, or that a partial
+ * subgraph failure produces a sensible response rather than a nulled supergraph.
+ *
+ * @param compositionPassed the supergraph composes
+ * @param entityContractsPassed entity keys and ownership verified
+ * @param latencyPassed cross-subgraph latency within budget
+ * @param failureContractsPassed partial-failure behaviour verified
+ */
+public record GraphQlFederationReleaseEvidence(
+ boolean compositionPassed,
+ boolean entityContractsPassed,
+ boolean latencyPassed,
+ boolean failureContractsPassed) {
+
+ /** Whether every kind of evidence is present. */
+ public boolean complete() {
+ return compositionPassed && entityContractsPassed && latencyPassed && failureContractsPassed;
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/composition/GraphQlFederationReleaseRejectedException.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/composition/GraphQlFederationReleaseRejectedException.java
new file mode 100644
index 00000000..1f09b1ca
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/composition/GraphQlFederationReleaseRejectedException.java
@@ -0,0 +1,21 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.composition;
+
+/**
+ * Raised when a federated release lacks required evidence.
+ *
+ *
Names what is missing: a federated deployment affects services owned by other teams, so
+ * "blocked" has to come with the reason.
+ */
+public class GraphQlFederationReleaseRejectedException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Creates the failure.
+ *
+ * @param reason which evidence is missing
+ */
+ public GraphQlFederationReleaseRejectedException(String reason) {
+ super(reason);
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/composition/GraphQlFederationUsageReport.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/composition/GraphQlFederationUsageReport.java
new file mode 100644
index 00000000..733bd634
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/composition/GraphQlFederationUsageReport.java
@@ -0,0 +1,29 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.composition;
+
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Which subgraphs use which of this subgraph's entity fields.
+ *
+ *
The precondition for changing an entity key: without it, the only way to find out who depended
+ * on a field is to remove it and wait for another team's incident.
+ *
+ * @param consumersByField consuming subgraph names, keyed by entity field
+ */
+public record GraphQlFederationUsageReport(Map> consumersByField) {
+
+ public GraphQlFederationUsageReport {
+ consumersByField = Map.copyOf(consumersByField);
+ }
+
+ /** Subgraphs that consume a field. */
+ public Set consumersOf(String field) {
+ return consumersByField.getOrDefault(field, Set.of());
+ }
+
+ /** Whether a field may be changed without coordinating with another team. */
+ public boolean safeToChange(String field) {
+ return consumersOf(field).isEmpty();
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/composition/GraphQlSubgraphContract.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/composition/GraphQlSubgraphContract.java
new file mode 100644
index 00000000..d71dd30a
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/composition/GraphQlSubgraphContract.java
@@ -0,0 +1,41 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.composition;
+
+import dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationEntityKey;
+import java.util.List;
+
+/**
+ * What one subgraph publishes into the supergraph.
+ *
+ *
An owner is required. In a federated topology an entity key change breaks other teams'
+ * queries, and "who approves this" has to be answerable before the change is proposed, not after it
+ * lands.
+ *
+ * @param subgraphName the subgraph's name
+ * @param owner the team that owns it
+ * @param schemaHash hash of the subgraph's schema
+ * @param entityKeys entity keys it publishes
+ */
+public record GraphQlSubgraphContract(
+ String subgraphName,
+ String owner,
+ String schemaHash,
+ List entityKeys) {
+
+ public GraphQlSubgraphContract {
+ if (subgraphName == null || subgraphName.isBlank()) {
+ throw new IllegalArgumentException("subgraph name is required");
+ }
+ if (owner == null || owner.isBlank()) {
+ throw new IllegalArgumentException("subgraph owner is required");
+ }
+ if (schemaHash == null || schemaHash.isBlank()) {
+ throw new IllegalArgumentException("subgraph schema hash is required");
+ }
+ entityKeys = List.copyOf(entityKeys);
+ }
+
+ /** Whether an entity key changed compared with the deployed contract. */
+ public boolean entityKeysChangedFrom(GraphQlSubgraphContract deployed) {
+ return deployed != null && !deployed.entityKeys().equals(entityKeys);
+ }
+}
diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/federation/GraphQlFederationBatchResolver.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/federation/GraphQlFederationBatchResolver.java
new file mode 100644
index 00000000..649307d8
--- /dev/null
+++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/federation/GraphQlFederationBatchResolver.java
@@ -0,0 +1,47 @@
+package dev.caskeleton.adapter.inbound.graphql.advanced.federation;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Resolves many entity representations as one batch.
+ *
+ *
A router sends the whole {@code _entities} array at once, so resolving them one at a time
+ * recreates the N+1 that DataLoader exists to prevent — except now it is one query per entity per
+ * subgraph per federated request.
+ */
+public final class GraphQlFederationBatchResolver {
+
+ private final GraphQlFederationEntityResolver resolver;
+
+ /**
+ * Creates the batch resolver.
+ *
+ * @param resolver the per-representation validator
+ */
+ public GraphQlFederationBatchResolver(GraphQlFederationEntityResolver resolver) {
+ this.resolver = Objects.requireNonNull(resolver);
+ }
+
+ /**
+ * Validates a batch and extracts the key values, preserving order.
+ *
+ *
Order matters: the router matches results back to representations positionally.
+ *
+ * @throws GraphQlFederationRepresentationException when any representation is incomplete
+ */
+ public List